CaiWuXiTong/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php

532 lines
26 KiB
PHP

<?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 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']],
self::ITEM_DATASET => [['as_id', 'Id']],
self::SUPPLEMENT_DATASET => [['as_id'], ['as_date', 'as_id'], ['so_id'], ['outer_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;
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,shop_name:string,sku_id: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];
}
/**
* 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,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);
}
/** @return list<string> */
private function selectColumns(): array
{
return [
'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id',
'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 ??= $this->connection->table('ops_jst_shop_info')
->get(['shop_id', 'shop_site'])->groupBy('shop_id')
->filter(function ($rows): bool {
$sites = $rows->pluck('shop_site')->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->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.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,shop_name:string,sku_id: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'));
if ($filters['shop_name'] !== '') {
$query->whereRaw("r.shop_name LIKE ? ESCAPE '!'", [$this->contains($filters['shop_name'])]);
}
}
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);
}
}