修正进销存月报按已确认进出仓明细取数

This commit is contained in:
chenxi 2026-09-25 20:10:45 +08:00
parent d9b42f9cda
commit 0143dfe693
3 changed files with 90 additions and 69 deletions

View File

@ -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
{

View File

@ -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

View File

@ -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');