[ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', ], 'upstream_mirrors.connection' => 'business_mirror', 'upstream_mirrors.table_sync.overlap_hours' => 48, 'upstream_mirrors.ding_payable_workflows' => [ 'dataset_key' => 'operations_replica.rpt.ding_payable_workflows', 'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST', 'target_table' => 'ding_payable_workflow_mirror', 'batch_size' => 100, 'max_batches_per_run' => 2, ], ]); DB::purge('business_mirror'); Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void { $table->string('dataset_key')->primary(); $table->string('source_key'); $table->string('source_object'); $table->string('target_table'); $table->string('status')->default('pending'); $table->string('run_mode')->nullable(); $table->string('generation')->nullable(); $table->json('cursor')->nullable(); $table->json('high_watermark')->nullable(); $table->dateTime('window_started_at')->nullable(); $table->dateTime('window_ends_at')->nullable(); $table->dateTime('initial_completed_at')->nullable(); $table->dateTime('last_full_succeeded_at')->nullable(); $table->dateTime('last_attempted_at')->nullable(); $table->dateTime('last_succeeded_at')->nullable(); $table->unsignedBigInteger('last_upserted_rows')->default(0); $table->unsignedBigInteger('mirror_row_count')->default(0); $table->string('last_error_code')->nullable(); $table->timestamps(); }); $this->query = new FakeDingPayableWorkflowQuery(); $factory = new class($this->query) extends UpstreamReadOnlyQueryFactory { public function __construct(private readonly UpstreamReadOnlyQuery $query) {} public function forSource(string $source): UpstreamReadOnlyQuery { return $this->query; } }; $this->synchronizer = new DingPayableWorkflowMirrorSynchronizer($factory); } public function test_full_sync_resumes_with_update_time_and_process_id_tie_breaker(): void { $sameUpdateAt = '2026-08-29 10:00:00.1234567'; $this->query->workflowBatches = [[ $this->workflow('A-process', $sameUpdateAt), $this->workflow('B-process', $sameUpdateAt), ]]; $result = $this->synchronizer->sync(true, 2, 1); $this->assertSame('running', $result['status']); $this->assertStringContainsString('ORDER BY COALESCE([updateAt], [createAt]), [processInstanceId]', $this->query->calls[0]['sql']); $state = DB::connection('business_mirror')->table('business_mirror_table_states')->first(); $this->assertSame([ 'updateAt' => $sameUpdateAt, 'processInstanceId' => 'B-process', ], json_decode((string) $state->cursor, true)); $this->query->calls = []; $this->query->workflowBatches = [[ $this->workflow('C-process', $sameUpdateAt), ]]; $result = $this->synchronizer->sync(true, 2, 0); $this->assertSame('succeeded', $result['status']); $this->assertStringContainsString( 'COALESCE([updateAt], [createAt]) > ? OR (COALESCE([updateAt], [createAt]) = ? AND [processInstanceId] > ?', $this->query->calls[0]['sql'], ); $this->assertSame([$sameUpdateAt, $sameUpdateAt, 'B-process'], array_slice($this->query->calls[0]['bindings'], -3)); $this->assertSame(3, DB::connection('business_mirror')->table('ding_payable_workflow_mirror')->count()); } public function test_incremental_sync_uses_overlap_window_and_keeps_fixed_process_code(): void { $this->query->workflowBatches = [[ $this->workflow('existing-process', now()->subHour()->format('Y-m-d H:i:s.u')), ]]; $this->synchronizer->sync(true, 10, 0); $this->query->calls = []; $this->query->workflowBatches = [[ $this->workflow('new-process', now()->format('Y-m-d H:i:s.u'), 'RUNNING'), ]]; $result = $this->synchronizer->sync(false, 10, 0); $this->assertSame('succeeded', $result['status']); $sql = $this->query->calls[0]['sql']; $this->assertStringContainsString('[processCode] = ?', $sql); $this->assertStringContainsString('COALESCE([updateAt], [createAt]) >= ? AND COALESCE([updateAt], [createAt]) < ?', $sql); $this->assertStringContainsString('ORDER BY COALESCE([updateAt], [createAt]), [processInstanceId]', $sql); $this->assertCount(3, $this->query->calls[0]['bindings']); $this->assertSame('PROC-TEST', $this->query->calls[0]['bindings'][0]); $this->assertSame('RUNNING', json_decode( (string) DB::connection('business_mirror')->table('ding_payable_workflow_mirror')->where('process_instance_id', 'new-process')->value('workflow_payload'), true, )['status']); $state = DB::connection('business_mirror')->table('business_mirror_table_states')->first(); $this->assertArrayHasKey('updateAt', json_decode((string) $state->high_watermark, true)); } public function test_legacy_process_id_only_watermark_does_not_skip_new_processes(): void { $this->query->workflowBatches = [[ $this->workflow('existing-process', now()->subHour()->format('Y-m-d H:i:s.u')), ]]; $this->synchronizer->sync(true, 10, 0); DB::connection('business_mirror')->table('business_mirror_table_states')->update([ 'high_watermark' => json_encode(['processInstanceId' => 'z-last-process']), ]); $this->query->calls = []; $this->query->workflowBatches = [[ $this->workflow('a-new-process', now()->format('Y-m-d H:i:s.u')), ]]; $this->synchronizer->sync(false, 10, 0); $this->assertStringNotContainsString('AND [processInstanceId] > ?', $this->query->calls[0]['sql']); $this->assertSame(2, DB::connection('business_mirror')->table('ding_payable_workflow_mirror')->count()); } public function test_extended_workflow_update_removes_rows_that_no_longer_match(): void { config(['upstream_mirrors.ding_online_daifa_price' => [ 'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price', 'target_table' => 'ding_online_daifa_price_mirror', 'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST', 'extend_columns' => true, 'only_status' => 'COMPLETED', 'column_map' => ['product_id' => '商品ID'], ]]); $sync = $this->synchronizer->forConfigKey('ding_online_daifa_price'); $this->query->workflowBatches = [[$this->workflow('changed', now()->subHour()->format('Y-m-d H:i:s.u'))]]; $sync->sync(true, 10, 0); $table = DB::connection('business_mirror')->table('ding_online_daifa_price_mirror'); $this->assertSame(1, $table->count()); $this->query->workflowBatches = [[$this->workflow('changed', now()->format('Y-m-d H:i:s.u'), 'RUNNING')]]; $sync->sync(false, 10, 0); $this->assertSame(0, $table->count()); } public function test_daifa_repository_includes_end_date_and_counts_only_resolved_products(): void { config(['upstream_mirrors.ding_online_daifa_price' => [ 'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price', 'target_table' => 'ding_online_daifa_price_mirror', 'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST', 'extend_columns' => true, 'column_map' => ['product_id' => '商品ID'], ]]); $this->query->workflowBatches = [[ $this->workflow('A', '2026-08-01 00:00:00'), $this->workflow('B', '2026-08-01 23:59:59'), $this->workflow('C', '2026-08-02 00:00:00'), ]]; $this->synchronizer->forConfigKey('ding_online_daifa_price')->sync(true, 10, 0); $db = DB::connection('business_mirror'); $db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'A') ->update(['product_id' => 'fixture-a', 'product_name' => '已匹配示例']); $db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'B') ->update(['product_id' => 'fixture-b', 'product_name' => null]); $db->enableQueryLog(); $result = app(\Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository::class) ->paginate(['created_from' => '2026-08-01', 'created_to' => '2026-08-01'], 1, 25); $this->assertSame(2, $result['total']); $this->assertSame(1, $result['summary']['matched_count']); $this->assertSame(['B', 'A'], array_column($result['data'], 'process_instance_id')); $queries = array_filter($db->getQueryLog(), fn ($q) => str_contains($q['query'], 'workflow_created_at')); $this->assertContains('2026-08-02 00:00:00', array_values($queries)[0]['bindings']); } public function test_daifa_repository_filters_supplier_and_material_no_for_page_and_export(): void { config(['upstream_mirrors.ding_online_daifa_price' => [ 'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price', 'target_table' => 'ding_online_daifa_price_mirror', 'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST', 'extend_columns' => true, 'column_map' => ['product_id' => '商品ID'], ]]); $this->query->workflowBatches = [[ $this->workflow('A', '2026-08-01 10:00:00'), $this->workflow('B', '2026-08-01 11:00:00'), $this->workflow('C', '2026-08-01 12:00:00'), ]]; $this->synchronizer->forConfigKey('ding_online_daifa_price')->sync(true, 10, 0); $db = DB::connection('business_mirror'); $db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'A')->update([ 'supplier_name' => '供应商%测试', 'material_no' => 'MAT-001', ]); $db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'B')->update([ 'supplier_name' => '供应商X测试', 'material_no' => 'MAT-001', ]); $db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'C')->update([ 'supplier_name' => '供应商%测试', 'material_no' => 'MAT-002', ]); $filters = ['supplier_name' => '供应商%测试', 'material_no' => 'MAT-001']; $repository = app(\Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository::class); $page = $repository->paginate($filters, 1, 25); $this->assertSame(1, $page['total']); $this->assertSame(['A'], array_column($page['data'], 'process_instance_id')); $exported = iterator_to_array($repository->exportRows($filters)); $this->assertSame(['A'], array_column($exported, 'process_instance_id')); $this->assertSame('供应商%测试', $exported[0]['supplier_name']); $this->assertSame('MAT-001', $exported[0]['material_no']); } public function test_daifa_repository_pages_in_sql_and_labels_the_payee(): void { config(['upstream_mirrors.ding_online_daifa_price' => [ 'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price', 'target_table' => 'ding_online_daifa_price_mirror', 'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST', 'extend_columns' => true, 'only_status' => 'COMPLETED', 'column_map' => ['product_id' => '商品ID'], ]]); $this->query->workflowBatches = [[ $this->workflow('A', '2026-08-01 10:00:00'), $this->workflow('B', '2026-08-01 11:00:00'), $this->workflow('C', '2026-08-01 12:00:00'), ]]; $this->synchronizer->forConfigKey('ding_online_daifa_price')->sync(true, 10, 0); $db = DB::connection('business_mirror'); $db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'B')->update([ 'payee_name' => '宁波顾家望业家居有限公司', ]); $db->flushQueryLog(); $db->enableQueryLog(); $result = app(\Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository::class) ->paginate([], 2, 1); $this->assertSame(3, $result['total']); $this->assertSame(3, $result['summary']['count']); $this->assertSame(['B'], array_column($result['data'], 'process_instance_id')); $this->assertSame('刘萧代发', $result['data'][0]['tag']); $this->assertTrue(collect($db->getQueryLog())->contains( static fn (array $query): bool => str_contains($query['query'], 'limit 1 offset 1') )); } /** @return array */ private function workflow(string $id, string $updateAt, string $status = 'COMPLETED'): array { return [ 'processInstanceId' => $id, 'processCode' => 'PROC-TEST', 'updateAt' => $updateAt, 'createAt' => $updateAt, 'status' => $status, 'originatorUserId' => '', ]; } } class FakeDingPayableWorkflowQuery extends UpstreamReadOnlyQuery { /** @var list>> */ public array $workflowBatches = []; /** @var list */ public array $calls = []; public function __construct() { parent::__construct('operations_replica'); } public function select(string $sql, array $bindings = []): array { $this->calls[] = compact('sql', 'bindings'); if (str_contains($sql, 'FROM [rpt].[ding_Workflow]')) { return array_shift($this->workflowBatches) ?? []; } return []; } }