CaiWuXiTong/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageSynchronizerTest.php

753 lines
33 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use Closure;
use Illuminate\Cache\CacheManager;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageUpstreamRepository;
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageSynchronizer;
use RuntimeException;
use Tests\TestCase;
class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
{
private FakeThirdPartyWarehouseUpstreamRepository $upstream;
private ThirdPartyWarehouseStorageSynchronizer $synchronizer;
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' => [
'header_scan_size' => 100,
'max_header_batches_per_run' => 10,
'inventory_batch_size' => 5,
'incremental_batch_size' => 2,
'incremental_overlap_hours' => 48,
'generation_retention_hours' => 48,
],
'upstreams.snapshots.cache_store' => 'array',
]);
DB::purge('business_mirror');
$this->createMirrorSchema();
$this->upstream = new FakeThirdPartyWarehouseUpstreamRepository();
$this->synchronizer = new ThirdPartyWarehouseStorageSynchronizer(
$this->upstream,
app('db'),
app(CacheManager::class),
);
}
public function test_shared_lock_prevents_a_second_sync_from_replacing_the_pending_generation(): void
{
$lock = app(CacheManager::class)->store('array')->lock(
ThirdPartyWarehouseStorageSynchronizer::LOCK_KEY,
60,
);
$this->assertTrue($lock->get());
try {
$result = $this->synchronizer->sync(true, 0, 100);
} finally {
$lock->release();
}
$this->assertSame('already_running', $result['status']);
$this->assertSame('locked', $result['phase']);
$this->assertSame([], $this->upstream->headerCalls);
$this->assertSame(0, DB::connection('business_mirror')
->table('third_party_warehouse_sync_states')->count());
}
public function test_empty_header_id_interval_still_advances_the_checkpoint(): void
{
$this->upstream->headerHighWatermark = 150;
$result = $this->synchronizer->sync(true, 1, 100);
$this->assertSame('running', $result['status']);
$this->assertSame('headers', $result['phase']);
$this->assertSame([[0, 100]], $this->upstream->headerCalls);
$state = $this->state();
$this->assertSame(100, (int) $state->header_cursor);
$this->assertSame('full', $state->run_mode);
}
public function test_interrupted_full_sync_resumes_with_the_same_generation_and_activates_only_when_complete(): void
{
$this->upstream->headerHighWatermark = 150;
$this->upstream->inventoryHighWatermark = 20;
$this->upstream->headerResolver = static fn (int $after): array => $after === 100 ? [[
'source_id' => 120,
'io_id' => 9001,
'wms_co_id' => 15045364,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-08-10T10:00:00.000',
'modified_at' => '2026-08-10T10:01:00.000',
]] : [];
$this->upstream->itemResolver = static fn (array $ioIds): array => $ioIds === [9001] ? [[
'source_id' => 501,
'io_id' => 9001,
'sku_code' => str_repeat('S', 200),
'qty' => 3,
'qty_type' => -1,
]] : [];
$this->upstream->inventoryResolver = static fn (): array => [[
'source_id' => 15,
'wms_co_id' => 15045364,
'sku_code' => 'SKU-1',
'qty' => 8,
'source_modified_at' => '2026-09-11T03:19:00.000',
]];
$first = $this->synchronizer->sync(true, 1, 100);
$interruptedState = $this->state();
$pendingGeneration = $interruptedState->pending_header_generation;
$second = $this->synchronizer->sync(false, 0, 100);
$this->assertSame('running', $first['status']);
$this->assertNull($interruptedState->active_header_generation);
$this->assertNull($interruptedState->active_item_generation);
$this->assertNull($interruptedState->active_inventory_generation);
$this->assertSame('succeeded', $second['status']);
$this->assertSame('full', $second['mode']);
$this->assertSame($pendingGeneration, $this->state()->active_header_generation);
$this->assertNull($this->state()->pending_header_generation);
$this->assertSame(1, DB::connection('business_mirror')->table('third_party_warehouse_inout_headers')->count());
$this->assertSame(191, mb_strlen((string) DB::connection('business_mirror')
->table('third_party_warehouse_inout_items')->value('sku_code')));
$this->assertSame(-1, (int) DB::connection('business_mirror')
->table('third_party_warehouse_inout_items')->value('qty_type'));
}
public function test_inventory_snapshot_switches_generation_after_a_complete_read(): void
{
$this->seedReadyMirror();
$this->upstream->headerHighWatermark = 100000;
$this->upstream->inventoryHighWatermark = 200;
$this->upstream->inventoryResolver = static fn (): array => [[
'source_id' => 125,
'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,
'wms_co_id' => 15045364,
'sku_code' => 'NEW-2',
'qty' => 4,
'source_modified_at' => '2026-09-11T03:19:00.000',
]];
$result = $this->synchronizer->sync(false, 0, 100);
$state = $this->state();
$this->assertSame('succeeded', $result['status']);
$this->assertSame('incremental', $result['mode']);
$this->assertSame(100000, (int) $state->header_cursor);
$this->assertNotSame('inventory-old', $state->active_inventory_generation);
$this->assertSame(1, DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')
->where('generation', 'inventory-old')->count());
$this->assertSame(['NEW-1', 'NEW-2'], DB::connection('business_mirror')
->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);
}
public function test_inventory_keyset_checkpoint_resumes_at_the_last_received_target_row(): void
{
$this->seedReadyMirror();
$this->upstream->headerHighWatermark = 100000;
$this->upstream->inventoryHighWatermark = 200;
$this->upstream->inventoryResolver = static fn (int $after): array => $after === 0
? array_map(static fn (int $id): array => [
'source_id' => $id,
'wms_co_id' => 15045364,
'sku_code' => 'SKU-' . $id,
'qty' => 1,
'source_modified_at' => '2026-09-11T03:19:00.000',
], [10, 20, 30, 40, 50])
: [[
'source_id' => 190,
'wms_co_id' => 15045364,
'sku_code' => 'SKU-190',
'qty' => 2,
'source_modified_at' => '2026-09-11T03:19:00.000',
]];
$first = $this->synchronizer->sync(false, 1, 100);
$pendingGeneration = $this->state()->pending_inventory_generation;
$this->assertSame(50, (int) $this->state()->inventory_cursor);
$this->assertSame(200, (int) $this->state()->inventory_high_watermark);
$second = $this->synchronizer->sync(false, 0, 100);
$this->assertSame('running', $first['status']);
$this->assertSame('inventory', $first['phase']);
$this->assertSame('succeeded', $second['status']);
$this->assertSame($pendingGeneration, $this->state()->active_inventory_generation);
$this->assertSame([[0, 200, 5], [50, 200, 5]], $this->upstream->inventoryCalls);
}
public function test_inventory_failure_keeps_the_previous_active_generation(): void
{
$this->seedReadyMirror();
$this->upstream->headerHighWatermark = 100000;
$this->upstream->inventoryHighWatermark = 200;
$this->upstream->inventoryFailure = new RuntimeException('upstream unavailable');
try {
$this->synchronizer->sync(false, 0, 100);
$this->fail('库存读取异常必须向命令层抛出。');
} catch (RuntimeException $exception) {
$this->assertSame('upstream unavailable', $exception->getMessage());
}
$state = $this->state();
$this->assertSame('failed', $state->status);
$this->assertSame('inventory-old', $state->active_inventory_generation);
$this->assertNotNull($state->pending_inventory_generation);
$this->assertSame(1, DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')
->where('generation', 'inventory-old')->count());
}
public function test_incremental_sync_uses_a_resumable_modified_and_id_cursor(): void
{
Carbon::setTestNow('2026-09-11 03:20:00');
$this->seedReadyMirror();
$this->upstream->modifiedHeaderResolver = static fn (): array => [[
'source_id' => 700,
'io_id' => 700,
'wms_co_id' => 15045364,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-09-10T10:00:00.000',
'modified_at' => '2026-09-10T10:01:00.000',
], [
'source_id' => 701,
'io_id' => 701,
'wms_co_id' => 15045364,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-09-10T10:00:00.000',
'modified_at' => '2026-09-10T10:01:00.000',
]];
$result = $this->synchronizer->sync(false, 1, 100);
$this->assertSame('running', $result['status']);
$this->assertSame([
['2026-09-09 03:20:00.000', 0, '2026-09-11 03:20:00.000', 2],
], $this->upstream->modifiedHeaderCalls);
$this->assertSame('2026-09-10 10:01:00.000', $this->state()->header_modified_cursor_at);
$this->assertSame(701, (int) $this->state()->header_modified_cursor_id);
$this->assertSame('2026-09-11 03:20:00.000', $this->state()->header_window_ends_at);
$this->assertSame('header-old', $this->state()->active_header_generation);
$this->assertSame('item-old', $this->state()->active_item_generation);
$this->assertSame('inventory-old', $this->state()->active_inventory_generation);
$this->assertNotSame('header-old', $this->state()->pending_header_generation);
$this->assertSame(0, DB::connection('business_mirror')
->table('third_party_warehouse_inout_headers')
->where('generation', 'header-old')->where('id', 700)->count());
}
public function test_incremental_overlap_removes_rows_that_left_the_target_warehouse(): void
{
$this->seedReadyMirror();
$db = DB::connection('business_mirror');
$db->table('third_party_warehouse_inout_headers')->insert([
'generation' => 'header-old', 'id' => 50050, 'io_id' => 50050,
'wms_co_id' => 15045364, 'status' => 'Confirmed', 'io_type' => '采购进仓',
'row_hash' => str_repeat('d', 64), 'synced_at' => now(),
]);
$db->table('third_party_warehouse_inout_items')->insert([
'generation' => 'item-old', 'id' => 50050, 'io_id' => 50050,
'sku_code' => 'MOVED-AWAY', 'qty' => 2, 'qty_type' => 1,
'row_hash' => str_repeat('e', 64), 'synced_at' => now(),
]);
$this->upstream->modifiedHeaderResolver = static fn (): array => [[
'source_id' => 60000,
'io_id' => 50050,
'wms_co_id' => 999,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-09-10T10:00:00.000',
'modified_at' => now()->subHour()->format('Y-m-d\\TH:i:s.u'),
]];
$this->upstream->inventoryHighWatermark = 1;
$this->upstream->inventoryResolver = static fn (): array => [[
'source_id' => 1,
'wms_co_id' => 15045364,
'sku_code' => 'OLD-1',
'qty' => 1,
'source_modified_at' => now()->subMinute()->format('Y-m-d\\TH:i:s.u'),
]];
$this->synchronizer->sync(false, 0, 100);
$state = $this->state();
$this->assertSame(0, $db->table('third_party_warehouse_inout_headers')
->where('generation', $state->active_header_generation)->where('id', 50050)->count());
$this->assertSame(0, $db->table('third_party_warehouse_inout_items')
->where('generation', $state->active_item_generation)->where('io_id', 50050)->count());
$this->assertSame(1, $db->table('third_party_warehouse_inout_headers')
->where('generation', 'header-old')->where('id', 50050)->count());
}
public function test_full_sync_catches_up_changes_through_the_inventory_capture_boundary(): void
{
Carbon::setTestNow('2026-09-01 03:20:00');
$this->upstream->headerHighWatermark = 10;
$this->upstream->headerResolver = static fn (): array => [[
'source_id' => 10,
'io_id' => 10,
'wms_co_id' => 15045364,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-08-01T10:00:00.000',
'modified_at' => '2026-08-01T10:01:00.000',
]];
$this->upstream->itemResolver = static fn (array $ioIds): array => [[
'source_id' => $ioIds === [10] ? 10 : 20,
'io_id' => $ioIds[0],
'sku_code' => $ioIds === [10] ? 'OLD-SKU' : 'NEW-SKU',
'qty' => 1,
'qty_type' => 1,
]];
$this->upstream->inventoryHighWatermark = 1;
$this->upstream->inventoryResolver = static function (): array {
Carbon::setTestNow('2026-09-04 03:20:00');
return [[
'source_id' => 1,
'wms_co_id' => 15045364,
'sku_code' => 'NEW-SKU',
'qty' => 1,
'source_modified_at' => '2026-09-04T03:19:00.000',
]];
};
$this->upstream->modifiedHeaderResolver = static function (): array {
Carbon::setTestNow('2026-09-05 03:20:00');
return [[
'source_id' => 20,
'io_id' => 20,
'wms_co_id' => 15045364,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-09-03T10:00:00.000',
'modified_at' => '2026-09-03T10:01:00.000',
]];
};
$result = $this->synchronizer->sync(true, 0, 100);
$this->assertSame('succeeded', $result['status']);
$this->assertSame([
['2026-08-30 03:20:00.000', 0, '2026-09-04 03:20:00.000', 2],
], $this->upstream->modifiedHeaderCalls);
$this->assertSame([10, 20], DB::connection('business_mirror')
->table('third_party_warehouse_inout_headers')->orderBy('id')->pluck('id')->all());
$this->assertSame('2026-09-04 03:20:00.000', $this->state()->header_modified_cursor_at);
$this->assertSame('2026-09-05 03:20:00', $this->state()->last_succeeded_at);
$this->upstream->modifiedHeaderCalls = [];
$this->upstream->modifiedHeaderResolver = static fn (): array => [];
$this->synchronizer->sync(false, 0, 100);
$this->assertSame('2026-09-02 03:20:00.000', $this->upstream->modifiedHeaderCalls[0][0]);
}
public function test_publish_retains_previous_generation_and_cleans_only_expired_releases(): void
{
Carbon::setTestNow('2026-09-11 03:20:00');
$this->seedReadyMirror();
$db = DB::connection('business_mirror');
$db->table('third_party_warehouse_inout_headers')->insert([
'generation' => 'header-expired', 'id' => 99, 'io_id' => 99, 'wms_co_id' => 15045364,
'row_hash' => str_repeat('d', 64), 'synced_at' => now(),
]);
$db->table('third_party_warehouse_inout_items')->insert([
'generation' => 'item-expired', 'id' => 99, 'io_id' => 99, 'sku_code' => 'EXPIRED',
'qty' => 1, 'qty_type' => 1, 'row_hash' => str_repeat('e', 64), 'synced_at' => now(),
]);
$db->table('third_party_warehouse_inventory_snapshots')->insert([
'generation' => 'inventory-expired', 'id' => 99, 'wms_co_id' => 15045364,
'sku_code' => 'EXPIRED', 'qty' => 1, 'captured_at' => now(),
'row_hash' => str_repeat('f', 64), 'synced_at' => now(),
]);
$db->table('third_party_warehouse_generation_releases')->insert([
'release_id' => 'release-expired',
'dataset_key' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY,
'header_generation' => 'header-expired',
'item_generation' => 'item-expired',
'inventory_generation' => 'inventory-expired',
'status' => 'retired',
'published_at' => '2026-09-01 03:20:00',
'retired_at' => '2026-09-08 03:19:59',
'created_at' => now(),
'updated_at' => now(),
]);
$this->upstream->inventoryHighWatermark = 1;
$this->upstream->inventoryResolver = static fn (): array => [[
'source_id' => 1,
'wms_co_id' => 15045364,
'sku_code' => 'NEW-1',
'qty' => 2,
'source_modified_at' => '2026-09-11T03:19:00.000',
]];
$result = $this->synchronizer->sync(false, 0, 100);
$state = $this->state();
$this->assertSame('succeeded', $result['status']);
$this->assertSame(1, $db->table('third_party_warehouse_inout_headers')
->where('generation', 'header-old')->count());
$this->assertSame(1, $db->table('third_party_warehouse_inout_items')
->where('generation', 'item-old')->count());
$this->assertSame(1, $db->table('third_party_warehouse_inventory_snapshots')
->where('generation', 'inventory-old')->count());
$this->assertSame('retired', $db->table('third_party_warehouse_generation_releases')
->where('header_generation', 'header-old')->value('status'));
$this->assertSame('active', $db->table('third_party_warehouse_generation_releases')
->where('header_generation', $state->active_header_generation)->value('status'));
$this->assertSame(0, $db->table('third_party_warehouse_inout_headers')
->where('generation', 'header-expired')->count());
$this->assertSame(0, $db->table('third_party_warehouse_generation_releases')
->where('release_id', 'release-expired')->count());
}
public function test_superseded_worker_cannot_write_rows_or_switch_the_active_generation(): void
{
Carbon::setTestNow('2026-09-11 03:20:00');
$this->seedReadyMirror();
$this->upstream->inventoryHighWatermark = 1;
$this->upstream->inventoryResolver = static fn (): array => [[
'source_id' => 1,
'wms_co_id' => 15045364,
'sku_code' => 'NEW-1',
'qty' => 2,
'source_modified_at' => '2026-09-11T03:19:00.000',
]];
$this->upstream->modifiedHeaderResolver = static function (): array {
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')
->where('dataset_key', ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY)
->update(['run_token' => 'replacement-run-token']);
return [[
'source_id' => 700,
'io_id' => 700,
'wms_co_id' => 15045364,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-09-10T10:00:00.000',
'modified_at' => '2026-09-10T10:01:00.000',
]];
};
$result = $this->synchronizer->sync(false, 0, 100);
$state = $this->state();
$this->assertSame('already_running', $result['status']);
$this->assertSame('superseded', $result['phase']);
$this->assertSame('replacement-run-token', $state->run_token);
$this->assertSame('header-old', $state->active_header_generation);
$this->assertSame('inventory-old', $state->active_inventory_generation);
$this->assertSame(0, DB::connection('business_mirror')
->table('third_party_warehouse_inout_headers')
->where('generation', $state->pending_header_generation)
->where('id', 700)
->count());
}
private function createMirrorSchema(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->unsignedTinyInteger('scope_version')->default(1);
$table->string('status')->default('pending');
$table->string('run_mode')->nullable();
$table->string('sync_phase')->nullable();
$table->string('run_token')->nullable();
$table->string('active_header_generation')->nullable();
$table->string('active_item_generation')->nullable();
$table->string('active_inventory_generation')->nullable();
$table->string('pending_header_generation')->nullable();
$table->string('pending_item_generation')->nullable();
$table->string('pending_inventory_generation')->nullable();
$table->unsignedBigInteger('header_cursor')->default(0);
$table->unsignedBigInteger('header_high_watermark')->nullable();
$table->dateTime('header_window_started_at')->nullable();
$table->dateTime('header_window_ends_at')->nullable();
$table->dateTime('header_modified_cursor_at')->nullable();
$table->unsignedBigInteger('header_modified_cursor_id')->default(0);
$table->unsignedBigInteger('inventory_cursor')->default(0);
$table->unsignedBigInteger('inventory_high_watermark')->nullable();
$table->unsignedBigInteger('header_row_count')->default(0);
$table->unsignedBigInteger('item_row_count')->default(0);
$table->unsignedBigInteger('inventory_row_count')->default(0);
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_attempted_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->dateTime('last_full_succeeded_at')->nullable();
$table->string('last_error_code')->nullable();
$table->timestamps();
});
$schema->create('third_party_warehouse_generation_releases', function (Blueprint $table): void {
$table->string('release_id')->primary();
$table->string('dataset_key');
$table->string('header_generation');
$table->string('item_generation');
$table->string('inventory_generation');
$table->string('status');
$table->dateTime('published_at');
$table->dateTime('retired_at')->nullable();
$table->timestamps();
$table->unique(['dataset_key', 'header_generation', 'item_generation', 'inventory_generation']);
});
$schema->create('third_party_warehouse_inout_headers', function (Blueprint $table): void {
$table->id('_mirror_id');
$table->string('generation');
$table->string('source_key')->default('jiansun');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('io_id');
$table->unsignedBigInteger('wms_co_id');
$table->unsignedBigInteger('wh_id')->default(0);
$table->string('status')->nullable();
$table->string('io_type')->nullable();
$table->dateTime('occurred_at')->nullable();
$table->dateTime('source_modified_at')->nullable();
$table->string('row_hash');
$table->dateTime('synced_at');
$table->unique(['generation', 'id']);
});
$schema->create('third_party_warehouse_inout_items', function (Blueprint $table): void {
$table->id('_mirror_id');
$table->string('generation');
$table->string('source_key')->default('jiansun');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('io_id');
$table->string('sku_code', 191);
$table->decimal('qty', 20, 4)->default(0);
$table->smallInteger('qty_type');
$table->dateTime('source_modified_at')->nullable();
$table->string('row_hash');
$table->dateTime('synced_at');
$table->unique(['generation', 'id']);
});
$schema->create('third_party_warehouse_inventory_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->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']);
$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
{
$now = now();
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')->insert([
'dataset_key' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY,
'status' => 'succeeded',
'active_header_generation' => 'header-old',
'active_item_generation' => 'item-old',
'active_inventory_generation' => 'inventory-old',
'header_cursor' => 100000,
'header_row_count' => 1,
'item_row_count' => 1,
'inventory_row_count' => 1,
'initial_completed_at' => $now,
'last_succeeded_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
DB::connection('business_mirror')->table('third_party_warehouse_inout_headers')->insert([
'generation' => 'header-old', 'id' => 1, 'io_id' => 11, 'wms_co_id' => 15045364,
'status' => 'Confirmed', 'io_type' => '采购进仓', 'row_hash' => str_repeat('a', 64),
'synced_at' => $now,
]);
DB::connection('business_mirror')->table('third_party_warehouse_inout_items')->insert([
'generation' => 'item-old', 'id' => 1, 'io_id' => 11, 'sku_code' => 'OLD-1',
'qty' => 1, 'qty_type' => 1, 'row_hash' => str_repeat('b', 64), 'synced_at' => $now,
]);
DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')->insert([
'generation' => 'inventory-old', 'id' => 1, 'wms_co_id' => 15045364,
'sku_code' => 'OLD-1', 'qty' => 1, 'row_hash' => str_repeat('c', 64),
'captured_at' => $now,
'synced_at' => $now,
]);
}
private function state(): object
{
return DB::connection('business_mirror')->table('third_party_warehouse_sync_states')
->where('dataset_key', ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY)->first();
}
}
class FakeThirdPartyWarehouseUpstreamRepository extends ThirdPartyWarehouseStorageUpstreamRepository
{
public int $headerHighWatermark = 0;
public int $inventoryHighWatermark = 0;
public ?Closure $headerResolver = null;
public ?Closure $modifiedHeaderResolver = null;
public ?Closure $latestHeaderResolver = null;
public ?Closure $itemResolver = null;
public ?Closure $inventoryResolver = null;
public ?RuntimeException $inventoryFailure = null;
/** @var list<array{int,int}> */
public array $headerCalls = [];
/** @var list<array{string,int,string,int}> */
public array $modifiedHeaderCalls = [];
/** @var list<array<string,mixed>> */
public array $lastModifiedHeaders = [];
/** @var list<list<int|string>> */
public array $latestHeaderCalls = [];
/** @var list<array{int,int,int}> */
public array $inventoryCalls = [];
public function __construct()
{
}
public function headerHighWatermark(): int
{
return $this->headerHighWatermark;
}
public function headerRange(int $afterId, int $throughId): array
{
$this->headerCalls[] = [$afterId, $throughId];
return $this->headerResolver === null
? []
: ($this->headerResolver)($afterId, $throughId);
}
public function modifiedHeadersBatch(
string $cursorAt,
int $cursorId,
string $throughAt,
int $limit,
): array {
$this->modifiedHeaderCalls[] = [$cursorAt, $cursorId, $throughAt, $limit];
$rows = $this->modifiedHeaderResolver === null
? []
: ($this->modifiedHeaderResolver)($cursorAt, $cursorId, $throughAt, $limit);
$this->lastModifiedHeaders = $rows;
return $rows;
}
public function latestHeadersForIoIds(array $ioIds, string $throughAt): array
{
$this->latestHeaderCalls[] = $ioIds;
if ($this->latestHeaderResolver !== null) {
return ($this->latestHeaderResolver)($ioIds);
}
$byIoId = [];
foreach ($this->lastModifiedHeaders as $row) {
$ioId = (int) ($row['io_id'] ?? 0);
if (! in_array($ioId, $ioIds, true)) {
continue;
}
if (! isset($byIoId[$ioId])
|| (int) ($row['source_id'] ?? 0) > (int) ($byIoId[$ioId]['source_id'] ?? 0)) {
$byIoId[$ioId] = $row;
}
}
return array_values($byIoId);
}
public function itemsForIoIds(array $ioIds): array
{
return $this->itemResolver === null ? [] : ($this->itemResolver)($ioIds);
}
public function inventoryHighWatermark(): int
{
return $this->inventoryHighWatermark;
}
public function inventoryBatch(int $afterId, int $highWatermark, int $limit): array
{
$this->inventoryCalls[] = [$afterId, $highWatermark, $limit];
if ($this->inventoryFailure !== null) {
throw $this->inventoryFailure;
}
return $this->inventoryResolver === null
? []
: ($this->inventoryResolver)($afterId, $highWatermark, $limit);
}
}