修复进销存九月期初结转
This commit is contained in:
parent
4d068382ac
commit
d3f728d1b6
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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}
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user