修复进销存漏掉已确认流水子仓维度 #264

Merged
chenxi merged 1 commits from fix/chenxi-inventory-movement-dimensions into main 2026-09-24 14:13:55 +08:00
2 changed files with 200 additions and 61 deletions

View File

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

View File

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