From f33e35e9402ed004ad79fa71aa3b7cd80b6d3de8 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Fri, 4 Sep 2026 11:49:00 +0800 Subject: [PATCH] feat: add Jushuitan small payment work order page --- app/Http/Controllers/Business/Pages.php | 187 ++++++++ config/business_navigation.php | 1 + config/upstream_mirrors.php | 40 ++ docs/上游业务镜像清单.md | 11 + .../SmallPaymentWorkOrderMirrorRepository.php | 452 ++++++++++++++++++ .../small-payment-work-orders.blade.php | 245 ++++++++++ routes/admin.php | 4 + .../SmallPaymentWorkOrderPageTest.php | 167 +++++++ tests/Feature/Settings/NavigationTest.php | 4 +- ...llPaymentWorkOrderMirrorRepositoryTest.php | 239 +++++++++ 10 files changed, 1348 insertions(+), 2 deletions(-) create mode 100644 modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php create mode 100644 resources/views/business/pages/refunds/small-payment-work-orders.blade.php create mode 100644 tests/Feature/Business/SmallPaymentWorkOrderPageTest.php create mode 100644 tests/Unit/EcommerceFinance/SmallPaymentWorkOrderMirrorRepositoryTest.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index aac77d52..448419f3 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -36,6 +36,7 @@ use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository; use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository; use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository; use Modules\EcommerceFinance\Repositories\DingPayableWorkflowMirrorRepository; +use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository; use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository; use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository; use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory; @@ -68,6 +69,7 @@ class Pages extends Controller private readonly InventoryRepository $inventory, private readonly InstallationDetailMirrorRepository $installationDetails, private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds, + private readonly SmallPaymentWorkOrderMirrorRepository $smallPaymentWorkOrders, private readonly ProductCatalogMirrorRepository $productCatalog, private readonly ProductPriceMirrorRepository $productPrices, private readonly CourierInformationMirrorRepository $courierInformation, @@ -490,6 +492,7 @@ class Pages extends Controller 'marketing-account-balance' => $this->contentForMarketingAccountBalance($tab), 'jingxi-refund-data' => $this->contentForJingxiRefundData(), 'non-jingxi-refund-data' => $this->contentForNonJingxiRefundData(), + 'small-payment-work-orders' => $this->contentForSmallPaymentWorkOrders(), 'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(), 'normal-products' => $this->contentForNormalProducts(), 'product-prices' => $this->contentForProductPrices(), @@ -790,6 +793,190 @@ class Pages extends Controller ]); } + protected function contentForSmallPaymentWorkOrders(): array + { + $request = request(); + $filters = $this->smallPaymentWorkOrderFilters($request); + $page = max((int) $request->query('page', 1), 1); + $limit = min(max((int) setting('default.list_limit', 25), 1), 100); + $rows = new LengthAwarePaginator([], 0, $limit, 1, [ + 'path' => $request->url(), + 'query' => $request->query(), + ]); + $syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null]; + $pagePaymentAmount = '0.00'; + $error = null; + $pending = false; + $indexPending = false; + + try { + if (! $this->smallPaymentWorkOrders->available()) { + $error = '共享业务镜像尚未建立,小额打款工单暂时无法读取。'; + } else { + $states = $this->smallPaymentWorkOrders->states(); + $syncStatus = $this->smallPaymentWorkOrders->syncStatus($states); + if (! $this->smallPaymentWorkOrders->ready()) { + $pending = true; + } elseif (! $this->smallPaymentWorkOrders->queryReady()) { + $indexPending = true; + } else { + $result = $this->smallPaymentWorkOrders->paginate($filters, $page, $limit); + $rows = new LengthAwarePaginator( + Collection::make($result['data'] ?? []), + (int) ($result['total'] ?? 0), + $limit, + $page, + ['path' => $request->url(), 'query' => $request->query()], + ); + $pagePaymentAmount = (string) ($result['page_payment_amount'] ?? '0.00'); + } + } + } catch (Throwable $exception) { + report($exception); + $error = '小额打款工单暂时无法读取,请稍后重试。'; + } + + return [ + 'view' => 'business.pages.refunds.small-payment-work-orders', + 'filters' => $filters, + 'rows' => $rows, + 'smallPaymentSyncStatus' => $syncStatus, + 'smallPaymentPageAmount' => $pagePaymentAmount, + 'smallPaymentError' => $error, + 'smallPaymentPending' => $pending, + 'smallPaymentIndexPending' => $indexPending, + ]; + } + + public function exportSmallPaymentWorkOrders(Request $request) + { + $filters = $this->smallPaymentWorkOrderFilters($request); + $redirectParameters = array_filter([ + 'section' => 'refunds', + 'page' => 'small-payment-work-orders', + ...$filters, + ], static fn (mixed $value): bool => $value !== '' && $value !== null); + + try { + if (! $this->smallPaymentWorkOrders->available()) { + return redirect()->route('business.pages.show', $redirectParameters) + ->with('error', '共享业务镜像尚未建立,小额打款工单暂时不能导出。'); + } + if (! $this->smallPaymentWorkOrders->ready()) { + return redirect()->route('business.pages.show', $redirectParameters) + ->with('error', '业务镜像首次同步尚未完成,小额打款工单暂时不能导出。'); + } + if (! $this->smallPaymentWorkOrders->queryReady()) { + return redirect()->route('business.pages.show', $redirectParameters) + ->with('error', '页面查询索引尚未就绪,小额打款工单暂时不能导出。'); + } + + return $this->streamSmallPaymentWorkOrderCsv( + $this->smallPaymentWorkOrders->exportRows($filters), + $filters, + $redirectParameters, + ); + } catch (Throwable $exception) { + report($exception); + + return redirect()->route('business.pages.show', $redirectParameters) + ->with('error', '小额打款工单导出暂时无法完成,请稍后重试。'); + } + } + + /** + * @return array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} + */ + private function smallPaymentWorkOrderFilters(Request $request): array + { + $defaultTo = now()->toDateString(); + $defaultFrom = now()->subDays(29)->toDateString(); + $from = $this->dateFilter($request->query('from', $defaultFrom)) ?: $defaultFrom; + $to = $this->dateFilter($request->query('to', $defaultTo)) ?: $defaultTo; + if ($from > $to) { + [$from, $to] = [$to, $from]; + } + + $text = static fn (mixed $value, int $length = 100): string => mb_substr(trim((string) $value), 0, $length); + + return [ + 'from' => $from, + 'to' => $to, + 'work_order_no' => $text($request->query('work_order_no', '')), + 'shop_name' => $text($request->query('shop_name', ''), 191), + 'transaction_no' => $text($request->query('transaction_no', '')), + 'online_order_no' => $text($request->query('online_order_no', '')), + 'status' => $text($request->query('status', ''), 60), + 'payment_reason' => $text($request->query('payment_reason', ''), 191), + ]; + } + + private function streamSmallPaymentWorkOrderCsv(iterable $rows, array $filters, array $redirectParameters) + { + $columns = [ + ['label' => '工单编号', 'field' => 'work_order_no'], + ['label' => '支付宝批次号', 'field' => 'alipay_batch_no'], + ['label' => '工单状态', 'field' => 'work_order_status'], + ['label' => '打款状态', 'field' => 'payment_status'], + ['label' => '工单标题', 'field' => 'work_order_title'], + ['label' => '工单内容', 'field' => 'work_order_content'], + ['label' => '工单类型', 'field' => 'work_order_type'], + ['label' => '优先级', 'field' => 'priority'], + ['label' => '打款金额', 'field' => 'payment_amount', 'number' => true], + ['label' => '催处理次数', 'field' => 'urging_count', 'number' => true], + ['label' => '店铺名称', 'field' => 'shop_name'], + ['label' => '线上订单号', 'field' => 'online_order_no'], + ['label' => '买家账号', 'field' => 'buyer_account'], + ['label' => '支付宝账号', 'field' => 'alipay_account'], + ['label' => '支付宝姓名', 'field' => 'alipay_name'], + ['label' => '商品编码', 'field' => 'product_code'], + ['label' => 'SKU编码', 'field' => 'sku_code'], + ['label' => '平台', 'field' => 'platform'], + ['label' => '实际已付金额', 'field' => 'actual_paid_amount', 'number' => true], + ['label' => '支付时间', 'field' => 'payment_time'], + ['label' => '打款原因', 'field' => 'payment_reason'], + ['label' => '创建人', 'field' => 'creator'], + ['label' => '创建时间', 'field' => 'work_order_created_at'], + ['label' => '完成时间', 'field' => 'completed_at'], + ]; + $csv = null; + + try { + $csv = fopen('php://temp/maxmemory:5242880', 'w+b'); + if ($csv === false) { + throw new \RuntimeException('无法创建小额打款工单导出流。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, array_column($columns, 'label')); + foreach ($rows as $row) { + fputcsv($csv, array_map( + fn (array $column): mixed => ($column['number'] ?? false) + ? data_get($row, $column['field']) + : $this->csvText(data_get($row, $column['field'])), + $columns, + )); + } + rewind($csv); + } catch (Throwable $exception) { + if (is_resource($csv)) { + fclose($csv); + } + report($exception); + + return redirect()->route('business.pages.show', $redirectParameters) + ->with('error', '小额打款工单导出暂时无法完成,请稍后重试。'); + } + + return response()->streamDownload(function () use ($csv): void { + fpassthru($csv); + fclose($csv); + }, 'small_payment_work_orders_' . $filters['from'] . '_' . $filters['to'] . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + protected function contentForMarketingAccountBalance(?string $tab): array { $content = [ diff --git a/config/business_navigation.php b/config/business_navigation.php index bf30b271..e806e810 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -402,6 +402,7 @@ return [ 'pages' => [ ['slug' => 'jingxi-refund-data', 'title' => '京喜退款数据'], ['slug' => 'non-jingxi-refund-data', 'title' => '售后(退货退款)'], + ['slug' => 'small-payment-work-orders', 'title' => '小额打款'], ], ], [ diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index d2fa293c..96360423 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -241,6 +241,46 @@ return [ ], ], + // 聚水潭聚工单:协同工单 > 打款工单 > 小额打款。 + // 页面只读共享镜像;businessFieldList 保留上游表单原文,由固定 + // Mirror Repository 解析页面明确使用的字段,不在 HTTP 请求直连上游。 + 'jiansun.dbo.jst_workOrderInfo' => [ + 'source_key' => 'jiansun', + 'schema' => 'dbo', + 'object' => 'jst_workOrderInfo', + 'target' => 'js_jst_work_order_info', + 'keys' => ['Id'], + 'watermark' => 'updateTime', + 'columns' => [ + 'Id', 'woNo', 'woTypeId', 'title', 'content', 'status', 'grade', + 'urging', 'createby', 'executors', 'beginTime', 'endTime', + 'completer', 'completeTime', 'updated', 'created', + 'businessFieldList', 'createTime', 'updateTime', + ], + 'indexes' => [ + ['woTypeId', 'woNo', 'Id'], + ['created', 'Id'], + ], + ], + 'jiansun.dbo.jst_small_payment_order_map' => [ + 'source_key' => 'jiansun', + 'schema' => 'dbo', + 'object' => 'jst_small_payment_order_map', + 'target' => 'js_jst_small_payment_order_map', + 'keys' => ['woNo'], + 'watermark' => 'updated_at', + 'columns' => [ + 'woNo', 'woTypeId', 'transaction_no', 'shop_name', + 'payment_reason', 'title', 'status', 'work_order_create_time', + 'work_order_update_time', 'parse_status', 'parse_error', + 'updated_at', + ], + 'indexes' => [ + ['woTypeId', 'work_order_create_time', 'woNo'], + ['status', 'woNo'], + ], + ], + // JianSun 旧业务库:营销账单、供应商应付、板材、封边条和库存。 'jiansun.dbo.tmall_spread_bills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'tmall_spread_bills', 'target' => 'js_tmall_spread_bills', 'keys' => ['id']], 'jiansun.dbo.pdd_spread_bills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pdd_spread_bills', 'target' => 'js_pdd_spread_bills', 'keys' => ['id'], 'watermark' => 'createTime'], diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index b6136f79..8d01eb8a 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -16,6 +16,7 @@ | 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 | | 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 | | 本次接入 | 聚水潭聚工单协同工单登记 `work_order_main`、`work_order_sub`、`work_order_form_config`、`work_order_form_value` 四张表;工单 API 固定筛选类型“仓内未发”,首次同步完成前返回同步未完成状态。 | +| 待首次同步 | 聚水潭聚工单“小额打款”读取 `jiansun.dbo.jst_workOrderInfo` 与已解析映射 `dbo.jst_small_payment_order_map`;退款模块的小额打款子页面固定筛选工单类型 `82D94F5C54544EE3833CD8A3AB8B9F73`,页面只读共享镜像,首次同步完成前明确显示待同步状态。 | | 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 | | 已开始逐域切换 | 公司、店铺、商标页面在对应镜像完成首次全量后自动改读完整镜像表;首次全量前保留旧快照作为兼容回滚。其他业务域仍需逐项切换,不得宣称全项目已经完成。 | | 中央同步接入 | 店铺维度报表从 `jiansun.dbo.jst_originalBillInfo` 和 `dbo.fys_shopInfo_flow` 自动、可断点同步;不再依赖开发者电脑、SQLite 或人工 XLSX。 | @@ -56,6 +57,7 @@ | 库存与货款对账 | `dbo.pm_boardInventoryDetail_snapshot`、`dbo.fys_supplierGoods_sttlement_purchase`、`dbo.fys_supplierGoods_sttlement_sale`、`dbo.pm_purchaseIn` | | 采购单价 | `dbo.jst_purchase`、`dbo.jst_purchaseItem`(受控字段联表镜像,不复制其他采购业务字段) | | 快递资料 | `dbo.wp_express`、`dbo.wp_express_expNet`、`dbo.wp_express_account`、`dbo.wp_express_extension` | +| 聚水潭小额打款工单 | `dbo.jst_workOrderInfo`(稳定键 `Id`、增量字段 `updateTime`)与 `dbo.jst_small_payment_order_map`(稳定键 `woNo`、增量字段 `updated_at`) | 其中 `fys_supplierGoods_sttlement_sale` 当前约 280 万行,是首次全量同步的最大对象。必须在低峰期小批量、可断点执行,禁止把全量同步塞进发布流程。 @@ -94,3 +96,12 @@ php artisan business-mirror:sync-tables operations_replica.rpt.work_order_sub -- php artisan business-mirror:sync-tables operations_replica.rpt.work_order_form_config --full php artisan business-mirror:sync-tables operations_replica.rpt.work_order_form_value --full ``` + +聚工单“小额打款”两个数据集同样必须在备份和负责人首次写入授权后分别执行全量同步;页面不会在 HTTP 请求中回退直连 `jiansun`: + +```bash +php artisan business-mirror:sync-tables jiansun.dbo.jst_workOrderInfo --full +php artisan business-mirror:sync-tables jiansun.dbo.jst_small_payment_order_map --full +``` + +两张表完成后,页面按映射表的工单创建时间稳定分页,并以 `woNo` 关联最新的工单主记录。筛选支持日期、工单号、店铺、支付宝批次号、线上订单号、状态和打款原因;表单详情从镜像中的 `businessFieldList` 解析,导出与页面使用同一筛选口径。首次全量未完成、查询索引未就绪或刷新失败时均保留明确状态,不能把空结果解释为没有小额打款工单。 diff --git a/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php b/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php new file mode 100644 index 00000000..f5b6e80c --- /dev/null +++ b/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php @@ -0,0 +1,452 @@ + [['woTypeId', 'woNo', 'Id']], + self::PAYMENT_MAP_DATASET => [['woTypeId', 'work_order_create_time', 'woNo']], + ]; + + private readonly ConnectionInterface $connection; + + 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()); + + try { + return $schema->hasTable('business_mirror_table_states') + && $schema->hasTable($this->dataset(self::WORK_ORDER_DATASET)->targetTable()) + && $schema->hasTable($this->dataset(self::PAYMENT_MAP_DATASET)->targetTable()); + } catch (Throwable) { + return false; + } + } + + public function ready(): bool + { + return $this->mirror->ready($this->dataset(self::WORK_ORDER_DATASET)) + && $this->mirror->ready($this->dataset(self::PAYMENT_MAP_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 + { + return [ + self::WORK_ORDER_DATASET => $this->mirror->state($this->dataset(self::WORK_ORDER_DATASET)), + self::PAYMENT_MAP_DATASET => $this->mirror->state($this->dataset(self::PAYMENT_MAP_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 ? 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,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters + * @return array{data:list>,total:int,page_payment_amount:string} + */ + public function paginate(array $filters, int $page, int $perPage): array + { + $this->ensureReady(); + + $page = max($page, 1); + $perPage = min(max($perPage, 1), 100); + $query = $this->baseQuery(); + $this->applyFilters($query, $filters); + + $total = (clone $query)->count(); + $rows = $query + ->select($this->selectColumns()) + ->orderByDesc('p.work_order_create_time') + ->orderByDesc('p.woNo') + ->forPage($page, $perPage) + ->get() + ->map(fn (object $row): array => $this->decorate((array) $row)) + ->all(); + + return [ + 'data' => $rows, + 'total' => $total, + 'page_payment_amount' => $this->sumMoney(array_column($rows, 'payment_amount')), + ]; + } + + /** + * @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters + * @return iterable> + */ + public function exportRows(array $filters, int $chunkSize = 100): iterable + { + $this->ensureReady(); + + $chunkSize = min(max($chunkSize, 1), 100); + $query = $this->baseQuery(); + $this->applyFilters($query, $filters); + $page = 1; + + do { + $rows = (clone $query) + ->select($this->selectColumns()) + ->orderByDesc('p.work_order_create_time') + ->orderByDesc('p.woNo') + ->forPage($page, $chunkSize) + ->get(); + + foreach ($rows as $row) { + yield $this->decorate((array) $row); + } + + $page++; + } while ($rows->count() === $chunkSize); + } + + private function ensureReady(): void + { + if (! $this->ready() || ! $this->queryReady()) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + } + + private function baseQuery(): Builder + { + $workOrders = $this->dataset(self::WORK_ORDER_DATASET)->targetTable(); + $paymentMap = $this->dataset(self::PAYMENT_MAP_DATASET)->targetTable(); + $latestWorkOrders = $this->connection->table($workOrders . ' AS work_order_versions') + ->selectRaw('woNo, MAX(Id) AS Id') + ->where('woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID) + ->groupBy('woNo'); + + return $this->connection->table($paymentMap . ' AS p') + ->joinSub($latestWorkOrders, 'work_order_versions', static function ($join): void { + $join->on('work_order_versions.woNo', '=', 'p.woNo'); + }) + ->join($workOrders . ' AS w', 'w.Id', '=', 'work_order_versions.Id') + ->where('p.woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID) + ->where('w.woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID); + } + + /** @return list */ + private function selectColumns(): array + { + return [ + 'w.Id AS source_id', 'p.woNo AS work_order_no', + 'p.transaction_no AS alipay_batch_no', 'w.status AS work_order_status', + 'p.status AS mapped_status', 'w.title AS work_order_title', + 'p.title AS mapped_title', 'w.content AS work_order_content', + 'w.grade AS priority', 'w.urging AS urging_count', + 'w.createby AS creator', 'w.executors', 'w.completer', + 'w.completeTime AS completed_at', 'w.created AS work_order_created_at', + 'w.updated AS work_order_updated_at', 'p.shop_name', + 'p.payment_reason', 'p.parse_status', 'p.parse_error', + 'p.work_order_create_time', 'p.work_order_update_time', + 'w.businessFieldList AS business_field_list', + ]; + } + + /** @param array $filters */ + private function applyFilters(Builder $query, array $filters): void + { + $query->where('p.work_order_create_time', '>=', $filters['from'] . ' 00:00:00') + ->where('p.work_order_create_time', '<', Carbon::parse($filters['to'])->addDay()->format('Y-m-d 00:00:00')); + + if ($filters['work_order_no'] !== '') { + $query->whereRaw("p.woNo LIKE ? ESCAPE '!'", [$this->contains($filters['work_order_no'])]); + } + if ($filters['shop_name'] !== '') { + $query->whereRaw("p.shop_name LIKE ? ESCAPE '!'", [$this->contains($filters['shop_name'])]); + } + if ($filters['transaction_no'] !== '') { + $query->whereRaw("p.transaction_no LIKE ? ESCAPE '!'", [$this->contains($filters['transaction_no'])]); + } + if ($filters['online_order_no'] !== '') { + $query->whereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['online_order_no'])]); + } + if ($filters['status'] !== '') { + $query->where(function (Builder $status) use ($filters): void { + $status->where('w.status', $filters['status']) + ->orWhere('p.status', $filters['status']) + ->orWhereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['status'])]); + }); + } + if ($filters['payment_reason'] !== '') { + $query->whereRaw("p.payment_reason LIKE ? ESCAPE '!'", [$this->contains($filters['payment_reason'])]); + } + } + + /** @param array $row @return array */ + private function decorate(array $row): array + { + $fields = $this->businessFields($row['business_field_list'] ?? null); + + $row['work_order_status'] = $this->firstFilled($row['work_order_status'] ?? null, $row['mapped_status'] ?? null); + $row['work_order_title'] = $this->firstFilled($row['work_order_title'] ?? null, $row['mapped_title'] ?? null); + $row['work_order_type'] = $this->field($fields, ['工单类型']) ?: '小额打款'; + $row['payment_status'] = $this->firstFilled( + $this->field($fields, ['打款状态', '支付打款状态', '付款状态']), + $row['mapped_status'] ?? null, + ); + $row['payment_amount'] = $this->money($this->field($fields, [ + '打款金额', '付款金额', '小额打款金额', 'amountField', + ])); + $row['online_order_no'] = $this->field($fields, [ + '线上订单号', '线上单号', '订单号', 'orderField', + ]); + $row['buyer_account'] = $this->field($fields, [ + '买家账号', '买家帐号', '买家', 'buyerIdField', + ]); + $row['alipay_account'] = $this->field($fields, [ + '支付宝账号', '支付宝帐号', 'alipayAccountField', + ]); + $row['alipay_name'] = $this->field($fields, [ + '支付宝姓名', '支付宝实名', '支付宝账户姓名', 'alipayNameField', + ]); + $row['product_code'] = $this->field($fields, [ + '商品编码', '商品ID', '商品编号', 'iid', + ]); + $row['sku_code'] = $this->field($fields, [ + 'SKU编码', 'SKU', '规格编码', 'skuId', + ]); + $row['platform'] = $this->field($fields, ['平台', '平台名称', 'platform']); + $row['actual_paid_amount'] = $this->money($this->field($fields, [ + '实际已付金额', '实付金额', '买家实付金额', 'actualPaidAmountField', + ])); + $row['payment_time'] = $this->field($fields, [ + '支付时间', '打款时间', '付款时间', 'payDateField', + ]); + unset($row['business_field_list'], $row['mapped_status'], $row['mapped_title']); + + return $row; + } + + /** @return array */ + private function businessFields(mixed $json): array + { + if (! is_string($json) || trim($json) === '') { + return []; + } + + try { + $decoded = json_decode($json, true, 64, JSON_THROW_ON_ERROR); + } catch (Throwable) { + return []; + } + + $fields = []; + $this->flattenFields($decoded, $fields); + + return $fields; + } + + /** @param array $fields */ + private function flattenFields(mixed $node, array &$fields): void + { + if (! is_array($node)) { + return; + } + + $label = $this->arrayValue($node, [ + 'fieldName', 'field_name', 'label', 'name', 'title', 'fieldId', 'field_id', + ]); + $value = $this->arrayValue($node, [ + 'fieldValue', 'field_value', 'valueLabel', 'value_label', 'value', 'text', + ]); + if ($label !== '' && $value !== '') { + $fields[$this->normalizeLabel($label)] = $value; + } + + foreach ($node as $key => $child) { + if (is_string($key) && ! is_array($child) && ! is_object($child)) { + $text = trim((string) $child); + if ($text !== '') { + $fields[$this->normalizeLabel($key)] ??= $text; + } + } + + if (is_array($child)) { + $this->flattenFields($child, $fields); + } + } + } + + /** @param array $row @param list $keys */ + private function arrayValue(array $row, array $keys): string + { + foreach ($keys as $key) { + if (array_key_exists($key, $row) && is_scalar($row[$key])) { + $value = trim((string) $row[$key]); + if ($value !== '') { + return $value; + } + } + } + + return ''; + } + + /** @param array $fields @param list $labels */ + private function field(array $fields, array $labels): string + { + foreach ($labels as $label) { + $value = $fields[$this->normalizeLabel($label)] ?? ''; + if ($value !== '') { + return $value; + } + } + + return ''; + } + + private function normalizeLabel(string $label): string + { + return mb_strtolower((string) preg_replace('/[\s::()()_\-]+/u', '', trim($label))); + } + + private function money(string $value): ?string + { + $normalized = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim($value)); + if (preg_match('/^(-?)(\d+)(?:\.(\d{1,6}))?$/', $normalized, $matches) !== 1) { + return null; + } + + $fraction = str_pad($matches[3] ?? '', 3, '0'); + $cents = ((int) $matches[2] * 100) + (int) substr($fraction, 0, 2); + if ((int) $fraction[2] >= 5) { + $cents++; + } + + $sign = $matches[1] === '-' && $cents !== 0 ? '-' : ''; + + return $sign . intdiv($cents, 100) . '.' . str_pad((string) ($cents % 100), 2, '0', STR_PAD_LEFT); + } + + /** @param list $amounts */ + private function sumMoney(array $amounts): string + { + $cents = 0; + foreach ($amounts as $amount) { + if ($amount === null || ! preg_match('/^(-?)(\d+)\.(\d{2})$/', (string) $amount, $matches)) { + continue; + } + + $value = ((int) $matches[2] * 100) + (int) $matches[3]; + $cents += $matches[1] === '-' ? -$value : $value; + } + + $sign = $cents < 0 ? '-' : ''; + $absolute = abs($cents); + + return $sign . intdiv($absolute, 100) . '.' . str_pad((string) ($absolute % 100), 2, '0', STR_PAD_LEFT); + } + + private function firstFilled(mixed ...$values): string + { + foreach ($values as $value) { + $text = trim((string) $value); + if ($text !== '') { + return $text; + } + } + + return ''; + } + + private function contains(string $value): string + { + return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%'; + } + + private function dataset(string $key): BusinessMirrorDataset + { + return $this->datasets->get($key); + } +} diff --git a/resources/views/business/pages/refunds/small-payment-work-orders.blade.php b/resources/views/business/pages/refunds/small-payment-work-orders.blade.php new file mode 100644 index 00000000..2d3795e1 --- /dev/null +++ b/resources/views/business/pages/refunds/small-payment-work-orders.blade.php @@ -0,0 +1,245 @@ +@php + $columns = [ + ['label' => '工单编号', 'field' => 'work_order_no'], + ['label' => '支付宝批次号', 'field' => 'alipay_batch_no'], + ['label' => '工单状态', 'field' => 'work_order_status'], + ['label' => '打款状态', 'field' => 'payment_status'], + ['label' => '工单标题', 'field' => 'work_order_title'], + ['label' => '工单内容', 'field' => 'work_order_content'], + ['label' => '工单类型', 'field' => 'work_order_type'], + ['label' => '优先级', 'field' => 'priority'], + ['label' => '打款金额', 'field' => 'payment_amount', 'number' => true], + ['label' => '催处理次数', 'field' => 'urging_count', 'number' => true], + ['label' => '店铺名称', 'field' => 'shop_name'], + ['label' => '线上订单号', 'field' => 'online_order_no'], + ['label' => '买家账号', 'field' => 'buyer_account'], + ['label' => '支付宝账号', 'field' => 'alipay_account'], + ['label' => '支付宝姓名', 'field' => 'alipay_name'], + ['label' => '商品编码', 'field' => 'product_code'], + ['label' => 'SKU编码', 'field' => 'sku_code'], + ['label' => '平台', 'field' => 'platform'], + ['label' => '实际已付金额', 'field' => 'actual_paid_amount', 'number' => true], + ['label' => '支付时间', 'field' => 'payment_time'], + ['label' => '打款原因', 'field' => 'payment_reason'], + ['label' => '创建人', 'field' => 'creator'], + ['label' => '创建时间', 'field' => 'work_order_created_at'], + ['label' => '完成时间', 'field' => 'completed_at'], + ]; + $syncCode = $smallPaymentSyncStatus['code'] ?? 'unavailable'; + $syncLabel = match ($syncCode) { + 'healthy' => '业务镜像已同步', + 'syncing' => '业务镜像同步中,当前显示最近成功数据', + 'failed' => '本轮同步失败,当前显示最近成功数据', + 'stale' => '业务镜像同步时间较早,当前显示最近成功数据', + 'initializing' => '业务镜像正在执行首次同步', + 'initial_failed' => '业务镜像首次同步失败', + default => '业务镜像尚未建立', + }; + $lastSucceededAt = $smallPaymentSyncStatus['last_succeeded_at'] ?? null; +@endphp + +
+ @include('business.pages.partials.upstream-data-styles') + + + +
+ + {{ $syncLabel }} + @if ($lastSucceededAt) + · 最近成功 {{ $lastSucceededAt }} + @endif +
+ + @if ($smallPaymentError) + + @elseif ($smallPaymentIndexPending) +
+ + 页面查询索引正在准备,生产同步轮转完成后将自动显示数据。 +
+ @endif + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ 筛选结果 + {{ number_format($rows->total()) }} 条 +
+
+ 本页打款金额合计 + ¥ {{ $smallPaymentPageAmount }} +
+
+ +
+
+ + + + @foreach ($columns as $column) + + @endforeach + + + + @forelse ($rows as $row) + + @foreach ($columns as $column) + @php + $value = data_get($row, $column['field']); + $isNumber = (bool) ($column['number'] ?? false); + @endphp + + @endforeach + + @empty + + + + @endforelse + +
$column['number'] ?? false])>{{ $column['label'] }}
$isNumber])> + {{ $value === null || $value === '' ? '-' : $value }} +
+ @if ($smallPaymentPending) + 首次同步尚未完成,完成后将自动显示小额打款工单。 + @elseif ($smallPaymentIndexPending) + 页面查询索引尚未就绪,当前不会执行高压力查询。 + @elseif ($smallPaymentError) + 数据暂时无法读取。 + @else + 当前筛选范围没有小额打款工单,可调整日期或清空筛选后重试。 + @endif +
+
+ +
+
diff --git a/routes/admin.php b/routes/admin.php index 77f0f702..b2e2c2d7 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -115,6 +115,10 @@ Route::get('workspace/refunds/non-jingxi-refund-data/export', 'Business\Pages@ex ->middleware('business.page:refunds,non-jingxi-refund-data') ->name('business.refunds.non-jingxi-refund-data.export'); +Route::get('workspace/refunds/small-payment-work-orders/export', 'Business\Pages@exportSmallPaymentWorkOrders') + ->middleware('business.page:refunds,small-payment-work-orders') + ->name('business.refunds.small-payment-work-orders.export'); + Route::get('workspace/courier-payables/courier-information/export', 'Business\Pages@exportCourierInformation') ->middleware('business.page:courier-payables,courier-information') ->name('business.courier-payables.courier-information.export'); diff --git a/tests/Feature/Business/SmallPaymentWorkOrderPageTest.php b/tests/Feature/Business/SmallPaymentWorkOrderPageTest.php new file mode 100644 index 00000000..16d316d0 --- /dev/null +++ b/tests/Feature/Business/SmallPaymentWorkOrderPageTest.php @@ -0,0 +1,167 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + } + + public function test_page_renders_filtered_mirror_rows_summary_and_pagination(): void + { + $this->grantAdminPermission(); + setting()->set('default.list_limit', 25); + $states = [ + SmallPaymentWorkOrderMirrorRepository::WORK_ORDER_DATASET => [ + 'status' => 'running', 'initial_completed_at' => '2026-09-01 00:00:00', + ], + SmallPaymentWorkOrderMirrorRepository::PAYMENT_MAP_DATASET => [ + 'status' => 'running', 'initial_completed_at' => '2026-09-01 00:00:00', + ], + ]; + $repository = \Mockery::mock(SmallPaymentWorkOrderMirrorRepository::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-09-03 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-09-01', 'to' => '2026-09-03', + 'work_order_no' => 'GD-001', 'shop_name' => '京东旗舰店', + 'transaction_no' => 'BATCH-001', 'online_order_no' => 'ORDER-001', + 'status' => '已完成', 'payment_reason' => '售后补偿', + ] && $page === 2 && $limit === 25 + )->andReturn([ + 'data' => [[ + 'work_order_no' => 'GD-001', 'alipay_batch_no' => 'BATCH-001', + 'work_order_status' => '已完成', 'payment_status' => '支付宝已打款', + 'work_order_title' => '小额打款', 'work_order_content' => '售后补偿', + 'work_order_type' => '小额打款', 'priority' => '普通', + 'payment_amount' => '50.00', 'urging_count' => 0, + 'shop_name' => '京东旗舰店', 'online_order_no' => 'ORDER-001', + 'buyer_account' => 'buyer-one', 'alipay_account' => 'account-one', + 'alipay_name' => '测试买家', 'product_code' => 'ITEM-001', + 'sku_code' => 'SKU-001', 'platform' => '京东', + 'actual_paid_amount' => '199.90', 'payment_time' => '2026-09-02 10:30:00', + 'payment_reason' => '售后补偿', 'creator' => '客服甲', + 'work_order_created_at' => '2026-09-02 09:00:00', + 'completed_at' => '2026-09-02 11:00:00', + ]], + 'total' => 26, + 'page_payment_amount' => '50.00', + ]); + $this->app->instance(SmallPaymentWorkOrderMirrorRepository::class, $repository); + + $url = route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'refunds', 'page' => 'small-payment-work-orders', + ]) . '?' . http_build_query([ + 'from' => '2026-09-01', 'to' => '2026-09-03', 'work_order_no' => 'GD-001', + 'shop_name' => '京东旗舰店', 'transaction_no' => 'BATCH-001', + 'online_order_no' => 'ORDER-001', 'status' => '已完成', + 'payment_reason' => '售后补偿', 'page' => 2, + ]); + + $this->loginAs()->get($url) + ->assertOk() + ->assertSee('data-small-payment-work-orders="true"', false) + ->assertSee('data-filter-contract="small-payment-work-orders.filters"', false) + ->assertSee('data-sync-state="syncing"', false) + ->assertSeeText('业务镜像同步中,当前显示最近成功数据') + ->assertSee('aria-label="聚水潭小额打款工单"', false) + ->assertSeeText('GD-001') + ->assertSeeText('支付宝已打款') + ->assertSeeText('¥ 50.00') + ->assertSeeText('共 26 条记录,本页 1 条') + ->assertSeeText('第 2 / 2 页') + ->assertSee('workspace/refunds/small-payment-work-orders/export', false) + ->assertSee('value="ORDER-001"', false); + } + + public function test_export_downloads_filtered_csv(): void + { + $this->grantAdminPermission(); + $repository = \Mockery::mock(SmallPaymentWorkOrderMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('queryReady')->once()->andReturnTrue(); + $repository->shouldReceive('exportRows')->once()->andReturn([[ + 'work_order_no' => 'GD-001', 'alipay_batch_no' => 'BATCH-001', + 'work_order_status' => '已完成', 'payment_status' => '支付宝已打款', + 'work_order_title' => '小额打款', 'work_order_content' => '售后补偿', + 'work_order_type' => '小额打款', 'priority' => '普通', + 'payment_amount' => '50.00', 'urging_count' => 0, + 'shop_name' => '京东旗舰店', 'online_order_no' => 'ORDER-001', + 'buyer_account' => 'buyer-one', 'alipay_account' => 'account-one', + 'alipay_name' => '测试买家', 'product_code' => 'ITEM-001', + 'sku_code' => 'SKU-001', 'platform' => '京东', + 'actual_paid_amount' => '199.90', 'payment_time' => '2026-09-02 10:30:00', + 'payment_reason' => '售后补偿', 'creator' => '客服甲', + 'work_order_created_at' => '2026-09-02 09:00:00', + 'completed_at' => '2026-09-02 11:00:00', + ]]); + $this->app->instance(SmallPaymentWorkOrderMirrorRepository::class, $repository); + + $response = $this->loginAs()->get(route('business.refunds.small-payment-work-orders.export', [ + 'from' => '2026-09-01', 'to' => '2026-09-03', + ])); + + $response->assertOk()->assertDownload('small_payment_work_orders_2026-09-01_2026-09-03.csv'); + $content = $response->streamedContent(); + $this->assertStringStartsWith("\xEF\xBB\xBF", $content); + $this->assertStringContainsString('工单编号', $content); + $this->assertStringContainsString('GD-001', $content); + $this->assertStringContainsString('50.00', $content); + } + + public function test_page_does_not_report_empty_before_both_mirrors_finish(): void + { + $this->grantAdminPermission(); + $states = [ + SmallPaymentWorkOrderMirrorRepository::WORK_ORDER_DATASET => ['status' => 'running'], + SmallPaymentWorkOrderMirrorRepository::PAYMENT_MAP_DATASET => ['status' => 'running'], + ]; + $repository = \Mockery::mock(SmallPaymentWorkOrderMirrorRepository::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('queryReady'); + $repository->shouldNotReceive('paginate'); + $this->app->instance(SmallPaymentWorkOrderMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'refunds', 'page' => 'small-payment-work-orders', + ])) + ->assertOk() + ->assertSeeText('业务镜像正在执行首次同步') + ->assertSeeText('首次同步尚未完成,完成后将自动显示小额打款工单。') + ->assertDontSeeText('当前筛选范围没有小额打款工单'); + } + + 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' => 'small-payment-work-orders', + ]))->assertForbidden(); + } +} diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 317d4e93..ceccd322 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -90,11 +90,11 @@ class NavigationTest extends FeatureTestCase $this->assertSame('退款', $refund['title']); $this->assertSame('assignment_return', $refund['icon']); $this->assertSame( - ['jingxi-refund-data', 'non-jingxi-refund-data'], + ['jingxi-refund-data', 'non-jingxi-refund-data', 'small-payment-work-orders'], array_column($refund['pages'], 'slug') ); $this->assertSame( - ['京喜退款数据', '售后(退货退款)'], + ['京喜退款数据', '售后(退货退款)', '小额打款'], array_column($refund['pages'], 'title') ); } diff --git a/tests/Unit/EcommerceFinance/SmallPaymentWorkOrderMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/SmallPaymentWorkOrderMirrorRepositoryTest.php new file mode 100644 index 00000000..9068bd75 --- /dev/null +++ b/tests/Unit/EcommerceFinance/SmallPaymentWorkOrderMirrorRepositoryTest.php @@ -0,0 +1,239 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.datasets' => [ + SmallPaymentWorkOrderMirrorRepository::WORK_ORDER_DATASET => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_workOrderInfo', + 'target' => 'fixture_work_orders', 'keys' => ['Id'], 'watermark' => 'updateTime', + 'indexes' => [['woTypeId', 'woNo', 'Id']], + ], + SmallPaymentWorkOrderMirrorRepository::PAYMENT_MAP_DATASET => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_small_payment_order_map', + 'target' => 'fixture_payment_map', 'keys' => ['woNo'], 'watermark' => 'updated_at', + 'indexes' => [['woTypeId', 'work_order_create_time', 'woNo']], + ], + ], + ]); + DB::purge('business_mirror'); + + $schema = Schema::connection('business_mirror'); + $schema->create('business_mirror_table_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + }); + $schema->create('fixture_work_orders', function (Blueprint $table): void { + $table->integer('Id'); + foreach (['woNo', 'woTypeId', 'title', 'content', 'status', 'grade', 'createby', 'executors', 'completer', 'completeTime', 'created', 'updated', 'updateTime', 'businessFieldList'] as $column) { + $table->text($column)->nullable(); + } + $table->integer('urging')->nullable(); + $table->index(['woTypeId', 'woNo', 'Id']); + }); + $schema->create('fixture_payment_map', function (Blueprint $table): void { + foreach (['woNo', 'woTypeId', 'transaction_no', 'shop_name', 'payment_reason', 'title', 'status', 'work_order_create_time', 'work_order_update_time', 'parse_status', 'parse_error', 'updated_at'] as $column) { + $table->text($column)->nullable(); + } + $table->index(['woTypeId', 'work_order_create_time', 'woNo']); + }); + + foreach (array_keys(config('upstream_mirrors.datasets')) as $key) { + DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ + 'dataset_key' => $key, + 'status' => 'succeeded', + 'initial_completed_at' => '2026-09-01 00:00:00', + 'last_succeeded_at' => '2026-09-03 12:00:00', + ]); + } + + $type = SmallPaymentWorkOrderMirrorRepository::SMALL_PAYMENT_WORK_TYPE_ID; + $workOrderDefaults = array_fill_keys([ + 'Id', 'woNo', 'woTypeId', 'title', 'content', 'status', 'grade', + 'urging', 'createby', 'executors', 'completer', 'completeTime', + 'created', 'updated', 'updateTime', 'businessFieldList', + ], null); + $workOrderRows = [ + [ + 'Id' => 1, 'woNo' => 'GD-001', 'woTypeId' => $type, + 'title' => '小额打款一', 'content' => '售后补偿', 'status' => '已完成', + 'grade' => '普通', 'urging' => 1, 'createby' => '客服甲', 'executors' => '财务甲', + 'completer' => '财务甲', 'completeTime' => '2026-09-02 11:00:00', + 'created' => '2026-09-02 09:00:00', 'updated' => '2026-09-02 11:00:00', + 'updateTime' => '2026-09-02 11:01:00', + 'businessFieldList' => json_encode([ + ['fieldName' => '打款状态', 'fieldValue' => '支付宝已打款'], + ['fieldName' => '打款金额', 'fieldValue' => '50.005'], + ['fieldName' => '线上订单号', 'fieldValue' => 'ORDER-001'], + ['fieldName' => '买家账号', 'fieldValue' => 'buyer-one'], + ['fieldName' => '支付宝账号', 'fieldValue' => 'account-one'], + ['fieldName' => '支付宝姓名', 'fieldValue' => '测试买家'], + ['fieldName' => '商品编码', 'fieldValue' => 'ITEM-001'], + ['fieldName' => 'SKU编码', 'fieldValue' => 'SKU-001'], + ['fieldName' => '平台', 'fieldValue' => '京东'], + ['fieldName' => '实际已付金额', 'fieldValue' => '199.90'], + ['fieldName' => '支付时间', 'fieldValue' => '2026-09-02 10:30:00'], + ], JSON_UNESCAPED_UNICODE), + ], + [ + 'Id' => 2, 'woNo' => 'GD-002', 'woTypeId' => $type, + 'title' => '小额打款二', 'content' => '少收多收', 'status' => '处理中', + 'grade' => '紧急', 'urging' => 0, 'createby' => '客服乙', + 'created' => '2026-09-03 09:00:00', 'updated' => '2026-09-03 09:30:00', + 'updateTime' => '2026-09-03 09:31:00', + 'businessFieldList' => json_encode([ + ['fieldId' => 'shopField', 'type' => 'SELECT', 'value' => '18526685', 'valueLabel' => '天猫-亿家达旗舰店'], + ['fieldId' => 'alipayAccountField', 'type' => 'TEXT', 'value' => 'account-two'], + ['fieldId' => 'alipayNameField', 'type' => 'TEXT', 'value' => '测试买家二'], + ['fieldId' => 'buyerIdField', 'type' => 'TEXT', 'value' => 'buyer-two'], + ['childFieldList' => [[ + ['fieldId' => 'iid', 'type' => 'TEXT', 'value' => 'ITEM-002'], + ['fieldId' => 'skuId', 'type' => 'TEXT', 'value' => 'SKU-002'], + ]], 'fieldId' => 'itemInfo', 'type' => 'FORM'], + ['fieldId' => 'actualPaidAmountField', 'type' => 'MONEY', 'value' => '188.88'], + ['fieldId' => 'platform', 'type' => 'SELECT', 'value' => '淘宝天猫'], + ['fieldId' => 'payDateField', 'type' => 'DATE', 'value' => '2026-09-03 08:30:00'], + ['fieldId' => 'amountField', 'type' => 'MONEY', 'value' => '20'], + ['fieldId' => 'orderField', 'type' => 'TEXT', 'value' => 'ORDER-002'], + ], JSON_UNESCAPED_UNICODE), + ], + [ + 'Id' => 3, 'woNo' => 'OTHER-001', 'woTypeId' => 'OTHER', + 'title' => '其他工单', 'status' => '已完成', + 'created' => '2026-09-03 10:00:00', 'updated' => '2026-09-03 10:00:00', + 'updateTime' => '2026-09-03 10:00:00', 'businessFieldList' => '{}', + ], + ]; + DB::connection('business_mirror')->table('fixture_work_orders')->insert(array_map( + static fn (array $row): array => array_replace($workOrderDefaults, $row), + $workOrderRows, + )); + DB::connection('business_mirror')->table('fixture_payment_map')->insert([ + [ + 'woNo' => 'GD-001', 'woTypeId' => $type, 'transaction_no' => 'BATCH-001', + 'shop_name' => '京东旗舰店', 'payment_reason' => '售后补偿', 'title' => '小额打款一', + 'status' => '已完成', 'work_order_create_time' => '2026-09-02 09:00:00', + 'work_order_update_time' => '2026-09-02 11:00:00', 'parse_status' => 'success', + 'updated_at' => '2026-09-02 11:01:00', + ], + [ + 'woNo' => 'GD-002', 'woTypeId' => $type, 'transaction_no' => 'BATCH-002', + 'shop_name' => '天猫旗舰店', 'payment_reason' => '少收多收', 'title' => '小额打款二', + 'status' => '处理中', 'work_order_create_time' => '2026-09-03 09:00:00', + 'work_order_update_time' => '2026-09-03 09:30:00', 'parse_status' => 'success', + 'updated_at' => '2026-09-03 09:31:00', + ], + [ + 'woNo' => 'OTHER-001', 'woTypeId' => 'OTHER', 'transaction_no' => 'BATCH-003', + 'shop_name' => '其他店铺', 'payment_reason' => '其他', 'title' => '其他工单', + 'status' => '已完成', 'work_order_create_time' => '2026-09-03 10:00:00', + 'work_order_update_time' => '2026-09-03 10:00:00', 'parse_status' => 'success', + 'updated_at' => '2026-09-03 10:00:00', + ], + ]); + + $this->repository = new SmallPaymentWorkOrderMirrorRepository( + new BusinessMirrorDatasetRegistry(), + new GenericBusinessMirrorRepository(app('db')), + ); + } + + public function test_filters_paginates_and_parses_small_payment_business_fields(): void + { + $result = $this->repository->paginate($this->filters([ + 'shop_name' => '京东', + 'online_order_no' => 'ORDER-001', + 'status' => '已完成', + ]), 1, 25); + + $this->assertSame(1, $result['total']); + $this->assertSame('50.01', $result['page_payment_amount']); + $this->assertSame('GD-001', $result['data'][0]['work_order_no']); + $this->assertSame('小额打款', $result['data'][0]['work_order_type']); + $this->assertSame('支付宝已打款', $result['data'][0]['payment_status']); + $this->assertSame('ORDER-001', $result['data'][0]['online_order_no']); + $this->assertSame('SKU-001', $result['data'][0]['sku_code']); + $this->assertSame('199.90', $result['data'][0]['actual_paid_amount']); + $this->assertArrayNotHasKey('business_field_list', $result['data'][0]); + } + + public function test_stable_order_second_page_and_exact_work_type_filter(): void + { + $result = $this->repository->paginate($this->filters(), 2, 1); + + $this->assertSame(2, $result['total']); + $this->assertSame('GD-001', $result['data'][0]['work_order_no']); + $this->assertSame('50.01', $result['page_payment_amount']); + } + + public function test_export_uses_same_filters_and_parsed_shape(): void + { + $rows = iterator_to_array($this->repository->exportRows($this->filters([ + 'transaction_no' => 'BATCH-002', + 'payment_reason' => '少收', + ]), 1)); + + $this->assertCount(1, $rows); + $this->assertSame('GD-002', $rows[0]['work_order_no']); + $this->assertSame('20.00', $rows[0]['payment_amount']); + $this->assertSame('ORDER-002', $rows[0]['online_order_no']); + $this->assertSame('buyer-two', $rows[0]['buyer_account']); + $this->assertSame('account-two', $rows[0]['alipay_account']); + $this->assertSame('测试买家二', $rows[0]['alipay_name']); + $this->assertSame('ITEM-002', $rows[0]['product_code']); + $this->assertSame('SKU-002', $rows[0]['sku_code']); + $this->assertSame('淘宝天猫', $rows[0]['platform']); + $this->assertSame('188.88', $rows[0]['actual_paid_amount']); + $this->assertSame('2026-09-03 08:30:00', $rows[0]['payment_time']); + $this->assertSame('处理中', $rows[0]['payment_status']); + } + + public function test_unready_state_is_explicit(): void + { + DB::connection('business_mirror')->table('business_mirror_table_states') + ->where('dataset_key', SmallPaymentWorkOrderMirrorRepository::PAYMENT_MAP_DATASET) + ->update(['initial_completed_at' => null, 'status' => 'running']); + + $states = $this->repository->states(); + $status = $this->repository->syncStatus($states); + + $this->assertFalse($this->repository->ready()); + $this->assertSame('initializing', $status['code']); + $this->expectException(\RuntimeException::class); + $this->repository->paginate($this->filters(), 1, 25); + } + + /** @param array $overrides @return array */ + private function filters(array $overrides = []): array + { + return array_replace([ + 'from' => '2026-09-01', 'to' => '2026-09-30', + 'work_order_no' => '', 'shop_name' => '', 'transaction_no' => '', + 'online_order_no' => '', 'status' => '', 'payment_reason' => '', + ], $overrides); + } +} -- 2.47.2