修复进销存漏掉已确认流水子仓维度 #264
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user