CaiWuXiTong/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php

579 lines
25 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
use RuntimeException;
use Tests\TestCase;
class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
{
private const HEADER_GENERATION = '11111111-1111-1111-1111-111111111111';
private const ITEM_GENERATION = '22222222-2222-2222-2222-222222222222';
private const INVENTORY_GENERATION = '33333333-3333-3333-3333-333333333333';
private ThirdPartyWarehouseStorageMirrorRepository $repository;
private int $nextHeaderId = 1;
private int $nextItemId = 1;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.third_party_warehouse_storage.owner_name' => '常州简尚家居有限公司',
'upstream_mirrors.third_party_warehouse_storage.stale_after_minutes' => 1560,
]);
DB::purge('business_mirror');
$this->createMirrorTables();
$this->createProductTable();
$this->createPriceHistoryTables();
$this->repository = new ThirdPartyWarehouseStorageMirrorRepository(app('db'));
}
public function test_it_refuses_queries_until_all_active_generations_finish_initial_sync(): void
{
$this->insertState([
'status' => 'running',
'run_mode' => 'full',
'active_header_generation' => self::HEADER_GENERATION,
'active_item_generation' => self::ITEM_GENERATION,
'active_inventory_generation' => null,
'initial_completed_at' => null,
]);
DB::connection('business_mirror')->table('business_mirror_table_states')
->where('dataset_key', 'jiansun.dbo.pm_priceAdjustment_log')
->update(['initial_completed_at' => null, 'status' => 'running']);
$this->assertTrue($this->repository->available());
$this->assertFalse($this->repository->ready());
$this->assertSame('initializing', $this->repository->syncStatus()['code']);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('third_party_warehouse_mirror_not_ready');
$this->repository->paginate($this->filters(), 1, 25);
}
public function test_failed_refresh_keeps_reading_only_active_generations(): void
{
$this->markReady(['status' => 'failed', 'last_error_code' => 'upstream_timeout']);
$this->insertProduct('SKU-A', ['autoid' => 10]);
$this->insertInventory('SKU-A', 6, '2026-09-01 00:00:00');
$this->insertInventory('SKU-OLD', 99, '2026-09-01 00:00:00', [
'generation' => '99999999-9999-9999-9999-999999999999',
]);
$this->insertMovement('SKU-A', '采购进仓', 1, 1, '2026-08-10 10:00:00');
$this->insertMovement('SKU-OLD', '采购进仓', 99, 1, '2026-08-10 10:00:00', [
'header_generation' => '99999999-9999-9999-9999-999999999999',
'item_generation' => '99999999-9999-9999-9999-999999999999',
]);
$result = $this->repository->paginate($this->filters(), 1, 25);
$this->assertTrue($this->repository->ready());
$this->assertSame('failed', $result['sync']['code']);
$this->assertSame(1, $result['total']);
$this->assertSame('SKU-A', $result['data'][0]['sku_code']);
}
public function test_monthly_report_uses_signed_movements_and_each_sku_snapshot_anchor(): void
{
$this->markReady();
$this->insertProduct('SKU-MONTH', [
'name' => '月报商品',
'short_name' => null,
'supplier_name' => '供应商甲',
'category' => '桌类',
'purchase_price' => 30,
]);
$this->insertInventory('SKU-MONTH', 25, '2026-09-03 12:00:00');
$this->insertMovement('SKU-MONTH', '采购进仓', 10, 1, '2026-08-01 00:00:00');
$this->insertMovement('SKU-MONTH', '销售出仓', 8, -1, '2026-08-15 12:00:00');
$this->insertMovement('SKU-MONTH', '其它进仓', 2, 1, '2026-08-31 23:59:59');
$this->insertMovement('SKU-MONTH', '采购进仓', 5, 1, '2026-09-01 00:00:00');
$this->insertMovement('SKU-MONTH', '销售出仓', 4, -1, '2026-09-02 00:00:00');
// 在该 SKU 自己的快照时间之后,不能参与从快照回推历史余额。
$this->insertMovement('SKU-MONTH', '采购进仓', 100, 1, '2026-09-04 00:00:00');
// 不提供统一 period 时仍兼容旧页面的 month 参数。
$row = $this->repository->paginate($this->filters(['period' => '']), 1, 25)['data'][0];
$this->assertSame('常州简尚家居有限公司', $row['owner']);
$this->assertSame('月报商品', $row['short_name']);
$this->assertSame(10.0, $row['purchase_in']);
$this->assertSame(-8.0, $row['sales_out']);
$this->assertSame(2.0, $row['other_in']);
$this->assertSame(24.0, $row['closing_qty']);
$this->assertSame(20.0, $row['opening_qty']);
$this->assertSame(93, $row['turnover_days']);
$this->assertSame(600.0, $row['opening_amount']);
$this->assertSame(720.0, $row['closing_amount']);
$this->assertCount(45, $row);
$this->insertProduct('SKU-EARLY', ['autoid' => 20, 'purchase_price' => 2]);
$this->insertInventory('SKU-EARLY', 10, '2026-08-10 00:00:00');
$this->insertMovement('SKU-EARLY', '采购进仓', 5, 1, '2026-08-20 00:00:00');
$early = collect($this->repository->paginate($this->filters(), 1, 25)['data'])
->firstWhere('sku_code', 'SKU-EARLY');
$this->assertSame(10.0, $early['opening_qty']);
$this->assertSame(15.0, $early['closing_qty']);
}
public function test_opening_and_closing_amounts_use_the_price_effective_at_each_boundary(): void
{
$this->markReady();
$this->insertProduct('SKU-PRICE', ['purchase_price' => 999]);
$this->insertInventory('SKU-PRICE', 28, '2026-09-03 12:00:00');
$this->insertMovement('SKU-PRICE', '销售出仓', 2, -1, '2026-08-10 12:00:00');
$this->insertPriceChange('SKU-PRICE', '2026-08-01 00:00:00', 154, 160);
$row = $this->repository->paginate($this->filters(), 1, 25)['data'][0];
$this->assertSame(30.0, $row['opening_qty']);
$this->assertSame(28.0, $row['closing_qty']);
$this->assertSame(4620.0, $row['opening_amount']);
$this->assertSame(4480.0, $row['closing_amount']);
}
public function test_price_history_must_finish_initial_sync_before_amounts_are_exposed(): void
{
$this->markReady();
DB::connection('business_mirror')->table('business_mirror_table_states')
->where('dataset_key', 'jiansun.dbo.pm_priceAdjustment_log')
->update(['initial_completed_at' => null, 'status' => 'running']);
$this->assertFalse($this->repository->ready());
$this->assertSame('dependency_pending', $this->repository->syncStatus()['code']);
}
public function test_product_catalog_must_finish_initial_sync_before_product_fields_are_exposed(): void
{
$this->markReady();
DB::connection('business_mirror')->table('business_mirror_table_states')
->where('dataset_key', 'operations_replica.rpt.jst_sku')
->update(['initial_completed_at' => null, 'status' => 'running']);
$this->assertFalse($this->repository->ready());
$this->assertSame('dependency_pending', $this->repository->syncStatus()['code']);
}
public function test_day_dimension_uses_half_open_boundary_and_one_day_turnover(): void
{
$this->markReady();
$this->insertProduct('SKU-DAY', ['name' => '按天商品', 'purchase_price' => 10]);
$this->insertInventory('SKU-DAY', 22, '2026-08-17 00:00:00');
$this->insertMovement('SKU-DAY', '采购进仓', 10, 1, '2026-08-15 00:00:00');
$this->insertMovement('SKU-DAY', '销售出仓', 4, -1, '2026-08-15 23:59:59');
// 次日零点不属于 8 月 15 日,但属于日末到库存锚点之间的回推区间。
$this->insertMovement('SKU-DAY', '采购进仓', 3, 1, '2026-08-16 00:00:00');
$row = $this->repository->paginate($this->filters([
'dimension' => 'day',
'period' => '2026-08-15',
'month' => '',
]), 1, 25)['data'][0];
$this->assertSame(10.0, $row['purchase_in']);
$this->assertSame(-4.0, $row['sales_out']);
$this->assertSame(19.0, $row['closing_qty']);
$this->assertSame(13.0, $row['opening_qty']);
$this->assertSame(5, $row['turnover_days']);
}
public function test_it_maps_all_twelve_types_and_excludes_wrong_scope_and_unknown_types(): void
{
$this->markReady();
$this->insertProduct('SKU-TYPES');
$this->insertInventory('SKU-TYPES', 50, '2026-09-01 00:00:00');
$types = [
'加工进仓' => [1, 1],
'加工出仓' => [2, -1],
'采购进仓' => [3, 1],
'采购退货' => [4, -1],
'其它进仓' => [5, 1],
'其它退货' => [6, -1],
'销售出仓' => [7, -1],
'销售退货' => [8, 1],
'盘点' => [9, -1],
'调拨出' => [10, -1],
'调拨入' => [11, 1],
'其它出库' => [12, -1],
];
foreach ($types as $type => [$qty, $qtyType]) {
$this->insertMovement('SKU-TYPES', $type, $qty, $qtyType, '2026-08-20 12:00:00');
}
$this->insertMovement('SKU-TYPES', '维修', 500, -1, '2026-08-20 12:00:00');
$this->insertMovement('SKU-TYPES', '虚拟仓调拨入', 500, 1, '2026-08-20 12:00:00');
$this->insertMovement('SKU-TYPES', '装箱快递', 500, -1, '2026-08-20 12:00:00');
$this->insertMovement('SKU-TYPES', '采购进仓', 500, 1, '2026-08-20 12:00:00', ['status' => 'WaitConfirm']);
$this->insertMovement('SKU-TYPES', '采购进仓', 500, 1, '2026-08-20 12:00:00', ['warehouse_id' => 999]);
// 同一单据出现更新版本时只认最新头记录,避免重复累计旧版本。
$this->insertMovement('SKU-TYPES', '采购进仓', 600, 1, '2026-08-20 12:00:00', [
'io_id' => 9000,
'header_id' => 9000,
]);
$this->insertHeaderOnly(9001, 9000, '采购进仓', '2026-08-20 12:00:00', 'WaitConfirm');
$row = $this->repository->paginate($this->filters(), 1, 25)['data'][0];
$expected = [
'processing_in' => 1.0,
'processing_out' => -2.0,
'purchase_in' => 3.0,
'purchase_return' => -4.0,
'other_in' => 5.0,
'other_return' => -6.0,
'sales_out' => -7.0,
'sales_return' => 8.0,
'stocktake' => -9.0,
'transfer_out' => -10.0,
'transfer_in' => 11.0,
'other_out' => -12.0,
];
foreach ($expected as $column => $value) {
$this->assertSame($value, $row[$column], $column);
}
}
public function test_filters_paging_summary_options_and_export_share_the_non_zero_scope(): void
{
$this->markReady();
foreach ([
['SKU-C', '椅子 C', '供应商乙', '椅类', 3],
['SKU-A', '桌子 A', '供应商甲', '桌类', 1],
['SKU-B', '桌子 B', '供应商甲', '桌类', 2],
] as [$sku, $name, $supplier, $category, $qty]) {
$this->insertProduct($sku, [
'name' => $name,
'supplier_name' => $supplier,
'category' => $category,
]);
$this->insertInventory($sku, $qty, '2026-09-01 00:00:00');
}
$this->insertProduct('SKU-ZERO', ['name' => '零库存', 'supplier_name' => '供应商甲', 'category' => '桌类']);
$this->insertInventory('SKU-ZERO', 0, '2026-09-01 00:00:00');
// 没有库存快照但存在相关流水的 SKU 也必须进入候选集合。
$this->insertProduct('SKU-FLOW', ['name' => '桌子流水', 'supplier_name' => '供应商甲', 'category' => '桌类']);
$this->insertMovement('SKU-FLOW', '销售出仓', 2, -1, '2026-08-10 00:00:00');
$this->insertMovement('SKU-FLOW', '销售出仓', 3, -1, '2026-09-02 00:00:00');
$filters = $this->filters(['product_name' => '桌子', 'supplier' => '供应商甲', 'category' => '桌类']);
$pageOne = $this->repository->paginate($filters, 1, 1);
$pageTwo = $this->repository->paginate($filters, 2, 1);
$summary = $this->repository->summary($filters);
$options = $this->repository->filterOptions($this->filters(['product_name' => '桌子']));
$export = iterator_to_array($this->repository->exportRows($filters, 1), false);
$this->assertSame(3, $pageOne['total']);
$this->assertSame('SKU-A', $pageOne['data'][0]['sku_code']);
$this->assertSame('SKU-B', $pageTwo['data'][0]['sku_code']);
$this->assertSame([
'count' => 3,
'opening_qty' => 8.0,
'closing_qty' => 6.0,
'opening_amount' => 440.0,
'closing_amount' => 330.0,
], $summary);
$this->assertSame(['供应商甲'], $options['suppliers']);
$this->assertSame(['桌类'], $options['categories']);
$this->assertSame(['SKU-A', 'SKU-B', 'SKU-FLOW'], array_column($export, 'sku_code'));
$this->assertSame(1, $this->repository->summary($this->filters(['sku_code' => 'SKU-B']))['count']);
}
public function test_export_keeps_its_initial_generation_when_publication_switches_between_batches(): void
{
$this->markReady();
foreach (['SKU-A', 'SKU-B'] as $sku) {
$this->insertProduct($sku);
$this->insertInventory($sku, 1, '2026-09-01 00:00:00');
}
$export = $this->repository->exportRows($this->filters(), 1);
$this->assertTrue($export->valid());
$this->assertSame('SKU-A', $export->current()['sku_code']);
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')->update([
'active_header_generation' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'active_item_generation' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'active_inventory_generation' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
]);
$export->next();
$this->assertTrue($export->valid());
$this->assertSame('SKU-B', $export->current()['sku_code']);
}
private function createMirrorTables(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('status')->nullable();
$table->string('run_mode')->nullable();
$table->string('active_header_generation')->nullable();
$table->string('active_item_generation')->nullable();
$table->string('active_inventory_generation')->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->string('last_error_code')->nullable();
});
$schema->create('third_party_warehouse_inout_headers', function (Blueprint $table): void {
$table->id('_mirror_id');
$table->string('generation');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('io_id');
$table->unsignedBigInteger('wms_co_id');
$table->string('status')->nullable();
$table->string('io_type')->nullable();
$table->dateTime('occurred_at')->nullable();
$table->dateTime('source_modified_at')->nullable();
});
$schema->create('third_party_warehouse_inout_items', function (Blueprint $table): void {
$table->id('_mirror_id');
$table->string('generation');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('io_id');
$table->string('sku_code');
$table->decimal('qty', 20, 4);
$table->smallInteger('qty_type');
$table->dateTime('source_modified_at')->nullable();
});
$schema->create('third_party_warehouse_inventory_snapshots', function (Blueprint $table): void {
$table->id('_mirror_id');
$table->string('generation');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('wms_co_id');
$table->string('sku_code');
$table->decimal('qty', 20, 4);
$table->dateTime('source_modified_at')->nullable();
$table->dateTime('captured_at')->nullable();
});
}
private function createProductTable(): void
{
Schema::connection('business_mirror')->create('ops_jst_sku', function (Blueprint $table): void {
$table->unsignedBigInteger('autoid');
$table->string('sku_id')->nullable();
$table->string('sku_type')->nullable();
foreach ([
'pic_big', 'pic', 'name', 'short_name', 'supplier_name', 'category',
'vc_name', 'supplier_sku_id', 'properties_value', 'color', 'brand',
'unit', 'other_price_1', 'other_price_2', 'other_price_3',
'other_price_4', 'other_1', 'other_2', 'other_3', 'other_4',
'other_5', 'other_6', 'other_7', 'other_8', 'other_9', 'other_10',
] as $column) {
$table->string($column)->nullable();
}
$table->decimal('pack_qty', 20, 4)->nullable();
$table->decimal('purchase_price', 20, 4)->nullable();
});
}
private function createPriceHistoryTables(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('status')->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
});
$schema->create('js_pm_price_adjustment_log', function (Blueprint $table): void {
$table->unsignedBigInteger('Id');
$table->string('skucode')->nullable();
$table->integer('priceType')->nullable();
$table->decimal('originalPrice', 20, 4)->nullable();
$table->decimal('afterPrice', 20, 4)->nullable();
$table->dateTime('effectStartTime')->nullable();
});
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
[
'dataset_key' => 'operations_replica.rpt.jst_sku',
'status' => 'succeeded',
'initial_completed_at' => '2026-09-01 03:00:00',
'last_succeeded_at' => '2026-09-11 03:00:00',
],
[
'dataset_key' => 'jiansun.dbo.pm_priceAdjustment_log',
'status' => 'succeeded',
'initial_completed_at' => '2026-09-01 03:00:00',
'last_succeeded_at' => '2026-09-11 03:00:00',
],
]);
}
/** @param array<string,mixed> $overrides */
private function markReady(array $overrides = []): void
{
$this->insertState(array_merge([
'status' => 'succeeded',
'run_mode' => null,
'active_header_generation' => self::HEADER_GENERATION,
'active_item_generation' => self::ITEM_GENERATION,
'active_inventory_generation' => self::INVENTORY_GENERATION,
'initial_completed_at' => '2026-09-11 03:20:00',
'last_succeeded_at' => '2026-09-11 03:21:00',
'last_error_code' => null,
], $overrides));
}
/** @param array<string,mixed> $values */
private function insertState(array $values): void
{
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')->insert(
['dataset_key' => ThirdPartyWarehouseStorageMirrorRepository::DATASET_KEY] + $values,
);
}
/** @param array<string,mixed> $overrides */
private function insertProduct(string $sku, array $overrides = []): void
{
DB::connection('business_mirror')->table('ops_jst_sku')->insert(array_merge([
'autoid' => 1,
'sku_id' => $sku,
'sku_type' => 'normal',
'pic_big' => 'https://example.test/' . $sku . '.jpg',
'pic' => null,
'name' => '测试商品',
'short_name' => '测试简称',
'supplier_name' => '测试供应商',
'category' => '测试分类',
'vc_name' => '测试虚拟分类',
'supplier_sku_id' => 'SUP-' . $sku,
'properties_value' => '原木色',
'color' => null,
'brand' => '测试品牌',
'unit' => '件',
'purchase_price' => 55,
'other_price_1' => '采购价',
'other_price_2' => '按需叫货',
'other_price_3' => null,
'other_price_4' => null,
'pack_qty' => 1,
'other_1' => '型号',
'other_2' => '板材',
'other_3' => '采购',
'other_4' => null,
'other_5' => '在产',
'other_6' => null,
'other_7' => 'ITEM-' . $sku,
'other_8' => '单包',
'other_9' => 'DRAW-' . $sku,
'other_10' => '木',
], $overrides));
}
/** @param array<string,mixed> $overrides */
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([
'generation' => self::INVENTORY_GENERATION,
'id' => $id++,
'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
'sku_code' => $sku,
'qty' => $qty,
'captured_at' => $capturedAt,
], $overrides));
}
private function insertPriceChange(
string $sku,
string $effectStartTime,
float $originalPrice,
float $afterPrice,
): void {
static $id = 1;
DB::connection('business_mirror')->table('js_pm_price_adjustment_log')->insert([
'Id' => $id++,
'skucode' => $sku,
'priceType' => 1,
'originalPrice' => $originalPrice,
'afterPrice' => $afterPrice,
'effectStartTime' => $effectStartTime,
]);
}
/** @param array<string,mixed> $overrides */
private function insertMovement(
string $sku,
string $type,
float $qty,
int $qtyType,
string $occurredAt,
array $overrides = [],
): void {
$ioId = $overrides['io_id'] ?? (1000 + $this->nextHeaderId);
$headerGeneration = $overrides['header_generation'] ?? self::HEADER_GENERATION;
$itemGeneration = $overrides['item_generation'] ?? self::ITEM_GENERATION;
DB::connection('business_mirror')->table('third_party_warehouse_inout_headers')->insert([
'generation' => $headerGeneration,
'id' => $overrides['header_id'] ?? $this->nextHeaderId++,
'io_id' => $ioId,
'wms_co_id' => $overrides['warehouse_id'] ?? ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
'status' => $overrides['status'] ?? 'Confirmed',
'io_type' => $type,
'occurred_at' => $occurredAt,
]);
DB::connection('business_mirror')->table('third_party_warehouse_inout_items')->insert([
'generation' => $itemGeneration,
'id' => $this->nextItemId++,
'io_id' => $ioId,
'sku_code' => $sku,
'qty' => $qty,
'qty_type' => $qtyType,
]);
}
private function insertHeaderOnly(
int $id,
int $ioId,
string $type,
string $occurredAt,
string $status,
): void {
DB::connection('business_mirror')->table('third_party_warehouse_inout_headers')->insert([
'generation' => self::HEADER_GENERATION,
'id' => $id,
'io_id' => $ioId,
'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
'status' => $status,
'io_type' => $type,
'occurred_at' => $occurredAt,
]);
}
/** @param array<string,mixed> $overrides */
private function filters(array $overrides = []): array
{
return array_merge([
'dimension' => 'month',
'period' => '2026-08',
'month' => '2026-08',
'date' => '',
'sku_code' => '',
'product_name' => '',
'supplier' => '',
'category' => '',
], $overrides);
}
}