From b66f223ebb4567f54d0a7da4de782f8437522304 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 9 Sep 2026 14:04:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=A6=E5=8D=95=E5=86=B2?= =?UTF-8?q?=E5=87=8F=E5=8F=96=E6=95=B0=E5=92=8C=E5=B7=A5=E5=8D=95=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=EF=BC=8C=E6=95=B4=E5=90=88=E5=BF=AB=E9=80=92=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=B8=8A=E7=BA=BF=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/SyncBillOffsetMirror.php | 69 ----- app/Console/Kernel.php | 7 - app/Http/Controllers/Business/Pages.php | 36 +-- config/business_data.php | 7 +- config/upstream_mirrors.php | 30 ++ ...0_index_after_sales_work_order_lookups.php | 47 +++ docs/上游业务镜像清单.md | 3 +- .../BillOffsetMirrorRepository.php | 280 ------------------ ...rFreightReconciliationMirrorRepository.php | 202 +++++++++---- .../Services/BusinessMirrorSchemaManager.php | 4 +- .../View/BillOffsetPageData.php | 76 ++--- .../View/OrderDataPageData.php | 12 +- .../js/components/business/AccountBills.vue | 5 +- .../business/TableColumnSettings.vue | 29 +- .../components/business/TaobaoAlipayBills.vue | 7 +- resources/assets/js/views/business/pages.js | 11 +- .../courier-surcharge.blade.php | 242 ++++++++++++++- .../freight-reconciliation.blade.php | 39 +-- .../pages/order-data/bill-offset.blade.php | 26 +- .../Business/CourierSurchargePageTest.php | 12 +- tests/Feature/Business/PagesTest.php | 154 +++++----- .../BillOffsetMirrorRepositoryTest.php | 116 -------- .../BusinessDataPlacementTest.php | 11 +- .../BusinessMirrorSynchronizerTest.php | 18 ++ ...ightReconciliationMirrorRepositoryTest.php | 151 ++++++---- .../OrderDataMirrorRepositoryTest.php | 1 + .../OrderDataRepositoryTest.php | 1 + .../WorkOrderLookupMigrationTest.php | 35 +++ 28 files changed, 838 insertions(+), 793 deletions(-) delete mode 100644 app/Console/Commands/SyncBillOffsetMirror.php create mode 100644 database/migrations/2026_09_09_140000_index_after_sales_work_order_lookups.php delete mode 100644 modules/EcommerceFinance/Repositories/BillOffsetMirrorRepository.php delete mode 100644 tests/Unit/EcommerceFinance/BillOffsetMirrorRepositoryTest.php create mode 100644 tests/Unit/EcommerceFinance/WorkOrderLookupMigrationTest.php diff --git a/app/Console/Commands/SyncBillOffsetMirror.php b/app/Console/Commands/SyncBillOffsetMirror.php deleted file mode 100644 index 8fe9be43..00000000 --- a/app/Console/Commands/SyncBillOffsetMirror.php +++ /dev/null @@ -1,69 +0,0 @@ -option('full')) { - return self::SUCCESS; - } - - try { - $result = $synchronizer->sync( - self::DATASET, - (bool) $this->option('full'), - $this->option('batch') === null ? null : (int) $this->option('batch'), - $this->option('max-batches') === null ? null : (int) $this->option('max-batches'), - ); - } catch (Throwable $exception) { - Log::error('账单冲减共享镜像同步失败。', [ - 'dataset' => self::DATASET, - 'exception' => $exception::class, - 'message' => $exception->getMessage(), - ]); - $this->error('账单冲减共享镜像同步失败,已保留上一版共享数据。'); - - return self::FAILURE; - } - - if ($result['status'] === 'requires_full_sync') { - $this->warn('账单冲减共享镜像尚未执行首次全量同步,请显式使用 --full。'); - - return self::SUCCESS; - } - - $this->info(sprintf( - '账单冲减镜像(%s):%s,本轮 %d 批,累计处理 %d 行。', - self::DATASET, - $result['status'] === 'succeeded' ? '同步完成' : '同步进行中', - $result['batches'], - $result['rows'], - )); - - return self::SUCCESS; - } -} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index c403c1e1..65613faa 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -50,13 +50,6 @@ class Kernel extends ConsoleKernel ->runInBackground(); } - // 账单冲减页面(500112 冲减收入)每 30 分钟续传到共享业务镜像。 - // 走专用命令避开 business_data.datasets 的 clickhouse 放置策略; - // refresh_enabled 仅在生产刷新节点开启,本地 schedule 不会真的拉上游。 - $schedule->command('business-mirror:sync-bill-offset --max-batches=2') - ->everyThirtyMinutes() - ->withoutOverlapping(15) - ->runInBackground(); } /** diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index df0ca982..d14562ac 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -25,7 +25,7 @@ use Illuminate\Support\Collection; use Illuminate\Database\QueryException; use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException; use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; -use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository; +use Modules\EcommerceFinance\View\BillOffsetPageData; use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; @@ -269,18 +269,9 @@ class Pages extends Controller 'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 100), 'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 100), ]; - $repository = app(BillOffsetMirrorRepository::class); - $page = 1; - try { - $result = $repository->paginate($filters, $page, 100); - } catch (\RuntimeException $exception) { - if ($exception->getMessage() === 'bill_offset_mirror_not_ready') { - abort(503, '账单冲减镜像尚未完成首次同步'); - } - - throw $exception; - } - abort_if(($result['sync']['status'] ?? 'unknown') === 'unknown', 503, '账单冲减镜像尚未完成首次同步'); + $repository = app(BillOffsetPageData::class); + $result = $repository->data($filters, 1, 100); + abort_if($result['billOffsetError'] !== null, 503, $result['billOffsetError']); return response()->streamDownload(function () use ($repository, $filters, $result): void { $csv = fopen('php://output', 'w'); @@ -292,23 +283,28 @@ class Pages extends Controller 'internal_order_no' => '内部订单号', 'online_order_no' => '线上订单号', 'shop_name' => '店铺名称', 'shop_code' => '店铺编码', 'product_code' => '商品编码', 'style_code' => '款式编码(参考)', 'shop_product_code' => '店铺商品编码', - 'shop_style_code' => '店铺款式编码', 'amount' => '500112冲减收入', + 'shop_style_code' => '店铺款式编码', 'account_500112' => '500112冲减收入', ]; fputcsv($csv, array_values($labels)); $page = 1; $current = $result; do { - foreach ($current['data'] as $row) { + foreach ($current['billOffsetRows'] as $row) { fputcsv($csv, array_map( - fn (string $field): string => $this->csvText($row[$field] ?? ''), + fn (string $field): string => $field === 'account_500112' + ? (string) (data_get($row, $field) ?? '') + : ($field === 'shop_name' + ? $this->csvText(data_get($row, $field) ?? '') + : $this->csvIdentifier(data_get($row, $field) ?? '')), array_keys($labels), )); } - $page++; - if (($page - 1) * 100 < ($current['total'] ?? 0)) { - $current = $repository->paginate($filters, $page, 100); + if (! $current['billOffsetRows']->hasMorePages()) break; + $current = $repository->data($filters, ++$page, 100); + if ($current['billOffsetError'] !== null) { + throw new \RuntimeException('账单冲减导出未完成,请重试。'); } - } while (($page - 1) * 100 < ($current['total'] ?? 0)); + } while (true); fclose($csv); }, '账单冲减_' . now()->format('Ymd_His') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', diff --git a/config/business_data.php b/config/business_data.php index 01a618bf..970de67b 100644 --- a/config/business_data.php +++ b/config/business_data.php @@ -73,8 +73,8 @@ return [ 'activation_group' => 'order-analysis', ], 'operations_replica.rpt.jst_originalBillInfo' => [ - 'role' => 'fact', 'target_store' => 'clickhouse', 'consumer' => '通用原始账单(当前无页面直读)', - 'partition_column' => 'fee_time', 'sync' => 'after_columnar_activation', + 'role' => 'fact', 'target_store' => 'clickhouse', 'consumer' => '店铺收款账户·淘系支付宝账单', + 'partition_column' => 'fee_time', 'sync' => 'always', ], // This 20M+ row mapping is not queried by the current promotion page. @@ -108,6 +108,9 @@ return [ 'jiansun.dbo.pm_unOrderDetails' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '未定价明细'], 'jiansun.dbo.finance_sampleDeduction' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '样品扣款'], 'jiansun.dbo.fys_express_courier_settlement' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '代发快递'], + 'jiansun.dbo.jst_order_express_details' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账物流明细'], + 'jiansun.dbo.Jst_SendData_View_extension' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账商品与收货区域'], + 'jiansun.dbo.jst_salesOutboundInfo' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账出库运费'], 'jiansun.dbo.finance_boardDeduction' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '板材扣款'], 'jiansun.dbo.pm_plainPlateData' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '素板资料'], 'jiansun.dbo.pm_boardPurchasePrice' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '板材采购价'], diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index e286e124..1ca2b58c 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -260,6 +260,7 @@ return [ ], 'indexes' => [ ['work_type', 'id'], + ['work_type', 'create_time', 'id'], ['create_time', 'id'], ], ], @@ -279,6 +280,7 @@ return [ ], 'indexes' => [ ['id'], + ['main_order_no', 'id'], ], ], 'operations_replica.rpt.work_order_form_config' => [ @@ -312,6 +314,7 @@ return [ ], 'indexes' => [ ['sort', 'id'], + ['main_order_no', 'sort', 'id'], ], ], @@ -404,6 +407,33 @@ return [ 'jiansun.dbo.pm_unOrderDetails' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_unOrderDetails', 'target' => 'js_pm_unorder_details', 'keys' => ['Id']], 'jiansun.dbo.finance_sampleDeduction' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_sampleDeduction', 'target' => 'js_finance_sample_deduction', 'keys' => ['id'], 'watermark' => 'finishTime'], 'jiansun.dbo.fys_express_courier_settlement' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_express_courier_settlement', 'target' => 'js_express_courier_settlement', 'keys' => ['Id'], 'watermark' => 'modify_time'], + 'jiansun.dbo.jst_order_express_details' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_order_express_details', + 'target' => 'js_jst_order_express_details', 'keys' => ['id'], 'watermark' => 'updatedAt', + 'indexes' => [['send_time', 'id'], ['inner_order_id', 'id']], + 'columns' => [ + 'id', 'updatedAt', 'inner_order_id', 'so_id', 'logistic_id', 'logistic_company_name', + 'logistic_status_name', 'platform_name', 'shop_name', 'warehouse_name', 'send_time', + 'weight', 'f_weight', + ], + ], + 'jiansun.dbo.Jst_SendData_View_extension' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'Jst_SendData_View_extension', + 'target' => 'js_jst_send_data_view_extension', 'keys' => ['Id'], 'watermark' => null, + 'indexes' => [['so_id', 'Id'], ['io_id', 'Id']], + 'columns' => [ + 'Id', 'io_id', 'qty', 'l', 'w', 'h', 'sku_ids', 'sku_name', 'so_id', 'shop_site', + 'shop_name', 'shop_i_id', 'supplier_name', 'receiver_state', 'receiver_city', + 'receiver_district', 'expressName', 'wareHoursName', 'volume', 'weight', + ], + ], + // 只同步运费对账需要的出库字段,不复制收货电话、买家信息等敏感列。 + 'jiansun.dbo.jst_salesOutboundInfo' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_salesOutboundInfo', + 'target' => 'js_jst_sales_outbound_info', 'keys' => ['Id'], 'watermark' => 'updateTime', + 'indexes' => [['io_id', 'Id']], + 'columns' => ['Id', 'io_id', 'modified', 'updateTime', 'f_weight', 'weight', 'f_freight', 'freight', 'pay_date', 'volume', 'f_volume'], + ], 'jiansun.dbo.finance_boardDeduction' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_boardDeduction', 'target' => 'js_finance_board_deduction', 'keys' => ['id']], 'jiansun.dbo.pm_plainPlateData' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_plainPlateData', 'target' => 'js_pm_plain_plate_data', 'keys' => ['Id'], 'watermark' => 'createTime'], 'jiansun.dbo.pm_boardPurchasePrice' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_boardPurchasePrice', 'target' => 'js_pm_board_purchase_price', 'keys' => ['id'], 'watermark' => 'updateTime'], diff --git a/database/migrations/2026_09_09_140000_index_after_sales_work_order_lookups.php b/database/migrations/2026_09_09_140000_index_after_sales_work_order_lookups.php new file mode 100644 index 00000000..5c7a91be --- /dev/null +++ b/database/migrations/2026_09_09_140000_index_after_sales_work_order_lookups.php @@ -0,0 +1,47 @@ + ['idx_work_order_type_created', ['work_type', 'create_time', 'id']], + 'ops_work_order_sub' => ['idx_work_order_sub_parent', ['main_order_no', 'id']], + 'ops_work_order_form_value' => ['idx_work_order_value_parent', ['main_order_no', 'sort', 'id']], + ]; + + public function up(): void + { + if (config('system_database.mode', 'bootstrap') === 'bootstrap') return; + $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); + $schema = Schema::connection($connection); + $db = DB::connection($connection); + foreach (self::INDEXES as $table => [$name, $columns]) { + if (! $schema->hasTable($table) || $schema->hasIndex($table, $name)) continue; + if (in_array('main_order_no', $columns, true)) { + $length = $db->getDriverName() === 'sqlite' ? 'length' : 'char_length'; + if ($db->table($table)->whereRaw($length . '(main_order_no) > 100')->exists()) { + throw new RuntimeException('工单关联编号超过上游 varchar(100) 定义,已停止变更。'); + } + $schema->table($table, static fn (Blueprint $t) => $t->string('main_order_no', 100)->nullable()->change()); + } + $schema->table($table, static fn (Blueprint $t) => $t->index($columns, $name)); + } + } + + public function down(): void + { + if (config('system_database.mode', 'bootstrap') === 'bootstrap') return; + $schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + foreach (self::INDEXES as $table => [$name, $columns]) { + if (! $schema->hasTable($table) || ! $schema->hasIndex($table, $name)) continue; + $schema->table($table, static fn (Blueprint $t) => $t->dropIndex($name)); + if (in_array('main_order_no', $columns, true)) { + $schema->table($table, static fn (Blueprint $t) => $t->longText('main_order_no')->nullable()->change()); + } + } + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 1c276d68..eb9cffbc 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -55,6 +55,7 @@ | 店铺收款账单旧来源(已停用) | `dbo.alipay_Bill_Detail` | | 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` | | 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` | +| 快递运费对账明细 | `dbo.jst_order_express_details`、`dbo.Jst_SendData_View_extension`、`dbo.jst_salesOutboundInfo` | | 安装明细 | `dbo.fys_Ticketing_install_view`、`dbo.fys_Ticketing_install_item` | | 板材 | `dbo.finance_boardDeduction`、`dbo.pm_plainPlateData`、`dbo.pm_boardPurchasePrice`、`dbo.pm_boardPriceChangeImport`、`dbo.pm_boardApplyDetail` | | 封边条 | `dbo.pm_edgeBandData`、`dbo.pm_edgeBandApplyDetail`、`dbo.pm_edgeBandProfitInfo`、`dbo.finance_edgeBandDeductio`、`dbo.pm_averageUnitPrice` | @@ -76,7 +77,7 @@ 7. 普通商品的采购单价同步以采购主表 `updateTime` 加明细 `Id` 作为稳定游标;页面按商品编码选择最近完成的 1688 一键代发价格。同步保存全部状态,使完成单后续取消时也能撤回旧价格;首次全量前只显示普通商品自身采购价。 8. 聚水潭官方接口尚未接入时,普通商品历史残留排除由负责人提供的最新完整 XLSX 导出刷新。只排除“导出生成前已创建、但导出中缺失”的镜像行,导出生成后的新增商品保持可见。导入使用新同步代次,只有完整解析成功才原子替换排除表;中断或错误保留上一版。后续接入聚水潭官方“普通商品资料查询”及删除/恢复记录后,由官方接口取代人工导出校准。 9. 店铺维度报表是已批准的中央专用数据集:生产节点按 `Id` 断点推进并回看最近 50000 行;原始账单同步后增量维护按店铺/日期汇总的 `store_report_daily_rows`,页面不得现场扫描千万级原始账单。开发机不运行刷新 worker。 -10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 +10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。 ## 后续切换顺序 diff --git a/modules/EcommerceFinance/Repositories/BillOffsetMirrorRepository.php b/modules/EcommerceFinance/Repositories/BillOffsetMirrorRepository.php deleted file mode 100644 index d4332c5a..00000000 --- a/modules/EcommerceFinance/Repositories/BillOffsetMirrorRepository.php +++ /dev/null @@ -1,280 +0,0 @@ ->,total:int,current_page:int,last_page:int,per_page:int,summary:array{amount:float,count:int},sync:array} - */ - public function paginate(array $filters, int $page, int $perPage): array - { - $dataset = $this->datasets->get(self::DATASET); - if (! $this->mirror->ready($dataset)) { - throw new RuntimeException('bill_offset_mirror_not_ready'); - } - - $page = max($page, 1); - $perPage = min(max($perPage, 1), 100); - - // 只有店铺名称、订单号(内部订单号)和款式编码这几类过滤必须依赖关联表; - // 只按日期/单据号/商品编码查时,统计与分页都不必把千万级账单表和订单表 - // 做全量关联,否则每次翻页都要扫一遍 ops_jst_orders。 - $withJoin = $this->requiresJoin($filters); - - $aggregate = $this->aggregate($filters, $withJoin); - $rows = $this->rows($filters, $page, $perPage, $withJoin); - $state = $this->mirror->state($dataset) ?? []; - - return [ - 'data' => $rows, - 'total' => $aggregate['total'], - 'current_page' => $page, - 'last_page' => max((int) ceil($aggregate['total'] / $perPage), 1), - 'per_page' => $perPage, - 'summary' => [ - 'amount' => $aggregate['amount'], - 'count' => $aggregate['total'], - ], - 'sync' => [ - 'status' => $state['status'] ?? 'unknown', - 'last_succeeded_at' => $state['last_succeeded_at'] ?? null, - ], - ]; - } - - /** - * 统计与分页以前各自跑一遍 count/sum 且都带着关联, - * 这里合并成一次聚合,并在不需要关联时退回单表。 - * - * @return array{total:int,amount:float} - */ - private function aggregate(array $filters, bool $withJoin): array - { - $query = $withJoin ? $this->query() : $this->billQuery(); - $this->applyBillOffsetScope($query); - $this->applyFilters($query, $filters); - - $row = $query->selectRaw( - 'COALESCE(SUM(COALESCE(b.amount, 0)), 0) AS amount, COUNT(*) AS aggregate_count' - )->first(); - - return [ - 'total' => (int) ($row->aggregate_count ?? 0), - 'amount' => round((float) ($row->amount ?? 0), 2), - ]; - } - - /** - * 取当前页行。不需要关联时先按 (fee_time, Id) 索引定位本页主键, - * 再只把这几行拿去关联订单/明细,避免整页数据参与大表 join + filesort。 - * - * @return list> - */ - private function rows(array $filters, int $page, int $perPage, bool $withJoin): array - { - $order = static function (Builder $query): void { - $query->orderByDesc('b.fee_time')->orderByDesc('b.Id'); - }; - - if ($withJoin) { - $query = $this->query(); - $this->applyBillOffsetScope($query); - $this->applyFilters($query, $filters); - $order($query); - - return $query->select($this->columns()) - ->forPage($page, $perPage) - ->get() - ->map(fn (object $row): array => $this->mapRow($row)) - ->all(); - } - - $probe = $this->billQuery()->select('b.Id'); - $this->applyBillOffsetScope($probe); - $this->applyFilters($probe, $filters); - $order($probe); - - $ids = $probe->forPage($page, $perPage)->pluck('Id')->all(); - - if ($ids === []) { - return []; - } - - $query = $this->query()->whereIn('b.Id', $ids); - $order($query); - - return $query->select($this->columns()) - ->get() - ->map(fn (object $row): array => $this->mapRow($row)) - ->all(); - } - - /** 判断本组过滤条件是否有任何一项必须靠关联表才能成立。 */ - private function requiresJoin(array $filters): bool - { - $orders = $this->ordersAvailable(); - - if ($orders && ($filters['shop_name'] ?? '') !== '') { - return true; - } - - if ($orders && ($filters['order_no'] ?? '') !== '') { - return true; - } - - return $orders && $this->orderItemsAvailable() && ($filters['sku_id'] ?? '') !== ''; - } - - private function billQuery(): Builder - { - return $this->mirror->connection()->table('ops_jst_original_bill_info as b'); - } - - private function query(): Builder - { - $query = $this->billQuery(); - $ordersAvailable = $this->ordersAvailable(); - - if ($ordersAvailable) { - $query->leftJoin('ops_jst_orders as o', function ($join): void { - $join->on('o.so_id', '=', 'b.so_id'); - }); - } - - if ($ordersAvailable && $this->orderItemsAvailable()) { - $query->leftJoin('ops_jst_order_item as i', function ($join): void { - $join->on('i.o_id', '=', 'o.o_id') - ->whereColumn('i.sku_id', 'b.sku_id'); - }); - } - - return $query; - } - - private function hasTable(string $table): bool - { - return Schema::connection($this->mirror->connection()->getName())->hasTable($table); - } - - private function ordersAvailable(): bool - { - return $this->hasTable('ops_jst_orders'); - } - - private function orderItemsAvailable(): bool - { - return $this->hasTable('ops_jst_order_item'); - } - - private function applyFilters(Builder $query, array $filters): void - { - if (($filters['from'] ?? '') !== '') { - $query->where('b.fee_time', '>=', $filters['from'] . ' 00:00:00'); - } - if (($filters['to'] ?? '') !== '') { - $query->where('b.fee_time', '<', date('Y-m-d 00:00:00', strtotime($filters['to'] . ' +1 day'))); - } - if (($filters['order_no'] ?? '') !== '') { - $needle = $this->contains((string) $filters['order_no']); - $query->where(function (Builder $order) use ($needle): void { - $order->whereRaw("CAST(b.so_id AS CHAR) LIKE ? ESCAPE '!'", [$needle]) - ->orWhereRaw("CAST(b.p_auto_id AS CHAR) LIKE ? ESCAPE '!'", [$needle]); - if ($this->ordersAvailable()) { - $order->orWhereRaw("CAST(o.o_id AS CHAR) LIKE ? ESCAPE '!'", [$needle]); - } - }); - } - if (($filters['shop_name'] ?? '') !== '') { - if ($this->ordersAvailable()) { - $query->whereRaw("o.shop_name LIKE ? ESCAPE '!'", [$this->contains((string) $filters['shop_name'])]); - } else { - $query->whereRaw('1 = 0'); - } - } - if (($filters['sku_id'] ?? '') !== '') { - $needle = $this->contains((string) $filters['sku_id']); - $query->where(function (Builder $sku) use ($needle): void { - foreach (['b.sku_id', 'b.shop_sku_id', 'b.shop_i_id'] as $column) { - $sku->orWhereRaw($column . " LIKE ? ESCAPE '!'", [$needle]); - } - if ($this->ordersAvailable() && $this->orderItemsAvailable()) { - $sku->orWhereRaw("i.i_id LIKE ? ESCAPE '!'", [$needle]); - } - }); - } - } - - private function applyBillOffsetScope(Builder $query): void - { - // 原始账单镜像供多个业务复用,这个页面只呈现 500112 冲减收入。 - // 兼容上游历史上仅保存名称、不带科目编号的“冲减收入”写法。 - $query->where(function (Builder $offset): void { - $offset->whereRaw("b.fee_name LIKE ? ESCAPE '!'", ['%500112%']) - ->orWhereRaw("b.fee_name LIKE ? ESCAPE '!'", ['%冲减收入%']); - }); - } - - /** @return list */ - private function columns(): array - { - $columns = [ - 'b.Id as record_id', 'b.fee_time', 'b.fee_name', 'b.inout_type', 'b.amount', - 'b.so_id as bill_order_no', 'b.sku_id as bill_product_code', - 'b.shop_sku_id as bill_shop_product_code', 'b.shop_i_id as bill_shop_style_code', - ]; - - if ($this->ordersAvailable()) { - $columns = [...$columns, 'o.o_id as internal_order_no', 'o.so_id as online_order_no', 'o.shop_name', 'o.shop_id as shop_code']; - } - if ($this->ordersAvailable() && $this->orderItemsAvailable()) { - $columns = [...$columns, 'i.sku_id as product_code', 'i.i_id as style_code', 'i.shop_sku_id as shop_product_code', 'i.shop_i_id as shop_style_code']; - } - - return $columns; - } - - /** @return array */ - private function mapRow(object $row): array - { - return [ - 'record_id' => (string) ($row->record_id ?? ''), - 'fee_time' => $this->dateTime($row->fee_time ?? null), - 'fee_name' => $row->fee_name ?? null, - 'inout_type' => $row->inout_type ?? null, - 'internal_order_no' => $row->internal_order_no ?? $row->bill_order_no ?? null, - 'online_order_no' => $row->online_order_no ?? $row->bill_order_no ?? null, - 'shop_name' => $row->shop_name ?? null, - 'shop_code' => $row->shop_code ?? null, - 'product_code' => $row->product_code ?? $row->bill_product_code ?? null, - 'style_code' => $row->style_code ?? null, - 'shop_product_code' => $row->shop_product_code ?? $row->bill_shop_product_code ?? null, - 'shop_style_code' => $row->shop_style_code ?? $row->bill_shop_style_code ?? null, - 'amount' => round((float) ($row->amount ?? 0), 2), - ]; - } - - private function dateTime(mixed $value): string - { - return $value === null || $value === '' ? '' : date('Y-m-d H:i:s', strtotime((string) $value)); - } - - private function contains(string $value): string - { - return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%'; - } -} diff --git a/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php b/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php index 75e8018c..f8b35f97 100644 --- a/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php @@ -13,17 +13,26 @@ use Throwable; /** 快递应付运费对账的共享业务镜像只读访问。 */ class CourierFreightReconciliationMirrorRepository { + /** 保留旧契约,供现有结算同步和调用方识别。 */ public const DATASET = 'jiansun.dbo.fys_express_courier_settlement'; + public const DETAILS_DATASET = 'jiansun.dbo.jst_order_express_details'; + public const EXTENSION_DATASET = 'jiansun.dbo.Jst_SendData_View_extension'; + public const OUTBOUND_DATASET = 'jiansun.dbo.jst_salesOutboundInfo'; - /** Only columns present in js_express_courier_settlement may be exposed. */ + /** @var list */ + private const REQUIRED_DATASETS = [ + self::DETAILS_DATASET, + self::EXTENSION_DATASET, + self::OUTBOUND_DATASET, + ]; + + /** 仅暴露附件中有演示值、且已经核对来源的字段。 */ private const COLUMNS = [ - 'Id', 'month', 'status', 'courier_company_name', 'expresscode', 'expressname', - 'replenish_courier', 'opening_balance', 'courier_provided_amount', - 'reconciliation_difference', 'confirmed_amount', 'waybill', 'advance_payment', - 'payable_amount', 'actual_payment', 'reconciliation_person', 'file_status', - 'create_time', 'modify_time', 'is_delete', 'taskid', 'guid', 'creator', - 'compensation_amount', 'other_replenishment', 'corporate_payment_amount', - 'personal_payment_amount', + 'detail_id', 'waybill', 'product_id', 'send_time', 'transaction_order_no', + 'province', 'city', 'district', 'barcode', 'express_name', 'express_name_2', 'quantity', + 'product_name', 'platform', 'shop_name', 'warehouse', 'volume', 'supplier', + 'length', 'width', 'height', 'freight', 'reconciliation_weight', + 'reconciliation_volume', 'month', 'status', ]; private readonly ConnectionInterface $connection; @@ -39,10 +48,17 @@ class CourierFreightReconciliationMirrorRepository { try { $schema = Schema::connection($this->connection->getName()); - $dataset = $this->datasets->get(self::DATASET); + if (! $schema->hasTable('business_mirror_table_states')) { + return false; + } - return $schema->hasTable('business_mirror_table_states') - && $schema->hasTable($dataset->targetTable()); + foreach (self::REQUIRED_DATASETS as $key) { + if (! $schema->hasTable($this->datasets->get($key)->targetTable())) { + return false; + } + } + + return true; } catch (Throwable) { return false; } @@ -51,7 +67,13 @@ class CourierFreightReconciliationMirrorRepository public function ready(): bool { try { - return $this->mirror->ready($this->datasets->get(self::DATASET)); + foreach (self::REQUIRED_DATASETS as $key) { + if (! $this->mirror->ready($this->datasets->get($key))) { + return false; + } + } + + return true; } catch (Throwable) { return false; } @@ -61,7 +83,36 @@ class CourierFreightReconciliationMirrorRepository public function state(): ?array { try { - return $this->mirror->state($this->datasets->get(self::DATASET)); + $states = []; + foreach (self::REQUIRED_DATASETS as $key) { + $state = $this->mirror->state($this->datasets->get($key)); + if (is_array($state)) { + $states[$key] = $state; + } + } + if ($states === []) { + return null; + } + + $first = $states[self::DETAILS_DATASET] ?? reset($states); + $initial = array_values(array_filter(array_map( + static fn (array $state): ?string => $state['initial_completed_at'] ?? null, + $states, + ))); + $succeeded = array_values(array_filter(array_map( + static fn (array $state): ?string => $state['last_succeeded_at'] ?? null, + $states, + ))); + $statuses = array_map(static fn (array $state): string => (string) ($state['status'] ?? 'pending'), $states); + + $first['initial_completed_at'] = count($initial) === count(self::REQUIRED_DATASETS) ? min($initial) : null; + $first['last_succeeded_at'] = count($succeeded) === count(self::REQUIRED_DATASETS) ? min($succeeded) : null; + $first['status'] = in_array('failed', $statuses, true) + ? 'failed' + : (in_array('running', $statuses, true) ? 'running' : (count(array_filter($statuses, static fn (string $value): bool => $value === 'succeeded')) === count(self::REQUIRED_DATASETS) ? 'succeeded' : 'pending')); + $first['datasets'] = $states; + + return $first; } catch (Throwable) { return null; } @@ -107,79 +158,111 @@ class CourierFreightReconciliationMirrorRepository */ public function paginate(array $filters, int $page, int $perPage): array { - $dataset = $this->datasets->get(self::DATASET); - if (! $this->mirror->ready($dataset)) { + if (! $this->ready()) { throw new RuntimeException('courier_freight_reconciliation_mirror_not_ready'); } $page = max($page, 1); $perPage = min(max($perPage, 1), 100); - $query = $this->filteredQuery($dataset->targetTable(), $filters); - $total = (clone $query)->count(); + $query = $this->filteredQuery($filters); + $total = (clone $query)->count('d.id'); $summary = (clone $query)->selectRaw( - 'COUNT(*) AS aggregate_count, ' - . 'COALESCE(SUM(payable_amount), 0) AS payable_amount, ' - . 'COALESCE(SUM(actual_payment), 0) AS actual_payment, ' - . 'COALESCE(SUM(confirmed_amount), 0) AS confirmed_amount, ' - . 'COALESCE(SUM(reconciliation_difference), 0) AS reconciliation_difference' + 'COUNT(DISTINCT d.id) AS aggregate_count, COALESCE(SUM(o.freight), 0) AS freight', )->first(); - $rows = (clone $query)->select(self::COLUMNS) - ->orderByDesc('Id') + $rows = (clone $query)->select($this->selectColumns()) + ->orderByDesc('d.id') ->forPage($page, $perPage) ->get() ->map(fn (object $row): array => $this->mapRow($row)) ->all(); - $state = $this->mirror->state($dataset); + $state = $this->state(); + $freight = (float) ($summary->freight ?? 0); return [ 'data' => $rows, 'total' => $total, 'summary' => [ 'count' => (int) ($summary->aggregate_count ?? 0), - 'payable_amount' => (float) ($summary->payable_amount ?? 0), - 'actual_payment' => (float) ($summary->actual_payment ?? 0), - 'confirmed_amount' => (float) ($summary->confirmed_amount ?? 0), - 'reconciliation_difference' => (float) ($summary->reconciliation_difference ?? 0), + 'freight' => $freight, + 'payable_amount' => $freight, + 'actual_payment' => 0.0, + 'confirmed_amount' => 0.0, + 'reconciliation_difference' => 0.0, ], 'state' => $state, 'sync' => $this->syncStatus($state), ]; } - private function filteredQuery(string $table, array $filters): Builder + private function filteredQuery(array $filters): Builder { - $query = $this->connection->table($table) - ->where(static function (Builder $query): void { - $query->whereNull('is_delete')->orWhere('is_delete', '=', 0); - }); - $this->containsFilter($query, 'waybill', $filters['express_no'] ?? ''); - $this->containsFilter($query, 'expressname', $filters['express_name'] ?? ''); + $query = $this->detailQuery()->whereNotNull('d.logistic_id')->where('d.logistic_id', '!=', ''); + $this->containsFilter($query, 'd.logistic_id', $filters['express_no'] ?? ''); - if (($filters['month'] ?? '') !== '') { - $query->where('month', '=', $filters['month']); + if (($filters['express_name'] ?? '') !== '') { + $needle = $this->contains((string) $filters['express_name']); + $query->where(function (Builder $name) use ($needle): void { + $name->whereRaw("d.logistic_company_name LIKE ? ESCAPE '!'", [$needle]) + ->orWhereRaw("x.expressName LIKE ? ESCAPE '!'", [$needle]); + }); + } + if (($filters['month'] ?? '') !== '' && preg_match('/^\d{4}-\d{2}$/', (string) $filters['month']) === 1) { + $from = (string) $filters['month'] . '-01'; + $query->where('d.send_time', '>=', $from . ' 00:00:00') + ->where('d.send_time', '<', date('Y-m-d 00:00:00', strtotime($from . ' +1 month'))); } if (($filters['status'] ?? '') !== '') { - $query->where('status', '=', $filters['status']); + $query->where('d.logistic_status_name', '=', $filters['status']); } if (($filters['modified_from'] ?? '') !== '') { - $query->where('modify_time', '>=', $filters['modified_from'] . ' 00:00:00'); + $query->where('d.updatedAt', '>=', $filters['modified_from'] . ' 00:00:00'); } if (($filters['modified_to'] ?? '') !== '') { - $query->where('modify_time', '<', date('Y-m-d 00:00:00', strtotime($filters['modified_to'] . ' +1 day'))); + $query->where('d.updatedAt', '<', date('Y-m-d 00:00:00', strtotime($filters['modified_to'] . ' +1 day'))); } return $query; } - private function containsFilter(Builder $query, string $column, mixed $value): void + private function detailQuery(): Builder { - $value = trim((string) $value); - if ($value === '') { - return; - } + $details = $this->datasets->get(self::DETAILS_DATASET)->targetTable(); + $extension = $this->datasets->get(self::EXTENSION_DATASET)->targetTable(); + $outbound = $this->datasets->get(self::OUTBOUND_DATASET)->targetTable(); - $escaped = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value); - $query->whereRaw($column . " LIKE ? ESCAPE '!'", ['%' . $escaped . '%']); + return $this->connection->table($details . ' as d') + ->leftJoinSub($this->latestBy($extension, 'so_id', 'Id'), 'x', 'x.so_id', '=', 'd.so_id') + ->leftJoinSub($this->latestBy($outbound, 'io_id', 'Id'), 'o', 'o.io_id', '=', 'x.io_id'); + } + + /** 固定取关联键下 Id 最大的一行,避免一对多重复页面行和运费汇总。 */ + private function latestBy(string $table, string $relationColumn, string $idColumn): Builder + { + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function (Builder $query) use ($table, $relationColumn, $idColumn): void { + $query->selectRaw('1') + ->from($table . ' as newer') + ->whereColumn('newer.' . $relationColumn, '=', 'candidate.' . $relationColumn) + ->whereColumn('newer.' . $idColumn, '>', 'candidate.' . $idColumn); + }); + } + + /** @return list */ + private function selectColumns(): array + { + return [ + 'd.id as detail_id', 'd.logistic_id as waybill', 'x.shop_i_id as product_id', + 'd.send_time', $this->connection->raw('COALESCE(x.so_id, d.so_id) as transaction_order_no'), 'x.receiver_state as province', + 'x.receiver_city as city', 'x.receiver_district as district', + 'x.sku_ids as barcode', + 'd.logistic_company_name as express_name', 'x.expressName as express_name_2', + 'x.qty as quantity', 'x.sku_name as product_name', 'd.platform_name as platform', + $this->connection->raw('COALESCE(x.shop_name, d.shop_name) as shop_name'), + $this->connection->raw('COALESCE(x.wareHoursName, d.warehouse_name) as warehouse'), 'x.volume as volume', + 'x.supplier_name as supplier', 'x.l as length', 'x.w as width', 'x.h as height', + 'o.freight as freight', 'd.f_weight as reconciliation_weight', + 'x.volume as reconciliation_volume', 'd.logistic_status_name as status', + ]; } /** @return array */ @@ -189,10 +272,29 @@ class CourierFreightReconciliationMirrorRepository foreach (self::COLUMNS as $column) { $mapped[$column] = $row->{$column} ?? null; } - foreach (['opening_balance', 'courier_provided_amount', 'reconciliation_difference', 'confirmed_amount', 'advance_payment', 'payable_amount', 'actual_payment', 'compensation_amount', 'other_replenishment', 'corporate_payment_amount', 'personal_payment_amount'] as $amount) { - $mapped[$amount] = $mapped[$amount] === null ? null : (float) $mapped[$amount]; + + $sendTime = $mapped['send_time']; + $mapped['month'] = is_string($sendTime) ? substr($sendTime, 0, 7) : null; + foreach (['length', 'width', 'height', 'volume', 'freight', 'reconciliation_weight', 'reconciliation_volume'] as $number) { + $mapped[$number] = $mapped[$number] === null || $mapped[$number] === '' ? null : (float) $mapped[$number]; + } + if ($mapped['quantity'] !== null && $mapped['quantity'] !== '') { + $mapped['quantity'] = (int) $mapped['quantity']; } return $mapped; } + + private function containsFilter(Builder $query, string $column, mixed $value): void + { + $value = trim((string) $value); + if ($value !== '') { + $query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]); + } + } + + private function contains(string $value): string + { + return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%'; + } } diff --git a/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php b/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php index 79ceec29..111a484d 100644 --- a/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php +++ b/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php @@ -148,7 +148,7 @@ class BusinessMirrorSchemaManager // may opt a bounded legacy identifier into VARCHAR; every other // wide string remains TEXT and is stored off-page. 'char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying' => $length > 0 - && ($length <= 64 || ($length <= 191 && $this->isConfiguredIndexColumn($dataset, $name))) + && ($length <= 64 || ($length <= 255 && $this->isConfiguredIndexColumn($dataset, $name))) ? $table->string($name, $length) : $table->longText($name), default => $table->longText($name), @@ -169,7 +169,7 @@ class BusinessMirrorSchemaManager return ! in_array($type, ['text', 'ntext', 'xml', 'image', 'binary', 'varbinary', 'bytea', 'json', 'jsonb', 'array', 'user-defined'], true) && (! in_array($type, ['char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying'], true) - || ($length > 0 && ($length <= 64 || ($length <= 191 && $dataset !== null && $this->isConfiguredIndexColumn($dataset, $name))))); + || ($length > 0 && ($length <= 64 || ($length <= 255 && $dataset !== null && $this->isConfiguredIndexColumn($dataset, $name))))); } return false; diff --git a/modules/EcommerceFinance/View/BillOffsetPageData.php b/modules/EcommerceFinance/View/BillOffsetPageData.php index befdaeb2..b2ca5723 100644 --- a/modules/EcommerceFinance/View/BillOffsetPageData.php +++ b/modules/EcommerceFinance/View/BillOffsetPageData.php @@ -2,76 +2,34 @@ namespace Modules\EcommerceFinance\View; -use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Collection; -use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository; -use Throwable; - +/** 账单冲减复用原始数据的订单售后费用明细,不重新推断科目。 */ class BillOffsetPageData { - public function __construct(private readonly BillOffsetMirrorRepository $bills) + public function __construct(private readonly OrderDataPageData $orders) { } - /** @return array */ public function page(): array { - $filters = [ - 'from' => $this->date(request('from')) ?: now()->subDays(6)->toDateString(), - 'to' => $this->date(request('to')) ?: now()->toDateString(), - 'order_no' => $this->text(request('order_no')), - 'shop_name' => $this->text(request('shop_name')), - 'sku_id' => $this->text(request('sku_id')), - ]; - $page = max((int) request('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'), - ]); - $summary = ['amount' => 0.0, 'count' => 0]; - $sync = ['status' => 'unavailable', 'last_succeeded_at' => null]; - $error = null; + return ['view' => 'business.pages.order-data.bill-offset'] + $this->data(); + } - try { - $result = $this->bills->paginate($filters, $page, $limit); - $rows = new LengthAwarePaginator( - Collection::make($result['data'] ?? []), - (int) ($result['total'] ?? 0), - $limit, - $page, - ['path' => request()->url(), 'query' => request()->except('page')], - ); - $summary = $result['summary'] ?? $summary; - $sync = $result['sync'] ?? $sync; - } catch (Throwable $exception) { - if (! str_contains($exception->getMessage(), 'bill_offset_mirror_not_ready')) { - report($exception); - } - $error = '账单冲减镜像尚未完成首次同步,当前不把空镜像解释为没有数据。'; - } + public function data(?array $filters = null, ?int $page = null, ?int $limit = null): array + { + $source = $this->orders->afterSales($filters, $page, $limit, false); + $rows = $source['orderDataRows']; + $amounts = collect($rows->items())->pluck('account_500112'); + // 未接入的科目值保留为空;只有本页金额都已知时才显示合计。 + $amount = $amounts->isNotEmpty() && $amounts->every(static fn ($value): bool => is_numeric($value)) + ? round((float) $amounts->sum(), 2) + : null; return [ - 'view' => 'business.pages.order-data.bill-offset', 'billOffsetRows' => $rows, - 'billOffsetFilters' => $filters, - 'billOffsetSummary' => $summary, - 'billOffsetSync' => $sync, - 'billOffsetError' => $error, + 'billOffsetFilters' => $source['orderDataFilters'], + 'billOffsetSummary' => ['count' => $rows->count(), 'amount' => $amount], + 'billOffsetStatus' => $source['orderDataStatus']['X-Caiwu-Data-Status'] ?? null, + 'billOffsetError' => $source['orderDataError'], ]; } - - private function text(mixed $value): string - { - return mb_substr(trim((string) $value), 0, 100); - } - - private function date(mixed $value): string - { - $value = trim((string) $value); - if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches) !== 1) { - return ''; - } - return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) ? $value : ''; - } } diff --git a/modules/EcommerceFinance/View/OrderDataPageData.php b/modules/EcommerceFinance/View/OrderDataPageData.php index 3a369b40..7ee2d62f 100644 --- a/modules/EcommerceFinance/View/OrderDataPageData.php +++ b/modules/EcommerceFinance/View/OrderDataPageData.php @@ -23,16 +23,18 @@ class OrderDataPageData ) { } - public function afterSales(): array + public function afterSales(?array $filters = null, ?int $page = null, ?int $limit = null, bool $includeWorkOrders = true): array { - $filters = [ + $filters ??= [ 'from' => $this->date(request('from')) ?: now()->subDays(6)->toDateString(), 'to' => $this->date(request('to')) ?: now()->toDateString(), 'order_no' => $this->text(request('order_no')), 'shop_name' => $this->text(request('shop_name')), 'sku_id' => $this->text(request('sku_id')), ]; - [$page, $limit] = $this->pageSettings(); + [$requestPage, $requestLimit] = $this->pageSettings(); + $page = max($page ?? $requestPage, 1); + $limit = min(max($limit ?? $requestLimit, 1), 100); try { $mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class); @@ -44,7 +46,7 @@ class OrderDataPageData 'orderDataFilters' => $filters, 'orderDataStatus' => ['X-Caiwu-Data-Status' => 'mirror'], 'orderDataError' => null, - 'afterSalesWorkOrders' => $this->afterSalesWorkOrders($filters), + 'afterSalesWorkOrders' => $includeWorkOrders ? $this->afterSalesWorkOrders($filters) : $this->emptyAfterSalesWorkOrders(), ]; } @@ -60,7 +62,7 @@ class OrderDataPageData 'orderDataFilters' => $filters, 'orderDataStatus' => $cache->responseHeaders(), 'orderDataError' => null, - 'afterSalesWorkOrders' => $this->afterSalesWorkOrders($filters), + 'afterSalesWorkOrders' => $includeWorkOrders ? $this->afterSalesWorkOrders($filters) : $this->emptyAfterSalesWorkOrders(), ]; } catch (ServiceUnavailableHttpException $exception) { return $this->failed($filters, $page, $limit, $exception->getMessage()); diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index a32aed35..4a806bec 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -42,10 +42,9 @@ import TableColumnSettings from './TableColumnSettings.vue'; const SORTABLE = ['occurred_at','income','expense','balance','record_code']; export default { - name:'AccountBills', components:{ TableColumnSettings }, props:{ apiBase:{type:String,required:true}, billType:{type:String,required:true} }, + name:'AccountBills', components:{ TableColumnSettings }, props:{ apiBase:{type:String,required:true}, billType:{type:String,required:true}, userKey:{type:[String,Number],required:true} }, data(){return{loading:false,loadError:false,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:{store_name:'',bill_item:'',start:'',end:'',keyword:''},sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};}, computed:{ - userKey(){const mount=document.querySelector('.business-blank-page');return mount&&mount.dataset.tablePreferencesUser?mount.dataset.tablePreferencesUser:'anonymous';}, visibleColumns(){return this.configuredColumns.filter(column=>column.visible);}, visibleGroups(){return this.visibleColumns.reduce((groups,column)=>{if(!column.group)return groups;const last=groups[groups.length-1];if(last&&last.label===column.group)last.span+=1;else groups.push({label:column.group,span:1});return groups;},[]);}, stats(){return[{label:'收入合计',value:this.money(this.summary.income),note:`${this.summary.count} 笔交易`,className:'is-income'},{label:'支出合计',value:this.money(this.summary.expense),note:'按当前筛选',className:'is-expense'},{label:'期末余额(最新)',value:this.money(this.summary.balance),note:'最新一条记录',className:''},{label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}];}, @@ -60,7 +59,7 @@ export default { getValue(row,field){if(!field)return'';if(field.indexOf('payload.')===0){const payload=row.payload||{};const value=payload[field.slice(8)];return value===null||value===undefined?'':value;}const value=row[field];return value===null||value===undefined?'':value;},rawValue(row,column){return this.getValue(row,column.field);}, displayValue(row,column){const value=this.rawValue(row,column);if(value==='')return'—';if(column.kind==='money_in')return Number(value)>0?`+${this.money(value)}`:'—';if(column.kind==='money_out')return Number(value)>0?`-${this.money(value)}`:'—';if(['balance','money'].indexOf(column.kind)!==-1)return this.money(value);return value;},cellClass(column){return{'is-number':this.isNumber(column.kind),'is-id':column.kind==='num','is-income':column.kind==='money_in','is-expense':column.kind==='money_out'};},isNumber(kind){return['money','money_in','money_out','balance','num'].indexOf(kind)!==-1;},money(value){return`¥${Number(value||0).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}`;}, exportRows(){if(this.exportMode==='page-visible'){this.downloadCurrentPage();return;}const params=new URLSearchParams(this.filters);params.set('export','1');params.set('sort',this.sort.key);params.set('dir',this.sort.dir);if(this.exportMode==='filtered-visible')this.visibleColumns.forEach(column=>params.append('columns[]',column.field));window.location.assign(`${this.apiBase}?${params.toString()}`);}, - downloadCurrentPage(){const csvRows=[this.visibleColumns.map(column=>column.label),...this.rows.map(row=>this.visibleColumns.map(column=>this.rawValue(row,column)))];const csv=csvRows.map(values=>values.map(this.csvCell).join(',')).join('\r\n');const link=document.createElement('a');link.href=URL.createObjectURL(new Blob(['\ufeff',csv],{type:'text/csv;charset=utf-8'}));link.download=`${this.billType}_account_bills_page_${this.currentPage}.csv`;link.click();URL.revokeObjectURL(link.href);},csvCell(value){const text=String(value===null||value===undefined?'':value);const safe=/^[=+\-@]/.test(text)?`'${text}`:text;return`"${safe.replace(/"/g,'""')}"`;} + downloadCurrentPage(){const csvRows=[this.visibleColumns.map(column=>column.label),...this.rows.map(row=>this.visibleColumns.map(column=>this.rawValue(row,column)))];const csv=csvRows.map(values=>values.map((value, index) => this.csvCell(value, this.visibleColumns[index])).join(',')).join('\r\n');const link=document.createElement('a');link.href=URL.createObjectURL(new Blob(['\ufeff',csv],{type:'text/csv;charset=utf-8'}));link.download=`${this.billType}_account_bills_page_${this.currentPage}.csv`;link.click();URL.revokeObjectURL(link.href);},csvCell(value,column){const text=String(value===null||value===undefined?'':value);const safe=column.kind==='num'&&/^(?:0\d|\d{12,}$)/.test(text)?`\t${text}`:(/^[=+\-@]/.test(text)?`'${text}`:text);return`"${safe.replace(/"/g,'""')}"`;} } }; diff --git a/resources/assets/js/components/business/TableColumnSettings.vue b/resources/assets/js/components/business/TableColumnSettings.vue index 938a7441..ca466dd6 100644 --- a/resources/assets/js/components/business/TableColumnSettings.vue +++ b/resources/assets/js/components/business/TableColumnSettings.vue @@ -44,6 +44,8 @@ {{ column.label }} 固定 + + @@ -93,10 +95,12 @@ export default { mounted() { document.addEventListener('click', this.onDocumentClick); + document.addEventListener('keydown', this.onKeydown); }, beforeDestroy() { document.removeEventListener('click', this.onDocumentClick); + document.removeEventListener('keydown', this.onKeydown); }, methods: { @@ -118,7 +122,7 @@ export default { saved = null; } - const savedMap = new Map(Array.isArray(saved) ? saved.map((item) => [item.key, item]) : []); + const savedMap = new Map(Array.isArray(saved) ? saved.filter((item) => item && typeof item.key === 'string').map((item) => [item.key, item]) : []); const ordered = defaults .map((column, defaultIndex) => ({ ...column, @@ -153,7 +157,7 @@ export default { this.emitChange(); }, reset() { - window.localStorage.removeItem(this.storageKey); + try { window.localStorage.removeItem(this.storageKey); } catch (_) {} this.workingColumns = this.normalizedDefaults(); this.emitChange(false); }, @@ -164,7 +168,7 @@ export default { visible: column.visible, order, })); - window.localStorage.setItem(this.storageKey, JSON.stringify(value)); + try { window.localStorage.setItem(this.storageKey, JSON.stringify(value)); } catch (_) {} } this.$emit('change', this.workingColumns.map((column) => ({ ...column }))); }, @@ -173,6 +177,21 @@ export default { this.open = false; } }, + move(index, direction) { + const destination = index + direction; + if (destination < 0 || destination >= this.workingColumns.length) return; + const next = this.workingColumns.slice(); + const [column] = next.splice(index, 1); + next.splice(destination, 0, column); + this.workingColumns = next; + this.emitChange(); + }, + onKeydown(event) { + if (event.key === 'Escape' && this.open) { + this.open = false; + this.$refs.root.querySelector('button').focus(); + } + }, }, }; @@ -221,4 +240,8 @@ export default { .table-column-settings__item input { accent-color: #856632; } .table-column-settings__drag { color: #856632; cursor: grab; font-size: 1.125rem; } .table-column-settings__fixed { color: #595959; font-size: 0.71875rem; } +.table-column-settings__move { border: 0; background: transparent; color: #856632; padding: .25rem; cursor: pointer; } +.table-column-settings__move .material-icons-outlined { font-size: 1rem; } +.table-column-settings__move:disabled { opacity: .35; cursor: default; } +.table-column-settings__move:focus-visible { outline: 2px solid #856632; outline-offset: 1px; } diff --git a/resources/assets/js/components/business/TaobaoAlipayBills.vue b/resources/assets/js/components/business/TaobaoAlipayBills.vue index 096997ae..d2663e30 100644 --- a/resources/assets/js/components/business/TaobaoAlipayBills.vue +++ b/resources/assets/js/components/business/TaobaoAlipayBills.vue @@ -80,10 +80,9 @@ const SORTABLE = ['occurred_at', 'income', 'expense', 'balance', 'record_code']; export default { name: 'TaobaoAlipayBills', components: { TableColumnSettings }, - props: { apiBase: { type: String, required: true }, exportBase: { type: String, required: true } }, + props: { apiBase: { type: String, required: true }, exportBase: { type: String, required: true }, userKey: { type: [String, Number], required: true } }, data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', alipay_account: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; }, computed: { - userKey() { const mount = document.querySelector('.business-blank-page'); return mount && mount.dataset.tablePreferencesUser ? mount.dataset.tablePreferencesUser : 'anonymous'; }, defaultColumns() { return COLUMNS; }, visibleColumns() { return this.configuredColumns.filter((column) => column.visible); }, visibleGroups() { return this.visibleColumns.reduce((groups, column) => { const last = groups[groups.length - 1]; if (last && last.label === column.group) last.span += 1; else groups.push({ label: column.group, span: 1 }); return groups; }, []); }, stats() { return [{ label: '收入合计', value: this.money(this.summary.income), note: `${this.summary.count} 笔交易`, className: 'is-income' }, { label: '支出合计', value: this.money(this.summary.expense), note: '按当前筛选', className: 'is-expense' }, { label: '收支净额', value: this.money(this.summary.balance), note: '收入合计 - 支出合计', className: '' }, { label: '交易笔数', value: this.summary.count, note: '当前筛选结果', className: '' }]; }, @@ -99,8 +98,8 @@ export default { displayValue(row, column) { const value = this.rawValue(row, column); if (value === '') return '—'; if (column.kind === 'money_in') return Number(value) > 0 ? `+${this.money(value)}` : '—'; if (column.kind === 'money_out') return Number(value) > 0 ? `-${this.money(value)}` : '—'; if (column.kind === 'money') return this.money(value); return value; }, cellClass(column) { return { 'is-number': this.isNumber(column.kind), 'is-id': column.kind === 'num', 'is-income': column.kind === 'money_in', 'is-expense': column.kind === 'money_out' }; }, isNumber(kind) { return ['num', 'money', 'money_in', 'money_out'].indexOf(kind) !== -1; }, money(value) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; }, exportRows() { if (this.exportMode === 'page-visible') { this.downloadCurrentPage(); return; } const params = new URLSearchParams(this.filters); params.set('sort', this.sort.key); params.set('dir', this.sort.dir); if (this.exportMode === 'filtered-visible') this.visibleColumns.forEach((column) => params.append('columns[]', column.field)); window.location.assign(`${this.exportBase}?${params.toString()}`); }, - downloadCurrentPage() { const csvRows = [this.visibleColumns.map((column) => column.label), ...this.rows.map((row) => this.visibleColumns.map((column) => this.rawValue(row, column)))]; const csv = csvRows.map((values) => values.map(this.csvCell).join(',')).join('\r\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' })); link.download = `taobao_alipay_bills_page_${this.currentPage}.csv`; link.click(); URL.revokeObjectURL(link.href); }, - csvCell(value) { const text = String(value === null || value === undefined ? '' : value); const safe = /^[=+\-@]/.test(text) ? `'${text}` : text; return `"${safe.replace(/"/g, '""')}"`; }, + downloadCurrentPage() { const csvRows = [this.visibleColumns.map((column) => column.label), ...this.rows.map((row) => this.visibleColumns.map((column) => this.rawValue(row, column)))]; const csv = csvRows.map((values) => values.map((value, index) => this.csvCell(value, this.visibleColumns[index])).join(',')).join('\r\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' })); link.download = `taobao_alipay_bills_page_${this.currentPage}.csv`; link.click(); URL.revokeObjectURL(link.href); }, + csvCell(value, column) { const text = String(value === null || value === undefined ? '' : value); const safe = column.kind === 'num' && /^(?:0\d|\d{12,}$)/.test(text) ? `\t${text}` : (/^[=+\-@]/.test(text) ? `'${text}` : text); return `"${safe.replace(/"/g, '""')}"`; }, }, }; diff --git a/resources/assets/js/views/business/pages.js b/resources/assets/js/views/business/pages.js index ddd4f876..0c0da74f 100644 --- a/resources/assets/js/views/business/pages.js +++ b/resources/assets/js/views/business/pages.js @@ -61,6 +61,9 @@ const mount = document.querySelector('.business-blank-page'); if (mount) { const initialContext = pageContext(); + // Vue replaces the original mount node; keep the identity across local tab switches. + const preferencesUser = mount.dataset.tablePreferencesUser || 'anonymous'; + const titleSuffix = document.title.includes(' - ') ? document.title.slice(document.title.lastIndexOf(' - ')) : ''; function isStoreReceivingContext(context) { return context.page === 'store-receiving-bills' @@ -80,6 +83,12 @@ if (mount) { link.removeAttribute('aria-current'); } }); + const label = document.querySelector('.business-tab-link[aria-current="page"] span:last-child')?.textContent.trim(); + const title = document.querySelector('h1 [data-title-truncate]'); + if (label && title) { + title.textContent = label; + document.title = label + titleSuffix; + } } /* eslint-disable no-new */ @@ -95,7 +104,7 @@ if (mount) { if (resolved) { return h(resolved.Component, { key: this.context.tab, - props: resolved.props, + props: { ...resolved.props, userKey: preferencesUser }, }); } diff --git a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php index 5825aa11..aef08971 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php @@ -6,9 +6,28 @@ $ready = (bool) ($courierSurchargeReady ?? false); $typeUsage = $courierSurchargeTypeUsage ?? []; $statusClass = static fn (string $status): string => $status === '已启用' ? 'is-active' : 'is-inactive'; + $columns = [ + ['key' => 'id', 'label' => '规则编号', 'fixed' => true, 'exportable' => true], + ['key' => 'courier', 'label' => '快递名称', 'exportable' => true], + ['key' => 'scope', 'label' => '快递归属', 'exportable' => true], + ['key' => 'reconciler', 'label' => '对账人', 'exportable' => true], + ['key' => 'type', 'label' => '附加费类型', 'exportable' => true], + ['key' => 'mode', 'label' => '结算方式', 'exportable' => true], + ['key' => 'standard', 'label' => '计价标准', 'exportable' => true], + ['key' => 'condition', 'label' => '生效条件', 'exportable' => true], + ['key' => 'effective_at', 'label' => '生效日期', 'exportable' => true], + ['key' => 'status', 'label' => '状态', 'exportable' => true], + ['key' => 'remark', 'label' => '备注', 'exportable' => true], + ['key' => 'actions', 'label' => '操作', 'fixed' => true, 'exportable' => false], + ]; + $columnStorageKey = sprintf( + 'caiwu:table-columns:%d:%d:courier-surcharge:quotes', + (int) auth()->id(), + (int) company_id(), + ); @endphp -
+
@@ -62,15 +83,31 @@
-
报价规则列表
规则按生效时间应用到后续结算页面
-
+
报价规则列表
规则按生效时间应用到后续结算页面
+
当前筛选结果(全部):{{ $rows->count() }} 条规则编号与操作列固定显示;列设置仅保存于当前用户
+
规则编号快递名称快递归属对账人附加费类型结算方式计价标准生效条件生效日期状态备注操作
+ @foreach ($columns as $column) + + @endforeach + @forelse ($rows as $row) - + + + @empty - + @endforelse
@if (empty($column['fixed']))@endif{{ $column['label'] }}
{{ $row['id'] }}{{ $row['courier'] }}{{ $row['scope'] }}{{ $row['reconciler'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['standard'] }}{{ $row['condition'] }}{{ $row['effective_at'] }}{{ $row['status'] }}{{ $row['remark'] ?: '—' }}
@csrf
{{ $row['id'] }}{{ $row['courier'] }}{{ $row['scope'] }}{{ $row['reconciler'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['standard'] }}{{ $row['condition'] }}{{ $row['effective_at'] }}{{ $row['status'] }}{{ $row['remark'] ?: '—' }}
@csrf
没有符合条件的报价规则,请点击“新增报价规则”。
没有符合条件的报价规则,请点击“新增报价规则”。
-
维护建议:固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。
+
维护建议:固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。当前筛选结果可导出为 CSV;本页暂未启用批量导入,避免在未确认字段规则前覆盖维护数据。
diff --git a/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php b/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php index e7082813..b21854f6 100644 --- a/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php +++ b/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php @@ -4,6 +4,7 @@ $sync = $freightReconciliationSync ?? ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null]; $statusLabels = ['fresh' => '数据新鲜', 'stale' => '数据可能滞后', 'syncing' => '同步中', 'initializing' => '首次同步中', 'pending' => '等待同步', 'failed' => '同步失败', 'initial_failed' => '首次同步失败', 'unavailable' => '镜像不可用']; $money = static fn (mixed $value): string => number_format((float) ($value ?? 0), 2); + $number = static fn (mixed $value): string => $value === null || $value === '' ? '-' : rtrim(rtrim(number_format((float) $value, 4, '.', ''), '0'), '.'); @endphp
@@ -24,7 +25,7 @@

{{ $page['title'] ?? '运费对账' }}

-

数据来源:JianSun · dbo.fys_express_courier_settlement · 共享业务镜像

+

数据来源:JianSun · dbo.jst_order_express_details + dbo.Jst_SendData_View_extension + dbo.jst_salesOutboundInfo · 共享业务镜像

{{ $statusLabels[$sync['code'] ?? 'unavailable'] ?? '镜像状态未知' }} @@ -36,16 +37,9 @@
{{ $freightReconciliationError }}
@endif -
- @foreach ([ - ['label' => '记录数', 'value' => number_format((int) ($summary['count'] ?? 0))], - ['label' => '应付金额', 'value' => '¥ ' . $money($summary['payable_amount'] ?? 0)], - ['label' => '已确认金额', 'value' => '¥ ' . $money($summary['confirmed_amount'] ?? 0)], - ['label' => '实际付款', 'value' => '¥ ' . $money($summary['actual_payment'] ?? 0)], - ['label' => '对账差额', 'value' => '¥ ' . $money($summary['reconciliation_difference'] ?? 0)], - ] as $stat) -
{{ $stat['label'] }}
{{ $stat['value'] }}
- @endforeach +
+
记录数
{{ number_format((int) ($summary['count'] ?? 0)) }}
+
运费合计
¥ {{ $money($summary['freight'] ?? $summary['payable_amount'] ?? 0) }}
@@ -54,7 +48,7 @@ ['name' => 'express_no', 'label' => '快递单号', 'placeholder' => '包含匹配'], ['name' => 'express_name', 'label' => '快递名称', 'placeholder' => '包含匹配'], ['name' => 'month', 'label' => '月份', 'placeholder' => '如 2026-08'], - ['name' => 'status', 'label' => '状态', 'placeholder' => '精确匹配'], + ['name' => 'status', 'label' => '物流状态', 'placeholder' => '精确匹配'], ] as $field) @endforeach @@ -66,18 +60,25 @@
- - + @foreach (['快递单号','商品ID','发货时间','交易订单号','省','市','区','条形码','快递名称1','快递名称2','数量','商品名称','平台','店铺名称','仓库','体积(cm3)','供应商','长(cm)','宽(cm)','高(cm)','运费','对账重量(kg)','对账体积(cm3)'] as $heading) + + @endforeach @forelse ($freightReconciliations ?? [] as $row) - - @foreach (['reconciliation_difference','confirmed_amount','payable_amount','actual_payment','advance_payment','compensation_amount','other_replenishment','corporate_payment_amount','personal_payment_amount'] as $amount) - + + + + + + + + + @foreach (['volume','length','width','height','freight','reconciliation_weight','reconciliation_volume'] as $numberField) + @endforeach - @empty - + @endforelse
快递单号快递名称月份状态对账差额已确认金额应付金额实际付款预付款赔偿金额其他补款公司付款个人付款创建时间修改时间{{ $heading }}
{{ $row['waybill'] ?? '-' }}{{ $row['expressname'] ?: ($row['courier_company_name'] ?? '-') }}{{ $row['month'] ?? '-' }}{{ $row['status'] ?? '-' }}{{ $row[$amount] === null ? '-' : $money($row[$amount]) }}
{{ $row['waybill'] ?? '-' }}{{ $row['product_id'] ?? '-' }}{{ $row['send_time'] ?? '-' }}{{ $row['transaction_order_no'] ?? '-' }}{{ $row['province'] ?? '-' }}{{ $row['city'] ?? '-' }}{{ $row['district'] ?? '-' }}{{ $row['barcode'] ?? '-' }}{{ $row['express_name'] ?? '-' }}{{ $row['express_name_2'] ?? '-' }}{{ $row['quantity'] ?? '-' }}{{ $row['product_name'] ?? '-' }}{{ $row['platform'] ?? '-' }}{{ $row['shop_name'] ?? '-' }}{{ $row['warehouse'] ?? '-' }}{{ $row['supplier'] ?? '-' }}{{ $row[$numberField] === null ? '-' : ($numberField === 'freight' ? $money($row[$numberField]) : $number($row[$numberField])) }}{{ $row['create_time'] ?? '-' }}{{ $row['modify_time'] ?? '-' }}
@if ($freightReconciliationPending ?? false) 正在等待镜像首次同步完成… @else 暂无符合条件的运费对账数据 @endif
@if ($freightReconciliationPending ?? false) 正在等待物流明细镜像首次同步完成… @else 暂无符合条件的运费对账数据 @endif
@if (($freightReconciliations ?? null)?->hasPages()) diff --git a/resources/views/business/pages/order-data/bill-offset.blade.php b/resources/views/business/pages/order-data/bill-offset.blade.php index 6fe889cd..38d75862 100644 --- a/resources/views/business/pages/order-data/bill-offset.blade.php +++ b/resources/views/business/pages/order-data/bill-offset.blade.php @@ -2,7 +2,7 @@ $rows = $billOffsetRows ?? null; $filters = $billOffsetFilters ?? []; $summary = $billOffsetSummary ?? ['amount' => 0, 'count' => 0]; - $sync = $billOffsetSync ?? ['status' => 'unavailable', 'last_succeeded_at' => null]; + $status = $billOffsetStatus ?? null; $columns = [ 'internal_order_no' => '内部订单号', 'online_order_no' => '线上订单号', @@ -12,7 +12,7 @@ 'style_code' => '款式编码(参考)', 'shop_product_code' => '店铺商品编码', 'shop_style_code' => '店铺款式编码', - 'amount' => '500112冲减收入', + 'account_500112' => '500112冲减收入', ]; @endphp @@ -59,7 +59,7 @@ @@ -71,21 +71,21 @@
-
记录数{{ number_format((int) ($summary['count'] ?? 0)) }}
-
冲减收入合计{{ number_format((float) ($summary['amount'] ?? 0), 2) }}
+
本页记录数{{ number_format((int) ($summary['count'] ?? 0)) }}
+
本页冲减收入合计{{ $summary['amount'] === null ? '-' : number_format((float) $summary['amount'], 2) }}
- @if (!empty($sync['last_succeeded_at'])) -
数据状态:{{ strtoupper((string) ($sync['status'] ?? 'mirror')) }} · 最近同步 {{ $sync['last_succeeded_at'] }}
+ @if ($status) +
数据状态:{{ strtoupper((string) $status) }}
@endif
- @foreach ($columns as $field => $header)@endforeach + @foreach ($columns as $field => $header)@endforeach @forelse ($rows ?? [] as $row) @foreach ($columns as $field => $header) @php $value = data_get($row, $field); @endphp - + @endforeach @empty @@ -95,10 +95,10 @@
$field === 'amount'])>{{ $header }}
$field === 'account_500112'])>{{ $header }}
$field === 'amount'])>{{ $field === 'amount' ? number_format((float) ($value ?? 0), 2) : (($value === null || $value === '') ? '-' : $value) }} $field === 'account_500112'])>{{ ($value === null || $value === '') ? '-' : ($field === 'account_500112' ? number_format((float) $value, 2) : $value) }}
- 当前页 {{ $rows?->count() ?? 0 }} 条 · 共 {{ $rows?->total() ?? 0 }} 条 + 当前页 {{ $rows?->count() ?? 0 }} 条
!$rows || $rows->onFirstPage()]) aria-label="上一页"> - 第 {{ $rows?->currentPage() ?? 1 }} / {{ $rows?->lastPage() ?? 1 }} 页 + 第 {{ $rows?->currentPage() ?? 1 }} 页 !$rows?->hasMorePages()]) aria-label="下一页">
@@ -108,10 +108,12 @@ diff --git a/tests/Feature/Business/CourierSurchargePageTest.php b/tests/Feature/Business/CourierSurchargePageTest.php index 1ab3dea9..b0f86695 100644 --- a/tests/Feature/Business/CourierSurchargePageTest.php +++ b/tests/Feature/Business/CourierSurchargePageTest.php @@ -37,8 +37,18 @@ class CourierSurchargePageTest extends FeatureTestCase ->assertSeeText('对账人') ->assertSeeText('张会计') ->assertSeeText('附加费类型(可多选)') + ->assertSeeText('列设置') + ->assertSeeText('导出当前列') + ->assertSeeText('导出全部字段') + ->assertSeeText('规则编号与操作列固定显示') + ->assertSee('data-column-storage-key="caiwu:table-columns:', false) + ->assertSee('data-surcharge-table', false) + ->assertSee('data-column-check', false) + ->assertSee('data-surcharge-export-current', false) + ->assertSee('data-surcharge-export-all', false) + ->assertSee('data-column-reset', false) ->assertSee('name="types[]"', false) - ->assertSeeInOrder(['快递名称', '快递归属', '对账人'], false) + ->assertSeeInOrder(['data-column-key="courier"', 'data-column-key="scope"', 'data-column-key="reconciler"'], false) ->assertSeeText('曹县德邦') ->assertSeeText('超长费') ->assertSeeText('按尺寸/最长边') diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index d8d037ca..1a701b38 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -4,7 +4,7 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; use Mockery; -use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository; +use Modules\EcommerceFinance\View\OrderDataPageData; use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository; use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository; @@ -27,25 +27,19 @@ class PagesTest extends FeatureTestCase Mockery::type('int'), )->andReturn([ 'data' => [[ - 'waybill' => 'FIXTURE-WAYBILL', - 'expressname' => '测试快递', - 'courier_company_name' => '测试快递', - 'month' => '2026-08', - 'status' => '已确认', - 'reconciliation_difference' => 1.25, - 'confirmed_amount' => 20.0, - 'payable_amount' => 20.0, - 'actual_payment' => 18.75, - 'advance_payment' => 0.0, - 'compensation_amount' => 0.0, - 'other_replenishment' => 0.0, - 'corporate_payment_amount' => 18.75, - 'personal_payment_amount' => 0.0, - 'create_time' => '2026-08-01 09:00:00', - 'modify_time' => '2026-08-02 09:00:00', + 'detail_id' => 1, + 'waybill' => 'FIXTURE-WAYBILL', 'product_id' => 'ITEM-1', + 'send_time' => '2026-08-01 09:00:00', 'transaction_order_no' => 'SO-1', + 'province' => '江苏省', 'city' => '常州市', 'district' => '武进区', 'barcode' => 'SKU-1', + 'express_name' => '测试快递', 'express_name_2' => '测试快递', 'quantity' => 1, + 'product_name' => '测试商品', 'platform' => '淘宝天猫', 'shop_name' => '测试店铺', + 'warehouse' => '测试仓库', 'volume' => 1.2, 'supplier' => '测试供应商', + 'length' => 1.0, 'width' => 2.0, 'height' => 3.0, 'freight' => 20.0, + 'weight' => 1.25, 'reconciliation_weight' => 1.25, 'reconciliation_volume' => 1.2, + 'month' => '2026-08', 'status' => '已发货', ]], 'total' => 1, - 'summary' => ['count' => 1, 'payable_amount' => 20.0, 'actual_payment' => 18.75, 'confirmed_amount' => 20.0, 'reconciliation_difference' => 1.25], + 'summary' => ['count' => 1, 'freight' => 20.0, 'payable_amount' => 20.0, 'actual_payment' => 0.0, 'confirmed_amount' => 0.0, 'reconciliation_difference' => 0.0], 'state' => ['status' => 'succeeded'], 'sync' => ['code' => 'fresh', 'last_succeeded_at' => '2026-08-02 09:00:00', 'lag_minutes' => 1], ]); @@ -59,7 +53,12 @@ class PagesTest extends FeatureTestCase ])) ->assertOk() ->assertSeeText('FIXTURE-WAYBILL') - ->assertSeeText('应付金额') + ->assertSeeText('条形码') + ->assertSeeText('交易订单号') + ->assertSeeText('对账体积(cm3)') + ->assertSeeText('SKU-1') + ->assertSeeText('测试供应商') + ->assertSeeText('运费合计') ->assertSeeText('20.00') ->assertSeeText('数据新鲜') ->assertSeeText('共享业务镜像') @@ -274,68 +273,83 @@ class PagesTest extends FeatureTestCase } } - public function testBillOffsetPageRendersRowsSummaryAndExportLink(): void + private function billOffsetSourceRows(array $rows, array $filters = [], bool $hasMore = false, ?string $error = null): array { - $this->user->attachPermission( - Permission::where('name', 'read-admin-panel')->firstOrFail() - ); - $repository = Mockery::mock(BillOffsetMirrorRepository::class); - $repository->shouldReceive('paginate')->once()->andReturn([ - 'data' => [[ - 'internal_order_no' => '9228703', 'online_order_no' => '5127724585360006527', - 'shop_name' => '天猫-亿家达旗舰店', 'shop_code' => 'SHOP-1', 'product_code' => 'DNZ005374N79V01', - 'style_code' => 'STYLE-1', 'shop_product_code' => 'SP-1', 'shop_style_code' => 'SI-1', 'amount' => -20, - ]], - 'total' => 1, 'current_page' => 1, 'last_page' => 1, 'per_page' => 25, - 'summary' => ['amount' => -20, 'count' => 1], - 'sync' => ['status' => 'succeeded', 'last_succeeded_at' => '2026-09-08 09:00:00'], - ]); - $this->app->instance(BillOffsetMirrorRepository::class, $repository); + $paginator = new \Illuminate\Pagination\Paginator($rows, 100, 1); + $paginator->hasMorePagesWhen($hasMore); + + return [ + 'orderDataRows' => $paginator, + 'orderDataFilters' => $filters, + 'orderDataStatus' => ['X-Caiwu-Data-Status' => 'mirror'], + 'orderDataError' => $error, + ]; + } + + public function testBillOffsetPageUsesTheSameOriginalOrderRowsAndAccountField(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $source = Mockery::mock(OrderDataPageData::class); + $source->shouldReceive('afterSales')->once()->with(null, null, null, false)->andReturn($this->billOffsetSourceRows([ + ['internal_order_no' => '9228703', 'online_order_no' => '5127724585360006527', + 'shop_name' => '测试店铺', 'product_code' => 'SKU-1', 'account_500112' => -20, + 'reversal_order_amount' => 987.65], + ], ['from' => '2026-08-01', 'to' => '2026-08-31'])); + $this->app->instance(OrderDataPageData::class, $source); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'bill-offset', 'from' => '2026-08-01', 'to' => '2026-08-31', - ]))->assertOk() - ->assertSeeText('9228703') - ->assertSeeText('天猫-亿家达旗舰店') - ->assertSeeText('-20.00') - ->assertSee(e(route('business.bill-offset.export', [ - 'from' => '2026-08-01', - 'to' => '2026-08-31', - ])), false) - ->assertSee('id="bill-offset-export-button"', false) - ->assertSeeText('数据状态:SUCCEEDED'); + ]))->assertOk()->assertSeeText('9228703')->assertSeeText('测试店铺')->assertSeeText('-20.00') + ->assertDontSeeText('987.65')->assertSeeText('本页冲减收入合计') + ->assertSee('id="bill-offset-export-button"', false)->assertSeeText('数据状态:MIRROR'); } - public function testBillOffsetExportReturnsCsvForTheFilteredRows(): void + public function testBillOffsetKeepsUnknownAccountAmountsEmpty(): void { - $this->user->attachPermission( - Permission::where('name', 'read-admin-panel')->firstOrFail() - ); - $repository = Mockery::mock(BillOffsetMirrorRepository::class); - $repository->shouldReceive('paginate')->once()->with([ - 'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '9228703', 'shop_name' => '', 'sku_id' => '', - ], 1, 100)->andReturn([ - 'data' => [[ - 'record_id' => '3', 'fee_time' => '2026-08-03 10:00:00', 'fee_name' => '平台冲减', 'inout_type' => '收入', - 'internal_order_no' => '9228703', 'online_order_no' => '5127724585360006527', 'shop_name' => '测试店铺', - 'shop_code' => 'SHOP-1', 'product_code' => 'SKU-3', 'style_code' => 'STYLE-3', 'shop_product_code' => 'SP-3', - 'shop_style_code' => 'SI-3', 'amount' => -20, - ]], - 'total' => 1, 'sync' => ['status' => 'succeeded', 'last_succeeded_at' => '2026-09-08 09:00:00'], - ]); - $this->app->instance(BillOffsetMirrorRepository::class, $repository); - - $response = $this->loginAs()->get(route('business.bill-offset.export', [ - 'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '9228703', + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $source = Mockery::mock(OrderDataPageData::class); + $source->shouldReceive('afterSales')->once()->andReturn($this->billOffsetSourceRows([ + ['internal_order_no' => 'ORDER-EMPTY', 'account_500112' => null, 'reversal_order_amount' => 987.65], ])); + $this->app->instance(OrderDataPageData::class, $source); + $this->loginAs()->get(route('business.pages.show', ['section' => 'order-data', 'page' => 'bill-offset'])) + ->assertOk()->assertSeeText('ORDER-EMPTY')->assertDontSeeText('987.65')->assertDontSeeText('0.00'); + } - $response->assertOk() - ->assertHeader('Content-Type', 'text/csv; charset=UTF-8'); - $this->assertStringContainsString('.csv', (string) $response->headers->get('Content-Disposition')); + public function testBillOffsetShowsTheOriginalDataErrorAndBlocksAnEmptyExport(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $source = Mockery::mock(OrderDataPageData::class); + $source->shouldReceive('afterSales')->twice()->andReturn($this->billOffsetSourceRows([], [], false, '订单售后费用数据暂时无法读取,请稍后重试。')); + $this->app->instance(OrderDataPageData::class, $source); + $this->loginAs()->get(route('business.pages.show', ['section' => 'order-data', 'page' => 'bill-offset'])) + ->assertOk()->assertSeeText('订单售后费用数据暂时无法读取,请稍后重试。'); + $this->withExceptionHandling()->get(route('business.bill-offset.export'))->assertStatus(503); + } + + public function testBillOffsetExportIncludesAllPagesFromOriginalDataWithTheSameFilters(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $source = Mockery::mock(OrderDataPageData::class); + $filters = ['from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => 'ORDER', 'shop_name' => '', 'sku_id' => '']; + $source->shouldReceive('afterSales')->once()->with($filters, 1, 100, false)->andReturn($this->billOffsetSourceRows([ + ['internal_order_no' => 'ORDER-1', 'online_order_no' => '001234567890123456789', 'shop_name' => '测试店铺', 'account_500112' => -20], + ], $filters, true)); + $source->shouldReceive('afterSales')->once()->with($filters, 2, 100, false)->andReturn($this->billOffsetSourceRows([ + ['internal_order_no' => 'ORDER-101', 'account_500112' => null, 'reversal_order_amount' => 987.65], + ], $filters)); + $this->app->instance(OrderDataPageData::class, $source); + $response = $this->loginAs()->get(route('business.bill-offset.export', $filters)); + $response->assertOk()->assertHeader('Content-Type', 'text/csv; charset=UTF-8'); $csv = $response->streamedContent(); $this->assertStringContainsString('500112冲减收入', $csv); - $this->assertStringContainsString('测试店铺', $csv); - $this->assertStringContainsString('-20', $csv); + $this->assertStringContainsString("'001234567890123456789", $csv); + $this->assertStringContainsString('ORDER-1', $csv); + $this->assertStringContainsString('ORDER-101', $csv); + $this->assertStringNotContainsString('987.65', $csv); + $lines = preg_split('/\r?\n/', trim($csv)); + $this->assertCount(3, $lines); + $this->assertSame('', str_getcsv($lines[2])[8]); } public function testFinancialAfterSalesPageRendersMirroredJushuitanRefunds(): void diff --git a/tests/Unit/EcommerceFinance/BillOffsetMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/BillOffsetMirrorRepositoryTest.php deleted file mode 100644 index 7e82c470..00000000 --- a/tests/Unit/EcommerceFinance/BillOffsetMirrorRepositoryTest.php +++ /dev/null @@ -1,116 +0,0 @@ - ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], - 'upstream_mirrors.connection' => 'business_mirror', - 'upstream_mirrors.datasets' => [BillOffsetMirrorRepository::DATASET => [ - 'source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_originalBillInfo', - 'target' => 'ops_jst_original_bill_info', 'keys' => ['Id'], - ]], - ]); - 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->dateTime('initial_completed_at')->nullable(); - $table->string('status')->nullable(); - $table->dateTime('last_succeeded_at')->nullable(); - }); - $schema->create('ops_jst_original_bill_info', function (Blueprint $table): void { - $table->integer('Id'); - $table->dateTime('fee_time')->nullable(); - $table->string('fee_name')->nullable(); - $table->string('inout_type')->nullable(); - $table->string('so_id')->nullable(); - $table->string('p_auto_id')->nullable(); - $table->string('sku_id')->nullable(); - $table->string('shop_sku_id')->nullable(); - $table->string('shop_i_id')->nullable(); - $table->decimal('amount', 18, 2)->nullable(); - }); - $schema->create('ops_jst_orders', function (Blueprint $table): void { - $table->integer('o_id'); $table->string('so_id')->nullable(); $table->string('shop_name')->nullable(); $table->string('shop_id')->nullable(); - }); - $schema->create('ops_jst_order_item', function (Blueprint $table): void { - $table->integer('o_id'); $table->string('sku_id')->nullable(); $table->string('i_id')->nullable(); $table->string('shop_sku_id')->nullable(); $table->string('shop_i_id')->nullable(); - }); - $this->repository = new BillOffsetMirrorRepository(new BusinessMirrorDatasetRegistry(), new GenericBusinessMirrorRepository(app('db'))); - } - - public function test_it_filters_joins_orders_and_calculates_summary(): void - { - DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ - 'dataset_key' => BillOffsetMirrorRepository::DATASET, - 'initial_completed_at' => '2026-09-08 09:00:00', 'status' => 'succeeded', 'last_succeeded_at' => '2026-09-08 09:00:00', - ]); - DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert([ - ['Id' => 1, 'fee_time' => '2026-08-01 10:00:00', 'fee_name' => '500112冲减收入', 'inout_type' => '收入', 'so_id' => 'SO-1', 'sku_id' => 'SKU-1', 'amount' => -20], - ['Id' => 2, 'fee_time' => '2026-08-02 10:00:00', 'fee_name' => '500112冲减收入', 'inout_type' => '收入', 'so_id' => 'SO-2', 'sku_id' => 'SKU-2', 'amount' => 18.25], - ['Id' => 4, 'fee_time' => '2026-08-01 10:00:00', 'fee_name' => '订单收入', 'inout_type' => '收入', 'so_id' => 'SO-1', 'sku_id' => 'SKU-1', 'amount' => 100], - ]); - DB::connection('business_mirror')->table('ops_jst_orders')->insert([ - ['o_id' => 1001, 'so_id' => 'SO-1', 'shop_name' => '测试店铺', 'shop_id' => 'SHOP-1'], - ['o_id' => 1002, 'so_id' => 'SO-2', 'shop_name' => '其他店铺', 'shop_id' => 'SHOP-2'], - ]); - DB::connection('business_mirror')->table('ops_jst_order_item')->insert([ - ['o_id' => 1001, 'sku_id' => 'SKU-1', 'i_id' => 'STYLE-1', 'shop_sku_id' => 'SP-1', 'shop_i_id' => 'SI-1'], - ]); - - $result = $this->repository->paginate(['from' => '2026-08-01', 'to' => '2026-08-01', 'shop_name' => '测试店铺', 'sku_id' => 'SKU-1'], 1, 10); - - $this->assertSame(1, $result['total']); - $this->assertSame('1001', (string) $result['data'][0]['internal_order_no']); - $this->assertSame('测试店铺', $result['data'][0]['shop_name']); - $this->assertSame('STYLE-1', $result['data'][0]['style_code']); - $this->assertSame(-20.0, $result['summary']['amount']); - $this->assertSame('succeeded', $result['sync']['status']); - } - - public function test_it_rejects_unready_mirror(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('bill_offset_mirror_not_ready'); - $this->repository->paginate([], 1, 10); - } - - public function test_it_reads_bill_rows_when_order_mirror_tables_are_not_available(): void - { - $schema = Schema::connection('business_mirror'); - $schema->dropIfExists('ops_jst_order_item'); - $schema->dropIfExists('ops_jst_orders'); - DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ - 'dataset_key' => BillOffsetMirrorRepository::DATASET, - 'initial_completed_at' => '2026-09-08 09:00:00', 'status' => 'succeeded', 'last_succeeded_at' => '2026-09-08 09:00:00', - ]); - DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert([ - 'Id' => 3, 'fee_time' => '2026-08-03 10:00:00', 'fee_name' => '500112冲减收入', 'inout_type' => '收入', - 'so_id' => 'SO-3', 'sku_id' => 'SKU-3', 'amount' => 12.5, - ]); - - $result = $this->repository->paginate([], 1, 10); - - $this->assertSame(1, $result['total']); - $this->assertSame('SO-3', $result['data'][0]['internal_order_no']); - $this->assertSame('SKU-3', $result['data'][0]['product_code']); - $this->assertSame(12.5, $result['summary']['amount']); - $this->assertSame(0, $this->repository->paginate(['shop_name' => '测试店铺'], 1, 10)['total']); - } -} diff --git a/tests/Unit/EcommerceFinance/BusinessDataPlacementTest.php b/tests/Unit/EcommerceFinance/BusinessDataPlacementTest.php index 63cf644e..2e90aa21 100644 --- a/tests/Unit/EcommerceFinance/BusinessDataPlacementTest.php +++ b/tests/Unit/EcommerceFinance/BusinessDataPlacementTest.php @@ -75,11 +75,20 @@ class BusinessDataPlacementTest extends TestCase $upstream = require base_path('config/upstream_mirrors.php'); $businessData = require base_path('config/business_data.php'); - $this->assertCount(52, $upstream['datasets']); + $this->assertCount(55, $upstream['datasets']); $upstreamKeys = array_keys($upstream['datasets']); $policyKeys = array_keys($businessData['datasets']); sort($upstreamKeys); sort($policyKeys); $this->assertSame($upstreamKeys, $policyKeys); } + + public function test_original_bills_keep_refreshing_for_the_current_alipay_reader(): void + { + $businessData = require base_path('config/business_data.php'); + config(['business_data.datasets' => $businessData['datasets']]); + $placement = new BusinessDataPlacement(app('db')); + $this->assertFalse($placement->isActive('operations_replica.rpt.jst_originalBillInfo', 'clickhouse')); + $this->assertTrue($placement->shouldSchedule('operations_replica.rpt.jst_originalBillInfo')); + } } diff --git a/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php index 24cc92ec..215bf85a 100644 --- a/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php @@ -317,6 +317,24 @@ class BusinessMirrorSynchronizerTest extends TestCase $this->assertContains(['created', 'id'], $indexes); } + public function test_declared_255_character_lookup_preserves_the_value_and_builds_an_index(): void + { + DB::table('upstream_schema_columns')->where('source_key', 'test')->where('column_name', 'displayName') + ->update(['max_length' => 255]); + $definitions = config('upstream_mirrors.datasets'); + $definitions['test.dbo.records']['indexes'] = [['displayName', 'Id']]; + config(['upstream_mirrors.datasets' => $definitions]); + $dataset = (new BusinessMirrorDatasetRegistry())->get('test.dbo.records'); + (new BusinessMirrorSchemaManager())->ensure($dataset); + $indexes = collect(Schema::connection('business_mirror')->getIndexes($dataset->targetTable())) + ->pluck('columns')->all(); + $this->assertContains(['displayName', 'Id'], $indexes); + $value = str_repeat('x', 255); + $this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => $value]]]; + $this->synchronizer->sync('test.dbo.records', true); + $this->assertSame($value, DB::connection('business_mirror')->table($dataset->targetTable())->value('displayName')); + } + public function test_empty_full_read_does_not_delete_previous_mirror_rows(): void { $this->query->batches = [[ diff --git a/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php index 67bb15ef..2801629b 100644 --- a/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php @@ -21,19 +21,21 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase config([ 'database.connections.business_mirror' => [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', + 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', ], 'upstream_mirrors.connection' => 'business_mirror', 'upstream_mirrors.datasets' => [ - CourierFreightReconciliationMirrorRepository::DATASET => [ - 'source_key' => 'jiansun', - 'schema' => 'dbo', - 'object' => 'fys_express_courier_settlement', - 'target' => 'js_express_courier_settlement', - 'keys' => ['Id'], - 'watermark' => 'modify_time', + CourierFreightReconciliationMirrorRepository::DETAILS_DATASET => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_order_express_details', + 'target' => 'js_jst_order_express_details', 'keys' => ['id'], 'watermark' => 'updatedAt', + ], + CourierFreightReconciliationMirrorRepository::EXTENSION_DATASET => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'Jst_SendData_View_extension', + 'target' => 'js_jst_send_data_view_extension', 'keys' => ['Id'], 'watermark' => null, + ], + CourierFreightReconciliationMirrorRepository::OUTBOUND_DATASET => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_salesOutboundInfo', + 'target' => 'js_jst_sales_outbound_info', 'keys' => ['Id'], 'watermark' => 'updateTime', ], ], ]); @@ -46,18 +48,30 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase $table->dateTime('last_succeeded_at')->nullable(); $table->string('run_mode')->nullable(); }); - Schema::connection('business_mirror')->create('js_express_courier_settlement', function (Blueprint $table): void { - $table->integer('Id'); - foreach (['month', 'status', 'courier_company_name', 'expresscode', 'expressname', 'replenish_courier', 'reconciliation_person', 'file_status', 'taskid', 'guid', 'creator'] as $column) { + Schema::connection('business_mirror')->create('js_jst_order_express_details', function (Blueprint $table): void { + $table->integer('id'); + foreach (['updatedAt', 'so_id', 'logistic_id', 'logistic_company_name', 'platform_name', 'shop_name', 'warehouse_name', 'logistic_status_name'] as $column) { $table->string($column)->nullable(); } - foreach (['opening_balance', 'courier_provided_amount', 'reconciliation_difference', 'confirmed_amount', 'advance_payment', 'payable_amount', 'actual_payment', 'compensation_amount', 'other_replenishment', 'corporate_payment_amount', 'personal_payment_amount'] as $column) { - $table->decimal($column, 20, 6)->nullable(); + $table->dateTime('send_time')->nullable(); + $table->decimal('weight', 20, 4)->nullable(); + $table->decimal('f_weight', 20, 4)->nullable(); + }); + Schema::connection('business_mirror')->create('js_jst_send_data_view_extension', function (Blueprint $table): void { + $table->integer('Id'); + $table->integer('io_id')->nullable(); + $table->integer('qty')->nullable(); + foreach (['l', 'w', 'h', 'volume'] as $column) { + $table->decimal($column, 20, 4)->nullable(); } - $table->string('waybill')->nullable(); - $table->dateTime('create_time')->nullable(); - $table->dateTime('modify_time')->nullable(); - $table->boolean('is_delete')->nullable(); + foreach (['so_id', 'sku_ids', 'sku_name', 'shop_name', 'shop_i_id', 'supplier_name', 'receiver_state', 'receiver_city', 'receiver_district', 'expressName', 'wareHoursName'] as $column) { + $table->string($column)->nullable(); + } + }); + Schema::connection('business_mirror')->create('js_jst_sales_outbound_info', function (Blueprint $table): void { + $table->integer('Id'); + $table->integer('io_id')->nullable(); + $table->decimal('freight', 20, 4)->nullable(); }); $this->repository = new CourierFreightReconciliationMirrorRepository( @@ -74,51 +88,78 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase $this->repository->paginate([], 1, 25); } - public function test_it_filters_sorts_and_summarizes_only_approved_mirror_fields(): void + public function test_it_joins_workbook_fields_and_keeps_freight_aggregate_unique(): void { - DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ - 'dataset_key' => CourierFreightReconciliationMirrorRepository::DATASET, - 'initial_completed_at' => '2026-09-01 09:00:00', - 'status' => 'succeeded', - 'last_succeeded_at' => now()->toDateTimeString(), + $this->markReady(); + DB::connection('business_mirror')->table('js_jst_order_express_details')->insert([ + 'id' => 1, 'updatedAt' => '2026-09-01 01:00:00', 'so_id' => 'SO-1', 'logistic_id' => 'YT-001', + 'logistic_company_name' => '圆通速递', 'platform_name' => '淘宝天猫', 'shop_name' => '店铺A', + 'warehouse_name' => '仓库A', 'send_time' => '2026-09-01 00:02:06', 'weight' => 0.8, + 'f_weight' => 0.7, 'logistic_status_name' => '已发货', ]); - DB::connection('business_mirror')->table('js_express_courier_settlement')->insert([ - $this->row(1, 'YT-001', '圆通速递', 100, 80, 20, '2026-08'), - $this->row(2, 'YT-002', '圆通速递', 50, 50, 0, '2026-08'), - $this->row(3, 'ZT-003', '中通礼品单', 30, 25, 5, '2026-09'), + DB::connection('business_mirror')->table('js_jst_send_data_view_extension')->insert([ + ['Id' => 10, 'io_id' => 100, 'so_id' => 'SO-1', 'qty' => 1, 'l' => 10, 'w' => 20, 'h' => 30, + 'sku_ids' => 'SKU-OLD', 'sku_name' => '旧商品', 'shop_name' => '旧店铺', 'shop_i_id' => 'OLD-I', + 'supplier_name' => '供应商A', 'receiver_state' => '江苏省', 'receiver_city' => '常州市', + 'receiver_district' => '武进区', 'expressName' => '圆通旧', 'wareHoursName' => '旧仓', 'volume' => 6000], + ['Id' => 11, 'io_id' => 100, 'so_id' => 'SO-1', 'qty' => 2, 'l' => 11, 'w' => 21, 'h' => 31, + 'sku_ids' => 'SKU-1', 'sku_name' => '演示商品', 'shop_name' => '店铺A', 'shop_i_id' => 'ITEM-1', + 'supplier_name' => '供应商A', 'receiver_state' => '江苏省', 'receiver_city' => '常州市', + 'receiver_district' => '武进区', 'expressName' => '圆通速递', 'wareHoursName' => '仓库A', 'volume' => 7161], + ]); + DB::connection('business_mirror')->table('js_jst_sales_outbound_info')->insert([ + ['Id' => 100, 'io_id' => 100, 'freight' => 0.5], + ['Id' => 101, 'io_id' => 100, 'freight' => 0.8], ]); - $result = $this->repository->paginate([ - 'express_no' => 'YT', - 'express_name' => '圆通', - 'month' => '2026-08', - ], 1, 1); + $result = $this->repository->paginate(['express_no' => 'YT', 'month' => '2026-09'], 1, 25); - $this->assertSame(2, $result['total']); - $this->assertSame('YT-002', $result['data'][0]['waybill']); - $this->assertSame(150.0, $result['summary']['payable_amount']); - $this->assertSame(130.0, $result['summary']['actual_payment']); - $this->assertSame(20.0, $result['summary']['reconciliation_difference']); + $this->assertSame(1, $result['total']); + $this->assertSame('YT-001', $result['data'][0]['waybill']); + $this->assertSame('ITEM-1', $result['data'][0]['product_id']); + $this->assertSame('SKU-1', $result['data'][0]['barcode']); + $this->assertSame('演示商品', $result['data'][0]['product_name']); + $this->assertSame('江苏省', $result['data'][0]['province']); + $this->assertSame(2, $result['data'][0]['quantity']); + $this->assertSame(0.8, $result['data'][0]['freight']); + $this->assertSame(0.8, $result['summary']['freight']); $this->assertSame('fresh', $result['sync']['code']); + $this->assertArrayNotHasKey('receiver_address', $result['data'][0]); $this->assertArrayNotHasKey('password', $result['data'][0]); } - /** @return array */ - private function row(int $id, string $waybill, string $express, float $payable, float $actual, float $difference, string $month): array + public function test_it_keeps_a_shipment_visible_when_optional_detail_rows_are_missing(): void { - return [ - 'Id' => $id, - 'waybill' => $waybill, - 'expressname' => $express, - 'courier_company_name' => $express, - 'month' => $month, - 'status' => '已确认', - 'payable_amount' => $payable, - 'actual_payment' => $actual, - 'confirmed_amount' => $payable, - 'reconciliation_difference' => $difference, - 'create_time' => '2026-09-07 09:00:00', - 'modify_time' => '2026-09-07 10:00:00', - ]; + $this->markReady(); + DB::connection('business_mirror')->table('js_jst_order_express_details')->insert([ + 'id' => 2, 'updatedAt' => '2026-09-02 01:00:00', 'so_id' => 'SO-2', + 'logistic_id' => 'ZT-002', 'logistic_company_name' => '中通快递', + 'platform_name' => '京东商城', 'shop_name' => '店铺B', 'warehouse_name' => '仓库B', + 'send_time' => '2026-09-02 00:00:00', 'logistic_status_name' => '已发货', + ]); + + $result = $this->repository->paginate(['express_no' => 'ZT-002'], 1, 25); + + $this->assertSame(1, $result['total']); + $this->assertSame('SO-2', $result['data'][0]['transaction_order_no']); + $this->assertSame('店铺B', $result['data'][0]['shop_name']); + $this->assertSame('仓库B', $result['data'][0]['warehouse']); + $this->assertNull($result['data'][0]['product_id']); + $this->assertNull($result['data'][0]['freight']); + } + + private function markReady(): void + { + $now = now()->toDateTimeString(); + foreach ([ + CourierFreightReconciliationMirrorRepository::DETAILS_DATASET, + CourierFreightReconciliationMirrorRepository::EXTENSION_DATASET, + CourierFreightReconciliationMirrorRepository::OUTBOUND_DATASET, + ] as $dataset) { + DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ + 'dataset_key' => $dataset, 'initial_completed_at' => '2026-09-01 09:00:00', + 'status' => 'succeeded', 'last_succeeded_at' => $now, + ]); + } } } diff --git a/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php index f602b41e..fed58fee 100644 --- a/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php @@ -76,6 +76,7 @@ class OrderDataMirrorRepositoryTest extends TestCase $this->assertSame(40.0, $row['product_cost']); $this->assertSame(1.0, $row['post_return_quantity']); $this->assertSame('https://item.jd.com/1001.html', $row['product_link']); + $this->assertNull($row['account_500112']); $this->assertNull($row['account_8001201']); } diff --git a/tests/Unit/EcommerceFinance/OrderDataRepositoryTest.php b/tests/Unit/EcommerceFinance/OrderDataRepositoryTest.php index 2e061c04..de6f9c2a 100644 --- a/tests/Unit/EcommerceFinance/OrderDataRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/OrderDataRepositoryTest.php @@ -101,6 +101,7 @@ class OrderDataRepositoryTest extends TestCase $this->assertSame('https://item.jd.com/1001.html', $result['rows'][0]['product_link']); $this->assertSame('AF-1', $result['rows'][0]['after_sales_no']); $this->assertSame(1.0, $result['rows'][0]['post_return_quantity']); + $this->assertNull($result['rows'][0]['account_500112']); $this->assertNull($result['rows'][0]['account_8001201']); } diff --git a/tests/Unit/EcommerceFinance/WorkOrderLookupMigrationTest.php b/tests/Unit/EcommerceFinance/WorkOrderLookupMigrationTest.php new file mode 100644 index 00000000..02f17e5f --- /dev/null +++ b/tests/Unit/EcommerceFinance/WorkOrderLookupMigrationTest.php @@ -0,0 +1,35 @@ + ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'upstream_mirrors.connection' => 'work_order_index_fixture', 'system_database.mode' => 'shared']); + $schema = Schema::connection('work_order_index_fixture'); + $db = DB::connection('work_order_index_fixture'); + foreach (['ops_work_order_sub', 'ops_work_order_form_value'] as $table) { + $schema->create($table, static function (Blueprint $t): void { + $t->integer('id'); $t->longText('main_order_no')->nullable(); $t->integer('sort')->nullable(); + }); + $db->table($table)->insert(['id' => 1, 'main_order_no' => str_repeat('1', 100), 'sort' => 0]); + } + $migration = require base_path('database/migrations/2026_09_09_140000_index_after_sales_work_order_lookups.php'); + $migration->up(); + $migration->up(); + $this->assertTrue($schema->hasIndex('ops_work_order_sub', 'idx_work_order_sub_parent')); + $this->assertTrue($schema->hasIndex('ops_work_order_form_value', 'idx_work_order_value_parent')); + $this->assertSame(str_repeat('1', 100), $db->table('ops_work_order_form_value')->value('main_order_no')); + $migration->down(); + $this->assertFalse($schema->hasIndex('ops_work_order_sub', 'idx_work_order_sub_parent')); + $this->assertSame(1, $db->table('ops_work_order_sub')->count()); + DB::purge('work_order_index_fixture'); + } +}