From d3f728d1b62588e3492c395ffe13cd57d9b89a31 Mon Sep 17 00:00:00 2001 From: chenxi Date: Mon, 21 Sep 2026 18:20:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=9B=E9=94=80=E5=AD=98?= =?UTF-8?q?=E4=B9=9D=E6=9C=88=E6=9C=9F=E5=88=9D=E7=BB=93=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...dd_inventory_report_roll_forward_index.php | 40 +++ ...dPartyWarehouseStorageMirrorRepository.php | 290 ++++++++++++++++++ ...tyWarehouseStorageMirrorRepositoryTest.php | 38 +++ 3 files changed, 368 insertions(+) create mode 100644 database/migrations/2026_09_21_100000_add_inventory_report_roll_forward_index.php diff --git a/database/migrations/2026_09_21_100000_add_inventory_report_roll_forward_index.php b/database/migrations/2026_09_21_100000_add_inventory_report_roll_forward_index.php new file mode 100644 index 00000000..34a519fb --- /dev/null +++ b/database/migrations/2026_09_21_100000_add_inventory_report_roll_forward_index.php @@ -0,0 +1,40 @@ +connection()); + if (! $schema->hasTable('inventory_report_authoritative_rows')) { + return; + } + + $schema->table('inventory_report_authoritative_rows', function (Blueprint $table): void { + $table->index( + ['generation', 'report_month', 'warehouse_provider', 'warehouse', 'product_code'], + 'inventory_report_authoritative_roll_forward_index', + ); + }); + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable('inventory_report_authoritative_rows')) { + return; + } + + $schema->table('inventory_report_authoritative_rows', function (Blueprint $table): void { + $table->dropIndex('inventory_report_authoritative_roll_forward_index'); + }); + } +}; diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index 35d0356c..db1613a5 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -351,6 +351,21 @@ class ThirdPartyWarehouseStorageMirrorRepository throw new RuntimeException('inventory_report_mirror_not_ready'); } + $rolledForward = $this->monthlyInventoryRollForward( + $period, + $filters, + $headerGeneration, + $itemGeneration, + $inventoryGeneration, + $page, + $perPage, + $includeTotal, + $state, + ); + if ($rolledForward !== null) { + return $rolledForward; + } + $anchorAt = $this->stateAnchor($state); $rangeStart = min($period['start'], $anchorAt); $rangeEnd = max($period['end'], $anchorAt); @@ -622,6 +637,281 @@ class ThirdPartyWarehouseStorageMirrorRepository } } + /** + * The month immediately after an authoritative report starts from that report's closing + * quantity. Current inventory is deliberately ignored because it cannot reproduce the + * historical opening balance of each subwarehouse. + * + * @param array{start:string,end:string} $period + * @param array $filters + * @param array $state + * @return array{data:list,total:int,state:array,sync:array}|null + */ + private function monthlyInventoryRollForward( + array $period, + array $filters, + string $headerGeneration, + string $itemGeneration, + string $inventoryGeneration, + int $page, + int $perPage, + bool $includeTotal, + array $state, + ): ?array { + try { + $authority = $this->previousMonthlyInventoryAuthority($period); + if ($authority === null) { + return null; + } + + $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'); + + $authorityRows = $this->connection + ->table(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE . ' as authority') + ->where('authority.generation', $authority['generation']) + ->whereDate('authority.report_month', $authority['source_month']); + $productCode = trim((string) ($filters['product_code'] ?? '')); + if ($productCode !== '') { + $authorityRows->where('authority.product_code', $productCode); + } + $this->containsFilter($authorityRows, 'authority.warehouse_provider', $filters['warehouse_provider'] ?? ''); + $this->containsFilter($authorityRows, 'authority.category', $filters['category'] ?? ''); + [$authorityWarehouseIdSql, $authorityWarehouseIdBindings] = $this->subwarehouseIdSql( + 'authority.warehouse_provider', + 'authority.warehouse', + ); + $authorityCandidates = (clone $authorityRows) + ->leftJoin(self::WAREHOUSE_TABLE . ' as authority_warehouse', function ($join): void { + $join->on('authority_warehouse.name', '=', 'authority.warehouse_provider'); + }) + ->leftJoinSub($latestWarehouses, 'authority_latest_warehouse', function ($join): void { + $join->on('authority_latest_warehouse.wms_co_id', '=', 'authority_warehouse.wms_co_id') + ->on('authority_latest_warehouse.Id', '=', 'authority_warehouse.Id'); + })->selectRaw( + 'authority.owner AS owner, authority.warehouse_provider AS warehouse_provider, ' + . 'authority.warehouse AS source_warehouse, authority_warehouse.wms_co_id AS wms_co_id, ' + . $authorityWarehouseIdSql . ' AS wh_id, authority.product_code AS sku_code, ' + . 'authority.category AS category, authority.closing_quantity AS opening_qty', + $authorityWarehouseIdBindings, + ); + $inventoryDimensions = $this->connection->table(self::SUBWAREHOUSE_INVENTORY_TABLE . ' as inventory') + ->where('inventory.generation', $inventoryGeneration) + ->where('inventory.qty', '!=', 0) + ->whereNotNull('inventory.sku_code')->where('inventory.sku_code', '!=', '') + ->joinSub($latestProducts, 'latest_product', 'latest_product.sku_id', '=', 'inventory.sku_code') + ->join(self::PRODUCT_TABLE . ' as s', function ($join): void { + $join->on('s.sku_id', '=', 'latest_product.sku_id') + ->on('s.autoid', '=', 'latest_product.autoid'); + }) + ->joinSub($latestWarehouses, 'latest_warehouse', 'latest_warehouse.wms_co_id', '=', 'inventory.wms_co_id') + ->join(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'); + }); + if ($productCode !== '') { + $inventoryDimensions->where('s.sku_id', $productCode); + } + $this->containsFilter($inventoryDimensions, 'w.name', $filters['warehouse_provider'] ?? ''); + $this->containsFilter($inventoryDimensions, 's.category', $filters['category'] ?? ''); + $owner = (string) config( + 'upstream_mirrors.third_party_warehouse_storage.owner_name', + '常州简尚家居有限公司', + ); + [$inventoryWarehouseSql, $inventoryWarehouseBindings] = $this->subwarehouseNameSql( + 'w.name', + 'inventory.wh_id', + ); + $inventoryDimensions->whereNotExists(function (Builder $prior) use ( + $authority, + $inventoryWarehouseSql, + $inventoryWarehouseBindings, + ): void { + $prior->selectRaw('1') + ->from(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE . ' as prior') + ->where('prior.generation', $authority['generation']) + ->whereDate('prior.report_month', $authority['source_month']) + ->whereColumn('prior.product_code', 'inventory.sku_code') + ->whereColumn('prior.warehouse_provider', 'w.name') + ->whereRaw('prior.warehouse = ' . $inventoryWarehouseSql, $inventoryWarehouseBindings); + }); + $inventoryDimensions->distinct() + ->selectRaw( + '? AS owner, w.name AS warehouse_provider, ' + . $inventoryWarehouseSql . ' AS source_warehouse, ' + . 'inventory.wms_co_id AS wms_co_id, inventory.wh_id AS wh_id, ' + . 'inventory.sku_code AS sku_code, s.category AS category, 0 AS opening_qty', + array_merge([$owner], $inventoryWarehouseBindings), + ); + + $candidateUnion = $authorityCandidates->unionAll($inventoryDimensions); + $candidates = $this->connection->query()->fromSub($candidateUnion, 'candidate_rows'); + + $total = $includeTotal ? $this->connection->query()->fromSub(clone $candidates, 'counted')->count() : 0; + $pageCandidates = (clone $candidates) + ->orderBy('warehouse_provider')->orderBy('wh_id')->orderBy('sku_code') + ->forPage($page, $perPage); + $pairRows = (clone $pageCandidates)->get(); + if ($pairRows->isEmpty()) { + return [ + 'data' => [], + 'total' => $total, + 'state' => $state, + 'sync' => $this->syncStatus($state), + ]; + } + + $movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null) + ->where('h.occurred_at', '>=', $period['start']) + ->where('h.occurred_at', '<', $period['end']) + ->whereIn('h.wms_co_id', $pairRows->pluck('wms_co_id')->filter()->unique()->values()->all()) + ->whereIn('i.sku_code', $pairRows->pluck('sku_code')->unique()->values()->all()) + ->select([ + 'h.wms_co_id', 'h.io_type', 'i.sku_code', + 'i.qty as movement_qty', 'i.qty_type as movement_qty_type', + ])->selectRaw('COALESCE(NULLIF(h.wh_id, 0), 1) AS wh_id'); + $signed = '(COALESCE(mv.movement_qty, 0) * COALESCE(mv.movement_qty_type, 0))'; + $monthly = $this->connection->query()->fromSub($pageCandidates, 'a') + ->leftJoinSub($movements, 'mv', function ($join): void { + $join->on('mv.wms_co_id', '=', 'a.wms_co_id') + ->on('mv.wh_id', '=', 'a.wh_id') + ->on('mv.sku_code', '=', 'a.sku_code'); + })->groupBy([ + 'a.owner', 'a.warehouse_provider', 'a.source_warehouse', 'a.wms_co_id', + 'a.wh_id', 'a.sku_code', 'a.category', 'a.opening_qty', + ])->select([ + 'a.owner', 'a.warehouse_provider', 'a.source_warehouse', 'a.wms_co_id', + 'a.wh_id', 'a.sku_code', 'a.category', 'a.opening_qty', + ]); + foreach (self::MOVEMENT_COLUMNS as $column => $type) { + $expression = 'COALESCE(SUM(CASE WHEN mv.io_type = ? THEN ' . $signed . ' ELSE 0 END), 0)'; + $monthly->selectRaw($expression . ' AS ' . $column, [$type]); + } + + $rows = $monthly->orderBy('a.warehouse_provider')->orderBy('a.wh_id')->orderBy('a.sku_code') + ->get()->map(function (object $row) use ($period): object { + $sourceWarehouse = trim((string) ($row->source_warehouse ?? '')); + $warehouse = $sourceWarehouse !== '' + ? $sourceWarehouse + : $this->subwarehouseName( + trim((string) $row->warehouse_provider), + (int) $row->wh_id, + ); + $closingQuantity = (float) $row->opening_qty; + foreach (array_keys(self::MOVEMENT_COLUMNS) as $column) { + $closingQuantity += (float) $row->{$column}; + } + + return (object) [ + 'owner' => $row->owner, + 'month' => Carbon::parse($period['start'])->toDateString(), + 'warehouse_provider' => $row->warehouse_provider, + 'warehouse' => $warehouse, + 'product_code' => $row->sku_code, + 'category' => $row->category, + 'opening_quantity' => $row->opening_qty, + 'closing_quantity' => $closingQuantity, + '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' => 0, + 'other_out' => $row->other_out, + ]; + })->all(); + + return [ + 'data' => $rows, + 'total' => $total, + 'state' => $state, + 'sync' => $this->syncStatus($state), + ]; + } catch (Throwable $exception) { + report($exception); + + return null; + } + } + + /** @param array{start:string,end:string} $period */ + private function previousMonthlyInventoryAuthority(array $period): ?array + { + $previousMonth = Carbon::parse($period['start'])->subMonth()->startOfMonth()->toDateString(); + $state = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) + ->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY) + ->whereNotNull('active_generation') + ->whereDate('source_month', $previousMonth) + ->first(); + if ($state === null || trim((string) $state->active_generation) === '') { + return null; + } + + return [ + 'generation' => (string) $state->active_generation, + 'source_month' => $previousMonth, + ]; + } + + /** @return array{0:string,1:list} */ + private function subwarehouseIdSql(string $providerColumn, string $warehouseColumn): array + { + $cases = []; + $bindings = []; + foreach (self::SPECIAL_SUBWAREHOUSE_NAMES as $provider => $names) { + foreach ($names as $id => $name) { + $cases[] = "WHEN {$providerColumn} = ? AND {$warehouseColumn} = ? THEN {$id}"; + array_push($bindings, $provider, $name); + } + } + foreach (self::SUBWAREHOUSE_SUFFIXES as $id => $suffix) { + $cases[] = 'WHEN ' . $warehouseColumn . ' = ' + . $this->concatSql([$providerColumn, '?']) . " THEN {$id}"; + $bindings[] = $suffix; + } + + return ['CASE ' . implode(' ', $cases) . ' ELSE -1 END', $bindings]; + } + + /** @return array{0:string,1:list} */ + private function subwarehouseNameSql(string $providerColumn, string $subwarehouseColumn): array + { + $cases = []; + $bindings = []; + foreach (self::SPECIAL_SUBWAREHOUSE_NAMES as $provider => $names) { + foreach ($names as $id => $name) { + $cases[] = "WHEN {$providerColumn} = ? AND {$subwarehouseColumn} = {$id} THEN ?"; + array_push($bindings, $provider, $name); + } + } + foreach (self::SUBWAREHOUSE_SUFFIXES as $id => $suffix) { + $cases[] = "WHEN {$subwarehouseColumn} = {$id} THEN " . $this->concatSql([$providerColumn, '?']); + $bindings[] = $suffix; + } + + $fallback = $this->concatSql([$providerColumn, "'其他子仓('", $subwarehouseColumn, "')'"]); + + return ['CASE ' . implode(' ', $cases) . ' ELSE ' . $fallback . ' END', $bindings]; + } + + /** @param list $parts */ + private function concatSql(array $parts): string + { + return $this->connection->getDriverName() === 'sqlite' + ? '(' . implode(' || ', $parts) . ')' + : 'CONCAT(' . implode(', ', $parts) . ')'; + } + /** * @param array $filters * @return array{count:int,opening_qty:float,closing_qty:float} diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php index 293655e9..4c8b2c1a 100644 --- a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php @@ -201,6 +201,44 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $this->assertSame(6.0, (float) $row->purchase_in); } + public function test_inventory_report_rolls_previous_month_closing_into_next_month_opening(): void + { + $this->markReady(); + $this->insertProduct('SKU-ROLL-FORWARD'); + $this->insertInventory('SKU-ROLL-FORWARD', 999, '2026-09-11 03:21:00'); + $this->insertInventoryReportAuthoritativeRow([ + 'warehouse_provider' => '三方-南昌樵舍仓', + 'warehouse' => '三方-南昌樵舍仓主仓', + 'product_code' => 'SKU-ROLL-FORWARD', + 'opening_quantity' => 14, + 'closing_quantity' => 20, + 'purchase_in' => 6, + ]); + $this->insertMovement('SKU-ROLL-FORWARD', '采购进仓', 3, 1, '2026-09-02 10:00:00'); + $this->insertInventory('SKU-ROLL-FORWARD', 5, '2026-09-11 03:21:00', [ + 'wms_co_id' => 15038426, + ]); + $this->insertMovement('SKU-ROLL-FORWARD', '调拨入', 5, 1, '2026-09-03 10:00:00', [ + 'warehouse_id' => 15038426, + ]); + + $result = $this->repository->paginateMonthlyInventoryReport([ + 'month' => '2026-09', + 'product_code' => 'SKU-ROLL-FORWARD', + 'warehouse_provider' => '', + 'category' => '', + ], 1, 25); + $rows = collect($result['data'])->keyBy('warehouse'); + + $this->assertSame(2, $result['total']); + $this->assertSame(20.0, (float) $rows['三方-南昌樵舍仓主仓']->opening_quantity); + $this->assertSame(23.0, (float) $rows['三方-南昌樵舍仓主仓']->closing_quantity); + $this->assertSame(3.0, (float) $rows['三方-南昌樵舍仓主仓']->purchase_in); + $this->assertSame(0.0, (float) $rows['简尚-崔桥工厂仓主仓']->opening_quantity); + $this->assertSame(5.0, (float) $rows['简尚-崔桥工厂仓主仓']->closing_quantity); + $this->assertSame(5.0, (float) $rows['简尚-崔桥工厂仓主仓']->transfer_in); + } + public function test_inventory_report_product_code_filter_is_exact(): void { $this->markReady();