[ '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->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); $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')); } /** @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('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 = []; /** @var list */ public array $calls = []; public function __construct() { parent::__construct('operations_replica'); } public function scalar(string $sql, array $bindings = []): mixed { 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, 'JOIN rpt.ding_WorkflowScheme') && str_contains($sql, 'NOT IN')) { return []; } if (str_contains($sql, 'FROM rpt.ding_Workflow w')) { return $this->workflows; } if (str_contains($sql, 'FROM rpt.ding_WorkflowScheme')) { $rows = []; foreach ($this->scheme as $name => $value) { $rows[] = [ 'processInstanceId' => (string) ($this->workflows[0]['processInstanceId'] ?? ''), 'name' => $name, 'value' => $value, ]; } return $rows; } return []; } }