修复进销存九月期初结转

This commit is contained in:
chenxi 2026-09-21 18:20:15 +08:00
parent 4d068382ac
commit d3f728d1b6
3 changed files with 368 additions and 0 deletions

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
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('inventory_report_authoritative_rows')) {
return;
}
$schema->table('inventory_report_authoritative_rows', function (Blueprint $table): void {
$table->index(
['generation', 'report_month', 'warehouse_provider', 'warehouse', 'product_code'],
'inventory_report_authoritative_roll_forward_index',
);
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('inventory_report_authoritative_rows')) {
return;
}
$schema->table('inventory_report_authoritative_rows', function (Blueprint $table): void {
$table->dropIndex('inventory_report_authoritative_roll_forward_index');
});
}
};

View File

@ -351,6 +351,21 @@ class ThirdPartyWarehouseStorageMirrorRepository
throw new RuntimeException('inventory_report_mirror_not_ready');
}
$rolledForward = $this->monthlyInventoryRollForward(
$period,
$filters,
$headerGeneration,
$itemGeneration,
$inventoryGeneration,
$page,
$perPage,
$includeTotal,
$state,
);
if ($rolledForward !== null) {
return $rolledForward;
}
$anchorAt = $this->stateAnchor($state);
$rangeStart = min($period['start'], $anchorAt);
$rangeEnd = max($period['end'], $anchorAt);
@ -622,6 +637,281 @@ class ThirdPartyWarehouseStorageMirrorRepository
}
}
/**
* The month immediately after an authoritative report starts from that report's closing
* quantity. Current inventory is deliberately ignored because it cannot reproduce the
* historical opening balance of each subwarehouse.
*
* @param array{start:string,end:string} $period
* @param array<string,mixed> $filters
* @param array<string,mixed> $state
* @return array{data:list<object>,total:int,state:array<string,mixed>,sync:array<string,mixed>}|null
*/
private function monthlyInventoryRollForward(
array $period,
array $filters,
string $headerGeneration,
string $itemGeneration,
string $inventoryGeneration,
int $page,
int $perPage,
bool $includeTotal,
array $state,
): ?array {
try {
$authority = $this->previousMonthlyInventoryAuthority($period);
if ($authority === null) {
return null;
}
$latestProducts = $this->connection->table(self::PRODUCT_TABLE)
->where('sku_type', 'normal')->whereNotNull('sku_id')->where('sku_id', '!=', '')
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
$latestWarehouses = $this->connection->table(self::WAREHOUSE_TABLE)
->whereNotNull('wms_co_id')->groupBy('wms_co_id')
->select('wms_co_id')->selectRaw('MAX(Id) AS Id');
$authorityRows = $this->connection
->table(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE . ' as authority')
->where('authority.generation', $authority['generation'])
->whereDate('authority.report_month', $authority['source_month']);
$productCode = trim((string) ($filters['product_code'] ?? ''));
if ($productCode !== '') {
$authorityRows->where('authority.product_code', $productCode);
}
$this->containsFilter($authorityRows, 'authority.warehouse_provider', $filters['warehouse_provider'] ?? '');
$this->containsFilter($authorityRows, 'authority.category', $filters['category'] ?? '');
[$authorityWarehouseIdSql, $authorityWarehouseIdBindings] = $this->subwarehouseIdSql(
'authority.warehouse_provider',
'authority.warehouse',
);
$authorityCandidates = (clone $authorityRows)
->leftJoin(self::WAREHOUSE_TABLE . ' as authority_warehouse', function ($join): void {
$join->on('authority_warehouse.name', '=', 'authority.warehouse_provider');
})
->leftJoinSub($latestWarehouses, 'authority_latest_warehouse', function ($join): void {
$join->on('authority_latest_warehouse.wms_co_id', '=', 'authority_warehouse.wms_co_id')
->on('authority_latest_warehouse.Id', '=', 'authority_warehouse.Id');
})->selectRaw(
'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',
$authorityWarehouseIdBindings,
);
$inventoryDimensions = $this->connection->table(self::SUBWAREHOUSE_INVENTORY_TABLE . ' as inventory')
->where('inventory.generation', $inventoryGeneration)
->where('inventory.qty', '!=', 0)
->whereNotNull('inventory.sku_code')->where('inventory.sku_code', '!=', '')
->joinSub($latestProducts, 'latest_product', 'latest_product.sku_id', '=', 'inventory.sku_code')
->join(self::PRODUCT_TABLE . ' as s', function ($join): void {
$join->on('s.sku_id', '=', 'latest_product.sku_id')
->on('s.autoid', '=', 'latest_product.autoid');
})
->joinSub($latestWarehouses, 'latest_warehouse', 'latest_warehouse.wms_co_id', '=', 'inventory.wms_co_id')
->join(self::WAREHOUSE_TABLE . ' as w', function ($join): void {
$join->on('w.wms_co_id', '=', 'latest_warehouse.wms_co_id')
->on('w.Id', '=', 'latest_warehouse.Id');
});
if ($productCode !== '') {
$inventoryDimensions->where('s.sku_id', $productCode);
}
$this->containsFilter($inventoryDimensions, 'w.name', $filters['warehouse_provider'] ?? '');
$this->containsFilter($inventoryDimensions, 's.category', $filters['category'] ?? '');
$owner = (string) config(
'upstream_mirrors.third_party_warehouse_storage.owner_name',
'常州简尚家居有限公司',
);
[$inventoryWarehouseSql, $inventoryWarehouseBindings] = $this->subwarehouseNameSql(
'w.name',
'inventory.wh_id',
);
$inventoryDimensions->whereNotExists(function (Builder $prior) use (
$authority,
$inventoryWarehouseSql,
$inventoryWarehouseBindings,
): void {
$prior->selectRaw('1')
->from(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE . ' as prior')
->where('prior.generation', $authority['generation'])
->whereDate('prior.report_month', $authority['source_month'])
->whereColumn('prior.product_code', 'inventory.sku_code')
->whereColumn('prior.warehouse_provider', 'w.name')
->whereRaw('prior.warehouse = ' . $inventoryWarehouseSql, $inventoryWarehouseBindings);
});
$inventoryDimensions->distinct()
->selectRaw(
'? AS owner, w.name AS warehouse_provider, '
. $inventoryWarehouseSql . ' AS source_warehouse, '
. 'inventory.wms_co_id AS wms_co_id, inventory.wh_id AS wh_id, '
. 'inventory.sku_code AS sku_code, s.category AS category, 0 AS opening_qty',
array_merge([$owner], $inventoryWarehouseBindings),
);
$candidateUnion = $authorityCandidates->unionAll($inventoryDimensions);
$candidates = $this->connection->query()->fromSub($candidateUnion, 'candidate_rows');
$total = $includeTotal ? $this->connection->query()->fromSub(clone $candidates, 'counted')->count() : 0;
$pageCandidates = (clone $candidates)
->orderBy('warehouse_provider')->orderBy('wh_id')->orderBy('sku_code')
->forPage($page, $perPage);
$pairRows = (clone $pageCandidates)->get();
if ($pairRows->isEmpty()) {
return [
'data' => [],
'total' => $total,
'state' => $state,
'sync' => $this->syncStatus($state),
];
}
$movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null)
->where('h.occurred_at', '>=', $period['start'])
->where('h.occurred_at', '<', $period['end'])
->whereIn('h.wms_co_id', $pairRows->pluck('wms_co_id')->filter()->unique()->values()->all())
->whereIn('i.sku_code', $pairRows->pluck('sku_code')->unique()->values()->all())
->select([
'h.wms_co_id', 'h.io_type', '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))';
$monthly = $this->connection->query()->fromSub($pageCandidates, '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.owner', 'a.warehouse_provider', 'a.source_warehouse', 'a.wms_co_id',
'a.wh_id', 'a.sku_code', 'a.category', 'a.opening_qty',
])->select([
'a.owner', 'a.warehouse_provider', 'a.source_warehouse', 'a.wms_co_id',
'a.wh_id', 'a.sku_code', 'a.category', 'a.opening_qty',
]);
foreach (self::MOVEMENT_COLUMNS as $column => $type) {
$expression = 'COALESCE(SUM(CASE WHEN mv.io_type = ? THEN ' . $signed . ' ELSE 0 END), 0)';
$monthly->selectRaw($expression . ' AS ' . $column, [$type]);
}
$rows = $monthly->orderBy('a.warehouse_provider')->orderBy('a.wh_id')->orderBy('a.sku_code')
->get()->map(function (object $row) use ($period): object {
$sourceWarehouse = trim((string) ($row->source_warehouse ?? ''));
$warehouse = $sourceWarehouse !== ''
? $sourceWarehouse
: $this->subwarehouseName(
trim((string) $row->warehouse_provider),
(int) $row->wh_id,
);
$closingQuantity = (float) $row->opening_qty;
foreach (array_keys(self::MOVEMENT_COLUMNS) as $column) {
$closingQuantity += (float) $row->{$column};
}
return (object) [
'owner' => $row->owner,
'month' => Carbon::parse($period['start'])->toDateString(),
'warehouse_provider' => $row->warehouse_provider,
'warehouse' => $warehouse,
'product_code' => $row->sku_code,
'category' => $row->category,
'opening_quantity' => $row->opening_qty,
'closing_quantity' => $closingQuantity,
'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' => 0,
'other_out' => $row->other_out,
];
})->all();
return [
'data' => $rows,
'total' => $total,
'state' => $state,
'sync' => $this->syncStatus($state),
];
} catch (Throwable $exception) {
report($exception);
return null;
}
}
/** @param array{start:string,end:string} $period */
private function previousMonthlyInventoryAuthority(array $period): ?array
{
$previousMonth = Carbon::parse($period['start'])->subMonth()->startOfMonth()->toDateString();
$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', $previousMonth)
->first();
if ($state === null || trim((string) $state->active_generation) === '') {
return null;
}
return [
'generation' => (string) $state->active_generation,
'source_month' => $previousMonth,
];
}
/** @return array{0:string,1:list<mixed>} */
private function subwarehouseIdSql(string $providerColumn, string $warehouseColumn): array
{
$cases = [];
$bindings = [];
foreach (self::SPECIAL_SUBWAREHOUSE_NAMES as $provider => $names) {
foreach ($names as $id => $name) {
$cases[] = "WHEN {$providerColumn} = ? AND {$warehouseColumn} = ? THEN {$id}";
array_push($bindings, $provider, $name);
}
}
foreach (self::SUBWAREHOUSE_SUFFIXES as $id => $suffix) {
$cases[] = 'WHEN ' . $warehouseColumn . ' = '
. $this->concatSql([$providerColumn, '?']) . " THEN {$id}";
$bindings[] = $suffix;
}
return ['CASE ' . implode(' ', $cases) . ' ELSE -1 END', $bindings];
}
/** @return array{0:string,1:list<mixed>} */
private function subwarehouseNameSql(string $providerColumn, string $subwarehouseColumn): array
{
$cases = [];
$bindings = [];
foreach (self::SPECIAL_SUBWAREHOUSE_NAMES as $provider => $names) {
foreach ($names as $id => $name) {
$cases[] = "WHEN {$providerColumn} = ? AND {$subwarehouseColumn} = {$id} THEN ?";
array_push($bindings, $provider, $name);
}
}
foreach (self::SUBWAREHOUSE_SUFFIXES as $id => $suffix) {
$cases[] = "WHEN {$subwarehouseColumn} = {$id} THEN " . $this->concatSql([$providerColumn, '?']);
$bindings[] = $suffix;
}
$fallback = $this->concatSql([$providerColumn, "'其他子仓('", $subwarehouseColumn, "')'"]);
return ['CASE ' . implode(' ', $cases) . ' ELSE ' . $fallback . ' END', $bindings];
}
/** @param list<string> $parts */
private function concatSql(array $parts): string
{
return $this->connection->getDriverName() === 'sqlite'
? '(' . implode(' || ', $parts) . ')'
: 'CONCAT(' . implode(', ', $parts) . ')';
}
/**
* @param array<string,mixed> $filters
* @return array{count:int,opening_qty:float,closing_qty:float}

View File

@ -201,6 +201,44 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
$this->assertSame(6.0, (float) $row->purchase_in);
}
public function test_inventory_report_rolls_previous_month_closing_into_next_month_opening(): void
{
$this->markReady();
$this->insertProduct('SKU-ROLL-FORWARD');
$this->insertInventory('SKU-ROLL-FORWARD', 999, '2026-09-11 03:21:00');
$this->insertInventoryReportAuthoritativeRow([
'warehouse_provider' => '三方-南昌樵舍仓',
'warehouse' => '三方-南昌樵舍仓主仓',
'product_code' => 'SKU-ROLL-FORWARD',
'opening_quantity' => 14,
'closing_quantity' => 20,
'purchase_in' => 6,
]);
$this->insertMovement('SKU-ROLL-FORWARD', '采购进仓', 3, 1, '2026-09-02 10:00:00');
$this->insertInventory('SKU-ROLL-FORWARD', 5, '2026-09-11 03:21:00', [
'wms_co_id' => 15038426,
]);
$this->insertMovement('SKU-ROLL-FORWARD', '调拨入', 5, 1, '2026-09-03 10:00:00', [
'warehouse_id' => 15038426,
]);
$result = $this->repository->paginateMonthlyInventoryReport([
'month' => '2026-09',
'product_code' => 'SKU-ROLL-FORWARD',
'warehouse_provider' => '',
'category' => '',
], 1, 25);
$rows = collect($result['data'])->keyBy('warehouse');
$this->assertSame(2, $result['total']);
$this->assertSame(20.0, (float) $rows['三方-南昌樵舍仓主仓']->opening_quantity);
$this->assertSame(23.0, (float) $rows['三方-南昌樵舍仓主仓']->closing_quantity);
$this->assertSame(3.0, (float) $rows['三方-南昌樵舍仓主仓']->purchase_in);
$this->assertSame(0.0, (float) $rows['简尚-崔桥工厂仓主仓']->opening_quantity);
$this->assertSame(5.0, (float) $rows['简尚-崔桥工厂仓主仓']->closing_quantity);
$this->assertSame(5.0, (float) $rows['简尚-崔桥工厂仓主仓']->transfer_in);
}
public function test_inventory_report_product_code_filter_is_exact(): void
{
$this->markReady();