[ '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_completed_workflow_uses_cashier_status_and_keeps_difference_separate(): void { $this->query->workflows = [[ 'processInstanceId' => 'process-cashier-completed', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202606031318000506330', 'title' => '苏木提交的线上代发业务报销', 'status' => 'COMPLETED', 'result' => 'AGREE', 'originatorUserId' => 'user-1', 'joinAt' => '2026-06-03 13:18:00', ]]; $this->query->scheme = [ '收款费用明细' => json_encode([[ 'rowValue' => [ ['label' => '代发订单金额', 'value' => '100.00'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '收款合计' => 100, '付款明细' => json_encode([[ 'rowValue' => [ ['label' => '对应序号', 'value' => '1'], ['label' => '付款金额', 'value' => '99.80'], ['label' => '付款方式', 'value' => '银行卡'], ['label' => '打款时间', 'value' => '2026-06-03 00:00'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), ]; $this->synchronizer->sync(true); $row = app(DingProcessDetailMirrorRepository::class) ->paginate(['business_id' => '202606031318000506330'], 1, 50)['data'][0]; $this->assertSame('completed', $row->cashier_status); $this->assertSame('已付款', $row->payment_state); $this->assertEqualsWithDelta(0.20, (float) $row->payment_difference, 0.001); } public function test_completed_but_refused_workflow_is_not_labelled_completed(): void { $this->query->workflows = [[ 'processInstanceId' => 'process-cashier-refused', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202610261048000410425', 'title' => '勇武提交的店铺日常报销', 'status' => 'COMPLETED', 'result' => 'REFUSE', 'originatorUserId' => 'user-1', 'joinAt' => '2026-10-26 10:49:07', ]]; $this->query->scheme = [ '收款费用明细' => json_encode([[ 'rowValue' => [ ['label' => '代发订单金额', 'value' => '1.00'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '收款合计' => 1, '付款明细' => '[]', ]; $this->synchronizer->sync(true); $row = app(DingProcessDetailMirrorRepository::class) ->paginate(['business_id' => '202610261048000410425'], 1, 50)['data'][0]; $this->assertSame('已拒绝', $row->status); $this->assertSame('terminated', $row->cashier_status); $this->assertSame('不涉及付款', $row->payment_state); } public function test_terminated_workflow_is_labelled_revoked(): void { $this->query->workflows = [[ 'processInstanceId' => 'process-cashier-terminated', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202610191640000251028', 'title' => '雁兰提交的应付申请流程', 'status' => 'TERMINATED', 'result' => 'AGREE', 'originatorUserId' => 'user-1', 'joinAt' => '2026-10-19 16:40:00', ]]; $this->query->scheme = [ '收款费用明细' => json_encode([[ 'rowValue' => [ ['label' => '代发订单金额', 'value' => '1.00'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '收款合计' => 1, '付款明细' => '[]', ]; $this->synchronizer->sync(true); $row = app(DingProcessDetailMirrorRepository::class) ->paginate(['business_id' => '202610191640000251028'], 1, 50)['data'][0]; $this->assertSame('已撤销', $row->status); $this->assertSame('不涉及付款', $row->payment_state); } public function test_running_workflow_detects_cashier_returned_from_official_tasks(): void { $this->query->workflows = [[ 'processInstanceId' => 'process-cashier-returned', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202610221534000124202', 'title' => '武松提交的线上代发业务报销', 'status' => 'RUNNING', 'result' => 'NONE', 'originatorUserId' => 'user-1', 'joinAt' => '2026-10-22 15:34:00', ]]; $this->query->scheme = [ '收款费用明细' => json_encode([[ 'rowValue' => [ ['label' => '代发订单金额', 'value' => '750.00'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '收款合计' => 750, '付款明细' => '[]', ]; $this->approvalClient->cashierStatusByInstance['process-cashier-returned'] = 'returned'; $this->synchronizer->sync(true); $row = app(DingProcessDetailMirrorRepository::class) ->paginate(['business_id' => '202610221534000124202'], 1, 50)['data'][0]; $this->assertSame('returned', $row->cashier_status); $this->assertSame('待付款', $row->payment_state); $this->assertEqualsWithDelta(750.0, (float) $row->payment_difference, 0.001); } public function test_running_workflow_stores_current_and_cashier_task_times(): void { $this->query->workflows = [[ 'processInstanceId' => 'process-task-state', 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => '202610291045000346211', 'title' => '苏木提交的种菜核销种菜报销流程', 'status' => 'RUNNING', 'result' => 'NONE', 'originatorUserId' => 'user-1', 'joinAt' => '2026-10-29 10:45:00', ]]; $this->query->scheme = [ '收款费用明细' => json_encode([[ 'rowValue' => [ ['label' => '代发订单金额', 'value' => '32622.59'], ['label' => '科目编码', 'value' => '1221.01'], ['label' => '科目名称', 'value' => '其他应收款_运营中心-种菜刷单'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '收款合计' => 32622.59, '付款明细' => '[]', ]; $this->approvalClient->cashierStatusByInstance['process-task-state'] = 'pending'; $this->synchronizer->sync(true); $row = DB::connection('business_mirror') ->table('ding_process_detail_mirror') ->where('business_id', '202610291045000346211') ->first(); $this->assertSame('出纳打款', $row->current_task_name); $this->assertSame('2026-06-03 15:34:00', (string) $row->current_task_started_at); $this->assertSame('出纳打款', $row->cashier_task_name); $this->assertSame('2026-06-03 15:34:00', (string) $row->cashier_task_started_at); $this->assertSame('pending', $row->cashier_status); $this->assertSame( '待付款', app(DingProcessDetailMirrorRepository::class) ->paginate(['business_id' => '202610291045000346211'], 1, 50)['data'][0] ->payment_state, ); } 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); $this->assertSame('not_started', $row->cashier_status); $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']); $this->assertFalse(collect($this->query->calls)->contains( static fn (array $call): bool => str_contains( (string) ($call['sql'] ?? ''), 'ORDER BY w.processInstanceId, w.processInstanceId', ), )); } 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_incremental_sync_reserves_api_quota_for_cashier_backlog(): void { $connection = DB::connection('business_mirror'); $now = now(); $backlog = []; $bulk = []; for ($index = 0; $index < 30; $index++) { $processInstanceId = sprintf('backlog-%03d', $index); $businessId = sprintf('20261022%09d', 100000000 + $index); $backlog[] = $this->runningWorkflow($processInstanceId, $businessId); $this->approvalClient->cashierStatusByInstance[$processInstanceId] = 'completed'; $connection->table('ding_process_detail_mirror')->insert([ 'business_id' => $businessId, 'process_instance_id' => $processInstanceId, 'line_no' => 1, 'business_month' => '202610', 'originator_name' => '李斯', 'status' => '审批中', 'payment_source_state' => 'present', 'cashier_status' => 'pending_sync', 'cashier_checked_at' => null, 'document_type' => '公摊借支-销账', 'purpose' => 'AI TOKEN费用', 'account_code' => '1221.04', 'account_name' => '其他应收款_借支销账', 'amount' => 100, 'source_scope' => 'personal_permission', 'synced_at' => $now->copy()->subHour(), 'created_at' => $now->copy()->subDay(), 'updated_at' => $now->copy()->subHour(), ]); } for ($index = 0; $index < 60; $index++) { $processInstanceId = sprintf('bulk-%03d', $index); $businessId = sprintf('202611%015d', $index); $bulk[] = $this->runningWorkflow($processInstanceId, $businessId); } $this->query->workflows = $bulk; $this->query->workflowsById = array_column( [...$backlog, ...$bulk], null, 'processInstanceId', ); $this->query->schemeHighWatermark = 100; $this->query->scheme = [ '收款费用明细' => json_encode([[ 'rowValue' => [ ['label' => '代发订单金额', 'value' => '100.00'], ['label' => '科目编码', 'value' => '1221.04'], ['label' => '科目名称', 'value' => '其他应收款_借支销账'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), '收款合计' => 100, '付款明细' => json_encode([[ 'rowValue' => [ ['label' => '对应序号', 'value' => '1'], ['label' => '付款金额', 'value' => '100.00'], ['label' => '付款方式', 'value' => '银行卡'], ['label' => '打款时间', 'value' => '2026-10-23'], ], ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), ]; $connection->table('business_mirror_table_states')->insert([ 'dataset_key' => 'ding.process_details.personal_permission', 'source_key' => 'operations_replica', 'source_object' => 'test', 'target_table' => 'ding_process_detail_mirror', 'status' => 'succeeded', 'run_mode' => 'incremental', 'generation' => 'test', 'cursor' => json_encode([ 'source' => 'structured', 'scheme_high_watermark' => 100, 'approval_api_cursor' => '', ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'initial_completed_at' => $now->copy()->subDay(), 'last_full_succeeded_at' => $now->copy()->subDay(), 'last_attempted_at' => $now->copy()->subMinutes(10), 'last_succeeded_at' => $now->copy()->subMinutes(10), 'last_upserted_rows' => 0, 'mirror_row_count' => 30, 'created_at' => $now->copy()->subDay(), 'updated_at' => $now->copy()->subMinutes(10), ]); $this->synchronizer->sync(false); $expectedBacklog = array_map( static fn (array $workflow): string => (string) $workflow['processInstanceId'], array_slice($backlog, 0, 25), ); $this->assertCount(50, $this->approvalClient->calls); $this->assertSame($expectedBacklog, array_slice($this->approvalClient->calls, 0, 25)); $this->assertSame( 'backlog-024', json_decode((string) $connection->table('business_mirror_table_states') ->value('cursor'), true)['approval_api_cursor'], ); $this->assertSame('completed', $connection->table('ding_process_detail_mirror') ->where('process_instance_id', 'backlog-024') ->value('cashier_status')); $this->assertNotNull($connection->table('ding_process_detail_mirror') ->where('process_instance_id', 'backlog-024') ->value('cashier_checked_at')); } 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), ]; } /** @return array */ private function runningWorkflow(string $processInstanceId, string $businessId): array { return [ 'processInstanceId' => $processInstanceId, 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', 'businessId' => $businessId, 'title' => '李斯提交的公摊借支-销账', 'status' => 'RUNNING', 'originatorUserId' => 'user-1', 'joinAt' => '2026-10-22 17:33:00', 'updateAt' => '2026-10-28 17:00:00', ]; } 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('cashier_status', 32)->nullable(); $table->dateTime('cashier_checked_at')->nullable(); $table->string('current_task_name', 255)->nullable(); $table->dateTime('current_task_started_at')->nullable(); $table->string('cashier_task_name', 100)->nullable(); $table->dateTime('cashier_task_started_at')->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 array> */ public array $workflowsById = []; /** @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')) { $wanted = array_fill_keys(array_map('strval', $bindings), true); $rows = []; $candidates = [ ...$this->workflows, ...$this->schemeChangedWorkflows, ...array_values($this->workflowsById), ]; foreach ($candidates as $workflow) { $processInstanceId = (string) ($workflow['processInstanceId'] ?? ''); if ($processInstanceId !== '' && isset($wanted[$processInstanceId])) { $rows[$processInstanceId] = $workflow; } } return array_values($rows); } if (str_contains($sql, 'FROM rpt.ding_Workflow w')) { return $this->workflows; } if (str_contains($sql, 'FROM rpt.ding_WorkflowScheme')) { $rows = []; $requested = array_fill_keys(array_map('strval', $bindings), true); $known = []; $candidates = [ ...$this->workflows, ...$this->schemeChangedWorkflows, ...array_values($this->workflowsById), ]; foreach ($candidates as $workflow) { $processInstanceId = (string) ($workflow['processInstanceId'] ?? ''); if ($processInstanceId !== '' && isset($requested[$processInstanceId])) { $known[$processInstanceId] = true; } } if ($known === []) { $fallback = ''; if ($this->workflows !== []) { $fallback = (string) ($this->workflows[0]['processInstanceId'] ?? ''); } elseif ($this->schemeChangedWorkflows !== []) { $fallback = (string) ($this->schemeChangedWorkflows[0]['processInstanceId'] ?? ''); } elseif ($this->workflowsById !== []) { $fallbackWorkflow = $this->workflowsById[array_key_first($this->workflowsById)]; $fallback = (string) ($fallbackWorkflow['processInstanceId'] ?? ''); } if ($fallback !== '') { $known[$fallback] = true; } } foreach (array_keys($known) as $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 array */ public array $cashierStatusByInstance = []; /** @var list */ public array $calls = []; public function isConfigured(): bool { return $this->configured; } public function formValues(string $processInstanceId): array { return $this->processSnapshot($processInstanceId)['form_values']; } public function processSnapshot(string $processInstanceId): array { $this->calls[] = $processInstanceId; $cashierStatus = $this->cashierStatusByInstance[$processInstanceId] ?? 'not_started'; $tasks = []; $operations = []; if ($cashierStatus === 'completed') { $tasks[] = [ 'taskGroupName' => '出纳打款', 'status' => 'COMPLETED', 'result' => 'AGREE', 'createTime' => '2026-06-03T15:34Z', 'finishTime' => '2026-06-03T16:42Z', ]; } elseif ($cashierStatus === 'pending') { $tasks[] = [ 'taskGroupName' => '出纳打款', 'status' => 'RUNNING', 'result' => 'NONE', 'createTime' => '2026-06-03T15:34Z', 'finishTime' => '', ]; } elseif ($cashierStatus === 'returned') { $operations[] = [ 'showName' => '出纳打款', 'type' => 'REDIRECT_PROCESS', 'result' => 'NONE', 'date' => '2026-06-03T16:55Z', ]; } return [ 'form_values' => $this->valuesByInstance[$processInstanceId] ?? [], 'tasks' => $tasks, 'operation_records' => $operations, 'status' => null, 'result' => null, ]; } }