From 48569beab91338e2a9b0862a337e2d5be89a4d59 Mon Sep 17 00:00:00 2001 From: chenxi Date: Thu, 24 Sep 2026 14:09:44 +0800 Subject: [PATCH] fix(inventory): include confirmed movement-only warehouse dimensions --- ...dPartyWarehouseStorageMirrorRepository.php | 191 ++++++++++++------ ...tyWarehouseStorageMirrorRepositoryTest.php | 70 ++++++- 2 files changed, 200 insertions(+), 61 deletions(-) diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index 2aee8833..a9b8cdcb 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -773,63 +773,138 @@ class ThirdPartyWarehouseStorageMirrorRepository array_merge([$owner], $inventoryWarehouseBindings), ); - if ($includeTotal) { - $candidateUnion = $authorityCandidates->unionAll($inventoryDimensions); - $candidates = $this->connection->query()->fromSub($candidateUnion, 'candidate_rows'); - $pageCandidates = (clone $candidates) - ->orderBy('warehouse_provider')->orderBy('wh_id')->orderBy('sku_code') - ->forPage($page, $perPage) - ->select('candidate_rows.*')->selectRaw('COUNT(*) OVER() AS report_total'); - $pairRows = $this->boundedGet(clone $pageCandidates); - $total = (int) ($pairRows->first()->report_total ?? 0); - } else { - // Export does not need a total. Page the authoritative baseline and newly - // discovered inventory dimensions separately so every batch avoids the - // expensive full UNION count/window query. - $authorityTotal = $this->boundedCount(clone $authorityRows); - $total = 0; - $offset = ($page - 1) * $perPage; - $remaining = $perPage; - $candidatePages = []; - - if ($offset < $authorityTotal) { - $authorityLimit = min($remaining, $authorityTotal - $offset); - $candidatePages[] = (clone $authorityCandidates) - ->orderBy('authority.warehouse_provider') - ->orderBy('wh_id') - ->orderBy('authority.product_code') - ->offset($offset) - ->limit($authorityLimit); - $remaining -= $authorityLimit; - $inventoryOffset = 0; - } else { - $inventoryOffset = $offset - $authorityTotal; - } - - if ($remaining > 0) { - $candidatePages[] = (clone $inventoryDimensions) - ->orderBy('w.name') - ->orderBy('inventory.wh_id') - ->orderBy('inventory.sku_code') - ->offset($inventoryOffset) - ->limit($remaining); - } - - if ($candidatePages === []) { - return [ - 'data' => [], - 'total' => 0, - 'state' => $state, - 'sync' => $this->syncStatus($state), - ]; - } - - $pageCandidates = array_shift($candidatePages); - foreach ($candidatePages as $candidatePage) { - $pageCandidates->unionAll($candidatePage); - } - $pairRows = $this->boundedGet(clone $pageCandidates); + $authorityEnd = Carbon::parse($authority['source_month']) + ->addMonth()->startOfMonth()->format('Y-m-d 00:00:00'); + $movementWarehouseIdSql = 'COALESCE(NULLIF(h.wh_id, 0), 1)'; + $movementPairs = $this->confirmedMovements($headerGeneration, $itemGeneration, null) + ->where('h.occurred_at', '>=', $authorityEnd) + ->where('h.occurred_at', '<', $period['end']) + ->where('i.qty', '!=', 0) + ->where('i.qty_type', '!=', 0) + ->select('h.wms_co_id', 'i.sku_code') + ->selectRaw($movementWarehouseIdSql . ' AS wh_id') + ->distinct(); + if ($productCode !== '') { + $movementPairs->where('i.sku_code', $productCode); } + $movementDimensions = $this->connection->query()->fromSub($movementPairs, 'movement_pair') + ->joinSub($latestWarehouses, 'movement_latest_warehouse', 'movement_latest_warehouse.wms_co_id', '=', 'movement_pair.wms_co_id') + ->join(self::WAREHOUSE_TABLE . ' as movement_warehouse', function ($join): void { + $join->on('movement_warehouse.wms_co_id', '=', 'movement_latest_warehouse.wms_co_id') + ->on('movement_warehouse.Id', '=', 'movement_latest_warehouse.Id'); + }); + $this->containsFilter($movementDimensions, 'movement_warehouse.name', $filters['warehouse_provider'] ?? ''); + [$movementWarehouseSql, $movementWarehouseBindings] = $this->subwarehouseNameSql( + 'movement_warehouse.name', + 'movement_pair.wh_id', + ); + $movementDimensions->whereNotExists(function (Builder $prior) use ( + $authority, + $movementWarehouseSql, + $movementWarehouseBindings, + ): void { + $prior->selectRaw('1') + ->from(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE . ' as prior') + ->where('prior.generation', $authority['generation']) + ->where('prior.report_month', $authority['source_month']) + ->whereColumn('prior.product_code', 'movement_pair.sku_code') + ->whereColumn('prior.warehouse_provider', 'movement_warehouse.name') + ->whereRaw('prior.warehouse = ' . $movementWarehouseSql, $movementWarehouseBindings); + })->whereNotExists(function (Builder $snapshot) use ($inventoryGeneration): void { + $snapshot->selectRaw('1') + ->from(self::SUBWAREHOUSE_INVENTORY_TABLE . ' as matching_inventory') + ->where('matching_inventory.generation', $inventoryGeneration) + ->where('matching_inventory.qty', '!=', 0) + ->whereColumn('matching_inventory.wms_co_id', 'movement_pair.wms_co_id') + ->whereRaw('COALESCE(NULLIF(matching_inventory.wh_id, 0), 1) = movement_pair.wh_id') + ->whereColumn('matching_inventory.sku_code', 'movement_pair.sku_code'); + }); + $movementCount = clone $movementDimensions; + if (trim((string) ($filters['category'] ?? '')) === '') { + $movementCount->whereExists(function (Builder $product): void { + $product->selectRaw('1')->from(self::PRODUCT_TABLE . ' as known_product') + ->where('known_product.sku_type', 'normal') + ->whereColumn('known_product.sku_id', 'movement_pair.sku_code'); + })->selectRaw('1 AS counted'); + } + $movementDimensions->joinSub($latestProducts, 'movement_latest_product', 'movement_latest_product.sku_id', '=', 'movement_pair.sku_code') + ->join(self::PRODUCT_TABLE . ' as movement_product', function ($join): void { + $join->on('movement_product.sku_id', '=', 'movement_latest_product.sku_id') + ->on('movement_product.autoid', '=', 'movement_latest_product.autoid'); + }); + $this->containsFilter($movementDimensions, 'movement_product.category', $filters['category'] ?? ''); + if (trim((string) ($filters['category'] ?? '')) !== '') { + $movementCount = (clone $movementDimensions)->selectRaw('1 AS counted'); + } + $movementDimensions->selectRaw( + '? AS owner, movement_warehouse.name AS warehouse_provider, ' + . $movementWarehouseSql . ' AS source_warehouse, ' + . 'movement_pair.wms_co_id AS wms_co_id, movement_pair.wh_id AS wh_id, ' + . 'movement_pair.sku_code AS sku_code, movement_product.category AS category, 0 AS opening_qty', + array_merge([$owner], $movementWarehouseBindings), + ); + + $sources = [ + [$authorityCandidates, $authorityCandidates, 'authority.warehouse_provider', 'authority.product_code'], + [$inventoryDimensions, $inventoryDimensions, 'w.name', 'inventory.sku_code'], + [$movementDimensions, $movementCount, 'movement_warehouse.name', 'movement_pair.sku_code'], + ]; + $counts = []; + $total = 0; + if ($includeTotal) { + $countCacheKey = $this->monthlyInventoryReportCacheKey( + $period, $filters, $authority, $headerGeneration, $itemGeneration, + $inventoryGeneration, 0, 0, + ) . ':source-counts'; + $cachedCounts = Cache::get($countCacheKey); + if (is_array($cachedCounts) && count($cachedCounts) === count($sources)) { + $counts = $cachedCounts; + } else { + foreach ($sources as $index => [, $countQuery]) { + $counts[$index] = $this->boundedCount(clone $countQuery); + } + Cache::put($countCacheKey, $counts, self::REPORT_CACHE_SECONDS); + } + $total = array_sum($counts); + } + $offset = ($page - 1) * $perPage; + $remaining = $perPage; + $candidatePages = []; + foreach ($sources as $index => [$candidates, $countQuery, $warehouseOrder, $productOrder]) { + if ($remaining === 0) { + break; + } + $sourceTotal = $counts[$index] ?? ($index < count($sources) - 1 + ? $this->boundedCount(clone $countQuery) : null); + if ($sourceTotal !== null && $offset >= $sourceTotal) { + $offset -= $sourceTotal; + continue; + } + $limit = $sourceTotal === null ? $remaining : min($remaining, $sourceTotal - $offset); + $candidatePages[] = (clone $candidates) + ->orderBy($warehouseOrder) + ->orderBy('wh_id') + ->orderBy($productOrder) + ->offset($offset) + ->limit($limit); + $remaining -= $limit; + $offset = 0; + } + + if ($candidatePages === []) { + return [ + 'data' => [], + 'total' => $total, + 'state' => $state, + 'sync' => $this->syncStatus($state), + ]; + } + + $pageCandidates = array_shift($candidatePages); + foreach ($candidatePages as $candidatePage) { + $pageCandidates->unionAll($candidatePage); + } + $pairRows = $this->boundedGet(clone $pageCandidates); if ($pairRows->isEmpty()) { return [ 'data' => [], @@ -839,8 +914,6 @@ class ThirdPartyWarehouseStorageMirrorRepository ]; } - $authorityEnd = Carbon::parse($authority['source_month']) - ->addMonth()->startOfMonth()->format('Y-m-d 00:00:00'); $movementPairs = $pairRows->filter( static fn (object $row): bool => $row->wms_co_id !== null && trim((string) $row->sku_code) !== '', )->unique(static fn (object $row): string => implode(':', [ @@ -964,7 +1037,7 @@ class ThirdPartyWarehouseStorageMirrorRepository 'category' => trim((string) ($filters['category'] ?? '')), ]; - return 'inventory-report:monthly-page:v3:' . hash('sha256', (string) json_encode([ + return 'inventory-report:monthly-page:v6:' . hash('sha256', (string) json_encode([ 'period' => $period, 'filters' => $normalizedFilters, 'authority' => $authority, diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php index 13ad5604..cccf0cd2 100644 --- a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php @@ -248,8 +248,10 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $queries, static fn (array $query): bool => str_contains(strtolower($query['query']), 'count(*)'), )); - $this->assertCount(1, $countQueries); - $this->assertStringContainsString('count(*) over()', strtolower($countQueries[0]['query'])); + $this->assertCount(3, $countQueries); + $this->assertFalse(collect($queries)->contains( + static fn (array $query): bool => str_contains(strtolower($query['query']), 'count(*) over()'), + )); $candidatePage = collect($queries) ->first(static fn (array $query): bool => str_contains($query['query'], '"prior"."report_month"')); $this->assertNotNull($candidatePage); @@ -298,6 +300,70 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $this->assertSame(-2.0, (float) $row->sales_out); } + public function test_inventory_report_includes_confirmed_movement_subwarehouse_without_current_stock(): void + { + $this->markReady(); + $this->insertProduct('CTG008616N1152V01'); + $this->insertInventoryReportAuthoritativeRow([ + 'source_month' => '2026-07-01', + 'report_month' => '2026-07-01', + 'warehouse_provider' => '简尚-崔桥工厂仓', + 'warehouse' => '简尚-崔桥工厂仓主仓', + 'product_code' => 'OTHER-SKU', + ]); + $this->insertInventory('CTG008616N1152V01', 39, '2026-09-11 03:21:00', [ + 'wms_co_id' => 15038426, + 'in_qty' => 0, + ]); + foreach ([30, 50, 30] as $quantity) { + $this->insertMovement('CTG008616N1152V01', '采购进仓', $quantity, 1, '2026-08-23 10:00:00', [ + 'warehouse_id' => 15038426, + 'wh_id' => 3, + ]); + } + $this->insertMovement('CTG008616N1152V01', '采购进仓', 999, 1, '2026-08-25 10:00:00', [ + 'warehouse_id' => 15038426, + 'wh_id' => 3, + 'status' => 'WaitConfirm', + ]); + $filters = [ + 'month' => '2026-08', + 'product_code' => 'CTG008616N1152V01', + 'warehouse_provider' => '崔桥工厂仓', + 'category' => '', + ]; + + $august = $this->repository->paginateMonthlyInventoryReport($filters, 1, 25); + $rows = collect($august['data'])->keyBy('warehouse'); + $this->assertSame(2, $august['total']); + $this->assertSame(0.0, (float) $rows['简尚-崔桥工厂仓进货仓']->opening_quantity); + $this->assertSame(110.0, (float) $rows['简尚-崔桥工厂仓进货仓']->purchase_in); + $this->assertSame(0.0, (float) $rows['简尚-崔桥工厂仓进货仓']->transfer_out); + $this->assertSame(110.0, (float) $rows['简尚-崔桥工厂仓进货仓']->closing_quantity); + + $secondPage = $this->repository->paginateMonthlyInventoryReport($filters, 2, 1); + $this->assertSame(2, $secondPage['total']); + $this->assertSame('简尚-崔桥工厂仓进货仓', $secondPage['data'][0]->warehouse); + + $categorized = $this->repository->paginateMonthlyInventoryReport( + array_merge($filters, ['category' => '测试分类']), 1, 25, + ); + $this->assertSame(2, $categorized['total']); + $this->assertSame(110.0, (float) collect($categorized['data']) + ->firstWhere('warehouse', '简尚-崔桥工厂仓进货仓')->closing_quantity); + + $exported = iterator_to_array($this->repository->exportMonthlyInventoryReportRows($filters, 1)); + $this->assertCount(2, $exported); + $this->assertSame(110.0, (float) collect($exported)->firstWhere('warehouse', '简尚-崔桥工厂仓进货仓')->purchase_in); + + $september = $this->repository->paginateMonthlyInventoryReport( + array_merge($filters, ['month' => '2026-09']), 1, 25, + ); + $nextRow = collect($september['data'])->firstWhere('warehouse', '简尚-崔桥工厂仓进货仓'); + $this->assertSame(110.0, (float) $nextRow->opening_quantity); + $this->assertSame(110.0, (float) $nextRow->closing_quantity); + } + public function test_inventory_report_product_code_filter_is_exact(): void { $this->markReady(); -- 2.47.2