diff --git a/app/Console/Commands/SyncThirdPartyWarehouseStorage.php b/app/Console/Commands/SyncThirdPartyWarehouseStorage.php index 7cb1d85c..a7f937a0 100644 --- a/app/Console/Commands/SyncThirdPartyWarehouseStorage.php +++ b/app/Console/Commands/SyncThirdPartyWarehouseStorage.php @@ -10,10 +10,10 @@ use Throwable; class SyncThirdPartyWarehouseStorage extends Command { protected $signature = 'business-mirror:sync-third-party-warehouse-storage - {--full : 启动或继续三方仓流水与库存的全量校准} + {--full : 启动或继续聚水潭全仓流水与库存的全量校准} {--max-batches= : 本轮最多批次数,0 表示直到完成}'; - protected $description = '同步南昌樵舍三方仓按商品进销存数据到共享业务镜像'; + protected $description = '同步聚水潭全部仓库按商品进销存数据到共享业务镜像'; public function handle(ThirdPartyWarehouseStorageSynchronizer $synchronizer): int { @@ -27,29 +27,29 @@ class SyncThirdPartyWarehouseStorage extends Command $this->option('max-batches') === null ? null : (int) $this->option('max-batches'), ); } catch (Throwable $exception) { - Log::error('三方仓仓储数据共享镜像同步失败。', [ + Log::error('聚水潭进销存数据共享镜像同步失败。', [ 'dataset' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY, 'exception' => $exception::class, ]); - $this->error('三方仓仓储数据同步失败,页面继续读取上一版完整数据。'); + $this->error('聚水潭进销存数据同步失败,页面继续读取上一版完整数据。'); return self::FAILURE; } if ($result['status'] === 'requires_full_sync') { - $this->warn('三方仓仓储数据尚未完成首次全量同步,请显式使用 --full。'); + $this->warn('聚水潭进销存数据尚未完成首次全量同步,请显式使用 --full。'); return self::SUCCESS; } if ($result['status'] === 'already_running') { - $this->line('三方仓仓储数据已有同步任务运行,本次不重复启动。'); + $this->line('聚水潭进销存数据已有同步任务运行,本次不重复启动。'); return self::SUCCESS; } $this->info(sprintf( - '三方仓仓储数据%s:%s,本轮 %d 批;流水 %d 条、明细 %d 条、库存 %d 条。', + '聚水潭进销存数据%s:%s,本轮 %d 批;流水 %d 条、明细 %d 条、库存 %d 条。', $result['mode'] === 'full' ? '全量校准' : '增量同步', $result['status'] === 'succeeded' ? '完成' : '进行中', $result['batches'], diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index cf8c957e..e68ce034 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -382,7 +382,7 @@ class Pages extends Controller $upstreamError = null; if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') { - $rows = $this->inventoryReportDemoRows(); + [$rows, $upstreamError] = $this->inventoryReportRows(); } if ($page && $content === null) { @@ -393,7 +393,7 @@ class Pages extends Controller ); if ($descriptor) { - if ($descriptor->readOnly) { + if ($descriptor->readOnly) { try { $rows = app(UpstreamBusinessInformation::class)->paginate( $section['slug'], @@ -432,7 +432,11 @@ class Pages extends Controller ]; } - if ($this->isStoreReceivingAccountPage($section['slug'], $page['slug'])) { + if (($section['slug'] ?? null) === 'product-inventory' + && ($page['slug'] ?? null) === 'inventory-report') { + // inventoryReportRows() 已从聚水潭共享镜像按全仓口径取数。 + // 这里必须保留该结果,不能再被目录表的旧演示数据覆盖。 + } elseif ($this->isStoreReceivingAccountPage($section['slug'], $page['slug'])) { try { app(\App\Business\StoreReceivingAccountSynchronizer::class)->sync((int) (company_id() ?: 1)); } catch (Throwable $exception) { @@ -485,24 +489,60 @@ class Pages extends Controller ]; } - private function inventoryReportDemoRows(): LengthAwarePaginator + /** @return array{0:LengthAwarePaginator,1:?string} */ + private function inventoryReportRows(): array { - $columns = config('inventory_report_demo.columns', []); - $rows = collect(config('inventory_report_demo.rows', [])) - ->map(fn (array $row): array => array_combine($columns, $row)); - $perPage = 50; - $currentPage = max(1, request()->integer('page', 1)); + $page = max(1, request()->integer('page', 1)); + $perPage = min(max((int) setting('default.list_limit', 25), 1), 100); + $month = trim((string) request('month', now()->startOfMonth()->subMonth()->format('Y-m'))); + if (preg_match('/^(\d{4})-(\d{2})$/', $month, $matches) !== 1 + || ! checkdate((int) $matches[2], 1, (int) $matches[1])) { + $month = now()->startOfMonth()->subMonth()->format('Y-m'); + } + $minimumMonth = substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7); + if ($month < $minimumMonth) { + $month = $minimumMonth; + } + $filters = [ + 'month' => $month, + 'product_code' => mb_substr(trim((string) request('product_code', '')), 0, 100), + 'warehouse_provider' => mb_substr(trim((string) request('warehouse_provider', '')), 0, 191), + 'category' => mb_substr(trim((string) request('category', '')), 0, 191), + ]; + $error = null; - return new LengthAwarePaginator( - $rows->forPage($currentPage, $perPage)->values(), - $rows->count(), - $perPage, - $currentPage, - [ - 'path' => request()->url(), - 'query' => request()->query(), - ], - ); + try { + if (! $this->thirdPartyWarehouseStorage->available()) { + $error = '进销存共享镜像尚未建立,暂时无法读取。'; + } elseif (! $this->thirdPartyWarehouseStorage->allWarehousesReady()) { + $error = '进销存全仓库共享镜像正在执行首次同步,完成前不会展示不完整的单仓数据。'; + } else { + $result = $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport( + $filters, + $page, + $perPage, + ); + + return [new LengthAwarePaginator( + $result['data'], + $result['total'], + $perPage, + $page, + [ + 'path' => request()->url(), + 'query' => request()->except('page'), + ], + ), null]; + } + } catch (Throwable $exception) { + report($exception); + $error = '进销存共享镜像暂时无法读取,请稍后重试。'; + } + + return [new LengthAwarePaginator([], 0, $perPage, $page, [ + 'path' => request()->url(), + 'query' => request()->except('page'), + ]), $error]; } private function isStoreReceivingAccountPage(string $section, string $page): bool diff --git a/config/business_navigation.php b/config/business_navigation.php index d51c89b9..c1e09224 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -307,9 +307,9 @@ $navigation = [ 'title' => '进销存报表', 'directory' => [ 'icon' => 'inventory', - 'status' => '演示数据', - 'description' => '按进销存报表模板展示货主、仓库、商品及库存变动数据。', - 'empty' => '暂无进销存演示数据。', + 'status' => '共享镜像', + 'description' => '复用“应付款项 → 快递应付 → 三方仓进销存”的已确认流水、库存锚点和业务类型口径,按所有仓库、商品和月份汇总。', + 'empty' => '进销存共享镜像尚未完成首次同步。', 'fields' => [ ['name' => 'owner', 'label' => '货主'], ['name' => 'month', 'label' => '月份', 'kind' => 'date'], diff --git a/config/inventory_report_demo.php b/config/inventory_report_demo.php deleted file mode 100644 index 97e8beea..00000000 --- a/config/inventory_report_demo.php +++ /dev/null @@ -1,32 +0,0 @@ - [ - 'owner', 'month', 'warehouse_provider', 'warehouse', 'product_code', 'category', - 'opening_quantity', 'closing_quantity', 'processing_in', 'processing_out', - 'purchase_in', 'purchase_return', 'other_in', 'other_return', 'sales_out', - 'sales_return', 'stocktake', 'transfer_out', 'transfer_in', 'opening', 'other_out', - ], - 'rows' => [ - ['常州简尚家居有限公司', '2026-09-01', '简尚-崔桥工厂仓', '简尚-崔桥工厂仓主仓', '100006981661', '住宅家具 - 书桌', 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '简尚-横林二仓', '客退临时上架仓', '110886Z15', null, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '121630-49', '其他 - 配件', -2, -2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '简尚-崔桥工厂仓', '简尚-崔桥工厂仓主仓', '121640', '住宅家具 - 门厅/玄关柜/玄关架', 12, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135150-13', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '简尚-分销部专用仓', '简尚-分销部专用仓主仓', '135580', '住宅家具 - 餐边柜', 5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-1', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-12', '其他 - 配件', -3, -3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-13', '其他 - 配件', -3, -4, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-14', '其他 - 配件', -4, -5, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-15', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-16', '其他 - 配件', -1, -3, 0, 0, 0, 0, 0, 0, -2, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-17', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-18', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-19', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-21', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-26', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-28', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-33', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ['常州简尚家居有限公司', '2026-09-01', '简尚-分销部崔桥专用仓', '简尚-分销部崔桥专用仓主仓', '135600', '住宅家具 - 餐边柜', 10, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ], -]; diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index cc839d6a..2239c7f9 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -87,8 +87,8 @@ return [ 'overlap_rows' => (int) env('STORE_REPORT_MIRROR_OVERLAP_ROWS', 50000), ], - // 聚水潭进销存报表:固定只读“三方-南昌樵舍仓”,页面只读共享镜像。 - // 头表按 Id 区间小批读取并固定仓库,避免对上游流水表做无界扫描。 + // 聚水潭进销存共享流水镜像:头表按 Id 区间小批读取全部仓库。 + // 三方仓页面在 Repository 层继续固定南昌樵舍仓,进销存报表按全仓库汇总。 'third_party_warehouse_storage' => [ 'dataset_key' => 'jiansun.third-party-warehouse-storage', 'source_key' => 'jiansun', @@ -222,6 +222,7 @@ return [ ['sku_type', 'autoid'], ['sku_type', 'i_id', 'autoid'], ['sku_type', 'sku_id', 'autoid'], + ['sku_id', 'autoid'], ['sku_type', 'enabled', 'autoid'], ['sku_type', 'stock_disabled', 'autoid'], ], diff --git a/database/migrations/2026_09_11_143000_create_third_party_warehouse_storage_mirror_tables.php b/database/migrations/2026_09_11_143000_create_third_party_warehouse_storage_mirror_tables.php index 9713eaa8..3447a3c2 100644 --- a/database/migrations/2026_09_11_143000_create_third_party_warehouse_storage_mirror_tables.php +++ b/database/migrations/2026_09_11_143000_create_third_party_warehouse_storage_mirror_tables.php @@ -18,6 +18,7 @@ return new class extends Migration if (! $schema->hasTable('third_party_warehouse_sync_states')) { $schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void { $table->string('dataset_key', 100)->primary(); + $table->unsignedTinyInteger('scope_version')->default(1); $table->string('status', 32)->default('pending'); $table->string('run_mode', 24)->nullable(); $table->string('sync_phase', 24)->nullable(); @@ -78,6 +79,7 @@ return new class extends Migration $table->unsignedBigInteger('id'); $table->unsignedBigInteger('io_id'); $table->unsignedBigInteger('wms_co_id'); + $table->unsignedBigInteger('wh_id')->default(0); $table->string('status', 32)->nullable(); $table->string('io_type', 64)->nullable(); $table->dateTime('occurred_at', 3)->nullable(); @@ -90,6 +92,10 @@ return new class extends Migration ['generation', 'status', 'occurred_at', 'io_type'], 'tpw_headers_report_index', ); + $table->index( + ['generation', 'wms_co_id', 'occurred_at', 'io_type'], + 'tpw_headers_warehouse_report_index', + ); }); } diff --git a/database/migrations/2026_09_20_150000_expand_inventory_flow_mirror_to_all_warehouses.php b/database/migrations/2026_09_20_150000_expand_inventory_flow_mirror_to_all_warehouses.php new file mode 100644 index 00000000..34415125 --- /dev/null +++ b/database/migrations/2026_09_20_150000_expand_inventory_flow_mirror_to_all_warehouses.php @@ -0,0 +1,41 @@ +connection()); + + if ($schema->hasTable('third_party_warehouse_inout_headers') + && ! $schema->hasColumn('third_party_warehouse_inout_headers', 'wh_id')) { + $schema->table('third_party_warehouse_inout_headers', function (Blueprint $table): void { + $table->unsignedBigInteger('wh_id')->default(0)->after('wms_co_id'); + $table->index( + ['generation', 'wms_co_id', 'occurred_at', 'io_type'], + 'tpw_headers_warehouse_report_index', + ); + }); + } + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if ($schema->hasTable('third_party_warehouse_inout_headers') + && $schema->hasColumn('third_party_warehouse_inout_headers', 'wh_id')) { + $schema->table('third_party_warehouse_inout_headers', function (Blueprint $table): void { + $table->dropIndex('tpw_headers_warehouse_report_index'); + $table->dropColumn('wh_id'); + }); + } + } +}; diff --git a/database/migrations/2026_09_20_151000_add_scope_version_to_inventory_flow_state.php b/database/migrations/2026_09_20_151000_add_scope_version_to_inventory_flow_state.php new file mode 100644 index 00000000..5c33bdd8 --- /dev/null +++ b/database/migrations/2026_09_20_151000_add_scope_version_to_inventory_flow_state.php @@ -0,0 +1,35 @@ +connection()); + if ($schema->hasTable('third_party_warehouse_sync_states') + && ! $schema->hasColumn('third_party_warehouse_sync_states', 'scope_version')) { + $schema->table('third_party_warehouse_sync_states', function (Blueprint $table): void { + $table->unsignedTinyInteger('scope_version')->default(1)->after('dataset_key'); + }); + } + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if ($schema->hasTable('third_party_warehouse_sync_states') + && $schema->hasColumn('third_party_warehouse_sync_states', 'scope_version')) { + $schema->table('third_party_warehouse_sync_states', function (Blueprint $table): void { + $table->dropColumn('scope_version'); + }); + } + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 66974ada..e672dfb8 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -12,7 +12,7 @@ | --- | --- | | 已完成 | `operations_replica/rpt.Bulk_Price_Limit` 已进入类型化表 `product_price_mirror_rows`,商品价格页面已读取镜像并支持完整分页。 | | 已完成 | `operations_replica/rpt.jst_sku` 已进入 `ops_jst_sku`,普通商品资料页面固定读取 `sku_type = normal` 的共享镜像,并支持稳定分页和业务筛选。 | -| 本次接入 | 聚水潭进销存“商品 + 按天”数据固定读取 `jiansun/dbo.jst_inoutWater + dbo.jst_inoutWaterItem + dbo.jst_inventoryInfo` 中仓库 `三方-南昌樵舍仓`(`wms_co_id = 15045364`),进入专用流水、明细、库存快照及同步状态镜像。页面位于“快递应付 / 三方仓进销存”,仅展示 2026-08-01 起的数据;按月或单日选择后均输出“日期 + 商品”明细,并支持分页、筛选、数量汇总和导出。 | +| 本次接入 | 聚水潭进销存共享镜像读取 `jiansun/dbo.jst_inoutWater + dbo.jst_inoutWaterItem + dbo.jst_inventoryInfo` 的全部仓库,保留 2026-08-01 起的已确认流水与库存锚点。“快递应付 / 三方仓进销存”在查询层继续固定 `wms_co_id = 15045364`;“商品价格管理 / 进销存报表”按所有仓库、商品和月份汇总。 | | 已接入校准 | 聚水潭“普通商品资料”完整 XLSX 导出通过 `product-catalog:import-authoritative-export` 流式导入;导出中不存在的经营分析副本历史行写入 `product_catalog_exclusions`,页面与导出统一排除,但不删除 `ops_jst_sku` 原始镜像行。 | | 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 | | 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 | @@ -64,7 +64,7 @@ | 营销账户 | `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.jst_inoutWater`、`dbo.jst_inoutWaterItem`、`dbo.jst_inventoryInfo`(仅 `wms_co_id = 15045364`、`io_date >= 2026-08-01`;专用稳定游标与完整代次镜像) | +| 聚水潭进销存 | `dbo.jst_inoutWater`、`dbo.jst_inoutWaterItem`、`dbo.jst_inventoryInfo`(全部仓库、`io_date >= 2026-08-01`;专用稳定游标与完整代次镜像。三方仓页面仍固定 `wms_co_id = 15045364`,商品价格管理的进销存报表读取全仓库) | | 安装明细 | `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` | @@ -98,7 +98,7 @@ 10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。 11. 拼多多货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving` 数据集。同步器必须以参数绑定固定 `channel = PinduoduoImport`,只复制该渠道切片;页面 Repository 只读 `js_jst_pdd_receiving_bill_rows`。首次全量按 `Id` 断点推进,后续按 `updateTime` 保留重叠窗口,不得切回落后一天的 `operations_replica` 副本。 12. 得物货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.dewu_receiving` 数据集。同步器必须以参数绑定固定 `channel IN (Dewu, DewuTK, DewuKJ)`,页面 Repository 对列表、分页、筛选、汇总和导出使用相同渠道范围,并保留原始渠道代码用于审计;`Dewubzj` 保证金不得混入。 -13. 三方仓进销存首次全量按头表 `Id` 区间小批断点续传,头表只保存 `io_date >= 2026-08-01` 的目标仓数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时页面始终读取上一成功代次。增量扫描不先限定仓库或业务日期,确保单据移出目标仓或日期改到起始日前时可从镜像撤回,重载当前版本时再固定仓库和起始日期。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个 SKU 的抓取时间为回推锚点;页面始终按“日期 + 商品”输出,日期区间固定为 `[当天 00:00, 次日 00:00)`。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。 +13. 聚水潭进销存首次全量按头表 `Id` 区间小批断点续传,头表保存全部仓库中 `io_date >= 2026-08-01` 的数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时三方仓页面继续读取上一成功代次,全仓报表在首份完整全仓代次发布前明确提示同步中。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个“仓库 + SKU”的抓取时间为回推锚点;三方仓页面仍固定 `wms_co_id = 15045364` 并按“日期 + 商品”输出,全仓报表按“月份 + 仓库 + 商品”汇总。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。 14. 聚水潭商品操作日志按最多 7 天的固定时间窗和接口页码稳定断点同步,每页最多 50 条,首次全量从 `2000-01-01` 起扫描;只保留备注内容含“采购价”的记录,并从“采购价:旧值->新值”解析前后价格和差额。日常同步回看 48 小时,按接口记录字段生成稳定键幂等更新;接口失败时保留上一版镜像。页面不得把这些聚水潭记录与 `jiansun.dbo.pm_priceAdjustment` 的工作平台价格记录混为同一来源。 工作平台采购价格首次全量需在备份和负责人授权后单独执行: diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index bcf78e80..54f7bebf 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -42,10 +42,14 @@ class ThirdPartyWarehouseStorageMirrorRepository private const PRODUCT_TABLE = 'ops_jst_sku'; + private const WAREHOUSE_TABLE = 'ops_jst_warehouse_info'; + private const GENERIC_STATE_TABLE = 'business_mirror_table_states'; private const PRODUCT_DATASET_KEY = 'operations_replica.rpt.jst_sku'; + private const WAREHOUSE_DATASET_KEY = 'operations_replica.rpt.jst_warehouseInfo'; + public const START_DATE = '2026-08-01'; /** @var array */ @@ -123,6 +127,15 @@ class ThirdPartyWarehouseStorageMirrorRepository && $this->dependenciesReady(); } + public function allWarehousesReady(): bool + { + $state = $this->state(); + + return $this->ready() + && (int) ($state['scope_version'] ?? 1) >= 2 + && $this->warehouseDependencyReady(); + } + /** @return array|null */ public function state(): ?array { @@ -218,6 +231,159 @@ class ThirdPartyWarehouseStorageMirrorRepository ]; } + /** + * 商品价格管理的进销存报表与三方仓共用同一套已确认流水、 + * 库存锚点和业务类型口径,只在这里将日明细再汇总为“商品 + 仓库 + 月”。 + * + * @param array $filters + * @return array{data:list,total:int,state:array,sync:array} + */ + public function paginateMonthlyInventoryReport(array $filters, int $page, int $perPage): array + { + $state = $this->requireReadyState(); + if ((int) ($state['scope_version'] ?? 1) < 2) { + throw new RuntimeException('inventory_report_all_warehouses_not_ready'); + } + $page = max($page, 1); + $perPage = min(max($perPage, 1), 100); + $period = $this->period([ + 'dimension' => 'month', + 'period' => $filters['month'] ?? '', + ]); + $headerGeneration = $this->generation($state, 'active_header_generation'); + $itemGeneration = $this->generation($state, 'active_item_generation'); + $inventoryGeneration = $this->generation($state, 'active_inventory_generation'); + if ($headerGeneration === null || $itemGeneration === null || $inventoryGeneration === null) { + throw new RuntimeException('inventory_report_mirror_not_ready'); + } + + $anchorAt = $this->stateAnchor($state); + $rangeStart = min($period['start'], $anchorAt); + $rangeEnd = max($period['end'], $anchorAt); + $movementPairs = $this->confirmedMovements($headerGeneration, $itemGeneration, null) + ->where('h.occurred_at', '>=', $rangeStart) + ->where('h.occurred_at', '<', $rangeEnd) + ->select(['h.wms_co_id', 'i.sku_code']); + $inventoryPairs = $this->connection->table(self::INVENTORY_TABLE) + ->where('generation', $inventoryGeneration) + ->whereNotNull('sku_code')->where('sku_code', '!=', '') + ->select(['wms_co_id', 'sku_code']) + ->union($movementPairs); + $anchors = $this->connection->query()->fromSub($inventoryPairs, 'pairs') + ->leftJoin(self::INVENTORY_TABLE . ' as inventory', function ($join) use ($inventoryGeneration): void { + $join->on('inventory.wms_co_id', '=', 'pairs.wms_co_id') + ->on('inventory.sku_code', '=', 'pairs.sku_code') + ->where('inventory.generation', '=', $inventoryGeneration); + }) + ->select(['pairs.wms_co_id', 'pairs.sku_code']) + ->selectRaw('COALESCE(inventory.qty, 0) AS snapshot_qty') + ->selectRaw('COALESCE(inventory.captured_at, ?) AS snapshot_at', [$anchorAt]); + + $movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null) + ->where('h.occurred_at', '>=', $rangeStart) + ->where('h.occurred_at', '<', $rangeEnd) + ->select([ + 'h.wms_co_id', 'h.io_type', 'h.occurred_at', 'i.sku_code', + 'i.qty as movement_qty', 'i.qty_type as movement_qty_type', + ]); + $signed = '(COALESCE(mv.movement_qty, 0) * COALESCE(mv.movement_qty_type, 0))'; + $balanceAt = static fn (string $target): array => [ + 'a.snapshot_qty + COALESCE(SUM(CASE ' + . 'WHEN a.snapshot_at < ? AND mv.occurred_at >= a.snapshot_at AND mv.occurred_at < ? THEN ' . $signed . ' ' + . 'WHEN a.snapshot_at >= ? AND mv.occurred_at >= ? AND mv.occurred_at < a.snapshot_at THEN -' . $signed . ' ' + . 'ELSE 0 END), 0)', + [$target, $target, $target, $target], + ]; + [$openingSql, $openingBindings] = $balanceAt($period['start']); + [$closingSql, $closingBindings] = $balanceAt($period['end']); + $monthly = $this->connection->query()->fromSub($anchors, 'a') + ->leftJoinSub($movements, 'mv', function ($join): void { + $join->on('mv.wms_co_id', '=', 'a.wms_co_id') + ->on('mv.sku_code', '=', 'a.sku_code'); + }) + ->groupBy(['a.wms_co_id', 'a.sku_code', 'a.snapshot_qty', 'a.snapshot_at']) + ->select(['a.wms_co_id', 'a.sku_code']) + ->selectRaw($openingSql . ' AS opening_qty', $openingBindings) + ->selectRaw($closingSql . ' AS closing_qty', $closingBindings); + foreach (self::MOVEMENT_COLUMNS as $column => $type) { + $monthly->selectRaw( + 'COALESCE(SUM(CASE WHEN mv.occurred_at >= ? AND mv.occurred_at < ? ' + . 'AND mv.io_type = ? THEN ' . $signed . ' ELSE 0 END), 0) AS ' . $column, + [$period['start'], $period['end'], $type], + ); + } + $monthly->selectRaw('0 AS opening_aux'); + + $latestProducts = $this->connection->table(self::PRODUCT_TABLE) + ->where('sku_type', 'normal')->whereNotNull('sku_id')->where('sku_id', '!=', '') + ->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid'); + $latestWarehouses = $this->connection->table(self::WAREHOUSE_TABLE) + ->whereNotNull('wms_co_id')->groupBy('wms_co_id') + ->select('wms_co_id')->selectRaw('MAX(Id) AS Id'); + $query = $this->connection->query()->fromSub($monthly, 'm') + ->leftJoinSub($latestProducts, 'latest_product', 'latest_product.sku_id', '=', 'm.sku_code') + ->leftJoin(self::PRODUCT_TABLE . ' as s', function ($join): void { + $join->on('s.sku_id', '=', 'latest_product.sku_id') + ->on('s.autoid', '=', 'latest_product.autoid'); + }) + ->leftJoinSub($latestWarehouses, 'latest_warehouse', 'latest_warehouse.wms_co_id', '=', 'm.wms_co_id') + ->leftJoin(self::WAREHOUSE_TABLE . ' as w', function ($join): void { + $join->on('w.wms_co_id', '=', 'latest_warehouse.wms_co_id') + ->on('w.Id', '=', 'latest_warehouse.Id'); + }) + ->select(['m.*', 's.category', 'w.name as warehouse_name']); + $this->containsFilter($query, 'm.sku_code', $filters['product_code'] ?? ''); + $this->containsFilter($query, 's.category', $filters['category'] ?? ''); + $this->containsFilter($query, 'w.name', $filters['warehouse_provider'] ?? ''); + $nonZero = ['opening_qty', 'closing_qty', ...array_keys(self::MOVEMENT_COLUMNS)]; + $query->where(function (Builder $values) use ($nonZero): void { + foreach ($nonZero as $index => $column) { + $method = $index === 0 ? 'where' : 'orWhere'; + $values->{$method}('m.' . $column, '<>', 0); + } + }); + + $total = (clone $query)->count(); + $rows = $query->orderBy('w.name')->orderBy('m.sku_code')->forPage($page, $perPage)->get() + ->map(static function (object $row) use ($period): object { + $warehouse = trim((string) ($row->warehouse_name ?? '')); + + return (object) [ + 'owner' => (string) config( + 'upstream_mirrors.third_party_warehouse_storage.owner_name', + '常州简尚家居有限公司', + ), + 'month' => Carbon::parse($period['start'])->toDateString(), + 'warehouse_provider' => $warehouse, + 'warehouse' => $warehouse, + 'product_code' => (string) ($row->sku_code ?? ''), + 'category' => (string) ($row->category ?? ''), + 'opening_quantity' => $row->opening_qty, + 'closing_quantity' => $row->closing_qty, + 'processing_in' => $row->processing_in, + 'processing_out' => $row->processing_out, + 'purchase_in' => $row->purchase_in, + 'purchase_return' => $row->purchase_return, + 'other_in' => $row->other_in, + 'other_return' => $row->other_return, + 'sales_out' => $row->sales_out, + 'sales_return' => $row->sales_return, + 'stocktake' => $row->stocktake, + 'transfer_out' => $row->transfer_out, + 'transfer_in' => $row->transfer_in, + 'opening' => $row->opening_aux, + 'other_out' => $row->other_out, + ]; + })->all(); + + return [ + 'data' => $rows, + 'total' => $total, + 'state' => $state, + 'sync' => $this->syncStatus($state), + ]; + } + /** * @param array $filters * @return array{count:int,opening_qty:float,closing_qty:float} @@ -536,15 +702,18 @@ class ThirdPartyWarehouseStorageMirrorRepository return $dates; } - private function confirmedMovements(string $headerGeneration, string $itemGeneration): Builder + private function confirmedMovements( + string $headerGeneration, + string $itemGeneration, + ?int $warehouseId = self::WAREHOUSE_ID, + ): Builder { - return $this->connection->table(self::ITEM_TABLE . ' as i') + $query = $this->connection->table(self::ITEM_TABLE . ' as i') ->join(self::HEADER_TABLE . ' as h', function ($join) use ($headerGeneration): void { $join->on('h.io_id', '=', 'i.io_id') ->where('h.generation', '=', $headerGeneration); }) ->where('i.generation', $itemGeneration) - ->where('h.wms_co_id', self::WAREHOUSE_ID) ->where('h.status', 'Confirmed') ->whereIn('h.io_type', array_values(self::MOVEMENT_COLUMNS)) ->whereNotExists(function (Builder $newer) use ($headerGeneration): void { @@ -554,6 +723,12 @@ class ThirdPartyWarehouseStorageMirrorRepository ->whereColumn('newer.id', '>', 'h.id'); }) ->whereNotNull('i.sku_code')->where('i.sku_code', '!=', ''); + + if ($warehouseId !== null) { + $query->where('h.wms_co_id', $warehouseId); + } + + return $query; } /** @@ -775,6 +950,23 @@ class ThirdPartyWarehouseStorageMirrorRepository } } + private function warehouseDependencyReady(): bool + { + try { + if (! $this->connection->getSchemaBuilder()->hasTable(self::WAREHOUSE_TABLE)) { + return false; + } + + return $this->connection->table(self::GENERIC_STATE_TABLE) + ->where('dataset_key', self::WAREHOUSE_DATASET_KEY) + ->whereNotNull('initial_completed_at') + ->whereIn('status', ['succeeded', 'running', 'failed']) + ->exists(); + } catch (Throwable) { + return false; + } + } + /** @param array $state */ private function generation(array $state, string $key): ?string { diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageUpstreamRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageUpstreamRepository.php index a128dfac..8da325f4 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageUpstreamRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageUpstreamRepository.php @@ -9,6 +9,7 @@ class ThirdPartyWarehouseStorageUpstreamRepository { public const SOURCE_KEY = 'jiansun'; + /** 三方仓页面仍固定读取该仓;共享流水镜像覆盖全部仓库。 */ public const WAREHOUSE_ID = 15045364; public const START_DATE = '2026-08-01'; @@ -22,8 +23,7 @@ class ThirdPartyWarehouseStorageUpstreamRepository public function headerHighWatermark(): int { return (int) $this->query()->scalar( - 'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inoutWater] WHERE [wms_co_id] = ?', - [self::WAREHOUSE_ID], + 'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inoutWater]', ); } @@ -39,6 +39,7 @@ SELECT h.[Id] AS source_id, h.[io_id], h.[wms_co_id], + h.[wh_id], h.[status], h.[type] AS io_type, CONVERT(varchar(27), h.[io_date], 126) AS occurred_at, @@ -46,14 +47,13 @@ SELECT FROM [dbo].[jst_inoutWater] h WHERE h.[Id] > ? AND h.[Id] <= ? - AND h.[wms_co_id] = ? AND h.[io_date] >= ? AND NOT EXISTS ( SELECT 1 FROM [dbo].[jst_inoutWater] newer WHERE newer.[io_id] = h.[io_id] AND newer.[Id] > h.[Id] ) ORDER BY h.[Id] -SQL, [$afterId, $throughId, self::WAREHOUSE_ID, self::START_DATE]); +SQL, [$afterId, $throughId, self::START_DATE]); } /** @return list> */ @@ -70,6 +70,7 @@ SQL, [$afterId, $throughId, self::WAREHOUSE_ID, self::START_DATE]); [Id] AS source_id, [io_id], [wms_co_id], + [wh_id], [status], [type] AS io_type, CONVERT(varchar(27), [io_date], 126) AS occurred_at, @@ -103,7 +104,7 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]); foreach (array_chunk($ioIds, 500) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); $batch = $this->query()->select( - 'SELECT h.[Id] AS source_id, h.[io_id], h.[wms_co_id], h.[status], ' + 'SELECT h.[Id] AS source_id, h.[io_id], h.[wms_co_id], h.[wh_id], h.[status], ' . 'h.[type] AS io_type, CONVERT(varchar(27), h.[io_date], 126) AS occurred_at, ' . 'CONVERT(varchar(27), h.[modified], 126) AS modified_at ' . 'FROM [dbo].[jst_inoutWater] h ' @@ -165,8 +166,7 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]); public function inventoryHighWatermark(): int { return (int) $this->query()->scalar( - 'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inventoryInfo] WHERE [wms_co_id] = ?', - [self::WAREHOUSE_ID], + 'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inventoryInfo]', ); } @@ -189,9 +189,8 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]); FROM [dbo].[jst_inventoryInfo] WHERE [Id] > ? AND [Id] <= ? - AND [wms_co_id] = ? ORDER BY [Id] -SQL, [$afterId, $highWatermark, self::WAREHOUSE_ID]); +SQL, [$afterId, $highWatermark]); } private function query(): UpstreamReadOnlyQuery diff --git a/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageSynchronizer.php b/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageSynchronizer.php index 87945062..519484b5 100644 --- a/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageSynchronizer.php +++ b/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageSynchronizer.php @@ -263,12 +263,7 @@ class ThirdPartyWarehouseStorageSynchronizer $changedHeaders, )))); $latestHeaders = $this->upstream->latestHeadersForIoIds($changedIoIds, $throughAt); - $targetHeaders = array_values(array_filter( - $latestHeaders, - static fn (array $row): bool => - (int) ($row['wms_co_id'] ?? 0) - === ThirdPartyWarehouseStorageUpstreamRepository::WAREHOUSE_ID, - )); + $targetHeaders = $latestHeaders; $items = $this->upstream->itemsForIoIds(array_map( static fn (array $row): mixed => $row['io_id'] ?? null, $targetHeaders, @@ -395,7 +390,7 @@ class ThirdPartyWarehouseStorageSynchronizer self::HEADER_TABLE, (string) ($state['active_header_generation'] ?? ''), (string) $values['pending_header_generation'], - ['source_key', 'id', 'io_id', 'wms_co_id', 'status', 'io_type', 'occurred_at', 'source_modified_at', 'row_hash', 'synced_at'], + ['source_key', 'id', 'io_id', 'wms_co_id', 'wh_id', 'status', 'io_type', 'occurred_at', 'source_modified_at', 'row_hash', 'synced_at'], ); $this->cloneActiveGeneration( self::ITEM_TABLE, @@ -692,6 +687,7 @@ class ThirdPartyWarehouseStorageSynchronizer ); $values['header_modified_cursor_id'] = 0; if ($mode === 'full') { + $values['scope_version'] = 2; $values['initial_completed_at'] = $now; $values['last_full_succeeded_at'] = $now; } @@ -1036,6 +1032,7 @@ class ThirdPartyWarehouseStorageSynchronizer 'id' => $id, 'io_id' => $ioId, 'wms_co_id' => $warehouseId, + 'wh_id' => max((int) ($row['wh_id'] ?? 0), 0), 'status' => $this->text($row['status'] ?? null, 32), 'io_type' => $this->text($row['io_type'] ?? null, 64), 'occurred_at' => $this->dateTime($row['occurred_at'] ?? null), @@ -1275,6 +1272,7 @@ class ThirdPartyWarehouseStorageSynchronizer 'inventory_row_count', 'initial_completed_at', 'last_full_succeeded_at', + 'scope_version', ] as $column) { if (! $schema->hasColumn(self::STATE_TABLE, $column)) { throw new RuntimeException('third_party_warehouse_mirror_state_missing:' . $column); diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index ffb0a6eb..5838a0de 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -452,6 +452,29 @@ 清除 + @elseif (($page['slug'] ?? '') === 'inventory-report') +
+ + + + +
+ + 清除 +
+
@endif
diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index 71a576d8..25d283f5 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -8,11 +8,30 @@ use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepo use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository; +use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository; use Modules\EcommerceFinance\View\OrderDataPageData; use Tests\Feature\FeatureTestCase; class PagesTest extends FeatureTestCase { + public function testInventoryReportDoesNotFallBackToLegacyRowsBeforeAllWarehouseMirrorIsReady(): void + { + $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('allWarehousesReady')->once()->andReturnFalse(); + $repository->shouldNotReceive('paginateMonthlyInventoryReport'); + $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'product-inventory', + 'page' => 'inventory-report', + ])) + ->assertOk() + ->assertSeeText('进销存全仓库共享镜像正在执行首次同步') + ->assertDontSeeText('已从共享业务镜像读取'); + } + public function testFreightReconciliationPageRendersSharedMirrorRowsAndSummary(): void { $this->user->attachPermission( diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 2cc4bf9f..e851fb90 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -37,11 +37,9 @@ class NavigationTest extends FeatureTestCase '采购退货', '其它进仓', '其它退货', '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库', ]) - ->assertSeeText('已按附件模板导入 20 条演示数据') - ->assertSeeText('常州简尚家居有限公司') - ->assertSeeText('100006981661') - ->assertSeeText('2026-09-01') - ->assertSeeText('住宅家具 - 门厅/玄关柜/玄关架') + ->assertSeeText('复用“应付款项 → 快递应付 → 三方仓进销存”') + ->assertSeeText('进销存共享镜像尚未建立') + ->assertDontSeeText('已按附件模板导入 20 条演示数据') ->assertDontSeeText('进销存报表字段和数据来源尚未定义。'); } diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php index 5bb97a92..77c55abd 100644 --- a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php @@ -42,6 +42,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $this->createMirrorTables(); $this->createProductTable(); + $this->createWarehouseTable(); $this->createDependencyStateTable(); $this->repository = new ThirdPartyWarehouseStorageMirrorRepository(app('db')); } @@ -139,6 +140,72 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $this->assertSame(15.0, $early['closing_qty']); } + public function test_inventory_report_aggregates_the_same_confirmed_movements_by_product_warehouse_and_month(): void + { + $this->markReady(); + $this->insertProduct('SKU-MONTHLY', ['category' => '住宅家具 - 书桌']); + $this->insertInventory('SKU-MONTHLY', 12, '2026-09-01 00:00:00'); + $this->insertMovement('SKU-MONTHLY', '采购进仓', 5, 1, '2026-08-02 10:00:00'); + $this->insertMovement('SKU-MONTHLY', '销售出仓', 3, -1, '2026-08-15 10:00:00'); + $this->insertMovement('SKU-MONTHLY', '销售退货', 2, 1, '2026-08-20 10:00:00'); + + $result = $this->repository->paginateMonthlyInventoryReport([ + 'month' => '2026-08', + 'product_code' => 'SKU-MONTHLY', + 'warehouse_provider' => '南昌樵舍', + 'category' => '书桌', + ], 1, 25); + $row = $result['data'][0]; + + $this->assertSame(1, $result['total']); + $this->assertSame('2026-08-01', $row->month); + $this->assertSame('三方-南昌樵舍仓', $row->warehouse_provider); + $this->assertSame('三方-南昌樵舍仓', $row->warehouse); + $this->assertSame('SKU-MONTHLY', $row->product_code); + $this->assertSame('住宅家具 - 书桌', $row->category); + $this->assertSame(8.0, (float) $row->opening_quantity); + $this->assertSame(12.0, (float) $row->closing_quantity); + $this->assertSame(5.0, (float) $row->purchase_in); + $this->assertSame(-3.0, (float) $row->sales_out); + $this->assertSame(2.0, (float) $row->sales_return); + } + + public function test_inventory_report_reads_and_filters_warehouses_outside_the_third_party_scope(): void + { + $this->markReady(); + $this->insertProduct('SKU-ALL-WAREHOUSES', ['category' => '住宅家具 - 餐边柜']); + $this->insertInventory('SKU-ALL-WAREHOUSES', 9, '2026-09-01 00:00:00', [ + 'wms_co_id' => 15038426, + ]); + $this->insertMovement('SKU-ALL-WAREHOUSES', '销售出仓', 20, -1, '2026-08-15 10:00:00', [ + 'warehouse_id' => 15038426, + ]); + + $result = $this->repository->paginateMonthlyInventoryReport([ + 'month' => '2026-08', + 'product_code' => 'SKU-ALL-WAREHOUSES', + 'warehouse_provider' => '崔桥', + 'category' => '餐边柜', + ], 1, 25); + + $this->assertSame(1, $result['total']); + $this->assertSame('简尚-崔桥工厂仓', $result['data'][0]->warehouse_provider); + $this->assertSame('SKU-ALL-WAREHOUSES', $result['data'][0]->product_code); + $this->assertSame(-20.0, (float) $result['data'][0]->sales_out); + } + + public function test_third_party_page_remains_ready_when_only_the_all_warehouse_dimension_is_missing(): void + { + $this->markReady(); + DB::connection('business_mirror')->table('business_mirror_table_states') + ->where('dataset_key', 'operations_replica.rpt.jst_warehouseInfo') + ->delete(); + Schema::connection('business_mirror')->drop('ops_jst_warehouse_info'); + + $this->assertTrue($this->repository->ready()); + $this->assertFalse($this->repository->allWarehousesReady()); + } + public function test_periods_before_the_supported_start_date_are_clamped(): void { $this->markReady(); @@ -411,6 +478,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $schema = Schema::connection('business_mirror'); $schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void { $table->string('dataset_key')->primary(); + $table->unsignedTinyInteger('scope_version')->default(1); $table->string('status')->nullable(); $table->string('run_mode')->nullable(); $table->string('active_header_generation')->nullable(); @@ -426,6 +494,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $table->unsignedBigInteger('id'); $table->unsignedBigInteger('io_id'); $table->unsignedBigInteger('wms_co_id'); + $table->unsignedBigInteger('wh_id')->default(0); $table->string('status')->nullable(); $table->string('io_type')->nullable(); $table->dateTime('occurred_at')->nullable(); @@ -518,6 +587,25 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase 'initial_completed_at' => '2026-09-01 03:00:00', 'last_succeeded_at' => '2026-09-11 03:00:00', ], + [ + 'dataset_key' => 'operations_replica.rpt.jst_warehouseInfo', + 'status' => 'succeeded', + 'initial_completed_at' => '2026-09-01 03:00:00', + 'last_succeeded_at' => '2026-09-11 03:00:00', + ], + ]); + } + + private function createWarehouseTable(): void + { + Schema::connection('business_mirror')->create('ops_jst_warehouse_info', function (Blueprint $table): void { + $table->unsignedBigInteger('Id'); + $table->unsignedBigInteger('wms_co_id'); + $table->string('name')->nullable(); + }); + DB::connection('business_mirror')->table('ops_jst_warehouse_info')->insert([ + ['Id' => 1, 'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID, 'name' => '三方-南昌樵舍仓'], + ['Id' => 2, 'wms_co_id' => 15038426, 'name' => '简尚-崔桥工厂仓'], ]); } @@ -525,6 +613,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase private function markReady(array $overrides = []): void { $this->insertState(array_merge([ + 'scope_version' => 2, 'status' => 'succeeded', 'run_mode' => null, 'active_header_generation' => self::HEADER_GENERATION, diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageSynchronizerTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageSynchronizerTest.php index 8242500f..70eddce5 100644 --- a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageSynchronizerTest.php @@ -479,6 +479,7 @@ class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase $schema = Schema::connection('business_mirror'); $schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void { $table->string('dataset_key')->primary(); + $table->unsignedTinyInteger('scope_version')->default(1); $table->string('status')->default('pending'); $table->string('run_mode')->nullable(); $table->string('sync_phase')->nullable(); @@ -526,6 +527,7 @@ class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase $table->unsignedBigInteger('id'); $table->unsignedBigInteger('io_id'); $table->unsignedBigInteger('wms_co_id'); + $table->unsignedBigInteger('wh_id')->default(0); $table->string('status')->nullable(); $table->string('io_type')->nullable(); $table->dateTime('occurred_at')->nullable(); diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageUpstreamRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageUpstreamRepositoryTest.php index 62674989..74c583c6 100644 --- a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageUpstreamRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageUpstreamRepositoryTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase { - public function test_header_queries_use_the_fixed_source_table_warehouse_and_id_window(): void + public function test_header_queries_cover_all_warehouses_with_a_bounded_id_window(): void { $query = new RecordingThirdPartyWarehouseQuery(); $query->scalarResult = 987654; @@ -20,11 +20,12 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase $this->assertStringContainsString('MAX([Id])', $query->scalarCalls[0]['sql']); $this->assertStringContainsString('[dbo].[jst_inoutWater]', $query->scalarCalls[0]['sql']); - $this->assertStringContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']); - $this->assertSame([15045364], $query->scalarCalls[0]['bindings']); + $this->assertStringNotContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']); + $this->assertSame([], $query->scalarCalls[0]['bindings']); $this->assertStringContainsString('[Id] > ?', $query->selectCalls[0]['sql']); $this->assertStringContainsString('[Id] <= ?', $query->selectCalls[0]['sql']); - $this->assertStringContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']); + $this->assertStringNotContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']); + $this->assertStringContainsString('h.[wh_id]', $query->selectCalls[0]['sql']); $this->assertStringContainsString('[io_date]', $query->selectCalls[0]['sql']); $this->assertStringContainsString('AS occurred_at', $query->selectCalls[0]['sql']); $this->assertStringContainsString('NOT EXISTS', $query->selectCalls[0]['sql']); @@ -32,7 +33,7 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase $this->assertStringContainsString('newer.[Id] > h.[Id]', $query->selectCalls[0]['sql']); $this->assertStringContainsString('ORDER BY h.[Id]', $query->selectCalls[0]['sql']); $this->assertStringContainsString('h.[io_date] >= ?', $query->selectCalls[0]['sql']); - $this->assertSame([100, 200, 15045364, '2026-08-01'], $query->selectCalls[0]['bindings']); + $this->assertSame([100, 200, '2026-08-01'], $query->selectCalls[0]['bindings']); } public function test_items_are_parameterized_and_split_into_bounded_io_id_batches(): void @@ -114,7 +115,7 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase $this->assertCount(2, $query->selectCalls); } - public function test_inventory_uses_target_warehouse_keyset_pagination_and_a_bounded_limit(): void + public function test_inventory_uses_all_warehouse_keyset_pagination_and_a_bounded_limit(): void { $query = new RecordingThirdPartyWarehouseQuery(); $query->scalarResult = 191818; @@ -124,16 +125,16 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase $repository->inventoryBatch(12350, 191818, 99999); $this->assertStringContainsString('[dbo].[jst_inventoryInfo]', $query->scalarCalls[0]['sql']); - $this->assertStringContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']); - $this->assertSame([15045364], $query->scalarCalls[0]['bindings']); + $this->assertStringNotContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']); + $this->assertSame([], $query->scalarCalls[0]['bindings']); $this->assertStringContainsString('SELECT TOP (5000)', $query->selectCalls[0]['sql']); $this->assertStringContainsString('[dbo].[jst_inventoryInfo]', $query->selectCalls[0]['sql']); $this->assertStringContainsString('[Id] > ?', $query->selectCalls[0]['sql']); $this->assertStringContainsString('[Id] <= ?', $query->selectCalls[0]['sql']); - $this->assertStringContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']); + $this->assertStringNotContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']); $this->assertStringContainsString('ORDER BY [Id]', $query->selectCalls[0]['sql']); $this->assertStringContainsString('AS source_modified_at', $query->selectCalls[0]['sql']); - $this->assertSame([12350, 191818, 15045364], $query->selectCalls[0]['bindings']); + $this->assertSame([12350, 191818], $query->selectCalls[0]['bindings']); $repository->inventoryBatch(191818, 191818, 2000); $this->assertCount(1, $query->selectCalls);