CaiWuXiTong/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php
renshen c7654fe74d feat: 退款月度汇总支持点击查看明细
月度合计移入汇总表最后一行,汇总数字支持按平台和月份查看明细。
2026-11-04 17:33:22 +08:00

849 lines
38 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonInterface;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
class NonJingxiRefundMirrorRepository
{
public const INFO_DATASET = 'operations_replica.rpt.jst_returnAndRefundInfo';
public const ITEM_DATASET = 'operations_replica.rpt.jst_returnAndRefundItem';
public const SUPPLEMENT_DATASET = 'operations_replica.rpt.jst_qmcust_refundlist';
public const SUPPLEMENT_ITEM_DATASET = 'operations_replica.rpt.jst_qmcust_refundlist_item';
public const SHOP_DATASET = 'operations_replica.rpt.jst_shopInfo';
private const PENDING_CONFIRMATION_STATUS = 'WaitConfirm';
private const PENDING_CONFIRMATION_ONLINE_STATUS = 'SUCCESS';
private const AUXILIARY_DATASETS = [
'operations_replica.rpt.jst_Orders',
'operations_replica.rpt.jst_OrderItem',
'operations_replica.rpt.jst_sku',
'operations_replica.rpt.fys_shopInfo',
];
private const REQUIRED_QUERY_INDEXES = [
self::INFO_DATASET => [['Id'], ['as_date', 'Id'], ['o_id', 'Id'], ['as_id', 'Id'], ['shop_id', 'outer_as_id'], ['so_id'], ['outer_as_id'], ['as_date', 'Id', 'as_id', 'shop_site'], ['status', 'shop_status', 'as_date', 'Id']],
self::ITEM_DATASET => [['as_id', 'Id']],
self::SUPPLEMENT_DATASET => [['as_id'], ['as_date', 'as_id'], ['so_id'], ['outer_as_id'], ['status', 'shop_status', 'as_date', 'as_id']],
self::SUPPLEMENT_ITEM_DATASET => [['as_id', 'asi_id']],
self::SHOP_DATASET => [['shop_id', 'Id']],
'operations_replica.rpt.jst_Orders' => [['o_id', 'Id']],
'operations_replica.rpt.jst_OrderItem' => [['o_id', 'Id']],
];
private readonly ConnectionInterface $connection;
private ?array $excludedShopIds = null;
/** @var array<string, list<string>>|null shop_id => historical shop_site values */
private ?array $supplementShopSites = null;
private ?string $supplementDateIndex = null;
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
$this->connection = $mirror->connection();
}
public function available(): bool
{
$schema = Schema::connection($this->connection->getName());
return $schema->hasTable('business_mirror_table_states')
&& collect([self::INFO_DATASET, self::ITEM_DATASET, self::SUPPLEMENT_DATASET, self::SUPPLEMENT_ITEM_DATASET, self::SHOP_DATASET])
->every(fn (string $key): bool => $schema->hasTable($this->dataset($key)->targetTable()));
}
public function ready(): bool
{
return collect([self::INFO_DATASET, self::ITEM_DATASET, self::SUPPLEMENT_DATASET, self::SUPPLEMENT_ITEM_DATASET, self::SHOP_DATASET])
->every(fn (string $key): bool => $this->mirror->ready($this->dataset($key)));
}
public function queryReady(): bool
{
$schema = Schema::connection($this->connection->getName());
try {
foreach (self::REQUIRED_QUERY_INDEXES as $datasetKey => $requiredIndexes) {
$table = $this->dataset($datasetKey)->targetTable();
if (! $schema->hasTable($table)) {
return false;
}
$tableIndexes = collect($schema->getIndexes($table));
if ($datasetKey === self::SUPPLEMENT_DATASET) {
$this->supplementDateIndex = $tableIndexes->first(
static fn (array $index): bool => array_map('strtolower', $index['columns']) === ['as_date', 'as_id']
)['name'] ?? null;
}
$indexes = $tableIndexes
->map(static fn (array $index): array => array_map(
'strtolower',
(array) ($index['columns'] ?? []),
));
foreach ($requiredIndexes as $required) {
if (! $indexes->contains(array_map('strtolower', $required))) {
return false;
}
}
}
} catch (\Throwable) {
return false;
}
return true;
}
/** @return array<string,array<string,mixed>|null> */
public function states(): array
{
return [
self::INFO_DATASET => $this->mirror->state($this->dataset(self::INFO_DATASET)),
self::ITEM_DATASET => $this->mirror->state($this->dataset(self::ITEM_DATASET)),
self::SUPPLEMENT_DATASET => $this->mirror->state($this->dataset(self::SUPPLEMENT_DATASET)),
self::SUPPLEMENT_ITEM_DATASET => $this->mirror->state($this->dataset(self::SUPPLEMENT_ITEM_DATASET)),
self::SHOP_DATASET => $this->mirror->state($this->dataset(self::SHOP_DATASET)),
];
}
/**
* @param array<string,array<string,mixed>|null>|null $states
* @return array{code:string,last_succeeded_at:?string}
*/
public function syncStatus(?array $states = null, ?CarbonInterface $at = null): array
{
$states ??= $this->states();
if (count(array_filter($states, 'is_array')) !== 5) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
$initialized = collect($states)->every(
static fn (?array $state): bool => ($state['initial_completed_at'] ?? null) !== null
);
$statuses = collect($states)->pluck('status')->map('strval');
$lastSucceededAt = collect($states)
->pluck('last_succeeded_at')
->filter()
->sort()
->first();
if (! $initialized) {
$code = $statuses->contains('failed') ? 'initial_failed' : 'initializing';
} elseif ($statuses->contains('failed')) {
$code = 'failed';
} elseif ($statuses->contains('running')) {
$code = 'syncing';
} else {
$lastSucceeded = $lastSucceededAt ? Carbon::parse($lastSucceededAt) : null;
$lagMinutes = $lastSucceeded === null
? null
: (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded->getTimestamp()) / 60);
$code = $lagMinutes === null || $lagMinutes > 30 ? 'stale' : 'healthy';
}
return ['code' => $code, 'last_succeeded_at' => $lastSucceededAt];
}
/**
* @param array{from:string,to:string,order_no:string,waybill_no:string,shop_name:string,sku_id:string,platform?:string} $filters
* @return array{data:list<object>,total:int}
*/
public function paginate(array $filters, int $page, int $perPage): array
{
if (! $this->ready() || ! $this->queryReady()) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$total = $this->sourceQuery($filters, false)->count()
+ $this->sourceQuery($filters, true)->count();
$keys = $this->orderedKeys($filters, null, $page * $perPage)->forPage($page, $perPage)->get();
$rows = $this->loadRows($keys);
return ['data' => $rows->all(), 'total' => $total];
}
/**
* 售后状态为待确认、且线上退款已成功的记录。
*
* @param array{from:string,to:string,order_no:string,waybill_no:string,shop_name:string,sku_id:string,platform?:string} $filters
* @return array{data:list<object>,total:int}
*/
public function paginatePendingConfirmation(array $filters, int $page, int $perPage): array
{
$result = $this->paginate($this->pendingConfirmationFilters($filters), $page, $perPage);
foreach ($result['data'] as $row) {
$this->normalizePendingConfirmationLabels($row);
}
return $result;
}
/**
* 按页读取待确认明细,不做全月 COUNT;has_more 由下一页键探测。
*
* @param array{from:string,to:string,order_no:string,waybill_no:string,shop_name:string,sku_id:string,platform?:string} $filters
* @return array{data:list<object>,has_more:bool}
*/
public function pagePendingConfirmation(array $filters, int $page, int $perPage): array
{
if (! $this->ready() || ! $this->queryReady()) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$pendingFilters = $this->pendingConfirmationFilters($filters);
$keys = $this->orderedKeys($pendingFilters, null, ($page * $perPage) + 1)->get();
$hasMore = $keys->count() > $page * $perPage;
$pageKeys = $keys->slice(($page - 1) * $perPage, $perPage)->values();
$rows = $this->loadRows($pageKeys)->all();
foreach ($rows as $row) {
$this->normalizePendingConfirmationLabels($row);
}
return ['data' => $rows, 'has_more' => $hasMore];
}
/**
* 按退款月份统计待确认售后记录数,primary 与 supplement 分开计数后合并。
*
* @return list<array{month:string,total:int,primary:int,supplement:int}>
*/
public function pendingConfirmationMonthlySummary(string $from, string $to): array
{
if (! $this->ready() || ! $this->queryReady()) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$start = Carbon::parse($from)->startOfMonth();
$end = Carbon::parse($to)->startOfMonth();
if ($start->greaterThan($end)) {
return [];
}
$months = [];
for ($month = $start->copy(); $month->lessThanOrEqualTo($end); $month->addMonth()) {
$months[$month->format('Y-m')] = [
'month' => $month->format('Y-m'),
'total' => 0,
'primary' => 0,
'supplement' => 0,
];
}
$baseFilters = $this->pendingConfirmationFilters([
'from' => $start->toDateString(),
'to' => Carbon::parse($to)->toDateString(),
'order_no' => '',
'waybill_no' => '',
'shop_name' => '',
'sku_id' => '',
]);
$monthExpression = $this->connection->getDriverName() === 'sqlite'
? "strftime('%Y-%m', r.as_date)"
: "DATE_FORMAT(r.as_date, '%Y-%m')";
foreach ([false, true] as $supplement) {
$key = $supplement ? 'supplement' : 'primary';
$counts = $this->sourceQuery($baseFilters, $supplement, false)
->selectRaw("{$monthExpression} AS refund_month, COUNT(*) AS total")
->groupByRaw($monthExpression)
->pluck('total', 'refund_month');
foreach ($counts as $monthKey => $count) {
if (! isset($months[$monthKey])) {
continue;
}
$count = (int) $count;
$months[$monthKey][$key] += $count;
$months[$monthKey]['total'] += $count;
}
}
return array_values($months);
}
/**
* 按平台和退款月份汇总待确认记录,并同时返回月份总计。
*
* @return array{
* months:list<array{month:string,total:int}>,
* platforms:list<array{platform:string,total:int,months:array<string,int>}>
* }
*/
public function pendingConfirmationPlatformMonthlySummary(string $from, string $to): array
{
if (! $this->ready() || ! $this->queryReady()) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$start = Carbon::parse($from)->startOfMonth();
$end = Carbon::parse($to)->startOfMonth();
if ($start->greaterThan($end)) {
return ['months' => [], 'platforms' => []];
}
$monthKeys = [];
for ($month = $start->copy(); $month->lessThanOrEqualTo($end); $month->addMonth()) {
$monthKeys[] = $month->format('Y-m');
}
$baseFilters = $this->pendingConfirmationFilters([
'from' => $start->toDateString(),
'to' => Carbon::parse($to)->toDateString(),
'order_no' => '',
'waybill_no' => '',
'shop_name' => '',
'sku_id' => '',
]);
$monthExpression = $this->connection->getDriverName() === 'sqlite'
? "strftime('%Y-%m', r.as_date)"
: "DATE_FORMAT(r.as_date, '%Y-%m')";
$platformCounts = [];
$addCount = static function (string $platform, string $month, int $count) use (&$platformCounts): void {
$platform = trim($platform) !== '' ? trim($platform) : '待确认';
$platformCounts[$platform][$month] = ($platformCounts[$platform][$month] ?? 0) + $count;
};
$primaryPlatform = "COALESCE(NULLIF(TRIM(r.shop_site), ''), '待确认')";
$primaryRows = $this->sourceQuery($baseFilters, false, false)
->selectRaw("{$monthExpression} AS refund_month, {$primaryPlatform} AS platform, COUNT(*) AS total")
->groupByRaw("{$monthExpression}, {$primaryPlatform}")
->get();
foreach ($primaryRows as $row) {
$addCount((string) $row->platform, (string) $row->refund_month, (int) $row->total);
}
$supplementRows = $this->sourceQuery($baseFilters, true, false)
->selectRaw("{$monthExpression} AS refund_month, r.shop_id, COUNT(*) AS total")
->groupByRaw("{$monthExpression}, r.shop_id")
->get();
$shopIds = $supplementRows->pluck('shop_id')->filter()->unique()->values();
$shopPlatforms = $shopIds->isEmpty() ? collect() : $this->connection->table('ops_jst_shop_info')
->whereIn('shop_id', $shopIds)
->get(['shop_id', 'shop_site'])
->groupBy('shop_id');
foreach ($supplementRows as $row) {
$sites = ($shopPlatforms->get($row->shop_id) ?? collect())
->pluck('shop_site')->filter()->map(static fn (mixed $site): string => trim((string) $site))
->filter()->unique()->values();
$addCount($sites->count() === 1 ? (string) $sites->first() : '待确认', (string) $row->refund_month, (int) $row->total);
}
$monthTotals = array_fill_keys($monthKeys, 0);
$platforms = [];
foreach ($platformCounts as $platform => $counts) {
$monthValues = [];
$total = 0;
foreach ($monthKeys as $month) {
$count = (int) ($counts[$month] ?? 0);
$monthValues[$month] = $count;
$monthTotals[$month] += $count;
$total += $count;
}
$platforms[] = ['platform' => (string) $platform, 'total' => $total, 'months' => $monthValues];
}
usort($platforms, static fn (array $left, array $right): int => [$right['total'], $left['platform']] <=> [$left['total'], $right['platform']]);
return [
'months' => array_map(
static fn (string $month): array => ['month' => $month, 'total' => $monthTotals[$month]],
$monthKeys,
),
'platforms' => $platforms,
];
}
/**
* Export all rows matching the supplied filters in the same order and
* shape as the paginated page data, without loading the full result set.
*
* @param array{from:string,to:string,order_no:string,waybill_no:string,shop_name:string,sku_id:string} $filters
* @return iterable<int,object>
*/
public function exportRows(array $filters, int $chunkSize = 1000): iterable
{
if (! $this->ready() || ! $this->queryReady()) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$chunkSize = min(max($chunkSize, 1), 1000);
$cursor = null;
do {
$keys = $this->orderedKeys($filters, $cursor, $chunkSize)->limit($chunkSize)->get();
if ($keys->isEmpty()) {
break;
}
foreach ($this->loadRows($keys) as $row) {
yield $row;
}
$cursor = $keys->last();
} while ($keys->count() === $chunkSize);
}
/**
* @param array{from:string,to:string,order_no:string,waybill_no:string,shop_name:string,sku_id:string} $filters
* @return iterable<int,object>
*/
public function exportPendingConfirmationRows(array $filters, int $chunkSize = 1000): iterable
{
foreach ($this->exportRows($this->pendingConfirmationFilters($filters), $chunkSize) as $row) {
$this->normalizePendingConfirmationLabels($row);
yield $row;
}
}
/** @return list<string> */
private function selectColumns(): array
{
return [
'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id', 'r.l_id as waybillNo',
'r.o_id', 'r.so_id', 'r.type', 'r.node', 'r.shop_type', 'r.shop_name',
'r.shop_site as platform', 'r.refund as totalRefundMoney',
'i.outer_oi_id', 'i.shop_sku_id', 'i.i_id', 'i.sku_id', 'i.qty',
'i.shop_amount as detailRefundMoney', 'i.type as refundType',
'i.r_qty as refundQty', 'r.receive_refund as RefundMoneyIncome',
'r.buyer_apply_refund as AfSalesMoney', 'r.createTime', 'r.status',
'r.shop_status', 'r.created', 'r.confirm_date',
];
}
private function sourceQuery(array $filters, bool $supplement, bool $withItems = true): Builder
{
$table = $supplement ? 'ops_jst_qmcust_refunds' : 'ops_jst_return_refund_info';
$query = $this->connection->table($table . ' as r');
if ($supplement && $filters['order_no'] === '' && $this->supplementDateIndex !== null
&& $this->connection->getDriverName() === 'mysql') {
// The legacy text payload makes a full table scan very expensive;
// keep date windows on the verified shared-mirror lookup index.
$query->forceIndex($this->supplementDateIndex);
}
if ($filters['order_no'] !== '') {
$needle = trim($filters['order_no']);
$key = $supplement ? 'as_id' : 'Id';
$matches = $this->connection->table($table)->select($key . ' as matched_id')->where('so_id', $needle)
->union($this->connection->table($table)->select($key . ' as matched_id')->where('outer_as_id', $needle));
if (ctype_digit($needle)) {
$matches->union($this->connection->table($table)->select($key . ' as matched_id')->where('o_id', $needle));
}
// Separate indexed lookups: MariaDB cannot reliably index-merge
// OR predicates across the replica's long text identifier fields.
$query->joinSub($matches, 'matching_refund', 'matching_refund.matched_id', '=', 'r.' . $key);
}
$itemTable = $supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item';
if ($withItems) {
$query->leftJoin($itemTable . ' as i', 'i.as_id', '=', 'r.as_id');
}
$platform = 'r.shop_site';
if ($supplement) {
// A shop may have several historical rows. An ambiguous platform is
// left unknown, and must never multiply the refund rows.
$this->excludedShopIds ??= collect($this->supplementShopSites())
->filter(function (array $sites): bool {
$sites = collect($sites)->filter()->unique();
$site = (string) $sites->first();
return $sites->count() === 1 && (str_contains($site, '京喜') || str_contains(strtolower($site), 'jingxi'));
})->keys()->map('strval')->all();
if ($this->excludedShopIds !== []) {
$query->where(fn (Builder $q) => $q->whereNull('r.shop_id')->orWhereNotIn('r.shop_id', $this->excludedShopIds));
}
$query->whereNotExists(function (Builder $existing): void {
$existing->selectRaw('1')->from('ops_jst_return_refund_info as existing')
->whereColumn('existing.as_id', 'r.as_id');
})
->whereNotExists(function (Builder $existing): void {
$existing->selectRaw('1')->from('ops_jst_return_refund_info as existing')
->whereNotNull('r.outer_as_id')->where('r.outer_as_id', '!=', '')
->whereNotNull('r.shop_id')->where('r.shop_id', '!=', '')
->whereColumn('existing.shop_id', 'r.shop_id')
->whereColumn('existing.outer_as_id', 'r.outer_as_id');
});
$platform = null;
}
if ($platform !== null) {
$query->where(function (Builder $query) use ($platform): void {
$query->whereNull($platform)->orWhere(function (Builder $query) use ($platform): void {
$query->where($platform, 'not like', '%京喜%')
->whereRaw('LOWER(' . $platform . ') NOT LIKE ?', ['%jingxi%']);
});
});
}
$this->applyPlatformFilter($query, $filters, $supplement);
$this->applyFilters($query, $filters);
if ($filters['sku_id'] !== '') {
if ($withItems) {
$this->applySkuFilter($query, $filters['sku_id']);
} else {
$query->whereExists(function (Builder $items) use ($itemTable, $filters): void {
$items->selectRaw('1')->from($itemTable . ' as i')->whereColumn('i.as_id', 'r.as_id');
$this->applySkuFilter($items, $filters['sku_id']);
});
}
}
return $query;
}
private const SORT_COLUMNS = ['refundDate', '_source_rank', '_head_id', '_source_id', '_item_id'];
private function orderedKeys(array $filters, ?object $cursor, int $limit): Builder
{
$branches = [];
foreach ([false, true] as $supplement) {
$rank = $supplement ? 1 : 0;
$headColumn = $supplement ? 'r.as_id' : 'r.Id';
$heads = $this->sourceQuery($filters, $supplement, false)
->select(['r.as_date', 'r.as_id'])->when(! $supplement, fn (Builder $q) => $q->addSelect('r.Id'));
if ($cursor !== null) {
$heads->where(function (Builder $after) use ($cursor, $rank, $headColumn, $supplement, $filters): void {
$after->where('r.as_date', '<', $cursor->refundDate);
if ($rank <= (int) $cursor->_source_rank) {
$after->orWhere(function (Builder $sameDate) use ($cursor, $rank, $headColumn, $supplement, $filters): void {
$sameDate->where('r.as_date', $cursor->refundDate);
if ($rank === (int) $cursor->_source_rank) {
$headValue = $supplement ? $cursor->_source_id : $cursor->_head_id;
$sameDate->where(function (Builder $head) use ($headColumn, $headValue, $cursor, $supplement, $filters): void {
$head->where($headColumn, '<', $headValue)
->orWhere(function (Builder $remaining) use ($headColumn, $headValue, $cursor, $supplement, $filters): void {
$remaining->where($headColumn, $headValue)->whereExists(function (Builder $items) use ($cursor, $supplement, $filters): void {
$items->selectRaw('1')->from(($supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item') . ' as i')
->whereColumn('i.as_id', 'r.as_id')
->where($supplement ? 'i.asi_id' : 'i.Id', '<', $cursor->_item_id);
if ($filters['sku_id'] !== '') {
$this->applySkuFilter($items, $filters['sku_id']);
}
});
});
});
}
});
}
});
}
$heads->orderByDesc('r.as_date')->orderByDesc($headColumn)->limit($limit);
// Each eligible header contributes at least one row. The newest N
// headers therefore contain the newest N matching detail rows.
$source = $this->connection->query()->fromSub($heads, 'r')
->leftJoin(($supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item') . ' as i', 'i.as_id', '=', 'r.as_id');
if ($filters['sku_id'] !== '') {
$this->applySkuFilter($source, $filters['sku_id']);
}
$source->selectRaw($supplement
? 'r.as_date AS refundDate, 1 AS _source_rank, 0 AS _head_id, COALESCE(i.asi_id, 0) AS _item_id, r.as_id AS _source_id'
: "r.as_date AS refundDate, 0 AS _source_rank, r.Id AS _head_id, COALESCE(i.Id, 0) AS _item_id, '' AS _source_id");
$branch = $this->connection->query()->fromSub($source, 'source_keys');
if ($cursor !== null) {
$branch->where(function (Builder $after) use ($cursor): void {
foreach (self::SORT_COLUMNS as $position => $column) {
$after->orWhere(function (Builder $part) use ($cursor, $position, $column): void {
foreach (array_slice(self::SORT_COLUMNS, 0, $position) as $equal) {
$part->where($equal, '=', $cursor->$equal);
}
$part->where($column, '<', $cursor->$column);
});
}
});
}
foreach (self::SORT_COLUMNS as $column) {
$branch->orderByDesc($column);
}
$branches[] = $this->connection->query()->fromSub($branch->limit($limit), 'bounded_keys');
}
// Bound both branches before the final merge, including cursor exports.
$query = $this->connection->query()->fromSub($branches[0]->unionAll($branches[1]), 'refund_keys');
foreach (self::SORT_COLUMNS as $column) {
$query->orderByDesc($column);
}
return $query;
}
private function loadRows($keys)
{
$loaded = collect();
foreach ([false, true] as $supplement) {
$sourceKeys = $keys->filter(fn (object $key): bool => (bool) $key->_source_rank === $supplement);
if ($sourceKeys->isEmpty()) {
continue;
}
$table = $supplement ? 'ops_jst_qmcust_refunds' : 'ops_jst_return_refund_info';
$itemTable = $supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item';
$itemKey = $supplement ? 'i.asi_id' : 'i.Id';
$query = $this->connection->table($table . ' as r')
->leftJoin($itemTable . ' as i', 'i.as_id', '=', 'r.as_id')
->whereIn($supplement ? 'r.as_id' : 'r.Id', $sourceKeys->pluck($supplement ? '_source_id' : '_head_id'))
->where(function (Builder $items) use ($sourceKeys, $itemKey): void {
$items->whereIn($itemKey, $sourceKeys->pluck('_item_id'));
if ($sourceKeys->contains(fn (object $key): bool => (int) $key->_item_id === 0)) {
$items->orWhereNull($itemKey);
}
});
if ($supplement) {
$query->select([
'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id', 'r.l_id as waybillNo',
'r.o_id', 'r.so_id', 'r.type', 'r.node', 'r.shop_type', 'r.shop_name',
'r.refund as totalRefundMoney', 'i.outer_oi_id', 'i.shop_sku_id', 'i.i_id', 'i.sku_id',
'i.qty', 'i.shop_amount as detailRefundMoney', 'i.type as refundType',
'i.r_qty as refundQty', 'r.buyer_apply_refund as AfSalesMoney',
'r.local_created as createTime', 'r.status', 'r.shop_status', 'r.created', 'r.confirm_date',
'r.shop_id',
])->selectRaw("COALESCE(i.asi_id, r.as_id) AS Id, NULL AS RefundMoneyIncome, '退款清单' AS dataSource, 1 AS _source_rank, 0 AS _head_id, COALESCE(i.asi_id, 0) AS _item_id, r.as_id AS _source_id");
} else {
$query->select($this->selectColumns())
->selectRaw("COALESCE(i.Id, r.Id) AS Id, '售后资料' AS dataSource, 0 AS _source_rank, r.Id AS _head_id, COALESCE(i.Id, 0) AS _item_id, '' AS _source_id");
}
$loaded = $loaded->concat($query->get());
}
$byIdentity = $loaded->keyBy(fn (object $row): string => $this->identity($row));
$rows = $keys->map(fn (object $key) => $byIdentity->get($this->identity($key)))->filter()->values();
$shopIds = $rows->where('_source_rank', 1)->pluck('shop_id')->unique();
$platforms = $shopIds->isEmpty() ? collect() : $this->connection->table('ops_jst_shop_info')
->whereIn('shop_id', $shopIds)->get(['shop_id', 'shop_site'])->groupBy('shop_id');
foreach ($rows as $row) {
if ((int) $row->_source_rank === 1) {
$sites = ($platforms->get($row->shop_id) ?? collect())->pluck('shop_site')->filter()->unique()->values();
$row->platform = $sites->count() === 1 ? $sites->first() : '待确认';
unset($row->shop_id);
}
$row->detailStatus = (int) $row->_item_id === 0 ? '暂无商品明细' : '已关联';
foreach (['totalRefundMoney', 'detailRefundMoney', 'RefundMoneyIncome', 'AfSalesMoney'] as $money) {
if (! is_numeric($row->$money)) {
$row->$money = null;
}
}
foreach (array_slice(self::SORT_COLUMNS, 1) as $column) {
unset($row->$column);
}
}
$this->hydratePage($rows);
return $rows;
}
private function identity(object $row): string
{
return implode('|', [$row->_source_rank, $row->_head_id, $row->_item_id, $row->_source_id]);
}
/** @param array{from:string,to:string,order_no:string,waybill_no:string,shop_name:string,sku_id:string,platform?:string} $filters */
private function applyFilters(Builder $query, array $filters): void
{
$query->where('r.as_date', '>=', $filters['from'] . ' 00:00:00')
->where('r.as_date', '<', Carbon::parse($filters['to'])->addDay()->format('Y-m-d 00:00:00'));
$status = trim((string) ($filters['status'] ?? ''));
if ($status !== '') {
$query->where('r.status', $status);
}
$shopStatus = trim((string) ($filters['shop_status'] ?? ''));
if ($shopStatus !== '') {
$query->where('r.shop_status', $shopStatus);
}
if ($filters['shop_name'] !== '') {
$query->whereRaw("r.shop_name LIKE ? ESCAPE '!'", [$this->contains($filters['shop_name'])]);
}
if ($filters['waybill_no'] !== '') {
$query->where('r.l_id', trim($filters['waybill_no']));
}
}
private function applyPlatformFilter(Builder $query, array $filters, bool $supplement): void
{
$target = trim((string) ($filters['platform'] ?? ''));
if ($target === '') {
return;
}
if (! $supplement) {
if ($target === '待确认') {
$query->where(function (Builder $platform): void {
$platform->whereNull('r.shop_site')
->orWhere('r.shop_site', '')
->orWhere('r.shop_site', '待确认');
});
} else {
$query->where('r.shop_site', $target);
}
return;
}
$matchedShopIds = [];
$knownShopIds = [];
foreach ($this->supplementShopSites() as $shopId => $sites) {
$sites = array_values(array_unique(array_filter(array_map(
static fn (mixed $site): string => trim((string) $site),
$sites,
))));
$site = count($sites) === 1 ? $sites[0] : '待确认';
if ($site === $target) {
$matchedShopIds[] = (string) $shopId;
}
if ($site !== '待确认') {
$knownShopIds[] = (string) $shopId;
}
}
if ($target === '待确认') {
if ($knownShopIds !== []) {
$query->where(function (Builder $platform) use ($knownShopIds): void {
$platform->whereNull('r.shop_id')
->orWhereNotIn('r.shop_id', $knownShopIds);
});
}
return;
}
if ($matchedShopIds === []) {
$query->whereRaw('1 = 0');
return;
}
$query->whereIn('r.shop_id', $matchedShopIds);
}
/** @return array<string, list<string>> */
private function supplementShopSites(): array
{
if ($this->supplementShopSites !== null) {
return $this->supplementShopSites;
}
$this->supplementShopSites = [];
foreach ($this->connection->table('ops_jst_shop_info')->get(['shop_id', 'shop_site']) as $row) {
$shopId = trim((string) ($row->shop_id ?? ''));
if ($shopId === '') {
continue;
}
$this->supplementShopSites[$shopId][] = (string) ($row->shop_site ?? '');
}
return $this->supplementShopSites;
}
/**
* @param array{from:string,to:string,order_no:string,waybill_no:string,shop_name:string,sku_id:string,platform?:string} $filters
* @return array{from:string,to:string,order_no:string,waybill_no:string,shop_name:string,sku_id:string,status:string,shop_status:string}
*/
private function pendingConfirmationFilters(array $filters): array
{
return array_replace($filters, [
'status' => self::PENDING_CONFIRMATION_STATUS,
'shop_status' => self::PENDING_CONFIRMATION_ONLINE_STATUS,
]);
}
private function normalizePendingConfirmationLabels(object $row): void
{
$row->status = '待确认';
$row->shop_status = '退款成功';
}
private function applySkuFilter(Builder $query, string $value): void
{
$needle = $this->contains($value);
$query->where(function (Builder $sku) use ($needle): void {
$sku->whereRaw("i.sku_id LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("i.shop_sku_id LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("i.i_id LIKE ? ESCAPE '!'", [$needle]);
});
}
/** @param \Illuminate\Support\Collection<int,object> $rows */
private function hydratePage($rows): void
{
if ($rows->isEmpty()) {
return;
}
$primaryRows = $rows->where('dataSource', '售后资料');
$orderIds = $primaryRows->pluck('o_id')->filter()->unique()->values()->all();
$skuIds = $rows->pluck('sku_id')->filter()->unique()->values()->all();
$shopNames = $rows->pluck('shop_name')->filter()->unique()->values()->all();
$orderItems = $this->auxiliaryReady(self::AUXILIARY_DATASETS[1])
? $this->connection->table('ops_jst_order_item')
->whereIn('o_id', $orderIds)
->whereIn('outer_oi_id', $primaryRows->pluck('outer_oi_id')->filter()->unique()->all())
->get(['o_id', 'outer_oi_id', 'shop_i_id', 'shop_sku_id', 'i_id', 'sku_id', 'buyer_paid_amount'])
->keyBy(static fn (object $row): string => $row->o_id . '|' . $row->outer_oi_id)
: collect();
$orders = $this->auxiliaryReady(self::AUXILIARY_DATASETS[0])
? $this->connection->table('ops_jst_orders')->whereIn('o_id', $orderIds)
->orderByDesc('Id')->get(['o_id', 'receiver_zip'])->unique('o_id')->keyBy('o_id')
: collect();
$skus = $this->auxiliaryReady(self::AUXILIARY_DATASETS[2])
? $this->connection->table('ops_jst_sku')->whereIn('sku_id', $skuIds)
->where('sku_type', 'normal')->orderByDesc('autoid')->get(['sku_id', 'cost_price'])
->unique('sku_id')->keyBy('sku_id')
: collect();
$shops = $this->auxiliaryReady(self::AUXILIARY_DATASETS[3])
? $this->connection->table('ops_fys_shop_info')->whereIn('erpShopName', $shopNames)
->orderByDesc('Id')->get(['erpShopName', 'responsibleDepartment'])
->unique('erpShopName')->keyBy('erpShopName')
: collect();
foreach ($rows as $row) {
$orderItem = $row->dataSource === '售后资料' ? $orderItems->get($row->o_id . '|' . $row->outer_oi_id) : null;
$skuId = $row->sku_id ?: ($orderItem->sku_id ?? null);
$unitCost = isset($skus[$skuId]) ? (float) $skus[$skuId]->cost_price : null;
$refundQuantity = $row->refundQty === null ? null : (float) $row->refundQty;
$row->isZC = str_contains((string) $row->shop_type, '自营') ? '是' : '否';
$row->responsibleDepartment = $shops[$row->shop_name]->responsibleDepartment ?? null;
$row->originPostcode = $row->dataSource === '售后资料' ? ($orders[$row->o_id]->receiver_zip ?? null) : null;
$row->shop_i_id = $orderItem->shop_i_id ?? null;
$row->shop_sku_id = $row->shop_sku_id ?: ($orderItem->shop_sku_id ?? null);
$row->i_id = $row->i_id ?: ($orderItem->i_id ?? null);
$row->sku_id = $skuId;
$row->buyer_paid_amount = $orderItem->buyer_paid_amount ?? null;
$row->unitCostGoods = $unitCost;
$row->refundCostGoods = $unitCost === null || $refundQuantity === null
? null
: $unitCost * $refundQuantity;
unset($row->shop_type, $row->outer_oi_id);
}
}
private function auxiliaryReady(string $datasetKey): bool
{
try {
return $this->mirror->ready($this->dataset($datasetKey));
} catch (\Throwable) {
return false;
}
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
}
private function dataset(string $key): BusinessMirrorDataset
{
return $this->datasets->get($key);
}
}