From 6f751408e6e10efaa2c5b44a7b74a9670d16550e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E6=9C=A8?= <594326199@qq.com> Date: Fri, 21 Aug 2026 15:36:48 +0800 Subject: [PATCH 1/3] =?UTF-8?q?WIP:=20=E6=8E=A5=E5=85=A5=E9=9D=9E=E4=BA=AC?= =?UTF-8?q?=E5=96=9C=E9=80=80=E6=AC=BE=E4=B8=9A=E5=8A=A1=E9=95=9C=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 67 +++++ config/upstream_mirrors.php | 38 ++- .../NonJingxiRefundMirrorRepository.php | 243 ++++++++++++++++++ .../refunds/non-jingxi-refund-data.blade.php | 147 +++++++++-- 4 files changed, 477 insertions(+), 18 deletions(-) create mode 100644 modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 0fdb8f45..ddb65f21 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -18,6 +18,7 @@ use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\InventoryRepository; use Modules\EcommerceFinance\Repositories\JdWalletBillRepository; +use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository; use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository; use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository; use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository; @@ -39,6 +40,7 @@ class Pages extends Controller public function __construct( private readonly EdgeBandingRepository $edgeBanding, private readonly InventoryRepository $inventory, + private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds, private readonly ProductCatalogMirrorRepository $productCatalog, private readonly ProductPriceMirrorRepository $productPrices, private readonly RawBoardPayablesRepository $rawBoardPayables, @@ -236,6 +238,7 @@ class Pages extends Controller return match ($page) { 'marketing-account-bills' => $this->contentForMarketingAccountBills($tab), 'marketing-account-balance' => $this->contentForMarketingAccountBalance($tab), + 'non-jingxi-refund-data' => $this->contentForNonJingxiRefundData(), 'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(), 'normal-products' => $this->contentForNormalProducts(), 'product-prices' => $this->contentForProductPrices(), @@ -245,6 +248,70 @@ class Pages extends Controller }; } + protected function contentForNonJingxiRefundData(): array + { + $request = request(); + $defaultTo = now()->toDateString(); + $defaultFrom = now()->subDays(6)->toDateString(); + $from = trim((string) $request->query('from', $defaultFrom)); + $to = trim((string) $request->query('to', $defaultTo)); + $from = preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) === 1 ? $from : $defaultFrom; + $to = preg_match('/^\d{4}-\d{2}-\d{2}$/', $to) === 1 ? $to : $defaultTo; + if ($from > $to) { + [$from, $to] = [$to, $from]; + } + + $filters = [ + 'from' => $from, + 'to' => $to, + 'order_no' => mb_substr(trim((string) $request->query('order_no', '')), 0, 100), + 'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 191), + 'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 100), + ]; + $page = max((int) $request->query('page', 1), 1); + $limit = min(max((int) setting('default.list_limit', 25), 1), 100); + $rows = new LengthAwarePaginator([], 0, $limit, $page, [ + 'path' => $request->url(), + 'query' => $request->query(), + ]); + $syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null]; + $error = null; + $pending = false; + + try { + if (! $this->nonJingxiRefunds->available()) { + $error = '共享业务镜像尚未建立,非京喜退款数据暂时无法读取。'; + } else { + $states = $this->nonJingxiRefunds->states(); + $syncStatus = $this->nonJingxiRefunds->syncStatus($states); + if (! $this->nonJingxiRefunds->ready()) { + $pending = true; + } else { + $result = $this->nonJingxiRefunds->paginate($filters, $page, $limit); + $rows = new LengthAwarePaginator( + Collection::make($result['data'] ?? []), + (int) ($result['total'] ?? 0), + $limit, + $page, + ['path' => $request->url(), 'query' => $request->query()], + ); + } + } + } catch (Throwable $exception) { + report($exception); + $error = '非京喜退款数据暂时无法读取,请稍后重试。'; + } + + return [ + 'view' => 'business.pages.refunds.non-jingxi-refund-data', + 'filters' => $filters, + 'rows' => $rows, + 'refundSyncStatus' => $syncStatus, + 'refundError' => $error, + 'refundPending' => $pending, + ]; + } + protected function contentForMarketingAccountBalance(?string $tab): array { $content = [ diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index d455e74f..09633101 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -73,7 +73,16 @@ return [ 'operations_replica.rpt.fys_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_shopInfo', 'target' => 'ops_fys_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], 'operations_replica.rpt.fys_TrademarkInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_TrademarkInfo', 'target' => 'ops_fys_trademark_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], 'operations_replica.rpt.jst_Orders' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_Orders', 'target' => 'ops_jst_orders', 'keys' => ['Id'], 'watermark' => 'updateTime'], - 'operations_replica.rpt.jst_OrderItem' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_OrderItem', 'target' => 'ops_jst_order_item', 'keys' => ['Id']], + 'operations_replica.rpt.jst_OrderItem' => [ + 'source_key' => 'operations_replica', + 'schema' => 'rpt', + 'object' => 'jst_OrderItem', + 'target' => 'ops_jst_order_item', + 'keys' => ['Id'], + 'indexes' => [ + ['o_id', 'outer_oi_id', 'Id'], + ], + ], 'operations_replica.rpt.jst_sku' => [ 'source_key' => 'operations_replica', 'schema' => 'rpt', @@ -92,8 +101,31 @@ return [ 'operations_replica.rpt.jst_warehouseInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_warehouseInfo', 'target' => 'ops_jst_warehouse_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], 'operations_replica.rpt.jst_skumap' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_skumap', 'target' => 'ops_jst_sku_map', 'keys' => ['shop_id', 'shop_i_id', 'shop_sku_id'], 'watermark' => 'modifiedTime'], 'operations_replica.rpt.ads_itemid_sales_cost_ratio_v2_di' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'ads_itemid_sales_cost_ratio_v2_di', 'target' => 'ops_item_sales_cost_ratio', 'keys' => ['stat_date', 'item_id'], 'watermark' => 'item_id_update'], - 'operations_replica.rpt.jst_returnAndRefundInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_returnAndRefundInfo', 'target' => 'ops_jst_return_refund_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], - 'operations_replica.rpt.jst_returnAndRefundItem' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_returnAndRefundItem', 'target' => 'ops_jst_return_refund_item', 'keys' => ['Id']], + 'operations_replica.rpt.jst_returnAndRefundInfo' => [ + 'source_key' => 'operations_replica', + 'schema' => 'rpt', + 'object' => 'jst_returnAndRefundInfo', + 'target' => 'ops_jst_return_refund_info', + 'keys' => ['Id'], + 'watermark' => 'updateTime', + 'indexes' => [ + ['as_date', 'Id'], + ['o_id', 'Id'], + ['as_id', 'Id'], + ], + ], + 'operations_replica.rpt.jst_returnAndRefundItem' => [ + 'source_key' => 'operations_replica', + 'schema' => 'rpt', + 'object' => 'jst_returnAndRefundItem', + 'target' => 'ops_jst_return_refund_item', + 'keys' => ['Id'], + 'indexes' => [ + ['as_id', 'Id'], + ['sku_id', 'Id'], + ['shop_sku_id', 'Id'], + ], + ], 'operations_replica.rpt.jst_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], // JianSun 旧业务库:营销账单、供应商应付、板材、封边条和库存。 diff --git a/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php b/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php new file mode 100644 index 00000000..294d5d9c --- /dev/null +++ b/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php @@ -0,0 +1,243 @@ +connection = $mirror->connection(); + } + + public function available(): bool + { + $schema = Schema::connection($this->connection->getName()); + + return $schema->hasTable('business_mirror_table_states') + && $schema->hasTable($this->dataset(self::INFO_DATASET)->targetTable()) + && $schema->hasTable($this->dataset(self::ITEM_DATASET)->targetTable()); + } + + public function ready(): bool + { + return $this->mirror->ready($this->dataset(self::INFO_DATASET)) + && $this->mirror->ready($this->dataset(self::ITEM_DATASET)); + } + + /** @return array|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)), + ]; + } + + /** + * @param array|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')) !== 2) { + 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 ? now()->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,total:int} + */ + public function paginate(array $filters, int $page, int $perPage): array + { + if (! $this->ready()) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + + $page = max($page, 1); + $perPage = min(max($perPage, 1), 100); + $query = $this->baseQuery(); + $this->applyFilters($query, $filters); + + $total = (clone $query)->count(); + $rows = $query + ->select([ + 'i.Id as Id', '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', + ]) + ->orderByDesc('r.as_date') + ->orderByDesc('r.Id') + ->orderByDesc('i.Id') + ->forPage($page, $perPage) + ->get(); + + $this->hydratePage($rows); + + return ['data' => $rows->all(), 'total' => $total]; + } + + private function baseQuery(): Builder + { + return $this->connection->table('ops_jst_return_refund_info as r') + ->join('ops_jst_return_refund_item as i', 'i.as_id', '=', 'r.as_id') + ->where(function (Builder $query): void { + $query->whereNull('r.shop_site') + ->orWhere(function (Builder $platform): void { + $platform->where('r.shop_site', 'not like', '%京喜%') + ->whereRaw('LOWER(r.shop_site) NOT LIKE ?', ['%jingxi%']); + }); + }); + } + + /** @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', '<', now()->parse($filters['to'])->addDay()->format('Y-m-d 00:00:00')); + + if ($filters['order_no'] !== '') { + $needle = '%' . $filters['order_no'] . '%'; + $query->where(function (Builder $order) use ($needle): void { + $order->where('r.o_id', 'like', $needle) + ->orWhere('r.so_id', 'like', $needle) + ->orWhere('r.outer_as_id', 'like', $needle); + }); + } + + if ($filters['shop_name'] !== '') { + $query->where('r.shop_name', 'like', '%' . $filters['shop_name'] . '%'); + } + + if ($filters['sku_id'] !== '') { + $needle = '%' . $filters['sku_id'] . '%'; + $query->where(function (Builder $sku) use ($needle): void { + $sku->where('i.sku_id', 'like', $needle) + ->orWhere('i.shop_sku_id', 'like', $needle) + ->orWhere('i.i_id', 'like', $needle); + }); + } + } + + /** @param \Illuminate\Support\Collection $rows */ + private function hydratePage($rows): void + { + if ($rows->isEmpty()) { + return; + } + + $orderIds = $rows->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', $rows->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 = $orderItems->get($row->o_id . '|' . $row->outer_oi_id); + $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 = $orders[$row->o_id]->receiver_zip ?? 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 dataset(string $key) + { + return $this->datasets->get($key); + } +} diff --git a/resources/views/business/pages/refunds/non-jingxi-refund-data.blade.php b/resources/views/business/pages/refunds/non-jingxi-refund-data.blade.php index 342f64e3..31402cc3 100644 --- a/resources/views/business/pages/refunds/non-jingxi-refund-data.blade.php +++ b/resources/views/business/pages/refunds/non-jingxi-refund-data.blade.php @@ -13,30 +13,41 @@ ['label' => '店铺名称', 'field' => 'shop_name'], ['label' => '平台', 'field' => 'platform'], ['label' => '责任部门', 'field' => 'responsibleDepartment'], - ['label' => '退款总金额', 'field' => 'totalRefundMoney'], + ['label' => '退款总金额', 'field' => 'totalRefundMoney', 'number' => true], ['label' => '原始邮编', 'field' => 'originPostcode'], ['label' => '店铺商品ID', 'field' => 'shop_i_id'], ['label' => '店铺SKU编码', 'field' => 'shop_sku_id'], ['label' => '商品ID', 'field' => 'i_id'], ['label' => 'SKU编码', 'field' => 'sku_id'], - ['label' => '买家实付金额', 'field' => 'buyer_paid_amount'], - ['label' => '数量', 'field' => 'qty'], - ['label' => '商品单位成本', 'field' => 'unitCostGoods'], - ['label' => '明细退款金额', 'field' => 'detailRefundMoney'], + ['label' => '买家实付金额', 'field' => 'buyer_paid_amount', 'number' => true], + ['label' => '数量', 'field' => 'qty', 'number' => true], + ['label' => '商品单位成本', 'field' => 'unitCostGoods', 'number' => true], + ['label' => '明细退款金额', 'field' => 'detailRefundMoney', 'number' => true], ['label' => '退款类型', 'field' => 'refundType'], - ['label' => '退款数量', 'field' => 'refundQty'], - ['label' => '退款商品成本', 'field' => 'refundCostGoods'], - ['label' => '退款回款金额', 'field' => 'RefundMoneyIncome'], - ['label' => '售后金额', 'field' => 'AfSalesMoney'], + ['label' => '退款数量', 'field' => 'refundQty', 'number' => true], + ['label' => '退款商品成本', 'field' => 'refundCostGoods', 'number' => true], + ['label' => '退款回款金额', 'field' => 'RefundMoneyIncome', 'number' => true], + ['label' => '售后金额', 'field' => 'AfSalesMoney', 'number' => true], ['label' => '创建时间', 'field' => 'createTime'], ['label' => '状态', 'field' => 'status'], ['label' => '店铺状态', 'field' => 'shop_status'], ['label' => '创建记录/创建时间', 'field' => 'created'], ['label' => '确认日期', 'field' => 'confirm_date'], ]; + $syncCode = $refundSyncStatus['code'] ?? 'unavailable'; + $syncLabel = match ($syncCode) { + 'healthy' => '业务镜像已同步', + 'syncing' => '业务镜像同步中,当前显示最近成功数据', + 'failed' => '本轮同步失败,当前显示最近成功数据', + 'stale' => '业务镜像同步时间较早,当前显示最近成功数据', + 'initializing' => '业务镜像正在执行首次同步', + 'initial_failed' => '业务镜像首次同步失败', + default => '业务镜像尚未建立', + }; + $lastSucceededAt = $refundSyncStatus['last_succeeded_at'] ?? null; @endphp -
+
@include('business.pages.partials.upstream-data-styles') +
+ + {{ $syncLabel }} + @if ($lastSucceededAt) + · 最近成功 {{ $lastSucceededAt }} + @endif +
+ + @if ($refundError) + + @endif + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + 重置 + +
+
+
@@ -64,15 +140,56 @@ - - - + @forelse ($rows as $row) + + @foreach ($columns as $column) + @php + $field = $column['field']; + $value = $field === '' ? null : data_get($row, $field); + $isNumber = (bool) ($column['number'] ?? false); + @endphp + + @endforeach + + @empty + + + + @endforelse
暂无数据
$isNumber])> + @if ($isNumber && $value !== null && $value !== '') + {{ number_format((float) $value, 2) }} + @else + {{ $value === null || $value === '' ? '-' : $value }} + @endif +
+ @if ($refundPending) + 首次同步尚未完成,完成后将自动显示数据。 + @elseif ($refundError) + 数据暂时无法读取。 + @else + 当前筛选范围暂无数据。 + @endif +
-- 2.47.2 From eebf8b4c2edab20c4f154b297520b33b9fc5c42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E6=9C=A8?= <594326199@qq.com> Date: Fri, 21 Aug 2026 16:11:48 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=9D=9E=E4=BA=AC?= =?UTF-8?q?=E5=96=9C=E9=80=80=E6=AC=BE=E9=95=9C=E5=83=8F=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E4=B8=8E=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 6 +- config/upstream_mirrors.php | 16 +- .../NonJingxiRefundMirrorRepository.php | 31 ++- .../Business/NonJingxiRefundPageTest.php | 214 ++++++++++++++++++ tests/Feature/Business/PagesTest.php | 21 +- .../NonJingxiRefundMirrorRepositoryTest.php | 208 +++++++++++++++++ 6 files changed, 473 insertions(+), 23 deletions(-) create mode 100644 tests/Feature/Business/NonJingxiRefundPageTest.php create mode 100644 tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index ddb65f21..5f15a937 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -253,10 +253,8 @@ class Pages extends Controller $request = request(); $defaultTo = now()->toDateString(); $defaultFrom = now()->subDays(6)->toDateString(); - $from = trim((string) $request->query('from', $defaultFrom)); - $to = trim((string) $request->query('to', $defaultTo)); - $from = preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) === 1 ? $from : $defaultFrom; - $to = preg_match('/^\d{4}-\d{2}-\d{2}$/', $to) === 1 ? $to : $defaultTo; + $from = $this->dateFilter($request->query('from', $defaultFrom)) ?: $defaultFrom; + $to = $this->dateFilter($request->query('to', $defaultTo)) ?: $defaultTo; if ($from > $to) { [$from, $to] = [$to, $from]; } diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 09633101..ae90c8e7 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -72,7 +72,17 @@ return [ 'operations_replica.rpt.fys_companyInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_companyInfo', 'target' => 'ops_fys_company_info', 'keys' => ['Id'], 'watermark' => 'updateTIme'], 'operations_replica.rpt.fys_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_shopInfo', 'target' => 'ops_fys_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], 'operations_replica.rpt.fys_TrademarkInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_TrademarkInfo', 'target' => 'ops_fys_trademark_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], - 'operations_replica.rpt.jst_Orders' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_Orders', 'target' => 'ops_jst_orders', 'keys' => ['Id'], 'watermark' => 'updateTime'], + 'operations_replica.rpt.jst_Orders' => [ + 'source_key' => 'operations_replica', + 'schema' => 'rpt', + 'object' => 'jst_Orders', + 'target' => 'ops_jst_orders', + 'keys' => ['Id'], + 'watermark' => 'updateTime', + 'indexes' => [ + ['o_id', 'Id'], + ], + ], 'operations_replica.rpt.jst_OrderItem' => [ 'source_key' => 'operations_replica', 'schema' => 'rpt', @@ -80,7 +90,7 @@ return [ 'target' => 'ops_jst_order_item', 'keys' => ['Id'], 'indexes' => [ - ['o_id', 'outer_oi_id', 'Id'], + ['o_id', 'Id'], ], ], 'operations_replica.rpt.jst_sku' => [ @@ -122,8 +132,6 @@ return [ 'keys' => ['Id'], 'indexes' => [ ['as_id', 'Id'], - ['sku_id', 'Id'], - ['shop_sku_id', 'Id'], ], ], 'operations_replica.rpt.jst_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], diff --git a/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php b/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php index 294d5d9c..4edc55f0 100644 --- a/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php @@ -5,7 +5,9 @@ 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; @@ -83,7 +85,7 @@ class NonJingxiRefundMirrorRepository } elseif ($statuses->contains('running')) { $code = 'syncing'; } else { - $lastSucceeded = $lastSucceededAt ? now()->parse($lastSucceededAt) : null; + $lastSucceeded = $lastSucceededAt ? Carbon::parse($lastSucceededAt) : null; $lagMinutes = $lastSucceeded === null ? null : (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded->getTimestamp()) / 60); @@ -148,27 +150,27 @@ class NonJingxiRefundMirrorRepository private function applyFilters(Builder $query, array $filters): void { $query->where('r.as_date', '>=', $filters['from'] . ' 00:00:00') - ->where('r.as_date', '<', now()->parse($filters['to'])->addDay()->format('Y-m-d 00:00:00')); + ->where('r.as_date', '<', Carbon::parse($filters['to'])->addDay()->format('Y-m-d 00:00:00')); if ($filters['order_no'] !== '') { - $needle = '%' . $filters['order_no'] . '%'; + $needle = $this->contains($filters['order_no']); $query->where(function (Builder $order) use ($needle): void { - $order->where('r.o_id', 'like', $needle) - ->orWhere('r.so_id', 'like', $needle) - ->orWhere('r.outer_as_id', 'like', $needle); + $order->whereRaw("CAST(r.o_id AS CHAR) LIKE ? ESCAPE '!'", [$needle]) + ->orWhereRaw("r.so_id LIKE ? ESCAPE '!'", [$needle]) + ->orWhereRaw("r.outer_as_id LIKE ? ESCAPE '!'", [$needle]); }); } if ($filters['shop_name'] !== '') { - $query->where('r.shop_name', 'like', '%' . $filters['shop_name'] . '%'); + $query->whereRaw("r.shop_name LIKE ? ESCAPE '!'", [$this->contains($filters['shop_name'])]); } if ($filters['sku_id'] !== '') { - $needle = '%' . $filters['sku_id'] . '%'; + $needle = $this->contains($filters['sku_id']); $query->where(function (Builder $sku) use ($needle): void { - $sku->where('i.sku_id', 'like', $needle) - ->orWhere('i.shop_sku_id', 'like', $needle) - ->orWhere('i.i_id', 'like', $needle); + $sku->whereRaw("i.sku_id LIKE ? ESCAPE '!'", [$needle]) + ->orWhereRaw("i.shop_sku_id LIKE ? ESCAPE '!'", [$needle]) + ->orWhereRaw("i.i_id LIKE ? ESCAPE '!'", [$needle]); }); } } @@ -236,7 +238,12 @@ class NonJingxiRefundMirrorRepository } } - private function dataset(string $key) + private function contains(string $value): string + { + return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; + } + + private function dataset(string $key): BusinessMirrorDataset { return $this->datasets->get($key); } diff --git a/tests/Feature/Business/NonJingxiRefundPageTest.php b/tests/Feature/Business/NonJingxiRefundPageTest.php new file mode 100644 index 00000000..0a8a57ed --- /dev/null +++ b/tests/Feature/Business/NonJingxiRefundPageTest.php @@ -0,0 +1,214 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + } + + public function test_page_renders_mirror_rows_second_page_and_filters(): void + { + $this->grantAdminPermission(); + setting()->set('default.list_limit', 25); + + $states = [ + NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'running', 'initial_completed_at' => '2026-08-15 00:00:00'], + NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'running', 'initial_completed_at' => '2026-08-15 00:00:00'], + ]; + $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('states')->once()->andReturn($states); + $repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([ + 'code' => 'syncing', + 'last_succeeded_at' => '2026-08-21 12:00:00', + ]); + $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('paginate')->once()->withArgs( + static fn (array $filters, int $page, int $limit): bool => + $filters === [ + 'from' => '2026-08-01', + 'to' => '2026-08-21', + 'order_no' => 'ORDER-1', + 'shop_name' => '测试店', + 'sku_id' => 'SKU-1', + ] && $page === 2 && $limit === 25 + )->andReturn([ + 'data' => [(object) [ + 'Id' => 102, + 'refundDate' => '2026-08-20 10:00:00', + 'as_id' => 2002, + 'outer_as_id' => 'AS-2002', + 'o_id' => 3002, + 'so_id' => 'ORDER-1', + 'type' => '退款', + 'node' => '完成', + 'isZC' => '是', + 'shop_name' => '测试店', + 'platform' => '天猫', + 'responsibleDepartment' => '电商部', + 'totalRefundMoney' => 99.5, + 'originPostcode' => 'ZIP', + 'shop_i_id' => 'ITEM-1', + 'shop_sku_id' => 'SHOP-SKU-1', + 'i_id' => 'STYLE-1', + 'sku_id' => 'SKU-1', + 'buyer_paid_amount' => 120, + 'qty' => 1, + 'unitCostGoods' => 50, + 'detailRefundMoney' => 99.5, + 'refundType' => '退货退款', + 'refundQty' => 1, + 'refundCostGoods' => 50, + 'RefundMoneyIncome' => 99.5, + 'AfSalesMoney' => 99.5, + 'createTime' => '2026-08-20 09:00:00', + 'status' => '完成', + 'shop_status' => '完成', + 'created' => '2026-08-20 09:00:00', + 'confirm_date' => '2026-08-20 10:00:00', + ]], + 'total' => 26, + ]); + $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); + + $url = route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'refunds', + 'page' => 'non-jingxi-refund-data', + ]) . '?' . http_build_query([ + 'from' => '2026-08-01', 'to' => '2026-08-21', 'order_no' => 'ORDER-1', + 'shop_name' => '测试店', 'sku_id' => 'SKU-1', 'page' => 2, + ]); + + $this->loginAs()->get($url) + ->assertOk() + ->assertSee('data-filter-contract="non-jingxi-refund-data.filters"', false) + ->assertSee('data-sync-state="syncing"', false) + ->assertSeeText('业务镜像同步中,当前显示最近成功数据') + ->assertSeeText('AS-2002') + ->assertSeeText('SKU-1') + ->assertSeeText('99.50') + ->assertSeeText('共 26 条记录') + ->assertSeeText('第 2 / 2 页') + ->assertSee('value="2026-08-01"', false) + ->assertSee('value="ORDER-1"', false); + } + + public function test_page_does_not_report_empty_data_before_initial_sync_finishes(): void + { + $this->grantAdminPermission(); + + $states = [ + NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'running'], + NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'running'], + ]; + $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('states')->once()->andReturn($states); + $repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([ + 'code' => 'initializing', + 'last_succeeded_at' => null, + ]); + $repository->shouldReceive('ready')->once()->andReturnFalse(); + $repository->shouldNotReceive('paginate'); + $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'refunds', + 'page' => 'non-jingxi-refund-data', + ])) + ->assertOk() + ->assertSeeText('业务镜像正在执行首次同步') + ->assertSeeText('首次同步尚未完成,完成后将自动显示数据。') + ->assertDontSeeText('当前筛选范围暂无数据。'); + } + + public function test_page_shows_a_real_empty_filter_result(): void + { + $this->grantAdminPermission(); + + $states = [ + NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'succeeded', 'initial_completed_at' => '2026-08-15 00:00:00'], + NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'succeeded', 'initial_completed_at' => '2026-08-15 00:00:00'], + ]; + $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('states')->once()->andReturn($states); + $repository->shouldReceive('syncStatus')->once()->andReturn(['code' => 'healthy', 'last_succeeded_at' => '2026-08-21 12:00:00']); + $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('paginate')->once()->andReturn(['data' => [], 'total' => 0]); + $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'refunds', + 'page' => 'non-jingxi-refund-data', + 'sku_id' => 'NOT-FOUND', + ])) + ->assertOk() + ->assertSeeText('当前筛选范围暂无数据。') + ->assertSeeText('共 0 条记录'); + } + + public function test_page_replaces_invalid_dates_with_the_default_range(): void + { + $this->grantAdminPermission(); + $this->travelTo('2026-08-21 12:00:00'); + + $states = [ + NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'succeeded', 'initial_completed_at' => '2026-08-15 00:00:00'], + NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'succeeded', 'initial_completed_at' => '2026-08-15 00:00:00'], + ]; + $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('states')->once()->andReturn($states); + $repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([ + 'code' => 'healthy', + 'last_succeeded_at' => '2026-08-21 12:00:00', + ]); + $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('paginate')->once()->withArgs( + static fn (array $filters, int $page, int $limit): bool => + $filters['from'] === '2026-08-15' + && $filters['to'] === '2026-08-21' + && $page === 1 + && $limit === 25 + )->andReturn(['data' => [], 'total' => 0]); + $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'refunds', + 'page' => 'non-jingxi-refund-data', + 'from' => '2026-02-31', + 'to' => 'not-a-date', + ])) + ->assertOk() + ->assertSee('value="2026-08-15"', false) + ->assertSee('value="2026-08-21"', false) + ->assertDontSee('非京喜退款数据暂时无法读取,请稍后重试。'); + } + + public function test_page_rejects_users_without_sidebar_permission(): void + { + $subject = 'user_' . $this->user->id; + setting()->set('system.sidebar_permissions', json_encode([ + 'configured' => [$subject], + 'subjects' => [ + $subject => ['company-management/basic-information'], + ], + ], JSON_UNESCAPED_UNICODE)); + + $this->withExceptionHandling()->loginAs()->get(route('business.pages.show', [ + 'section' => 'refunds', + 'page' => 'non-jingxi-refund-data', + ]))->assertForbidden(); + } +} diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index 1af7213e..a3aae668 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; use Modules\EcommerceFinance\Models\AlipayBill; +use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository; use Tests\Feature\FeatureTestCase; class PagesTest extends FeatureTestCase @@ -150,6 +151,14 @@ class PagesTest extends FeatureTestCase Permission::where('name', 'read-admin-panel')->firstOrFail() ); + $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnFalse(); + $repository->shouldNotReceive('states'); + $repository->shouldNotReceive('syncStatus'); + $repository->shouldNotReceive('ready'); + $repository->shouldNotReceive('paginate'); + $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); + $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'refunds', @@ -177,7 +186,8 @@ class PagesTest extends FeatureTestCase ->assertOk() ->assertSeeText('非京喜退款数据') ->assertSee('aria-label="非京喜退款数据"', false) - ->assertSeeText('暂无数据') + ->assertSeeText('共享业务镜像尚未建立') + ->assertSeeText('数据暂时无法读取') ->assertSee('class="upstream-data-table refund-data-table refund-data-table--wide"', false) ->assertSee('data-header-row="labels"', false) ->assertSee('data-header-row="fields"', false) @@ -219,10 +229,15 @@ class PagesTest extends FeatureTestCase 'AfSalesMoney', 'createTime', 'status', 'shop_status', 'created', 'confirm_date', ], $headers->get('fields')); - $this->withExceptionHandling()->get(route('business.pages.show', [ + $this->withExceptionHandling()->loginAs()->get(route('business.pages.show', [ + 'company_id' => $this->company->id, 'section' => 'refunds', 'page' => 'unknown-refund-page', - ]))->assertNotFound(); + ]))->assertRedirect(route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'refunds', + 'page' => 'non-jingxi-refund-data', + ])); } public function testPaperMaterialPagesRenderAsSeparateTables() diff --git a/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php new file mode 100644 index 00000000..00a9951f --- /dev/null +++ b/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php @@ -0,0 +1,208 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.datasets' => $this->definitions(), + ]); + DB::purge('business_mirror'); + $this->createTables(); + $this->markReady(); + + $generic = new GenericBusinessMirrorRepository(app('db')); + $this->repository = new NonJingxiRefundMirrorRepository( + new BusinessMirrorDatasetRegistry(), + $generic, + ); + } + + public function test_ready_mirror_supports_second_page_filters_and_current_page_enrichment(): void + { + $db = DB::connection('business_mirror'); + $db->table('ops_jst_return_refund_info')->insert([ + $this->refundInfo(1, 101, '2026-08-19 10:00:00', '天猫', '一号店'), + $this->refundInfo(2, 102, '2026-08-20 10:00:00', '抖音', '二号店'), + $this->refundInfo(3, 103, '2026-08-21 10:00:00', 'jingxi', '京喜店'), + ]); + $db->table('ops_jst_return_refund_item')->insert([ + $this->refundItem(11, 101, 'SKU-1', 'SUB-1'), + $this->refundItem(12, 102, 'SKU-2', 'SUB-2'), + $this->refundItem(13, 103, 'SKU-JX', 'SUB-JX'), + ]); + $db->table('ops_jst_orders')->insert([ + ['Id' => 1, 'o_id' => 8901, 'receiver_zip' => 'ZIP-1'], + ['Id' => 2, 'o_id' => 8902, 'receiver_zip' => 'ZIP-2'], + ]); + $db->table('ops_jst_order_item')->insert([ + ['Id' => 1, 'o_id' => 8901, 'outer_oi_id' => 'SUB-1', 'shop_i_id' => 'ITEM-1', 'shop_sku_id' => 'SHOP-SKU-1', 'i_id' => 'STYLE-1', 'sku_id' => 'SKU-1', 'buyer_paid_amount' => 88], + ['Id' => 2, 'o_id' => 8902, 'outer_oi_id' => 'SUB-2', 'shop_i_id' => 'ITEM-2', 'shop_sku_id' => 'SHOP-SKU-2', 'i_id' => 'STYLE-2', 'sku_id' => 'SKU-2', 'buyer_paid_amount' => 99], + ]); + $db->table('ops_jst_sku')->insert([ + ['autoid' => 1, 'sku_id' => 'SKU-1', 'sku_type' => 'normal', 'cost_price' => 20], + ['autoid' => 2, 'sku_id' => 'SKU-2', 'sku_type' => 'normal', 'cost_price' => 30], + ]); + $db->table('ops_fys_shop_info')->insert([ + ['Id' => 1, 'erpShopName' => '一号店', 'responsibleDepartment' => '部门甲'], + ['Id' => 2, 'erpShopName' => '二号店', 'responsibleDepartment' => '部门乙'], + ]); + + $filters = ['from' => '2026-08-18', 'to' => '2026-08-21', 'order_no' => '', 'shop_name' => '', 'sku_id' => '']; + $page2 = $this->repository->paginate($filters, 2, 1); + + $this->assertSame(2, $page2['total']); + $this->assertSame(11, $page2['data'][0]->Id); + $this->assertSame('ZIP-1', $page2['data'][0]->originPostcode); + $this->assertSame('ITEM-1', $page2['data'][0]->shop_i_id); + $this->assertSame(88, $page2['data'][0]->buyer_paid_amount); + $this->assertSame(20.0, $page2['data'][0]->unitCostGoods); + $this->assertSame(40.0, $page2['data'][0]->refundCostGoods); + $this->assertSame('部门甲', $page2['data'][0]->responsibleDepartment); + + $filters['shop_name'] = '二号'; + $this->assertSame(1, $this->repository->paginate($filters, 1, 25)['total']); + $filters['shop_name'] = ''; + $filters['shop_name'] = '%'; + $this->assertSame(0, $this->repository->paginate($filters, 1, 25)['total']); + $filters['shop_name'] = ''; + $filters['sku_id'] = 'SKU-1'; + $this->assertSame(1, $this->repository->paginate($filters, 1, 25)['total']); + $filters['sku_id'] = ''; + $filters['order_no'] = '8902'; + $this->assertSame(1, $this->repository->paginate($filters, 1, 25)['total']); + } + + public function test_sync_status_distinguishes_rotation_from_initial_sync(): void + { + $states = $this->repository->states(); + foreach ($states as &$state) { + $state['status'] = 'running'; + $state['last_succeeded_at'] = '2026-08-21 12:00:00'; + } + unset($state); + + $this->assertSame('syncing', $this->repository->syncStatus( + $states, + Carbon::parse('2026-08-21 12:05:00'), + )['code']); + + $states[NonJingxiRefundMirrorRepository::INFO_DATASET]['initial_completed_at'] = null; + $this->assertSame('initializing', $this->repository->syncStatus($states)['code']); + } + + /** @return array> */ + private function definitions(): array + { + return [ + NonJingxiRefundMirrorRepository::INFO_DATASET => $this->definition('jst_returnAndRefundInfo', 'ops_jst_return_refund_info'), + NonJingxiRefundMirrorRepository::ITEM_DATASET => $this->definition('jst_returnAndRefundItem', 'ops_jst_return_refund_item'), + 'operations_replica.rpt.jst_Orders' => $this->definition('jst_Orders', 'ops_jst_orders'), + 'operations_replica.rpt.jst_OrderItem' => $this->definition('jst_OrderItem', 'ops_jst_order_item'), + 'operations_replica.rpt.jst_sku' => $this->definition('jst_sku', 'ops_jst_sku'), + 'operations_replica.rpt.fys_shopInfo' => $this->definition('fys_shopInfo', 'ops_fys_shop_info'), + ]; + } + + /** @return array */ + private function definition(string $object, string $target): array + { + return ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => $object, 'target' => $target, 'keys' => ['Id']]; + } + + private function createTables(): void + { + $schema = Schema::connection('business_mirror'); + $schema->create('business_mirror_table_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status')->default('succeeded'); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + }); + $schema->create('ops_jst_return_refund_info', function (Blueprint $table): void { + $table->integer('Id'); $table->integer('as_id'); $table->dateTime('as_date'); $table->string('outer_as_id')->nullable(); + $table->integer('o_id'); $table->string('so_id')->nullable(); $table->string('type')->nullable(); $table->string('node')->nullable(); + $table->string('shop_type')->nullable(); $table->string('shop_name')->nullable(); $table->string('shop_site')->nullable(); + $table->decimal('refund', 18, 2)->nullable(); $table->decimal('receive_refund', 18, 2)->nullable(); + $table->decimal('buyer_apply_refund', 18, 2)->nullable(); $table->dateTime('createTime')->nullable(); + $table->string('status')->nullable(); $table->string('shop_status')->nullable(); $table->dateTime('created')->nullable(); + $table->dateTime('confirm_date')->nullable(); + }); + $schema->create('ops_jst_return_refund_item', function (Blueprint $table): void { + $table->integer('Id'); $table->integer('as_id'); $table->string('outer_oi_id')->nullable(); + $table->string('shop_sku_id')->nullable(); $table->string('i_id')->nullable(); $table->string('sku_id')->nullable(); + $table->integer('qty')->nullable(); $table->decimal('shop_amount', 18, 2)->nullable(); + $table->string('type')->nullable(); $table->integer('r_qty')->nullable(); + }); + $schema->create('ops_jst_orders', function (Blueprint $table): void { + $table->integer('Id'); $table->integer('o_id'); $table->string('receiver_zip')->nullable(); + }); + $schema->create('ops_jst_order_item', function (Blueprint $table): void { + $table->integer('Id'); $table->integer('o_id'); $table->string('outer_oi_id'); + $table->string('shop_i_id')->nullable(); $table->string('shop_sku_id')->nullable(); $table->string('i_id')->nullable(); + $table->string('sku_id')->nullable(); $table->decimal('buyer_paid_amount', 18, 2)->nullable(); + }); + $schema->create('ops_jst_sku', function (Blueprint $table): void { + $table->integer('autoid'); $table->string('sku_id'); $table->string('sku_type'); $table->decimal('cost_price', 18, 2)->nullable(); + }); + $schema->create('ops_fys_shop_info', function (Blueprint $table): void { + $table->integer('Id'); $table->string('erpShopName'); $table->string('responsibleDepartment')->nullable(); + }); + } + + private function markReady(): void + { + foreach (array_keys($this->definitions()) as $key) { + DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ + 'dataset_key' => $key, + 'status' => 'succeeded', + 'initial_completed_at' => '2026-08-15 00:00:00', + 'last_succeeded_at' => '2026-08-21 12:00:00', + ]); + } + } + + /** @return array */ + private function refundInfo(int $id, int $asId, string $date, string $platform, string $shopName): array + { + return [ + 'Id' => $id, 'as_id' => $asId, 'as_date' => $date, 'outer_as_id' => 'AS-' . $asId, + 'o_id' => 8900 + $id, 'so_id' => 'SO-' . $id, 'type' => '退款', 'node' => '完成', + 'shop_type' => '自营', 'shop_name' => $shopName, 'shop_site' => $platform, + 'refund' => 100, 'receive_refund' => 80, 'buyer_apply_refund' => 100, + 'createTime' => $date, 'status' => '完成', 'shop_status' => '完成', + 'created' => $date, 'confirm_date' => $date, + ]; + } + + /** @return array */ + private function refundItem(int $id, int $asId, string $skuId, string $outerOrderItemId): array + { + return [ + 'Id' => $id, 'as_id' => $asId, 'outer_oi_id' => $outerOrderItemId, + 'shop_sku_id' => 'SHOP-' . $skuId, 'i_id' => 'STYLE-' . $id, 'sku_id' => $skuId, + 'qty' => 2, 'shop_amount' => 80, 'type' => '退货退款', 'r_qty' => 2, + ]; + } +} -- 2.47.2 From 26ea2e1f0b478f0ed269fd5ac4e541580a137ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E6=9C=A8?= <594326199@qq.com> Date: Fri, 21 Aug 2026 18:17:13 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E9=80=80=E6=AC=BE=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E6=9C=AA=E5=B0=B1=E7=BB=AA=E6=97=B6=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E9=A1=B5=E9=9D=A2=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 6 ++- .../NonJingxiRefundMirrorRepository.php | 38 +++++++++++++++++- .../refunds/non-jingxi-refund-data.blade.php | 12 ++++++ .../Business/NonJingxiRefundPageTest.php | 40 +++++++++++++++++++ tests/Feature/Business/PagesTest.php | 1 + .../NonJingxiRefundMirrorRepositoryTest.php | 24 +++++++++++ 6 files changed, 119 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 5f15a937..26e6c3b1 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -268,13 +268,14 @@ class Pages extends Controller ]; $page = max((int) $request->query('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); - $rows = new LengthAwarePaginator([], 0, $limit, $page, [ + $rows = new LengthAwarePaginator([], 0, $limit, 1, [ 'path' => $request->url(), 'query' => $request->query(), ]); $syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null]; $error = null; $pending = false; + $indexPending = false; try { if (! $this->nonJingxiRefunds->available()) { @@ -284,6 +285,8 @@ class Pages extends Controller $syncStatus = $this->nonJingxiRefunds->syncStatus($states); if (! $this->nonJingxiRefunds->ready()) { $pending = true; + } elseif (! $this->nonJingxiRefunds->queryReady()) { + $indexPending = true; } else { $result = $this->nonJingxiRefunds->paginate($filters, $page, $limit); $rows = new LengthAwarePaginator( @@ -307,6 +310,7 @@ class Pages extends Controller 'refundSyncStatus' => $syncStatus, 'refundError' => $error, 'refundPending' => $pending, + 'refundIndexPending' => $indexPending, ]; } diff --git a/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php b/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php index 4edc55f0..fe1ed510 100644 --- a/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php @@ -24,6 +24,13 @@ class NonJingxiRefundMirrorRepository 'operations_replica.rpt.fys_shopInfo', ]; + private const REQUIRED_QUERY_INDEXES = [ + self::INFO_DATASET => [['as_date', 'Id'], ['o_id', 'Id'], ['as_id', 'Id']], + self::ITEM_DATASET => [['as_id', 'Id']], + 'operations_replica.rpt.jst_Orders' => [['o_id', 'Id']], + 'operations_replica.rpt.jst_OrderItem' => [['o_id', 'Id']], + ]; + private readonly ConnectionInterface $connection; public function __construct( @@ -48,6 +55,35 @@ class NonJingxiRefundMirrorRepository && $this->mirror->ready($this->dataset(self::ITEM_DATASET)); } + 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; + } + + $indexes = collect($schema->getIndexes($table)) + ->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|null> */ public function states(): array { @@ -101,7 +137,7 @@ class NonJingxiRefundMirrorRepository */ public function paginate(array $filters, int $page, int $perPage): array { - if (! $this->ready()) { + if (! $this->ready() || ! $this->queryReady()) { throw new RuntimeException('business_mirror_dataset_not_ready'); } diff --git a/resources/views/business/pages/refunds/non-jingxi-refund-data.blade.php b/resources/views/business/pages/refunds/non-jingxi-refund-data.blade.php index 31402cc3..f704d874 100644 --- a/resources/views/business/pages/refunds/non-jingxi-refund-data.blade.php +++ b/resources/views/business/pages/refunds/non-jingxi-refund-data.blade.php @@ -69,6 +69,11 @@ color: var(--vip-content-accent-dark, #856632); font-size: 1.125rem; } + .upstream-data-alert--warning { + border-color: rgba(147, 95, 7, 0.35); + background: rgba(147, 95, 7, 0.06); + color: var(--vip-warning, #935f07); + }
@@ -84,6 +89,11 @@ {{ $refundError }}
+ @elseif ($refundIndexPending) +
+ + 页面查询索引正在准备,生产同步轮转完成后将自动显示数据。 +
@endif
@if ($refundPending) 首次同步尚未完成,完成后将自动显示数据。 + @elseif ($refundIndexPending) + 页面查询索引尚未就绪,当前不会执行高压力查询。 @elseif ($refundError) 数据暂时无法读取。 @else diff --git a/tests/Feature/Business/NonJingxiRefundPageTest.php b/tests/Feature/Business/NonJingxiRefundPageTest.php index 0a8a57ed..50495f43 100644 --- a/tests/Feature/Business/NonJingxiRefundPageTest.php +++ b/tests/Feature/Business/NonJingxiRefundPageTest.php @@ -32,6 +32,7 @@ class NonJingxiRefundPageTest extends FeatureTestCase 'last_succeeded_at' => '2026-08-21 12:00:00', ]); $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('queryReady')->once()->andReturnTrue(); $repository->shouldReceive('paginate')->once()->withArgs( static fn (array $filters, int $page, int $limit): bool => $filters === [ @@ -119,6 +120,7 @@ class NonJingxiRefundPageTest extends FeatureTestCase 'last_succeeded_at' => null, ]); $repository->shouldReceive('ready')->once()->andReturnFalse(); + $repository->shouldNotReceive('queryReady'); $repository->shouldNotReceive('paginate'); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); @@ -145,6 +147,7 @@ class NonJingxiRefundPageTest extends FeatureTestCase $repository->shouldReceive('states')->once()->andReturn($states); $repository->shouldReceive('syncStatus')->once()->andReturn(['code' => 'healthy', 'last_succeeded_at' => '2026-08-21 12:00:00']); $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('queryReady')->once()->andReturnTrue(); $repository->shouldReceive('paginate')->once()->andReturn(['data' => [], 'total' => 0]); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); @@ -158,6 +161,42 @@ class NonJingxiRefundPageTest extends FeatureTestCase ->assertSeeText('共 0 条记录'); } + public function test_page_does_not_run_the_query_before_required_indexes_exist(): void + { + $this->grantAdminPermission(); + + $states = [ + NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'running', 'initial_completed_at' => '2026-08-15 00:00:00'], + NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'running', 'initial_completed_at' => '2026-08-15 00:00:00'], + ]; + $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('states')->once()->andReturn($states); + $repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([ + 'code' => 'syncing', + 'last_succeeded_at' => '2026-08-17 10:05:07', + ]); + $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('queryReady')->once()->andReturnFalse(); + $repository->shouldNotReceive('paginate'); + $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); + + $this->loginAs(); + $url = route('business.pages.show', [ + 'section' => 'refunds', + 'page' => 'non-jingxi-refund-data', + ]).'?page=2'; + + $this->get($url) + ->assertOk() + ->assertSee('upstream-data-alert--warning', false) + ->assertSeeText('页面查询索引正在准备,生产同步轮转完成后将自动显示数据。') + ->assertSeeText('页面查询索引尚未就绪,当前不会执行高压力查询。') + ->assertSeeText('第 1 / 1 页') + ->assertDontSeeText('第 2 / 1 页') + ->assertDontSeeText('当前筛选范围暂无数据。'); + } + public function test_page_replaces_invalid_dates_with_the_default_range(): void { $this->grantAdminPermission(); @@ -175,6 +214,7 @@ class NonJingxiRefundPageTest extends FeatureTestCase 'last_succeeded_at' => '2026-08-21 12:00:00', ]); $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('queryReady')->once()->andReturnTrue(); $repository->shouldReceive('paginate')->once()->withArgs( static fn (array $filters, int $page, int $limit): bool => $filters['from'] === '2026-08-15' diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index a3aae668..38c50315 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -156,6 +156,7 @@ class PagesTest extends FeatureTestCase $repository->shouldNotReceive('states'); $repository->shouldNotReceive('syncStatus'); $repository->shouldNotReceive('ready'); + $repository->shouldNotReceive('queryReady'); $repository->shouldNotReceive('paginate'); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); diff --git a/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php index 00a9951f..2ae7c23e 100644 --- a/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php @@ -112,6 +112,24 @@ class NonJingxiRefundMirrorRepositoryTest extends TestCase $this->assertSame('initializing', $this->repository->syncStatus($states)['code']); } + public function test_query_is_blocked_when_a_required_lookup_index_is_missing(): void + { + Schema::connection('business_mirror')->table( + 'ops_jst_return_refund_info', + static fn (Blueprint $table) => $table->dropIndex(['as_date', 'Id']), + ); + + $this->assertFalse($this->repository->queryReady()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('business_mirror_dataset_not_ready'); + $this->repository->paginate( + ['from' => '2026-08-18', 'to' => '2026-08-21', 'order_no' => '', 'shop_name' => '', 'sku_id' => ''], + 1, + 25, + ); + } + /** @return array> */ private function definitions(): array { @@ -148,20 +166,26 @@ class NonJingxiRefundMirrorRepositoryTest extends TestCase $table->decimal('buyer_apply_refund', 18, 2)->nullable(); $table->dateTime('createTime')->nullable(); $table->string('status')->nullable(); $table->string('shop_status')->nullable(); $table->dateTime('created')->nullable(); $table->dateTime('confirm_date')->nullable(); + $table->index(['as_date', 'Id']); + $table->index(['o_id', 'Id']); + $table->index(['as_id', 'Id']); }); $schema->create('ops_jst_return_refund_item', function (Blueprint $table): void { $table->integer('Id'); $table->integer('as_id'); $table->string('outer_oi_id')->nullable(); $table->string('shop_sku_id')->nullable(); $table->string('i_id')->nullable(); $table->string('sku_id')->nullable(); $table->integer('qty')->nullable(); $table->decimal('shop_amount', 18, 2)->nullable(); $table->string('type')->nullable(); $table->integer('r_qty')->nullable(); + $table->index(['as_id', 'Id']); }); $schema->create('ops_jst_orders', function (Blueprint $table): void { $table->integer('Id'); $table->integer('o_id'); $table->string('receiver_zip')->nullable(); + $table->index(['o_id', 'Id']); }); $schema->create('ops_jst_order_item', function (Blueprint $table): void { $table->integer('Id'); $table->integer('o_id'); $table->string('outer_oi_id'); $table->string('shop_i_id')->nullable(); $table->string('shop_sku_id')->nullable(); $table->string('i_id')->nullable(); $table->string('sku_id')->nullable(); $table->decimal('buyer_paid_amount', 18, 2)->nullable(); + $table->index(['o_id', 'Id']); }); $schema->create('ops_jst_sku', function (Blueprint $table): void { $table->integer('autoid'); $table->string('sku_id'); $table->string('sku_type'); $table->decimal('cost_price', 18, 2)->nullable(); -- 2.47.2