修复进销存报表子仓维度 (#220)

This commit is contained in:
chenxi 2026-09-20 22:19:11 +08:00
parent d8622dfd3f
commit caf2030bdf
7 changed files with 258 additions and 18 deletions

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if ($schema->hasTable('third_party_warehouse_inventory_subwarehouse_snapshots')) {
return;
}
$schema->create('third_party_warehouse_inventory_subwarehouse_snapshots', function (Blueprint $table): void {
$table->bigIncrements('_mirror_id');
$table->uuid('generation');
$table->string('source_key', 64)->default('jiansun');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('wms_co_id');
$table->unsignedSmallInteger('wh_id');
$table->string('sku_code', 191);
$table->decimal('qty', 20, 4)->default(0);
$table->dateTime('source_modified_at', 3)->nullable();
$table->dateTime('captured_at', 6);
$table->string('row_hash', 64);
$table->dateTime('synced_at', 6);
$table->unique(['generation', 'id', 'wh_id'], 'tpw_subinventory_generation_id_wh_unique');
$table->index(
['generation', 'wms_co_id', 'wh_id', 'sku_code'],
'tpw_subinventory_report_index',
);
$table->index(['generation', 'sku_code'], 'tpw_subinventory_sku_index');
});
$connection = DB::connection($this->connection());
$connection->table('third_party_warehouse_inventory_subwarehouse_snapshots')->insertUsing(
[
'generation', 'source_key', 'id', 'wms_co_id', 'wh_id', 'sku_code', 'qty',
'source_modified_at', 'captured_at', 'row_hash', 'synced_at',
],
$connection->table('third_party_warehouse_inventory_snapshots')->select([
'generation', 'source_key', 'id', 'wms_co_id', DB::raw('1'), 'sku_code', 'qty',
'source_modified_at', 'captured_at', 'row_hash', 'synced_at',
]),
);
}
public function down(): void
{
Schema::connection($this->connection())
->dropIfExists('third_party_warehouse_inventory_subwarehouse_snapshots');
}
};

View File

