diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index a9b8cdcb..853170c2 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -314,47 +314,7 @@ class ThirdPartyWarehouseStorageMirrorRepository $page = max($page, 1); $perPage = min(max($perPage, 1), 5000); $period = $this->inventoryReportPeriod((string) ($filters['month'] ?? '')); - $authoritative = $this->monthlyInventoryAuthoritativeQuery($period, $filters); - if ($authoritative !== null) { - $total = $includeTotal ? $this->boundedCount(clone $authoritative) : 0; - $authoritativePage = (clone $authoritative) - ->orderBy('warehouse_provider') - ->orderBy('warehouse') - ->orderBy('product_code') - ->forPage($page, $perPage); - $rows = $this->boundedGet($authoritativePage) - ->map(static fn (object $row): object => (object) [ - 'owner' => $row->owner, - 'month' => $row->report_month, - 'warehouse_provider' => $row->warehouse_provider, - 'warehouse' => $row->warehouse, - 'product_code' => $row->product_code, - 'category' => $row->category, - 'opening_quantity' => $row->opening_quantity, - 'closing_quantity' => $row->closing_quantity, - '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, - 'other_out' => $row->other_out, - ]) - ->all(); - - return [ - 'data' => $rows, - 'total' => $total, - 'state' => $state, - 'sync' => $this->syncStatus($state), - ]; - } + // 月报的进出数量统一来自聚水潭进出仓明细;权威月报只提供同月的期初维度。 $headerGeneration = $this->generation($state, 'active_header_generation'); $itemGeneration = $this->generation($state, 'active_item_generation'); $inventoryGeneration = $this->generation($state, 'active_inventory_generation'); @@ -362,7 +322,9 @@ class ThirdPartyWarehouseStorageMirrorRepository throw new RuntimeException('inventory_report_mirror_not_ready'); } - $authority = $this->previousMonthlyInventoryAuthority($period); + $currentAuthority = $this->currentMonthlyInventoryAuthority($period); + $authority = $currentAuthority ?? $this->previousMonthlyInventoryAuthority($period); + $authorityIsCurrentMonth = $currentAuthority !== null; $resolveRollForward = fn (): ?array => $authority === null ? null : $this->monthlyInventoryRollForward( @@ -376,6 +338,7 @@ class ThirdPartyWarehouseStorageMirrorRepository $perPage, $includeTotal, $state, + $authorityIsCurrentMonth, ); $rolledForward = $includeTotal && $perPage <= 100 && $authority !== null ? $this->rememberMonthlyInventoryReport( @@ -687,6 +650,7 @@ class ThirdPartyWarehouseStorageMirrorRepository int $perPage, bool $includeTotal, array $state, + bool $authorityIsCurrentMonth = false, ): ?array { try { $latestProducts = $this->connection->table(self::PRODUCT_TABLE) @@ -710,6 +674,9 @@ class ThirdPartyWarehouseStorageMirrorRepository 'authority.warehouse_provider', 'authority.warehouse', ); + $authorityOpeningColumn = $authorityIsCurrentMonth + ? 'authority.opening_quantity' + : 'authority.closing_quantity'; $authorityCandidates = (clone $authorityRows) ->leftJoin(self::WAREHOUSE_TABLE . ' as authority_warehouse', function ($join): void { $join->on('authority_warehouse.name', '=', 'authority.warehouse_provider'); @@ -721,7 +688,7 @@ class ThirdPartyWarehouseStorageMirrorRepository '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', + . 'authority.category AS category, ' . $authorityOpeningColumn . ' AS opening_qty', $authorityWarehouseIdBindings, ); $inventoryDimensions = $this->connection->table(self::SUBWAREHOUSE_INVENTORY_TABLE . ' as inventory') @@ -775,9 +742,10 @@ class ThirdPartyWarehouseStorageMirrorRepository $authorityEnd = Carbon::parse($authority['source_month']) ->addMonth()->startOfMonth()->format('Y-m-d 00:00:00'); + $movementStart = $authorityIsCurrentMonth ? $period['start'] : $authorityEnd; $movementWarehouseIdSql = 'COALESCE(NULLIF(h.wh_id, 0), 1)'; $movementPairs = $this->confirmedMovements($headerGeneration, $itemGeneration, null) - ->where('h.occurred_at', '>=', $authorityEnd) + ->where('h.occurred_at', '>=', $movementStart) ->where('h.occurred_at', '<', $period['end']) ->where('i.qty', '!=', 0) ->where('i.qty_type', '!=', 0) @@ -810,15 +778,18 @@ class ThirdPartyWarehouseStorageMirrorRepository ->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'); }); + if (! $authorityIsCurrentMonth) { + $movementDimensions->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 { @@ -844,11 +815,16 @@ class ThirdPartyWarehouseStorageMirrorRepository 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'], - ]; + $sources = $authorityIsCurrentMonth + ? [ + [$authorityCandidates, $authorityCandidates, 'authority.warehouse_provider', 'authority.product_code'], + [$movementDimensions, $movementCount, 'movement_warehouse.name', 'movement_pair.sku_code'], + ] + : [ + [$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) { @@ -924,7 +900,7 @@ class ThirdPartyWarehouseStorageMirrorRepository $movementRows = collect(); if ($movementPairs->isNotEmpty()) { $movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null) - ->where('h.occurred_at', '>=', $authorityEnd) + ->where('h.occurred_at', '>=', $movementStart) ->where('h.occurred_at', '<', $period['end']) ->where(function (Builder $pairs) use ($movementPairs): void { foreach ($movementPairs as $pair) { @@ -1037,7 +1013,7 @@ class ThirdPartyWarehouseStorageMirrorRepository 'category' => trim((string) ($filters['category'] ?? '')), ]; - return 'inventory-report:monthly-page:v6:' . hash('sha256', (string) json_encode([ + return 'inventory-report:monthly-page:v10:' . hash('sha256', (string) json_encode([ 'period' => $period, 'filters' => $normalizedFilters, 'authority' => $authority, @@ -1082,6 +1058,25 @@ class ThirdPartyWarehouseStorageMirrorRepository } } + /** @param array{start:string,end:string} $period */ + private function currentMonthlyInventoryAuthority(array $period): ?array + { + $month = Carbon::parse($period['start'])->startOfMonth(); + $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', '=', $month->toDateString()) + ->first(); + if ($state === null || trim((string) $state->active_generation) === '') { + return null; + } + + return [ + 'generation' => (string) $state->active_generation, + 'source_month' => $month->toDateString(), + ]; + } + /** @param array{start:string,end:string} $period */ private function previousMonthlyInventoryAuthority(array $period): ?array { diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php index cccf0cd2..0905df76 100644 --- a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php @@ -173,9 +173,20 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase public function test_inventory_report_prefers_authoritative_month_rows_with_exact_opening_quantities(): void { $this->markReady(); - $this->insertProduct('SKU-AUTH'); + $this->insertProduct('SKU-AUTH', ['category' => '权威分类']); $this->insertInventory('SKU-AUTH', 999, '2026-09-01 00:00:00'); - $this->insertMovement('SKU-AUTH', '采购进仓', 500, 1, '2026-08-20 10:00:00'); + $this->insertMovement('SKU-AUTH', '采购进仓', 500, 1, '2026-08-20 10:00:00', [ + 'warehouse_id' => 15038426, + 'wh_id' => 3, + ]); + $this->insertMovement('SKU-AUTH', '销售出仓', 2, -1, '2026-08-21 10:00:00', [ + 'warehouse_id' => 15038426, + 'wh_id' => 3, + ]); + $this->insertMovement('SKU-AUTH', '采购进仓', 90, 1, '2026-09-01 00:00:00', [ + 'warehouse_id' => 15038426, + 'wh_id' => 3, + ]); $this->insertInventoryReportAuthoritativeRow([ 'warehouse_provider' => '简尚-崔桥工厂仓', 'warehouse' => '简尚-崔桥工厂仓进货仓', @@ -197,8 +208,9 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $this->assertSame(1, $result['total']); $this->assertSame('简尚-崔桥工厂仓进货仓', $row->warehouse); $this->assertSame(14.0, (float) $row->opening_quantity); - $this->assertSame(20.0, (float) $row->closing_quantity); - $this->assertSame(6.0, (float) $row->purchase_in); + $this->assertSame(512.0, (float) $row->closing_quantity); + $this->assertSame(500.0, (float) $row->purchase_in); + $this->assertSame(-2.0, (float) $row->sales_out); } public function test_inventory_report_rolls_previous_month_closing_into_next_month_opening(): void @@ -321,6 +333,19 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase 'wh_id' => 3, ]); } + // 按确认的进出仓明细口径汇总日期、类型、仓储方、仓库、商品和带符号数量。 + $this->insertMovement('CTG008616N1152V01', '销售出仓', 1, -1, '2026-08-24 09:00:00', [ + 'warehouse_id' => 15038426, + 'wh_id' => 3, + ]); + $this->insertMovement('CTG008616N1152V01', '销售出仓', 2, -1, '2026-08-24 09:30:00', [ + 'warehouse_id' => 15038426, + 'wh_id' => 3, + ]); + $this->insertMovement('CTG008616N1152V01', '销售出仓', 999, -1, '2026-08-24 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, @@ -338,8 +363,9 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $this->assertSame(2, $august['total']); $this->assertSame(0.0, (float) $rows['简尚-崔桥工厂仓进货仓']->opening_quantity); $this->assertSame(110.0, (float) $rows['简尚-崔桥工厂仓进货仓']->purchase_in); + $this->assertSame(-1002.0, (float) $rows['简尚-崔桥工厂仓进货仓']->sales_out); $this->assertSame(0.0, (float) $rows['简尚-崔桥工厂仓进货仓']->transfer_out); - $this->assertSame(110.0, (float) $rows['简尚-崔桥工厂仓进货仓']->closing_quantity); + $this->assertSame(-892.0, (float) $rows['简尚-崔桥工厂仓进货仓']->closing_quantity); $secondPage = $this->repository->paginateMonthlyInventoryReport($filters, 2, 1); $this->assertSame(2, $secondPage['total']); @@ -349,7 +375,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase array_merge($filters, ['category' => '测试分类']), 1, 25, ); $this->assertSame(2, $categorized['total']); - $this->assertSame(110.0, (float) collect($categorized['data']) + $this->assertSame(-892.0, (float) collect($categorized['data']) ->firstWhere('warehouse', '简尚-崔桥工厂仓进货仓')->closing_quantity); $exported = iterator_to_array($this->repository->exportMonthlyInventoryReportRows($filters, 1)); @@ -360,8 +386,8 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase 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); + $this->assertSame(-892.0, (float) $nextRow->opening_quantity); + $this->assertSame(-892.0, (float) $nextRow->closing_quantity); } public function test_inventory_report_product_code_filter_is_exact(): void diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageUpstreamRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageUpstreamRepositoryTest.php index b313931f..560bfca7 100644 --- a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageUpstreamRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageUpstreamRepositoryTest.php @@ -33,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, '2026-08-01'], $query->selectCalls[0]['bindings']); + $this->assertSame([100, 200, '2026-07-01'], $query->selectCalls[0]['bindings']); } public function test_items_are_parameterized_and_split_into_bounded_io_id_batches(): void @@ -108,7 +108,7 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase $this->assertStringNotContainsString('[modified] < ? AND [io_date] >= ?', $query->selectCalls[0]['sql']); $this->assertStringNotContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']); $this->assertSame('2026-09-11 03:20:00.000', $query->selectCalls[0]['bindings'][500]); - $this->assertSame('2026-08-01', $query->selectCalls[0]['bindings'][501]); + $this->assertSame('2026-07-01', $query->selectCalls[0]['bindings'][501]); $this->assertSame([1, 2], array_column($rows, 'source_id')); $repository->latestHeadersForIoIds([], '2026-09-11 03:20:00.000');