diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index df3da862..fb9ee109 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -18,6 +18,7 @@ use Illuminate\Support\Collection; use Illuminate\Database\QueryException; use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException; use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; +use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\InventoryRepository; use Modules\EcommerceFinance\Repositories\JdWalletBillRepository; @@ -48,6 +49,7 @@ class Pages extends Controller private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds, private readonly ProductCatalogMirrorRepository $productCatalog, private readonly ProductPriceMirrorRepository $productPrices, + private readonly CourierInformationMirrorRepository $courierInformation, private readonly RawBoardPayablesRepository $rawBoardPayables, private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows, private readonly SupplierPayableReportRepository $supplierPayableReports, @@ -288,6 +290,7 @@ class Pages extends Controller 'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(), 'normal-products' => $this->contentForNormalProducts(), 'product-prices' => $this->contentForProductPrices(), + 'courier-information' => $this->contentForCourierInformation(), 'board-deductions' => $this->contentForBoardDeductions(), 'supplier-payables-report' => $this->contentForSupplierPayablesReport(), 'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(), @@ -1166,6 +1169,66 @@ class Pages extends Controller ]); } + public function exportCourierInformation(Request $request): StreamedResponse + { + abort_unless($this->courierInformation->available() && $this->courierInformation->ready(), 503); + + $filters = $this->courierInformationFilters($request); + + return response()->streamDownload(function () use ($filters): void { + $csv = fopen('php://output', 'w'); + if ($csv === false) { + throw new \RuntimeException('无法创建快递资料导出流。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, [ + 'ID', '快递公司代码', '快递公司名称', '外部编码', '杉橙快递代码', '运费打款方式', + '是否启用', '可达服务', '是否快运', '自动寻号', '配货单号寻号', '数量拆单', '重量0寻号', + '停运时间', '合同开始时间', '合同结束时间', '联系人', '联系电话', '自定义1', '自定义2', '自定义3', + '体积0寻号', '手工单号', '解密', '最后修改', + ]); + + $bool = static fn (mixed $value): string => in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否'; + $date = static fn (mixed $value): string => $value === null || $value === '' ? '' : substr((string) $value, 0, 10); + + foreach ($this->courierInformation->exportRows($filters) as $row) { + fputcsv($csv, [ + $row->Id, + $this->csvText($row->expcode), + $this->csvText($row->expname), + $this->csvText($row->outcode), + $this->csvText($row->scexpcode), + $this->csvText($row->paymethod), + $bool($row->status), + $bool($row->addvalidsgn), + $bool($row->kuaiyunsgn), + $bool($row->autonosgn), + $bool($row->packnosgn), + $bool($row->skucountsplitsgn), + $bool($row->weight0sgn), + $date($row->stopdate), + $date($row->tradebtime), + $date($row->tradeetime), + $this->csvText($row->linker), + $this->csvText($row->linkmobile), + $this->csvText($row->undefined1), + $this->csvText($row->undefined2), + $this->csvText($row->undefined3), + $bool($row->volume0sgn), + $bool($row->handnosgn), + $bool($row->decryptsgn), + $date($row->modified), + ]); + } + + fclose($csv); + }, '快递资料_' . now()->format('Ymd_His') . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + /** @return array */ private function normalProductFilters(Request $request): array { @@ -1236,6 +1299,129 @@ class Pages extends Controller ]; } + protected function contentForCourierInformation(): array + { + $filters = $this->courierInformationFilters(request()); + $tab = (string) request()->query('courier_tab', 'express'); + $tab = in_array($tab, ['express', 'networks', 'accounts', 'logs'], true) ? $tab : 'express'; + $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()->except('page'), + ]); + $error = null; + $pending = false; + $state = null; + $syncStatus = ['code' => 'unavailable', 'lag_minutes' => null]; + $relatedAvailability = $this->courierInformation->relatedAvailability(); + + try { + if ($tab === 'express') { + if (! $this->courierInformation->available()) { + $error = '共享业务镜像尚未建立,快递资料暂时无法读取。'; + } else { + $state = $this->courierInformation->state(); + $syncStatus = $this->courierInformation->syncStatus($state); + if (! $this->courierInformation->ready()) { + $pending = true; + $error = '快递资料正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'; + } else { + $result = $this->courierInformation->paginate($filters, $page, $limit); + $rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page); + } + } + } elseif ($tab === 'networks') { + if (! ($relatedAvailability['networks'] ?? false)) { + $error = '快递网点镜像尚未建立,请先完成 wp_express_expNet 的首次同步。'; + } elseif (! ($relatedAvailability['network_ready'] ?? false)) { + $pending = true; + $error = '快递网点正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'; + } else { + $result = $this->courierInformation->paginateNetworks($filters['expcode'] ?: null, $page, $limit); + $rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page); + } + } elseif ($tab === 'accounts') { + if (! ($relatedAvailability['accounts'] ?? false)) { + $error = '打款账户镜像尚未建立,请先完成 wp_express_account 的首次同步。'; + } elseif (! ($relatedAvailability['account_ready'] ?? false)) { + $pending = true; + $error = '打款账户正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'; + } else { + $result = $this->courierInformation->paginateAccounts($filters['expcode'] ?: null, $page, $limit); + $rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page); + } + } else { + if (! ($relatedAvailability['logs'] ?? false)) { + $error = '操作日志镜像尚未建立,请先完成 wp_express_extension 的首次同步。'; + } elseif (! ($relatedAvailability['log_ready'] ?? false)) { + $pending = true; + $error = '操作日志正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'; + } else { + $result = $this->courierInformation->paginateLogs($filters['expcode'] ?: null, $page, $limit); + $rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page); + } + } + } catch (Throwable $exception) { + report($exception); + $error = '快递资料共享镜像暂时无法读取,请稍后重试。'; + } + + return [ + 'view' => 'business.pages.courier-payables.courier-information', + 'courierTab' => $tab, + 'courierFilters' => $filters, + 'courierRows' => $rows, + 'courierState' => $state, + 'courierSyncStatus' => $syncStatus, + 'courierError' => $error, + 'courierPending' => $pending, + 'courierRelatedAvailability' => $relatedAvailability, + ]; + } + + /** @return array */ + private function courierInformationFilters(Request $request): array + { + $filters = [ + 'expcode' => mb_substr(trim((string) $request->query('expcode', '')), 0, 64), + 'expname' => mb_substr(trim((string) $request->query('expname', '')), 0, 100), + 'undefined1' => mb_substr(trim((string) $request->query('undefined1', '')), 0, 100), + 'undefined2' => mb_substr(trim((string) $request->query('undefined2', '')), 0, 100), + 'undefined3' => mb_substr(trim((string) $request->query('undefined3', '')), 0, 100), + 'tradebtime_from' => $this->dateFilter($request->query('tradebtime_from')), + 'tradebtime_to' => $this->dateFilter($request->query('tradebtime_to')), + 'status' => in_array((string) $request->query('status', ''), ['0', '1'], true) + ? (string) $request->query('status') + : '', + ]; + + if ($filters['tradebtime_from'] !== '' + && $filters['tradebtime_to'] !== '' + && $filters['tradebtime_from'] > $filters['tradebtime_to']) { + [$filters['tradebtime_from'], $filters['tradebtime_to']] = [ + $filters['tradebtime_to'], + $filters['tradebtime_from'], + ]; + } + + return $filters; + } + + private function courierPaginator(array $rows, int $total, int $limit, int $page): LengthAwarePaginator + { + return new LengthAwarePaginator( + Collection::make($rows), + $total, + $limit, + $page, + [ + 'path' => request()->url(), + 'query' => request()->except('page'), + ], + ); + } + protected function contentForProductPrices(): array { $filters = [ diff --git a/config/business_navigation.php b/config/business_navigation.php index 35bc03f9..2631123a 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -418,6 +418,7 @@ return [ 'icon' => 'local_shipping', 'pages' => [ ['slug' => 'quote-management', 'title' => '报价管理'], + ['slug' => 'courier-information', 'title' => '快递资料'], ['slug' => 'agreement-signing', 'title' => '协议签署'], ['slug' => 'freight-reconciliation', 'title' => '运费对账'], ['slug' => 'bookkeeping-details', 'title' => '记账明细'], diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index ddd826c1..5b287a52 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -55,6 +55,10 @@ return [ 'reconcile_window_hours' => (int) env('PRODUCT_PURCHASE_PRICE_MIRROR_RECONCILE_WINDOW_HOURS', 6), ], + 'courier_information' => [ + 'stale_after_minutes' => (int) env('COURIER_INFORMATION_MIRROR_STALE_MINUTES', 60), + ], + /* |-------------------------------------------------------------------------- | 上游原表镜像目录 @@ -174,5 +178,53 @@ return [ 'jiansun.dbo.fys_supplierGoods_sttlement_purchase' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_supplierGoods_sttlement_purchase', 'target' => 'js_supplier_goods_purchase', 'keys' => ['Id'], 'watermark' => 'createTime'], 'jiansun.dbo.fys_supplierGoods_sttlement_sale' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_supplierGoods_sttlement_sale', 'target' => 'js_supplier_goods_sale', 'keys' => ['Id'], 'watermark' => 'createTime'], 'jiansun.dbo.pm_purchaseIn' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_purchaseIn', 'target' => 'js_pm_purchase_in', 'keys' => ['id'], 'watermark' => 'updateTime'], + + // 工作平台 2.0:快递资料及其网点、打款账户。页面只读取共享镜像, + // 首次使用前需由刷新节点显式执行对应数据集的 --full 同步。 + 'jiansun.dbo.wp_express' => [ + 'source_key' => 'jiansun', + 'schema' => 'dbo', + 'object' => 'wp_express', + 'target' => 'js_wp_express', + 'keys' => ['Id'], + 'watermark' => 'modified', + 'indexes' => [ + ['expcode'], + ['status', 'Id'], + ], + ], + 'jiansun.dbo.wp_express_expNet' => [ + 'source_key' => 'jiansun', + 'schema' => 'dbo', + 'object' => 'wp_express_expNet', + 'target' => 'js_wp_express_exp_net', + 'keys' => ['id'], + 'indexes' => [ + ['expid', 'id'], + ['status', 'id'], + ], + ], + 'jiansun.dbo.wp_express_account' => [ + 'source_key' => 'jiansun', + 'schema' => 'dbo', + 'object' => 'wp_express_account', + 'target' => 'js_wp_express_account', + 'keys' => ['Id'], + 'indexes' => [ + ['expcode', 'Id'], + ['accountType', 'Id'], + ], + ], + 'jiansun.dbo.wp_express_extension' => [ + 'source_key' => 'jiansun', + 'schema' => 'dbo', + 'object' => 'wp_express_extension', + 'target' => 'js_wp_express_extension', + 'keys' => ['Id'], + 'watermark' => 'modifyTime', + 'indexes' => [ + ['expcode', 'Id'], + ], + ], ], ]; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 8a08a0b1..11710982 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -2,7 +2,7 @@ ## 结论 -项目不是只使用 `rpt.Bulk_Price_Limit` 一张表。当前主线代码直接读取两套上游中的约 40 个表或视图,覆盖组织资料、订单、退款、营销账户、供应商应付、板材、封边条和库存。 +项目不是只使用 `rpt.Bulk_Price_Limit` 一张表。当前主线代码直接读取两套上游中的约 43 个表或视图,覆盖组织资料、订单、退款、营销账户、供应商应付、快递资料、板材、封边条和库存。 `caiwu_business_mirror` 的目标是把项目实际使用的上游原表数据保存为共享业务镜像。网页只读镜像,后台刷新节点在低压、可断点的条件下完成首次全量和后续增量;不得再按页码或筛选条件临时生成不完整的数据副本。 @@ -14,6 +14,7 @@ | 已完成 | `operations_replica/rpt.jst_sku` 已进入 `ops_jst_sku`,普通商品资料页面固定读取 `sku_type = normal` 的共享镜像,并支持稳定分页和业务筛选。 | | 已接入校准 | 聚水潭“普通商品资料”完整 XLSX 导出通过 `product-catalog:import-authoritative-export` 流式导入;导出中不存在的经营分析副本历史行写入 `product_catalog_exclusions`,页面与导出统一排除,但不删除 `ops_jst_sku` 原始镜像行。 | | 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 | +| 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 | | 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 | | 已开始逐域切换 | 公司、店铺、商标页面在对应镜像完成首次全量后自动改读完整镜像表;首次全量前保留旧快照作为兼容回滚。其他业务域仍需逐项切换,不得宣称全项目已经完成。 | @@ -42,6 +43,7 @@ | 封边条 | `dbo.pm_edgeBandData`、`dbo.pm_edgeBandApplyDetail`、`dbo.pm_edgeBandProfitInfo`、`dbo.finance_edgeBandDeductio`、`dbo.pm_averageUnitPrice` | | 库存与货款对账 | `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` | 其中 `fys_supplierGoods_sttlement_sale` 当前约 280 万行,是首次全量同步的最大对象。必须在低峰期小批量、可断点执行,禁止把全量同步塞进发布流程。 @@ -60,8 +62,9 @@ 1. 公司、店铺、商标等小型维表。 2. 营销账户五张单表账单。 -3. 供应商应付与货款对账。 -4. 订单、退款和推广费用。 -5. 板材、封边条和库存的跨表查询。 +3. 快递资料、网点和打款账户。 +4. 供应商应付与货款对账。 +5. 订单、退款和推广费用。 +6. 板材、封边条和库存的跨表查询。 每个阶段都必须先完成首次全量和数量核对,再将对应 Repository 切到镜像;任一阶段失败时保留上一版镜像和旧只读链路用于回滚。 diff --git a/modules/EcommerceFinance/Repositories/CourierInformationMirrorRepository.php b/modules/EcommerceFinance/Repositories/CourierInformationMirrorRepository.php new file mode 100644 index 00000000..dc331d5a --- /dev/null +++ b/modules/EcommerceFinance/Repositories/CourierInformationMirrorRepository.php @@ -0,0 +1,319 @@ +connection = $mirror->connection(); + } + + public function available(): bool + { + $schema = Schema::connection($this->connection->getName()); + + return $schema->hasTable('business_mirror_table_states') + && $schema->hasTable($this->dataset()->targetTable()); + } + + public function ready(): bool + { + return $this->mirror->ready($this->dataset()); + } + + /** @return array|null */ + public function state(): ?array + { + return $this->mirror->state($this->dataset()); + } + + /** + * @param array|null $state + * @return array{code:string,lag_minutes:?int} + */ + public function syncStatus(?array $state = null, ?CarbonInterface $at = null): array + { + $state ??= $this->state(); + if (! is_array($state)) { + return ['code' => 'unavailable', 'lag_minutes' => null]; + } + + $lastSucceeded = $this->timestamp($state['last_succeeded_at'] ?? null); + $lagMinutes = $lastSucceeded === null + ? null + : (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded) / 60); + $status = (string) ($state['status'] ?? 'pending'); + + if (($state['initial_completed_at'] ?? null) === null) { + $code = $status === 'failed' + ? 'initial_failed' + : ($status === 'running' ? 'initializing' : 'pending'); + } elseif ($status === 'failed') { + $code = 'failed'; + } elseif ($status === 'running') { + $code = ($state['run_mode'] ?? null) === 'full' ? 'reconciling' : 'syncing'; + } elseif ($lagMinutes === null + || $lagMinutes > max((int) config('upstream_mirrors.courier_information.stale_after_minutes', 60), 1)) { + $code = 'delayed'; + } else { + $code = 'healthy'; + } + + return ['code' => $code, 'lag_minutes' => $lagMinutes]; + } + + /** + * @param array $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->filteredExpressQuery($filters); + $total = (clone $query)->count(); + $rows = $query->orderBy('Id') + ->forPage($page, $perPage) + ->get() + ->all(); + + return ['data' => $rows, 'total' => $total]; + } + + /** + * @param array $filters + * @return \Generator + */ + public function exportRows(array $filters, int $batchSize = 500): \Generator + { + if (! $this->ready()) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + + $batchSize = min(max($batchSize, 1), 5000); + $lastId = null; + + while (true) { + $query = $this->filteredExpressQuery($filters); + if ($lastId !== null) { + $query->where('Id', '>', $lastId); + } + + $rows = $query->orderBy('Id')->limit($batchSize)->get()->all(); + if ($rows === []) { + return; + } + + foreach ($rows as $row) { + yield $row; + } + + $lastId = (int) $rows[array_key_last($rows)]->Id; + if (count($rows) < $batchSize) { + return; + } + } + } + + /** + * 网点标签数据。快递编码为空时返回全部网点;传入编码时只返回对应网点。 + * @return array{data:list,total:int} + */ + public function paginateNetworks(?string $expcode, int $page, int $perPage): array + { + $dataset = $this->datasets->get(self::NETWORK_DATASET_KEY); + if (! $this->mirror->ready($dataset)) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + + $query = $this->connection->table($dataset->targetTable())->select([ + 'id', 'expid', 'expcode', 'netcode', 'netno', 'netname', 'isbill', + 'province', 'city', 'area', 'town', 'address', 'linker', 'linkermobile', + 'status', 'apitype', 'apipltmname', 'created', 'expnum', + ]); + if ($expcode !== null && $expcode !== '') { + $query->where('expcode', $expcode); + } + + $page = max($page, 1); + $perPage = min(max($perPage, 1), 100); + $total = (clone $query)->count(); + + return [ + 'data' => $query->orderByDesc('id')->forPage($page, $perPage)->get()->all(), + 'total' => $total, + ]; + } + + /** + * 打款账户标签数据。 + * @return array{data:list,total:int} + */ + public function paginateAccounts(?string $expcode, int $page, int $perPage): array + { + $dataset = $this->datasets->get(self::ACCOUNT_DATASET_KEY); + if (! $this->mirror->ready($dataset)) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + + $query = $this->connection->table($dataset->targetTable())->select([ + 'Id', 'expcode', 'expname', 'accountType', 'account', 'accountName', + 'accountkhh', 'createTime', 'invoice', 'erpId', 'erpAccountId', + ]); + if ($expcode !== null && $expcode !== '') { + $query->where('expcode', $expcode); + } + + $page = max($page, 1); + $perPage = min(max($perPage, 1), 100); + $total = (clone $query)->count(); + + return [ + 'data' => $query->orderByDesc('Id')->forPage($page, $perPage)->get()->all(), + 'total' => $total, + ]; + } + + /** + * 快递资料操作日志。 + * @return array{data:list,total:int} + */ + public function paginateLogs(?string $expcode, int $page, int $perPage): array + { + $dataset = $this->datasets->get(self::LOG_DATASET_KEY); + if (! $this->mirror->ready($dataset)) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + + $query = $this->connection->table($dataset->targetTable())->select([ + 'Id', 'expcode', 'doTime', 'createTime', 'modifyTime', + ]); + if ($expcode !== null && $expcode !== '') { + $query->where('expcode', $expcode); + } + + $page = max($page, 1); + $perPage = min(max($perPage, 1), 100); + $total = (clone $query)->count(); + + return [ + 'data' => $query->orderByDesc('Id')->forPage($page, $perPage)->get()->all(), + 'total' => $total, + ]; + } + + /** @return array{main:bool,networks:bool,accounts:bool,logs:bool,network_ready:bool,account_ready:bool,log_ready:bool} */ + public function relatedAvailability(): array + { + try { + $schema = Schema::connection($this->connection->getName()); + $networkDataset = $this->datasets->get(self::NETWORK_DATASET_KEY); + $accountDataset = $this->datasets->get(self::ACCOUNT_DATASET_KEY); + $logDataset = $this->datasets->get(self::LOG_DATASET_KEY); + + return [ + 'main' => $schema->hasTable($this->dataset()->targetTable()), + 'networks' => $schema->hasTable($networkDataset->targetTable()), + 'accounts' => $schema->hasTable($accountDataset->targetTable()), + 'logs' => $schema->hasTable($logDataset->targetTable()), + 'network_ready' => $this->mirror->ready($networkDataset), + 'account_ready' => $this->mirror->ready($accountDataset), + 'log_ready' => $this->mirror->ready($logDataset), + ]; + } catch (Throwable) { + return ['main' => false, 'networks' => false, 'accounts' => false, 'logs' => false, 'network_ready' => false, 'account_ready' => false, 'log_ready' => false]; + } + } + + /** @param array $filters */ + private function filteredExpressQuery(array $filters): Builder + { + $query = $this->connection->table($this->dataset()->targetTable())->select([ + 'Id', 'expcode', 'expname', 'outcode', 'scexpcode', 'status', 'paymethod', + 'addvalidsgn', 'kuaiyunsgn', 'autonosgn', 'packnosgn', 'skucountsplitsgn', + 'weight0sgn', 'stopdate', 'tradebtime', 'tradeetime', 'signcom', 'signmode', + 'linker', 'linkmobile', 'paytype', 'undefined1', 'undefined2', 'undefined3', + 'volume0sgn', 'handnosgn', 'decryptsgn', 'pj1sgn', 'undefinedtype', + 'undefined4', 'undefined5', 'dzqzjbr', 'guid', 'is_delete', 'exp_properties', + 'modified', + ]); + + if (($filters['expcode'] ?? '') !== '') { + $query->where('expcode', $filters['expcode']); + } + if (($filters['expname'] ?? '') !== '') { + $query->whereRaw("expname LIKE ? ESCAPE '!'", [$this->contains($filters['expname'])]); + } + foreach (['undefined1', 'undefined2', 'undefined3'] as $column) { + if (($filters[$column] ?? '') !== '') { + $query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($filters[$column])]); + } + } + if (($filters['tradebtime_from'] ?? '') !== '') { + $query->whereDate('tradebtime', '>=', $filters['tradebtime_from']); + } + if (($filters['tradebtime_to'] ?? '') !== '') { + $query->whereDate('tradebtime', '<=', $filters['tradebtime_to']); + } + if (($filters['status'] ?? '') !== '') { + $query->where('status', (int) $filters['status']); + } + + return $query; + } + + private function dataset(): \Modules\EcommerceFinance\Contracts\BusinessMirrorDataset + { + return $this->datasets->get(self::DATASET_KEY); + } + + private function contains(string $value): string + { + return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; + } + + private function timestamp(mixed $value): ?int + { + if (! is_string($value) || $value === '') { + return null; + } + + try { + return Carbon::parse($value)->getTimestamp(); + } catch (Throwable) { + return null; + } + } +} diff --git a/resources/views/business/pages/courier-payables/courier-information.blade.php b/resources/views/business/pages/courier-payables/courier-information.blade.php new file mode 100644 index 00000000..8a411df9 --- /dev/null +++ b/resources/views/business/pages/courier-payables/courier-information.blade.php @@ -0,0 +1,495 @@ +@php + $tab = $courierTab ?? 'express'; + $filters = $courierFilters ?? []; + $rows = $courierRows ?? null; + $sync = $courierSyncStatus ?? ['code' => 'unavailable', 'lag_minutes' => null]; + $state = $courierState ?? null; + $statusLabels = [ + 'healthy' => '同步正常', + 'delayed' => '同步延迟', + 'syncing' => '增量同步中', + 'reconciling' => '全量校准中', + 'initializing' => '首次同步中', + 'initial_failed' => '首次同步失败', + 'failed' => '同步失败', + 'pending' => '等待首次同步', + 'unavailable' => '镜像未建立', + ]; + $statusClass = in_array($sync['code'] ?? '', ['healthy'], true) + ? 'is-success' + : (in_array($sync['code'] ?? '', ['failed', 'initial_failed', 'unavailable'], true) ? 'is-danger' : 'is-warning'); + $date = static function ($value): string { + if ($value === null || $value === '') { + return '-'; + } + + try { + return \Illuminate\Support\Carbon::parse((string) $value)->format('Y-m-d'); + } catch (\Throwable) { + return (string) $value; + } + }; + $datetime = static function ($value): string { + if ($value === null || $value === '') { + return '-'; + } + + try { + return \Illuminate\Support\Carbon::parse((string) $value)->format('Y-m-d H:i'); + } catch (\Throwable) { + return (string) $value; + } + }; + $bool = static function ($value): string { + return in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否'; + }; + $tabUrl = static function (string $targetTab) use ($filters): string { + return route('business.pages.show', array_merge( + ['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => $targetTab], + array_filter($filters, static fn ($value) => $value !== '' && $value !== null), + )); + }; +@endphp + + + +
+
+
+ + {{ $statusLabels[$sync['code'] ?? 'unavailable'] ?? '镜像状态未知' }} +
+
+ @if (!empty($state['last_succeeded_at'])) + 最近同步 {{ $datetime($state['last_succeeded_at']) }} + @endif + @if (($sync['lag_minutes'] ?? null) !== null) + 数据延迟 {{ number_format((int) $sync['lag_minutes']) }} 分钟 + @endif + @if (isset($state['mirror_row_count'])) + 主表 {{ number_format((int) $state['mirror_row_count']) }} 条 + @endif + @if ($rows && !$courierError) + 当前结果 {{ number_format($rows->total()) }} 条 + @endif +
+
+ + + + @if ($tab === 'express') +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + 导出 + 重置 +
+
+ @elseif (in_array($tab, ['networks', 'accounts'], true)) +
+ +
+ + +
+
+ +
+
+ @endif + + @if ($courierError) + + @elseif ($courierPending) +
+ + 首次全量同步完成后刷新本页,即可查询工作平台中的快递资料。 +
+ @endif + + @if ($tab === 'logs') +
+ + + + @forelse ($rows ?? [] as $row) + + @empty + + @endforelse + +
ID快递代码操作时间创建时间修改时间
{{ $row->Id ?? '-' }}{{ $row->expcode ?? '-' }}{{ $row->doTime ?? '-' }}{{ $datetime($row->createTime ?? null) }}{{ $datetime($row->modifyTime ?? null) }}
{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有操作日志。' }}
+ @if ($rows && !$courierError) + + @endif +
+ @elseif ($tab === 'express') +
+ + + + + + + + + + + @forelse ($rows ?? [] as $row) + @php $enabled = $bool($row->status ?? null) === '是'; @endphp + + + + + + + + + + + + + + + + + @empty + + @endforelse + +
ID快递公司代码快递公司名称外部编码杉橙快递代码运费打款方式是否启用可达服务是否快运自动寻号配货单号寻号数量拆单重量0寻号停运时间合同开始时间合同结束时间联系人联系电话自定义1自定义2自定义3体积0寻号手工单号解密最后修改
{{ $row->Id ?? '-' }}{{ $row->expcode ?? '-' }}{{ $row->expname ?? '-' }}{{ $row->outcode ?? '-' }}{{ $row->scexpcode ?? '-' }}{{ $row->paymethod ?? '-' }}{{ $enabled ? '是' : '否' }}{{ $bool($row->addvalidsgn ?? null) }}{{ $bool($row->kuaiyunsgn ?? null) }}{{ $bool($row->autonosgn ?? null) }}{{ $bool($row->packnosgn ?? null) }}{{ $bool($row->skucountsplitsgn ?? null) }}{{ $bool($row->weight0sgn ?? null) }}{{ $date($row->stopdate ?? null) }}{{ $date($row->tradebtime ?? null) }}{{ $date($row->tradeetime ?? null) }}{{ $row->linker ?? '-' }}{{ $row->linkmobile ?? '-' }}{{ $row->undefined1 ?? '-' }}{{ $row->undefined2 ?? '-' }}{{ $row->undefined3 ?? '-' }}{{ $bool($row->volume0sgn ?? null) }}{{ $bool($row->handnosgn ?? null) }}{{ $bool($row->decryptsgn ?? null) }}{{ $datetime($row->modified ?? null) }}
{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有快递资料。' }}
+ @if ($rows && !$courierError) + + @endif +
+ @elseif ($tab === 'networks') +
+ + + + @forelse ($rows ?? [] as $row) + + @empty + + @endforelse + +
ID快递代码网点代码网点名称网点号省市区乡镇地址联系人联系电话是否启用接口平台创建时间剩余单号
{{ $row->id ?? '-' }}{{ $row->expcode ?? '-' }}{{ $row->netcode ?? '-' }}{{ $row->netname ?? '-' }}{{ $row->netno ?? '-' }}{{ $row->province ?? '-' }}{{ $row->city ?? '-' }}{{ $row->area ?? '-' }}{{ $row->town ?? '-' }}{{ $row->address ?? '-' }}{{ $row->linker ?? '-' }}{{ $row->linkermobile ?? '-' }}{{ $bool($row->status ?? null) }}{{ $row->apipltmname ?? ($row->apitype ?? '-') }}{{ $datetime($row->created ?? null) }}{{ $row->expnum ?? '-' }}
{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有网点资料。' }}
+ @if ($rows && !$courierError) + + @endif +
+ @else +
+ + + + @forelse ($rows ?? [] as $row) + + @empty + + @endforelse + +
ID快递代码快递名称账户类型账户账户名称开户行创建时间发票信息ERP账户ID
{{ $row->Id ?? '-' }}{{ $row->expcode ?? '-' }}{{ $row->expname ?? '-' }}{{ $row->accountType ?? '-' }}{{ $row->account ?? '-' }}{{ $row->accountName ?? '-' }}{{ $row->accountkhh ?? '-' }}{{ $datetime($row->createTime ?? null) }}{{ $row->invoice ?? '-' }}{{ $row->erpAccountId ?? '-' }}
{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有打款账户。' }}
+ @if ($rows && !$courierError) + + @endif +
+ @endif +
diff --git a/routes/admin.php b/routes/admin.php index 3ff6ce24..58ced581 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -110,6 +110,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/courier-payables/courier-information/export', 'Business\Pages@exportCourierInformation') + ->middleware('business.page:courier-payables,courier-information') + ->name('business.courier-payables.courier-information.export'); + Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export') ->name('business.pages.export'); diff --git a/tests/Feature/Business/CourierInformationPageTest.php b/tests/Feature/Business/CourierInformationPageTest.php new file mode 100644 index 00000000..56d6e3aa --- /dev/null +++ b/tests/Feature/Business/CourierInformationPageTest.php @@ -0,0 +1,91 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $repository = Mockery::mock(CourierInformationMirrorRepository::class); + $repository->shouldReceive('relatedAvailability')->once()->andReturn([ + 'main' => false, 'networks' => false, 'accounts' => false, + 'network_ready' => false, 'account_ready' => false, + ]); + $repository->shouldReceive('available')->once()->andReturnFalse(); + $repository->shouldNotReceive('state'); + $repository->shouldNotReceive('syncStatus'); + $repository->shouldNotReceive('ready'); + $repository->shouldNotReceive('paginate'); + $this->app->instance(CourierInformationMirrorRepository::class, $repository); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-information', + ])) + ->assertOk() + ->assertSeeText('快递资料') + ->assertSeeText('共享业务镜像尚未建立,快递资料暂时无法读取。') + ->assertSeeText('网点') + ->assertSeeText('打款信息') + ->assertSeeText('操作日志'); + } + + public function test_page_renders_mirrored_courier_rows_and_filters(): void + { + $this->user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $repository = Mockery::mock(CourierInformationMirrorRepository::class); + $repository->shouldReceive('relatedAvailability')->once()->andReturn([ + 'main' => true, 'networks' => false, 'accounts' => false, + 'network_ready' => false, 'account_ready' => false, + ]); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('state')->once()->andReturn([ + 'status' => 'succeeded', + 'initial_completed_at' => '2026-08-28 10:00:00', + 'last_succeeded_at' => '2026-08-28 10:00:00', + 'mirror_row_count' => 1, + ]); + $repository->shouldReceive('syncStatus')->once()->andReturn(['code' => 'healthy', 'lag_minutes' => 3]); + $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('paginate')->once()->withArgs(function (array $filters, int $page, int $limit): bool { + return $filters['expcode'] === 'SF' && $filters['status'] === '1' && $page === 1 && $limit > 0; + })->andReturn([ + 'data' => [(object) [ + 'Id' => 1, + 'expcode' => 'SF', + 'expname' => '顺丰速运', + 'status' => 1, + 'tradebtime' => '2026-01-01 00:00:00', + ]], + 'total' => 1, + ]); + $this->app->instance(CourierInformationMirrorRepository::class, $repository); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-information', + 'expcode' => 'SF', + 'status' => '1', + ])) + ->assertOk() + ->assertSeeText('顺丰速运') + ->assertSeeText('SF') + ->assertSeeText('同步正常') + ->assertSeeText('可达服务') + ->assertSee('name="expcode"', false); + } +} diff --git a/tests/Unit/EcommerceFinance/CourierInformationMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/CourierInformationMirrorRepositoryTest.php new file mode 100644 index 00000000..40537075 --- /dev/null +++ b/tests/Unit/EcommerceFinance/CourierInformationMirrorRepositoryTest.php @@ -0,0 +1,191 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.datasets' => [ + CourierInformationMirrorRepository::DATASET_KEY => $this->definition('wp_express', 'js_wp_express', ['Id'], 'modified'), + CourierInformationMirrorRepository::NETWORK_DATASET_KEY => $this->definition('wp_express_expNet', 'js_wp_express_exp_net', ['id']), + CourierInformationMirrorRepository::ACCOUNT_DATASET_KEY => $this->definition('wp_express_account', 'js_wp_express_account', ['Id']), + CourierInformationMirrorRepository::LOG_DATASET_KEY => $this->definition('wp_express_extension', 'js_wp_express_extension', ['Id'], 'modifyTime'), + ], + ]); + DB::purge('business_mirror'); + + Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status')->nullable(); + $table->string('run_mode')->nullable(); + $table->json('cursor')->nullable(); + $table->json('high_watermark')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + $table->unsignedBigInteger('mirror_row_count')->default(0); + }); + $this->createExpressTable(); + $this->createRelatedTables(); + + $this->repository = new CourierInformationMirrorRepository( + new BusinessMirrorDatasetRegistry(), + new GenericBusinessMirrorRepository(app('db')), + ); + } + + public function test_it_filters_and_pages_mirrored_courier_records(): void + { + $this->markReady(2); + $this->insertExpress(1, ['expcode' => 'SF', 'expname' => '顺丰', 'status' => 1]); + $this->insertExpress(2, ['expcode' => 'YD', 'expname' => '韵达', 'status' => 0]); + + $result = $this->repository->paginate([ + 'expcode' => 'SF', + 'expname' => '', + 'undefined1' => '', + 'undefined2' => '', + 'undefined3' => '', + 'tradebtime_from' => '', + 'tradebtime_to' => '', + 'status' => '1', + ], 1, 1); + + $this->assertSame(1, $result['total']); + $this->assertSame('SF', $result['data'][0]->expcode); + $this->assertTrue($this->repository->available()); + $this->assertTrue($this->repository->ready()); + } + + public function test_it_reads_networks_and_accounts_by_courier_code(): void + { + $this->markReady(1); + $this->markDatasetReady(CourierInformationMirrorRepository::NETWORK_DATASET_KEY); + $this->markDatasetReady(CourierInformationMirrorRepository::ACCOUNT_DATASET_KEY); + $this->markDatasetReady(CourierInformationMirrorRepository::LOG_DATASET_KEY); + DB::connection('business_mirror')->table('js_wp_express_exp_net')->insert([ + '_mirror_key' => 'network-1', 'id' => 1, 'expid' => 1, 'expcode' => 'SF', 'netname' => '浦东网点', + ]); + DB::connection('business_mirror')->table('js_wp_express_account')->insert([ + '_mirror_key' => 'account-1', 'Id' => 1, 'expcode' => 'SF', 'accountName' => '顺丰账户', + ]); + DB::connection('business_mirror')->table('js_wp_express_extension')->insert([ + '_mirror_key' => 'log-1', 'Id' => 1, 'expcode' => 'SF', 'doTime' => 2, + 'createTime' => '2026-08-28 10:00:00', 'modifyTime' => '2026-08-28 10:00:00', + ]); + + $networks = $this->repository->paginateNetworks('SF', 1, 20); + $accounts = $this->repository->paginateAccounts('SF', 1, 20); + $logs = $this->repository->paginateLogs('SF', 1, 20); + + $this->assertSame(1, $networks['total']); + $this->assertSame('浦东网点', $networks['data'][0]->netname); + $this->assertSame(1, $accounts['total']); + $this->assertSame('顺丰账户', $accounts['data'][0]->accountName); + $this->assertSame(1, $logs['total']); + $this->assertSame(2, $logs['data'][0]->doTime); + } + + private function createExpressTable(): void + { + Schema::connection('business_mirror')->create('js_wp_express', function (Blueprint $table): void { + $table->string('_mirror_key')->primary(); + $table->integer('Id'); + foreach (['expcode', 'expname', 'outcode', 'scexpcode', 'paymethod', 'signcom', 'signmode', 'linker', 'linkmobile', 'paytype', 'undefined1', 'undefined2', 'undefined3', 'undefinedtype', 'undefined4', 'undefined5', 'dzqzjbr', 'guid', 'exp_properties'] as $column) { + $table->string($column)->nullable(); + } + foreach (['status', 'addvalidsgn', 'kuaiyunsgn', 'autonosgn', 'packnosgn', 'skucountsplitsgn', 'weight0sgn', 'volume0sgn', 'handnosgn', 'decryptsgn', 'pj1sgn', 'is_delete'] as $column) { + $table->integer($column)->nullable(); + } + foreach (['stopdate', 'tradebtime', 'tradeetime', 'modified'] as $column) { + $table->dateTime($column)->nullable(); + } + }); + } + + private function createRelatedTables(): void + { + Schema::connection('business_mirror')->create('js_wp_express_exp_net', function (Blueprint $table): void { + $table->string('_mirror_key')->primary(); + $table->integer('id'); + $table->integer('expid')->nullable(); + foreach (['expcode', 'netcode', 'netno', 'netname', 'isbill', 'province', 'city', 'area', 'town', 'address', 'linker', 'linkermobile', 'status', 'apitype', 'apipltmname', 'created', 'expnum'] as $column) { + $table->string($column)->nullable(); + } + }); + Schema::connection('business_mirror')->create('js_wp_express_account', function (Blueprint $table): void { + $table->string('_mirror_key')->primary(); + $table->integer('Id'); + foreach (['expcode', 'expname', 'accountType', 'account', 'accountName', 'accountkhh', 'createTime', 'invoice', 'erpId', 'erpAccountId'] as $column) { + $table->string($column)->nullable(); + } + }); + Schema::connection('business_mirror')->create('js_wp_express_extension', function (Blueprint $table): void { + $table->string('_mirror_key')->primary(); + $table->integer('Id'); + $table->string('expcode')->nullable(); + $table->integer('doTime')->nullable(); + $table->dateTime('createTime')->nullable(); + $table->dateTime('modifyTime')->nullable(); + }); + } + + private function insertExpress(int $id, array $overrides): void + { + DB::connection('business_mirror')->table('js_wp_express')->insert(array_merge([ + '_mirror_key' => 'express-' . $id, + 'Id' => $id, + 'expcode' => 'EXP-' . $id, + 'expname' => '测试快递', + 'status' => 1, + 'modified' => '2026-08-28 10:00:00', + ], $overrides)); + } + + private function markReady(int $rows): void + { + $this->markDatasetReady(CourierInformationMirrorRepository::DATASET_KEY, $rows); + } + + private function markDatasetReady(string $key, int $rows = 1): void + { + DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ + 'dataset_key' => $key, + 'status' => 'succeeded', + 'initial_completed_at' => '2026-08-28 10:00:00', + 'last_succeeded_at' => '2026-08-28 10:00:00', + 'mirror_row_count' => $rows, + ]); + } + + private function definition(string $object, string $target, array $keys, ?string $watermark = null): array + { + return [ + 'source_key' => 'jiansun', + 'schema' => 'dbo', + 'object' => $object, + 'target' => $target, + 'keys' => $keys, + 'watermark' => $watermark, + ]; + } +}