[ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', ], 'upstream_mirrors.connection' => 'business_mirror', 'upstream_mirrors.ding_process_details' => [ 'scope' => 'personal_permission', 'start_date' => '2026-01-01', 'batch_size' => 20, 'sync_interval_minutes' => 5, 'incremental_overlap_hours' => 48, 'processes' => [ 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF' => [ 'document_type' => '线上代发业务报销', 'detail_table' => '收款费用明细', 'detail_amount_fields' => ['代发订单金额'], 'amount_total_fields' => ['收款合计'], 'payment_table' => '付款明细', ], ], ], ]); DB::purge('business_mirror'); $this->createMirrorTables(); $this->query = new FakeDingProcessDetailQuery(); $factory = new class($this->query) extends UpstreamReadOnlyQueryFactory { public function __construct(private readonly UpstreamReadOnlyQuery $query) {} public function forSource(string $source): UpstreamReadOnlyQuery { return $this->query; } public function forSourceWithTimeout(string $source, int $timeoutSeconds): UpstreamReadOnlyQuery { return $this->query; } }; $this->app->instance(UpstreamReadOnlyQueryFactory::class, $factory); $this->approvalClient = new FakeDingTalkProcessInstanceClient(); $this->app->instance(DingTalkProcessInstanceClient::class, $this->approvalClient); $this->synchronizer = app(DingProcessDetailMirrorSynchronizer::class); } public function test_structured_sync_counts_a_multi_line_payment_once(): void { $this->query->workflows = [[ 'processInstanceId' => 'process-daifa-1', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202605041811000260216', 'title' => '陈芝豹提交的线上代发业务报销', 'status' => 'COMPLETED', 'originatorUserId' => 'user-1', 'joinAt' => '2026-05-04 18:12:20', 'updateAt' => '2026-05-08 09:00:00', ]]; $this->query->scheme = $this->daifaScheme(); $result = $this->synchronizer->sync(true); $this->assertSame('succeeded', $result['status']); $this->assertSame(1, $result['workflows']); $this->assertSame(14, $result['details']); $this->assertSame(1, $result['payments']); $this->assertEqualsWithDelta(1114.57, $result['amount_total'], 0.001); $this->assertEqualsWithDelta(1114.57, $result['bank_amount_total'], 0.001); $this->assertSame(14, DB::connection('business_mirror') ->table('ding_process_detail_mirror')->count()); $this->assertSame(1, DB::connection('business_mirror') ->table('ding_process_payment_mirror')->count()); $this->assertEqualsWithDelta(1114.57, (float) DB::connection('business_mirror') ->table('ding_process_detail_mirror')->sum('bank_amount'), 0.001); $this->assertSame('present', DB::connection('business_mirror') ->table('ding_process_detail_mirror')->value('payment_source_state')); $summary = app(DingProcessDetailMirrorRepository::class) ->paginate(['status' => '已完成'], 1, 50)['summary']; $this->assertEqualsWithDelta(1114.57, $summary['amount_total'], 0.001); $this->assertEqualsWithDelta(1114.57, $summary['bank_amount_total'], 0.001); } public function test_validation_failure_keeps_the_previous_published_rows(): void { $this->query->workflows = [[ 'processInstanceId' => 'process-old', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202605041811000260216', 'title' => '陈芝豹提交的线上代发业务报销', 'status' => 'COMPLETED', 'originatorUserId' => 'user-1', 'joinAt' => '2026-05-04 18:12:20', 'updateAt' => '2026-05-08 09:00:00', ]]; $this->query->scheme = $this->daifaScheme(); $this->synchronizer->sync(true); $this->query->scheme['收款合计'] = 999999; $this->query->workflows = [[ 'processInstanceId' => 'process-new', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202605051811000260217', 'title' => '陈芝豹提交的线上代发业务报销', 'status' => 'COMPLETED', 'originatorUserId' => 'user-1', 'joinAt' => '2026-05-05 18:12:20', 'updateAt' => '2026-05-09 09:00:00', ]]; try { $this->synchronizer->sync(true); $this->fail('明细合计与审批合计不一致时应停止发布。'); } catch (RuntimeException $exception) { $this->assertStringContainsString('明细合计', $exception->getMessage()); } $this->assertSame(14, DB::connection('business_mirror') ->table('ding_process_detail_mirror')->count()); $this->assertTrue(DB::connection('business_mirror') ->table('ding_process_detail_mirror') ->where('business_id', '202605041811000260216') ->exists()); $this->assertSame('failed', DB::connection('business_mirror') ->table('business_mirror_table_states') ->value('status')); } public function test_empty_workflow_stays_visible_with_a_null_amount(): void { $this->query->workflows = [[ 'processInstanceId' => 'process-empty', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202610081714000311523', 'title' => '陈芝豹提交的线上代发业务报销', 'status' => 'RUNNING', 'originatorUserId' => 'user-1', 'joinAt' => '2026-10-08 17:15:33', 'updateAt' => '2026-10-08 17:15:33', ]]; $this->query->scheme = []; $result = $this->synchronizer->sync(true); $this->assertSame(1, $result['workflows']); $this->assertSame(1, $result['details']); $this->assertSame(0, $result['payments']); $row = DB::connection('business_mirror')->table('ding_process_detail_mirror')->first(); $this->assertNotNull($row); $this->assertNull($row->amount); $this->assertSame('审批中', $row->status); $this->assertSame('missing', $row->payment_source_state); $page = app(DingProcessDetailMirrorRepository::class)->paginate( ['payment_state' => '付款明细待同步'], 1, 50, ); $this->assertSame(1, $page['total']); } public function test_incremental_sync_detects_late_payment_rows_without_workflow_update(): void { $workflow = [ 'processInstanceId' => 'process-late-payment', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202610151743000310225', 'title' => '李斯提交的线上代发业务报销', 'status' => 'RUNNING', 'originatorUserId' => 'user-1', 'joinAt' => '2026-10-15 17:44:06', 'updateAt' => '2026-10-15 17:44:06', ]; $this->query->workflows = [$workflow]; $this->query->schemeHighWatermark = 100; $this->query->scheme = [ '收款费用明细' => json_encode([[ 'rowValue' => [ ['label' => '代发订单金额', 'value' => '15535.37'], ['label' => '科目编码', 'value' => '5601.05.03'], ['label' => '科目名称', 'value' => '销售费用_营销费用_软件服务费'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '收款合计' => 15535.37, ]; $this->synchronizer->sync(true); $this->assertSame('missing', DB::connection('business_mirror') ->table('ding_process_detail_mirror')->value('payment_source_state')); DB::connection('business_mirror') ->table('business_mirror_table_states') ->where('dataset_key', 'ding.process_details.personal_permission') ->update(['last_succeeded_at' => now()->subMinutes(10)]); $this->query->workflows = []; $this->query->schemeChangedWorkflows = [$workflow]; $this->query->schemeChangedProcessIds = ['process-late-payment']; $this->query->schemeHighWatermark = 101; $this->query->scheme['付款明细'] = json_encode([[ 'rowValue' => [ ['label' => '对应序号', 'value' => '1'], ['label' => '付款方式', 'value' => '银行卡'], ['label' => '付款金额', 'value' => '15535.37'], ['label' => '科目编码', 'value' => '1002.01.544'], ['label' => '科目名称', 'value' => '华夏银行-福尼达'], ['label' => '打款时间', 'value' => '2026-10-18'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); $result = $this->synchronizer->sync(false); $this->assertSame(1, $result['workflows']); $this->assertSame(1, $result['payments']); $this->assertSame('present', DB::connection('business_mirror') ->table('ding_process_detail_mirror')->value('payment_source_state')); $this->assertSame(1, DB::connection('business_mirror') ->table('ding_process_payment_mirror')->count()); $state = DB::connection('business_mirror') ->table('business_mirror_table_states') ->first(); $this->assertSame(101, (int) json_decode((string) $state->cursor, true)['scheme_high_watermark']); } public function test_official_api_fills_payment_when_replica_omits_payment_table(): void { $workflow = [ 'processInstanceId' => 'process-api-payment', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202610151743000310225', 'title' => '李斯提交的线上代发业务报销', 'status' => 'RUNNING', 'originatorUserId' => 'user-1', 'joinAt' => '2026-10-15 17:44:06', 'updateAt' => '2026-10-15 17:44:06', ]; $this->query->workflows = [$workflow]; $this->query->scheme = [ '收款费用明细' => json_encode([[ 'rowValue' => [ ['label' => '代发订单金额', 'value' => '15535.37'], ['label' => '科目编码', 'value' => '5601.05.03'], ['label' => '科目名称', 'value' => '销售费用_营销费用_软件服务费'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '收款合计' => 15535.37, ]; $this->approvalClient->valuesByInstance['process-api-payment'] = [ '付款明细' => json_encode([[ 'rowValue' => [ ['label' => '对应序号', 'value' => '1'], ['label' => '付款方式', 'value' => '银行卡'], ['label' => '付款金额', 'value' => '15535.37'], ['label' => '科目编码', 'value' => '1002.01.544'], ['label' => '科目名称', 'value' => '华夏银行-福尼达'], ['label' => '打款时间', 'value' => '2026-10-18 00:00'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '付款金额合计' => 15535.37, ]; $result = $this->synchronizer->sync(true); $this->assertSame(1, $result['payments']); $row = DB::connection('business_mirror')->table('ding_process_detail_mirror')->first(); $this->assertSame('api', $row->payment_source_state); $this->assertSame('银行卡', $row->payment_method); $this->assertSame('2026-10-18 00:00', $row->paid_at); $this->assertSame(['process-api-payment'], $this->approvalClient->calls); } public function test_incremental_sync_rotates_missing_payments_through_official_api(): void { $workflow = [ 'processInstanceId' => 'process-api-queue', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202610151743000310226', 'title' => '李斯提交的线上代发业务报销', 'status' => 'RUNNING', 'originatorUserId' => 'user-1', 'joinAt' => '2026-10-15 17:44:06', 'updateAt' => '2026-10-15 17:44:06', ]; $this->query->workflows = [$workflow]; $this->query->scheme = [ '收款费用明细' => json_encode([[ 'rowValue' => [['label' => '代发订单金额', 'value' => '100.00']], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '收款合计' => 100, ]; $this->synchronizer->sync(true); $this->assertSame('missing', DB::connection('business_mirror') ->table('ding_process_detail_mirror')->value('payment_source_state')); DB::connection('business_mirror') ->table('business_mirror_table_states') ->where('dataset_key', 'ding.process_details.personal_permission') ->update(['last_succeeded_at' => now()->subMinutes(10)]); $this->query->workflows = []; $this->query->schemeChangedWorkflows = [$workflow]; $this->approvalClient->valuesByInstance['process-api-queue'] = [ '付款明细' => json_encode([[ 'rowValue' => [ ['label' => '对应序号', 'value' => '1'], ['label' => '付款方式', 'value' => '银行卡'], ['label' => '付款金额', 'value' => '100.00'], ['label' => '科目编码', 'value' => '1002.01.544'], ['label' => '打款时间', 'value' => '2026-10-18 00:00'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '付款金额合计' => 100, ]; $result = $this->synchronizer->sync(false); $this->assertSame(1, $result['payments']); $this->assertSame('api', DB::connection('business_mirror') ->table('ding_process_detail_mirror')->value('payment_source_state')); $state = DB::connection('business_mirror') ->table('business_mirror_table_states') ->first(); $this->assertSame( 'process-api-queue', json_decode((string) $state->cursor, true)['approval_api_cursor'], ); } public function test_business_id_dry_run_uses_official_api_without_publishing(): void { $workflow = [ 'processInstanceId' => 'process-target-api', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202610151743000310227', 'title' => '李斯提交的线上代发业务报销', 'status' => 'RUNNING', 'originatorUserId' => 'user-1', 'joinAt' => '2026-10-15 17:44:06', 'updateAt' => '2026-10-15 17:44:06', ]; $this->query->workflows = [$workflow]; $this->query->scheme = [ '收款费用明细' => json_encode([[ 'rowValue' => [['label' => '代发订单金额', 'value' => '100.00']], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '收款合计' => 100, ]; $this->synchronizer->sync(true); $this->approvalClient->valuesByInstance['process-target-api'] = [ '付款明细' => json_encode([[ 'rowValue' => [ ['label' => '对应序号', 'value' => '1'], ['label' => '付款金额', 'value' => '100.00'], ['label' => '付款方式', 'value' => '银行卡'], ['label' => '打款时间', 'value' => '2026-10-18 00:00'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '付款金额合计' => 100, ]; $result = $this->synchronizer->syncBusinessIds( ['202610151743000310227'], true, ); $this->assertSame('validated', $result['status']); $this->assertSame(1, $result['payments']); $this->assertSame('missing', DB::connection('business_mirror') ->table('ding_process_detail_mirror')->value('payment_source_state')); $this->assertSame(0, DB::connection('business_mirror') ->table('ding_process_payment_mirror')->count()); } public function test_business_id_sync_preserves_incremental_watermarks(): void { $workflow = [ 'processInstanceId' => 'process-preserve-cursors', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202610151743000310228', 'title' => '李斯提交的线上代发业务报销', 'status' => 'RUNNING', 'originatorUserId' => 'user-1', 'joinAt' => '2026-10-15 17:44:06', 'updateAt' => '2026-10-15 17:44:06', ]; $this->query->workflows = [$workflow]; $this->query->scheme = [ '收款费用明细' => json_encode([[ 'rowValue' => [['label' => '代发订单金额', 'value' => '100.00']], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '收款合计' => 100, '付款明细' => '[]', ]; $this->query->schemeHighWatermark = 500; $this->synchronizer->sync(true); $state = DB::connection('business_mirror') ->table('business_mirror_table_states') ->first(); $cursor = json_decode((string) $state->cursor, true); $cursor['scheme_high_watermark'] = 500; $cursor['approval_api_cursor'] = 'api-cursor-500'; DB::connection('business_mirror') ->table('business_mirror_table_states') ->update(['cursor' => json_encode($cursor, JSON_UNESCAPED_UNICODE)]); $this->synchronizer->syncBusinessIds(['202610151743000310228']); $state = DB::connection('business_mirror') ->table('business_mirror_table_states') ->first(); $cursor = json_decode((string) $state->cursor, true); $this->assertSame(500, (int) $cursor['scheme_high_watermark']); $this->assertSame('api-cursor-500', $cursor['approval_api_cursor']); } /** @return array */ private function daifaScheme(): array { $amounts = [157, 153, 56.1, 63.1, 85, 90, 56.1, 63.1, 66.1, 61.67, 66.1, 65.1, 66.1, 66.1]; $details = array_map(static fn (float $amount): array => [ 'rowValue' => [ ['label' => '序号', 'value' => '1'], ['label' => '商品ID', 'value' => '877714260849'], ['label' => '代发订单金额', 'value' => (string) $amount], ['label' => '科目编码', 'value' => '1405.06'], ['label' => '科目名称', 'value' => '库存商品_线上代发'], ], ], $amounts); $payment = [[ 'rowValue' => [ ['label' => '对应序号', 'value' => '1-2-3-4-5-6-7-8-9-10-11-12-13-14'], ['label' => '付款方式', 'value' => '银行卡'], ['label' => '付款金额', 'value' => '1114.57'], ['label' => '科目编码', 'value' => '1002.02.114'], ['label' => '科目名称', 'value' => '华夏银行-胡燕'], ['label' => '打款时间', 'value' => '2026-05-08'], ], ]]; return [ '收款费用明细' => json_encode($details, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '收款合计' => 1114.57, '付款明细' => json_encode($payment, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), ]; } private function createMirrorTables(): void { 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(); }, ); Schema::connection('business_mirror')->create( 'ding_process_detail_mirror', function (Blueprint $table): void { $table->bigIncrements('id'); $table->string('business_id', 32); $table->string('process_instance_id', 100)->nullable(); $table->unsignedInteger('line_no'); $table->string('business_month', 6); $table->string('originator_name', 100)->nullable(); $table->string('status', 20)->nullable(); $table->string('payment_source_state', 24)->nullable(); $table->string('document_type', 100)->nullable(); $table->text('purpose')->nullable(); $table->string('account_code', 64)->nullable(); $table->string('account_name', 255)->nullable(); $table->decimal('amount', 18, 2)->nullable(); $table->string('bank_account_code', 255)->nullable(); $table->string('bank_account_name', 255)->nullable(); $table->decimal('bank_amount', 18, 2)->nullable(); $table->string('payment_method', 50)->nullable(); $table->string('paid_at', 50)->nullable(); $table->string('source_scope', 40); $table->dateTime('synced_at', 6); $table->timestamps(); $table->unique(['business_id', 'line_no']); }, ); Schema::connection('business_mirror')->create( 'ding_process_payment_mirror', function (Blueprint $table): void { $table->bigIncrements('id'); $table->string('business_id', 32); $table->string('process_instance_id', 100); $table->unsignedInteger('payment_no'); $table->string('sequence_spec', 255)->nullable(); $table->decimal('amount', 18, 2); $table->string('account_code', 64)->nullable(); $table->string('account_name', 255)->nullable(); $table->string('payment_method', 50)->nullable(); $table->string('paid_at', 50)->nullable(); $table->string('source_scope', 40); $table->dateTime('synced_at', 6); $table->timestamps(); $table->unique(['source_scope', 'business_id', 'payment_no']); }, ); } } class FakeDingProcessDetailQuery extends UpstreamReadOnlyQuery { /** @var list> */ public array $workflows = []; /** @var array */ public array $scheme = []; public array $schemeChangedWorkflows = []; /** @var list */ public array $schemeChangedProcessIds = []; public int $schemeHighWatermark = 0; /** @var list */ public array $calls = []; public function __construct() { parent::__construct('operations_replica'); } public function scalar(string $sql, array $bindings = []): mixed { if (str_contains($sql, 'MAX(TRY_CONVERT(bigint, id))')) { return $this->schemeHighWatermark; } if (str_contains($sql, 'COUNT(*)') && str_contains($sql, 'rpt.ding_Workflow')) { return count($this->workflows); } return parent::scalar($sql, $bindings); } public function select(string $sql, array $bindings = []): array { $this->calls[] = compact('sql', 'bindings'); if (str_contains($sql, 'FROM rpt.hr_employee')) { return [['userid' => 'user-1', 'name' => '陈芝豹']]; } if (str_contains($sql, 'SELECT DISTINCT s.processInstanceId')) { return array_map( static fn (string $id): array => ['processInstanceId' => $id], $this->schemeChangedProcessIds, ); } if (str_contains($sql, 'JOIN rpt.ding_WorkflowScheme') && str_contains($sql, 'NOT IN')) { return []; } if (str_contains($sql, 'FROM rpt.ding_Workflow w') && str_contains($sql, 'w.processInstanceId IN')) { return $this->schemeChangedWorkflows; } if (str_contains($sql, 'FROM rpt.ding_Workflow w')) { return $this->workflows; } if (str_contains($sql, 'FROM rpt.ding_WorkflowScheme')) { $rows = []; $processInstanceId = (string) ( $this->workflows[0]['processInstanceId'] ?? $this->schemeChangedWorkflows[0]['processInstanceId'] ?? '' ); foreach ($this->scheme as $name => $value) { $rows[] = [ 'processInstanceId' => $processInstanceId, 'name' => $name, 'value' => $value, ]; } return $rows; } return []; } } class FakeDingTalkProcessInstanceClient extends DingTalkProcessInstanceClient { public bool $configured = true; /** @var array> */ public array $valuesByInstance = []; /** @var list */ public array $calls = []; public function isConfigured(): bool { return $this->configured; } public function formValues(string $processInstanceId): array { $this->calls[] = $processInstanceId; return $this->valuesByInstance[$processInstanceId] ?? []; } }