@ -32,6 +32,8 @@ class ThirdPartyWarehouseStorageMirrorRepository
private const INVENTORY_TABLE = 'third_party_warehouse_inventory_snapshots';
private const SUBWAREHOUSE_INVENTORY_TABLE = 'third_party_warehouse_inventory_subwarehouse_snapshots';
private const AUTHORITATIVE_STATE_TABLE = 'third_party_warehouse_authoritative_anchor_states';
private const AUTHORITATIVE_ANCHOR_TABLE = 'third_party_warehouse_authoritative_anchors';
@ -68,6 +70,17 @@ class ThirdPartyWarehouseStorageMirrorRepository
'other_out' => '其它出库',
];
/** @var array<int,string> */
private const SUBWAREHOUSE_NAMES = [
1 => '主仓',
2 => '销退仓',
3 => '进货仓',
4 => '次品仓',
6 => '自定义仓1',
7 => '自定义仓2',
8 => '自定义仓3',
];
/** @var list<string> */
private const OUTPUT_COLUMNS = [
'owner', 'date', 'sku_code', 'image_url', 'product_name', 'short_name',
@ -272,13 +285,14 @@ class ThirdPartyWarehouseStorageMirrorRepository
$latestWarehouses = $this->connection->table(self::WAREHOUSE_TABLE)
->whereNotNull('wms_co_id')->groupBy('wms_co_id')
->select('wms_co_id')->selectRaw('MAX(Id) AS Id');
$inventoryPairs = $this->connection->table(self::INVENTORY_TABLE)
$inventoryDimensions = $this->connection->table(self::SUBWAREHOUSE_INVENTORY_TABLE)
->where('generation', $inventoryGeneration)
->where('qty', '!=', 0)
->whereNotNull('sku_code')->where('sku_code', '!=', '')
->select(['wms_co_id', 'sku_code']);
$candidates = $this->connection->query()->fromSub($inventoryPairs, 'pairs')
->select(['pairs.wms_co_id', 'pairs.sku_code']);
->select(['wms_co_id', 'wh_id', 'sku_code', 'captured_at'])
->selectRaw('qty AS snapshot_qty');
$candidates = $this->connection->query()->fromSub($inventoryDimensions, 'pairs')
->select(['pairs.wms_co_id', 'pairs.sku_code', 'pairs.wh_id']);
$this->containsFilter($candidates, 'pairs.sku_code', $filters['product_code'] ?? '');
$warehouseFilter = trim((string) ($filters['warehouse_provider'] ?? ''));
if ($warehouseFilter !== '') {
@ -304,7 +318,7 @@ class ThirdPartyWarehouseStorageMirrorRepository
$total = $includeTotal ? (clone $candidates)->count() : 0;
$pagePairs = (clone $candidates)
->orderBy('pairs.wms_co_id')->orderBy('pairs.sku_code')
->orderBy('pairs.wms_co_id')->orderBy('pairs.wh_id')->orderBy('pairs.sku_code')
->forPage($page, $perPage);
$pairRows = (clone $pagePairs)->get();
if ($pairRows->isEmpty()) {
@ -327,15 +341,15 @@ class ThirdPartyWarehouseStorageMirrorRepository
$join->on('w.wms_co_id', '=', 'latest_warehouse.wms_co_id')
->on('w.Id', '=', 'latest_warehouse.Id');
})
->select(['pairs.wms_co_id', 'pairs.sku_code', 's.category', 'w.name as warehouse_name']);
->select(['pairs.wms_co_id', 'pairs.wh_id', 'pairs.sku_code', 's.category', 'w.name as warehouse_name']);
$anchors = $this->connection->query()->fromSub($dimensionedPagePairs, 'pairs')
->leftJoin(self::INVENTORY_TABLE . ' as inventory', function ($join) use ($inventoryGeneration): void {
->leftJoinSub(clone $inventoryDimensions, 'inventory', function ($join): void {
$join->on('inventory.wms_co_id', '=', 'pairs.wms_co_id')
->on('inventory.sku_code', '=', 'pairs.sku_code')
->where('inventory.generation', '=', $inventoryGeneration);
->on('inventory.wh_id', '=', 'pairs.wh_id');
})
->select(['pairs.wms_co_id', 'pairs.sku_code', 'pairs.category', 'pairs.warehouse_name'])
->selectRaw('COALESCE(inventory.qty, 0) AS snapshot_qty')
->select(['pairs.wms_co_id', 'pairs.wh_id', 'pairs.sku_code', 'pairs.category', 'pairs.warehouse_name'])
->selectRaw('COALESCE(inventory.snapshot_qty, 0) AS snapshot_qty')
->selectRaw('COALESCE(inventory.captured_at, ?) AS snapshot_at', [$anchorAt]);
$movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null)
@ -346,7 +360,8 @@ class ThirdPartyWarehouseStorageMirrorRepository
->select([
'h.wms_co_id', 'h.io_type', 'h.occurred_at', 'i.sku_code',
'i.qty as movement_qty', 'i.qty_type as movement_qty_type',
]);
])
->selectRaw('COALESCE(NULLIF(h.wh_id, 0), 1) AS wh_id');
$signed = '(COALESCE(mv.movement_qty, 0) * COALESCE(mv.movement_qty_type, 0))';
$balanceAt = static fn (string $target): array => [
'a.snapshot_qty + COALESCE(SUM(CASE '
@ -360,13 +375,14 @@ class ThirdPartyWarehouseStorageMirrorRepository
$monthly = $this->connection->query()->fromSub($anchors, 'a')
->leftJoinSub($movements, 'mv', function ($join): void {
$join->on('mv.wms_co_id', '=', 'a.wms_co_id')
->on('mv.wh_id', '=', 'a.wh_id')
->on('mv.sku_code', '=', 'a.sku_code');
})
->groupBy([
'a.wms_co_id', 'a.sku_code', 'a.category', 'a.warehouse_name',
'a.wms_co_id', 'a.wh_id', 'a.sku_code', 'a.category', 'a.warehouse_name',
'a.snapshot_qty', 'a.snapshot_at',
])
->select(['a.wms_co_id', 'a.sku_code', 'a.category', 'a.warehouse_name'])
->select(['a.wms_co_id', 'a.wh_id', 'a.sku_code', 'a.category', 'a.warehouse_name'])
->selectRaw($openingSql . ' AS opening_qty', $openingBindings)
->selectRaw($closingSql . ' AS closing_qty', $closingBindings);
foreach (self::MOVEMENT_COLUMNS as $column => $type) {
@ -379,9 +395,12 @@ class ThirdPartyWarehouseStorageMirrorRepository
$monthly->selectRaw('0 AS opening_aux');
$rows = $this->connection->query()->fromSub($monthly, 'm')
->select('m.*')->orderBy('m.warehouse_name')->orderBy('m.sku_code')->get()
->select('m.*')->orderBy('m.warehouse_name')->orderBy('m.wh_id')->orderBy('m.sku_code')->get()
->map(static function (object $row) use ($period): object {
$warehouse = trim((string) ($row->warehouse_name ?? ''));
$subwarehouseId = (int) ($row->wh_id ?? 0);
$subwarehouse = self::SUBWAREHOUSE_NAMES[$subwarehouseId]
?? '其他子仓(' . $subwarehouseId . ')';
return (object) [
'owner' => (string) config(
@ -390,7 +409,7 @@ class ThirdPartyWarehouseStorageMirrorRepository
),
'month' => Carbon::parse($period['start'])->toDateString(),
'warehouse_provider' => $warehouse,
'warehouse' => $warehouse,
'warehouse' => $subwarehouse,
'product_code' => (string) ($row->sku_code ?? ''),
'category' => (string) ($row->category ?? ''),
'opening_quantity' => $row->opening_qty,

View File

@ -185,6 +185,12 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]);
[wms_co_id],
[sku_id] AS sku_code,
[qty],
[return_qty],
[in_qty],
[defective_qty],
[customize_qty_1],
[customize_qty_2],
[customize_qty_3],
CONVERT(varchar(27), [modified], 126) AS source_modified_at
FROM [dbo].[jst_inventoryInfo]
WHERE [Id] > ?

View File

@ -30,6 +30,14 @@ class ThirdPartyWarehouseStorageSynchronizer
private const INVENTORY_TABLE = 'third_party_warehouse_inventory_snapshots';
private const SUBWAREHOUSE_INVENTORY_TABLE = 'third_party_warehouse_inventory_subwarehouse_snapshots';
/** @var array<int,string> */
private const SUBWAREHOUSE_QUANTITY_FIELDS = [
1 => 'qty', 2 => 'return_qty', 3 => 'in_qty', 4 => 'defective_qty',
6 => 'customize_qty_1', 7 => 'customize_qty_2', 8 => 'customize_qty_3',
];
private readonly ConnectionInterface $connection;
private readonly Repository $cache;
@ -610,9 +618,14 @@ class ThirdPartyWarehouseStorageSynchronizer
fn (array $row): ?array => $this->mapInventory($row, $generation, $now),
$rows,
)));
$subwarehouseMapped = [];
foreach ($rows as $row) {
array_push($subwarehouseMapped, ...$this->mapSubwarehouseInventory($row, $generation, $now));
}
$this->connection->transaction(function () use (
$mapped,
$subwarehouseMapped,
$generation,
$cursor,
$now,
@ -620,6 +633,11 @@ class ThirdPartyWarehouseStorageSynchronizer
): void {
$this->assertCurrentRunToken($runToken);
$this->upsertRows(self::INVENTORY_TABLE, $mapped, ['generation', 'id']);
$this->upsertRows(
self::SUBWAREHOUSE_INVENTORY_TABLE,
$subwarehouseMapped,
['generation', 'id', 'wh_id'],
);
$this->updateStateForToken([
'inventory_cursor' => $cursor,
'updated_at' => $now,
@ -987,6 +1005,7 @@ class ThirdPartyWarehouseStorageSynchronizer
[self::HEADER_TABLE, 'header_generation', (string) $release->header_generation],
[self::ITEM_TABLE, 'item_generation', (string) $release->item_generation],
[self::INVENTORY_TABLE, 'inventory_generation', (string) $release->inventory_generation],
[self::SUBWAREHOUSE_INVENTORY_TABLE, 'inventory_generation', (string) $release->inventory_generation],
] as [$table, $column, $generation]) {
if ($generation === '' || in_array($generation, $protected, true)) {
continue;
@ -1110,6 +1129,40 @@ class ThirdPartyWarehouseStorageSynchronizer
];
}
/** @return list<array<string,mixed>> */
private function mapSubwarehouseInventory(array $row, string $generation, mixed $now): array
{
$base = $this->mapInventory($row, $generation, $now);
if ($base === null) {
return [];
}
$rows = [];
foreach (self::SUBWAREHOUSE_QUANTITY_FIELDS as $warehouseId => $field) {
$quantity = (float) ($row[$field] ?? 0);
if ($quantity == 0.0) {
continue;
}
$values = [
'generation' => $generation,
'source_key' => $base['source_key'],
'id' => $base['id'],
'wms_co_id' => $base['wms_co_id'],
'wh_id' => $warehouseId,
'sku_code' => $base['sku_code'],
'qty' => $quantity,
'source_modified_at' => $base['source_modified_at'],
'captured_at' => $base['captured_at'],
];
$rows[] = $values + [
'row_hash' => $this->rowHash($values),
'synced_at' => $now,
];
}
return $rows;
}
/**
* @param list<array<string,mixed>> $rows
* @param list<string> $uniqueBy
@ -1223,6 +1276,7 @@ class ThirdPartyWarehouseStorageSynchronizer
self::HEADER_TABLE,
self::ITEM_TABLE,
self::INVENTORY_TABLE,
self::SUBWAREHOUSE_INVENTORY_TABLE,
] as $table) {
if (! $schema->hasTable($table)) {
throw new RuntimeException('third_party_warehouse_mirror_schema_missing:' . $table);

View File

@ -160,7 +160,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
$this->assertSame(1, $result['total']);
$this->assertSame('2026-08-01', $row->month);
$this->assertSame('三方-南昌樵舍仓', $row->warehouse_provider);
$this->assertSame('三方-南昌樵舍仓', $row->warehouse);
$this->assertSame('主仓', $row->warehouse);
$this->assertSame('SKU-MONTHLY', $row->product_code);
$this->assertSame('住宅家具 - 书桌', $row->category);
$this->assertSame(8.0, (float) $row->opening_quantity);
@ -170,6 +170,35 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
$this->assertSame(2.0, (float) $row->sales_return);
}
public function test_inventory_report_splits_main_return_inbound_and_other_subwarehouses(): void
{
$this->markReady();
$this->insertProduct('SKU-SUBWAREHOUSE', ['category' => '测试分类']);
$this->insertInventory('SKU-SUBWAREHOUSE', 10, '2026-09-01 00:00:00', [
'return_qty' => 4,
'in_qty' => 3,
'customize_qty_1' => 2,
]);
$this->insertMovement('SKU-SUBWAREHOUSE', '销售退货', 2, 1, '2026-08-20 10:00:00', ['wh_id' => 2]);
$this->insertMovement('SKU-SUBWAREHOUSE', '采购进仓', 3, 1, '2026-08-21 10:00:00', ['wh_id' => 3]);
$result = $this->repository->paginateMonthlyInventoryReport([
'month' => '2026-08',
'product_code' => 'SKU-SUBWAREHOUSE',
'warehouse_provider' => '',
'category' => '',
], 1, 25);
$rows = collect($result['data'])->keyBy('warehouse');
$this->assertSame(4, $result['total']);
$this->assertSame(['主仓', '销退仓', '进货仓', '自定义仓1'], $rows->keys()->all());
$this->assertSame(2.0, (float) $rows['销退仓']->opening_quantity);
$this->assertSame(4.0, (float) $rows['销退仓']->closing_quantity);
$this->assertSame(2.0, (float) $rows['销退仓']->sales_return);
$this->assertSame(0.0, (float) $rows['进货仓']->opening_quantity);
$this->assertSame(3.0, (float) $rows['进货仓']->closing_quantity);
}
public function test_inventory_report_reads_and_filters_warehouses_outside_the_third_party_scope(): void
{
$this->markReady();
@ -539,6 +568,16 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
$table->dateTime('source_modified_at')->nullable();
$table->dateTime('captured_at')->nullable();
});
$schema->create('third_party_warehouse_inventory_subwarehouse_snapshots', function (Blueprint $table): void {
$table->id('_mirror_id');
$table->string('generation');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('wms_co_id');
$table->unsignedSmallInteger('wh_id');
$table->string('sku_code');
$table->decimal('qty', 20, 4);
$table->dateTime('captured_at')->nullable();
});
$schema->create('third_party_warehouse_authoritative_anchor_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('status');
@ -694,14 +733,41 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
private function insertInventory(string $sku, float $qty, string $capturedAt, array $overrides = []): void
{
static $id = 1;
DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')->insert(array_merge([
$subwarehouseValues = array_merge([
'qty' => $qty,
'return_qty' => 0,
'in_qty' => 0,
'defective_qty' => 0,
'customize_qty_1' => 0,
'customize_qty_2' => 0,
'customize_qty_3' => 0,
], array_intersect_key($overrides, array_flip([
'qty', 'return_qty', 'in_qty', 'defective_qty',
'customize_qty_1', 'customize_qty_2', 'customize_qty_3',
])));
$values = array_merge([
'generation' => self::INVENTORY_GENERATION,
'id' => $id++,
'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
'sku_code' => $sku,
'qty' => $qty,
'captured_at' => $capturedAt,
], $overrides));
], array_diff_key($overrides, $subwarehouseValues));
DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')->insert($values);
foreach ([
1 => 'qty', 2 => 'return_qty', 3 => 'in_qty', 4 => 'defective_qty',
6 => 'customize_qty_1', 7 => 'customize_qty_2', 8 => 'customize_qty_3',
] as $warehouseId => $column) {
DB::connection('business_mirror')->table('third_party_warehouse_inventory_subwarehouse_snapshots')->insert([
'generation' => $values['generation'],
'id' => $values['id'],
'wms_co_id' => $values['wms_co_id'],
'wh_id' => $warehouseId,
'sku_code' => $values['sku_code'],
'qty' => $subwarehouseValues[$column],
'captured_at' => $values['captured_at'],
]);
}
}
private function insertAuthoritativeAnchor(string $sku, float $qty, string $anchorAt): void
@ -757,6 +823,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
'id' => $overrides['header_id'] ?? $this->nextHeaderId++,
'io_id' => $ioId,
'wms_co_id' => $overrides['warehouse_id'] ?? ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
'wh_id' => $overrides['wh_id'] ?? 1,
'status' => $overrides['status'] ?? 'Confirmed',
'io_type' => $type,
'occurred_at' => $occurredAt,

View File

@ -144,6 +144,9 @@ class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
'wms_co_id' => 15045364,
'sku_code' => 'NEW-1',
'qty' => 12,
'return_qty' => 3,
'in_qty' => 2,
'customize_qty_1' => 1,
'source_modified_at' => '2026-09-11T03:19:00.000',
], [
'source_id' => 190,
@ -166,6 +169,18 @@ class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
->table('third_party_warehouse_inventory_snapshots')
->where('generation', $state->active_inventory_generation)
->orderBy('id')->pluck('sku_code')->all());
$this->assertSame([
['wh_id' => 1, 'qty' => 12.0],
['wh_id' => 2, 'qty' => 3.0],
['wh_id' => 3, 'qty' => 2.0],
['wh_id' => 6, 'qty' => 1.0],
['wh_id' => 1, 'qty' => 4.0],
], DB::connection('business_mirror')
->table('third_party_warehouse_inventory_subwarehouse_snapshots')
->where('generation', $state->active_inventory_generation)
->orderBy('id')->orderBy('wh_id')->get(['wh_id', 'qty'])
->map(fn (object $row): array => ['wh_id' => (int) $row->wh_id, 'qty' => (float) $row->qty])
->all());
$this->assertSame([[0, 200, 5]], $this->upstream->inventoryCalls);
}
@ -565,6 +580,21 @@ class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
$table->unique(['generation', 'id']);
$table->unique(['generation', 'wms_co_id', 'sku_code']);
});
$schema->create('third_party_warehouse_inventory_subwarehouse_snapshots', function (Blueprint $table): void {
$table->id('_mirror_id');
$table->string('generation');
$table->string('source_key')->default('jiansun');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('wms_co_id');
$table->unsignedSmallInteger('wh_id');
$table->string('sku_code', 191);
$table->decimal('qty', 20, 4)->default(0);
$table->dateTime('source_modified_at')->nullable();
$table->dateTime('captured_at')->nullable();
$table->string('row_hash');
$table->dateTime('synced_at');
$table->unique(['generation', 'id', 'wh_id']);
});
}
private function seedReadyMirror(): void

View File

@ -134,6 +134,9 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
$this->assertStringNotContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('ORDER BY [Id]', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('AS source_modified_at', $query->selectCalls[0]['sql']);
foreach (['return_qty', 'in_qty', 'defective_qty', 'customize_qty_1', 'customize_qty_2', 'customize_qty_3'] as $column) {
$this->assertStringContainsString("[{$column}]", $query->selectCalls[0]['sql']);
}
$this->assertSame([12350, 191818], $query->selectCalls[0]['bindings']);
$repository->inventoryBatch(191818, 191818, 2000);