[ '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 */ public array $headerCalls = []; /** @var list */ public array $modifiedHeaderCalls = []; /** @var list> */ public array $lastModifiedHeaders = []; /** @var list> */ public array $latestHeaderCalls = []; /** @var list */ 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); } }