*/ private array $tempFiles = []; protected function setUp(): void { parent::setUp(); config([ 'database.connections.business_mirror' => [ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', ], 'upstream_mirrors.connection' => 'business_mirror', 'upstream_mirrors.ding_process_details.scope' => 'personal_permission', ]); DB::purge('business_mirror'); $this->createMirrorTables(); $this->fixture = base_path('tests/Fixtures/ding_process_details_sample.csv'); } protected function tearDown(): void { foreach ($this->tempFiles as $file) { if (is_file($file)) { unlink($file); } } parent::tearDown(); } public function test_imports_csv_and_publishes_mirror_state(): void { $result = app(DingProcessDetailMirrorImporter::class) ->import($this->fixture, 'personal_permission', true); $this->assertSame('succeeded', $result['status']); $this->assertSame(4, $result['rows']); $this->assertSame(0, $result['invalid']); $this->assertSame(0, $result['placeholders']); $this->assertSame(4, DB::connection('business_mirror') ->table('ding_process_detail_mirror')->count()); $row = DB::connection('business_mirror') ->table('ding_process_detail_mirror') ->where('business_id', '202601011200000000001') ->where('line_no', 1) ->first(); $this->assertNotNull($row); $this->assertSame('张三', $row->originator_name); $this->assertSame('已完成', $row->status); $this->assertSame('公摊日常报销', $row->document_type); $this->assertSame('5602.04', $row->account_code); $this->assertSame('管理费用_办公费', $row->account_name); $this->assertEqualsWithDelta(100.0, (float) $row->amount, 0.001); $this->assertSame('202601', $row->business_month); $this->assertSame('personal_permission', $row->source_scope); $state = DB::connection('business_mirror') ->table('business_mirror_table_states') ->where('dataset_key', 'ding.process_details.personal_permission') ->first(); $this->assertNotNull($state); $this->assertSame('succeeded', $state->status); $this->assertSame('ding_process_detail_mirror', $state->target_table); $this->assertNotNull($state->initial_completed_at); $this->assertSame(4, (int) $state->mirror_row_count); } public function test_import_is_idempotent_by_business_id_and_line_number(): void { $importer = app(DingProcessDetailMirrorImporter::class); $importer->import($this->fixture, 'personal_permission', true); $importer->import($this->fixture, 'personal_permission', false); $this->assertSame(4, DB::connection('business_mirror') ->table('ding_process_detail_mirror')->count()); } public function test_replace_removes_rows_that_are_not_in_the_new_file(): void { $importer = app(DingProcessDetailMirrorImporter::class); $importer->import($this->fixture, 'personal_permission', true); $replacement = $this->makeCsv([[ '1', '202603011200000000003', '王五', '已完成', '店铺日常报销', '替换后的流程', '1', '5601.01', '销售费用', '10.00', '1002.01.003', '银行-测试3', '10.00', '银行卡', '2026-03-02 00:00', ]]); $importer->import($replacement, 'personal_permission', true); $this->assertSame(1, DB::connection('business_mirror') ->table('ding_process_detail_mirror')->count()); $this->assertSame( '202603011200000000003', (string) DB::connection('business_mirror') ->table('ding_process_detail_mirror') ->value('business_id'), ); } public function test_missing_headers_does_not_delete_existing_rows(): void { $importer = app(DingProcessDetailMirrorImporter::class); $importer->import($this->fixture, 'personal_permission', true); $path = tempnam(sys_get_temp_dir(), 'ding-process-bad-'); file_put_contents($path, "序号,审批编号\n1,202603011200000000003\n"); $this->tempFiles[] = $path; try { $importer->import($path, 'personal_permission', true); $this->fail('缺少表头时应抛出异常。'); } catch (RuntimeException $exception) { $this->assertStringContainsString('缺少列', $exception->getMessage()); } $this->assertSame(4, DB::connection('business_mirror') ->table('ding_process_detail_mirror')->count()); } public function test_placeholder_rows_are_skipped_and_the_better_duplicate_wins(): void { $path = $this->makeCsv([ [ '1', '202604011200000000004', '赵六', '已完成', '公摊日常报销', '真实费用', '1', '5602.04', '管理费用_办公费', '100.00', '1002.01.001', '华夏银行-测试', '100.00', '银行卡', '2026-04-02 00:00', ], [ '2', '202604011200000000004', '赵六', '已完成', '公摊日常报销', '251', '1', '251', '251', '', '251', '251', '251', '251', '251', ], [ '3', '202604011200000000004', '赵六', '已完成', '公摊日常报销', '251', '1', '1221.05', '251', '0.00', '1002.01.001', '华夏银行-测试', '100.00', '银行卡', '2026-04-02 00:00', ], [ '4', '202604021200000000005', '钱七', '已完成', '店铺日常报销', '251', '1', '251', '251', '', '251', '251', '251', '251', '251', ], ]); $result = app(DingProcessDetailMirrorImporter::class) ->import($path, 'personal_permission', true); $this->assertSame(1, $result['rows']); $this->assertSame(2, $result['placeholders']); $this->assertSame(1, $result['duplicates']); $row = DB::connection('business_mirror') ->table('ding_process_detail_mirror') ->first(); $this->assertSame('5602.04', $row->account_code); $this->assertSame('管理费用_办公费', $row->account_name); $this->assertEqualsWithDelta(100.0, (float) $row->amount, 0.001); } public function test_artisan_command_imports_the_file(): void { $this->artisan('ding:import-process-details', [ 'file' => $this->fixture, '--scope' => 'personal_permission', '--replace' => true, ])->assertSuccessful(); $this->assertSame(4, DB::connection('business_mirror') ->table('ding_process_detail_mirror')->count()); } 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->unsignedInteger('line_no'); $table->string('business_month', 6)->index(); $table->string('originator_name', 100)->nullable()->index(); $table->string('status', 20)->nullable()->index(); $table->string('document_type', 100)->nullable()->index(); $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)->default('personal_permission')->index(); $table->dateTime('synced_at', 6); $table->timestamps(); $table->unique(['business_id', 'line_no']); }, ); } /** @param list> $rows */ private function makeCsv(array $rows): string { $path = tempnam(sys_get_temp_dir(), 'ding-process-'); $handle = fopen($path, 'wb'); fputcsv($handle, DingProcessDetailMirrorImporter::HEADERS); foreach ($rows as $row) { fputcsv($handle, $row); } fclose($handle); $this->tempFiles[] = $path; return $path; } }