*/ private array $files = []; protected function setUp(): void { parent::setUp(); config([ 'database.connections.business_mirror' => [ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', ], 'upstream_mirrors.connection' => 'business_mirror', ]); DB::purge('business_mirror'); $this->createTables(); } protected function tearDown(): void { foreach ($this->files as $file) { @unlink($file); } parent::tearDown(); } public function test_it_imports_current_jushengsuan_headers_keeps_all_entry_types_and_is_idempotent(): void { $this->insertActiveRow('OLD-IN-PERIOD', '2026-08-15 12:00:00'); $this->insertActiveRow('KEEP-OUTSIDE-PERIOD', '2026-07-31 23:59:59'); $path = $this->xlsx([ $this->row('REC-INCOME', '2026-08-01 00:00:19', '100.00', '0', '100.00'), $this->row('REC-EXPENSE', '2026-08-02 12:00:00', '0', '25.00', '-25.00', '支出', '退款'), $this->row('REC-ZERO', '2026-08-03 12:00:00', '0', '0', '0'), $this->row('REC-OTHER', '2026-08-04 12:00:00', '0', '3.00', '-3.00', '其他'), ], false); $importer = app(TaobaoWechatSettlementBillImporter::class); $result = $importer->import( $path, '2026-08-01', '2026-08-31', 4, '100.00', '28.00', 100, ); $this->assertSame('succeeded', $result['status']); $this->assertSame(4, $result['row_count']); $this->assertSame(0, $result['excluded_rows']); $this->assertSame(1, $result['store_count']); $this->assertSame(2, $result['bill_item_count']); $this->assertSame([ '收入' => 2, '支出' => 1, '其他' => 1, ], $result['entry_type_counts']); $this->assertSame('100.00', $result['income_total']); $this->assertSame('28.00', $result['expense_total']); $this->assertSame('72.00', $result['net_total']); $this->assertDatabaseMissing('taobao_wechat_bill_rows', ['record_code' => 'OLD-IN-PERIOD'], 'business_mirror'); $this->assertDatabaseHas('taobao_wechat_bill_rows', ['record_code' => 'KEEP-OUTSIDE-PERIOD'], 'business_mirror'); $this->assertDatabaseHas('taobao_wechat_bill_rows', [ 'record_code' => 'REC-OTHER', 'entry_type' => '其他', 'signed_amount' => -3, ], 'business_mirror'); $this->assertDatabaseHas('taobao_wechat_bill_rows', [ 'record_code' => 'REC-INCOME', 'signed_amount' => 100, ], 'business_mirror'); $repository = app(TaobaoWechatSettlementBillRepository::class); $this->assertTrue($repository->ready()); $this->assertSame([ 'income_total' => 100.0, 'expense_total' => 28.0, 'cnt' => 4, ], $repository->summarize(null, '2026-08-01', '2026-08-31', null)); $this->assertSame(1, $repository->paginate(null, '2026-08-01', '2026-08-31', 'income', 1, 25)['total']); $this->assertSame(2, $repository->paginate(null, '2026-08-01', '2026-08-31', 'expense', 1, 25)['total']); $zero = $repository->paginate(null, '2026-08-03', '2026-08-03', null, 1, 25); $this->assertSame(0, $zero['rows'][0]['flowType']); $unchanged = $importer->import( $path, '2026-08-01', '2026-08-31', 4, '100.00', '28.00', 100, ); $this->assertSame('unchanged', $unchanged['status']); $this->assertSame(5, DB::connection('business_mirror')->table('taobao_wechat_bill_rows')->count()); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('本次账期或权威控制数与历史记录不一致'); $importer->import($path, '2026-08-01', '2026-08-31', 5, '100.00', '28.00', 100); } public function test_local_authoritative_export_matches_known_controls_when_configured(): void { $path = getenv('TAOBAO_WECHAT_AUTHORITATIVE_FILE'); if (! is_string($path) || $path === '') { $this->markTestSkipped('未配置本地淘系微信权威账单文件。'); } $result = app(TaobaoWechatSettlementBillImporter::class)->import( $path, '2026-08-01', '2026-09-22', 30301, '1635044.56', '1771430.54', 1000, ); $this->assertSame('succeeded', $result['status']); $this->assertSame(30301, $result['row_count']); $this->assertSame(21, $result['store_count']); $this->assertSame(27, $result['bill_item_count']); $this->assertCount(7, $result['entry_type_counts']); $this->assertSame('-136385.98', $result['net_total']); } public function test_control_mismatch_does_not_replace_the_active_period(): void { $this->insertActiveRow('ACTIVE-ROW', '2026-08-15 12:00:00'); $path = $this->xlsx([ $this->row('NEW-ROW', '2026-08-16 12:00:00', '10.00', '0', '10.00'), ]); try { app(TaobaoWechatSettlementBillImporter::class)->import( $path, '2026-08-01', '2026-08-31', 2, '10.00', '0.00', 100, ); $this->fail('控制数不一致时必须拒绝激活。'); } catch (RuntimeException $exception) { $this->assertStringContainsString('权威控制数不一致', $exception->getMessage()); } $this->assertDatabaseHas('taobao_wechat_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror'); $this->assertDatabaseMissing('taobao_wechat_bill_rows', ['record_code' => 'NEW-ROW'], 'business_mirror'); $this->assertSame('failed', DB::connection('business_mirror')->table('taobao_wechat_bill_imports')->value('status')); $this->assertSame(2, DB::connection('business_mirror')->table('taobao_wechat_bill_imports')->value('expected_row_count')); $this->assertSame(1, DB::connection('business_mirror')->table('taobao_wechat_bill_imports')->value('row_count')); $this->assertSame(0, DB::connection('business_mirror')->table('taobao_wechat_bill_staging')->count()); } public function test_duplicate_record_codes_are_rejected_without_replacing_active_rows(): void { $this->insertActiveRow('ACTIVE-ROW', '2026-08-15 12:00:00'); $path = $this->xlsx([ $this->row('DUPLICATE', '2026-08-16 12:00:00', '10.00', '0', '10.00'), $this->row('DUPLICATE', '2026-08-17 12:00:00', '20.00', '0', '20.00'), ]); $failure = null; try { app(TaobaoWechatSettlementBillImporter::class)->import( $path, '2026-08-01', '2026-08-31', 2, '30.00', '0.00', 100, ); } catch (\Throwable $exception) { $failure = $exception; } $this->assertNotNull($failure, '重复记录编码必须拒绝导入。'); $this->assertDatabaseHas('taobao_wechat_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror'); $this->assertDatabaseMissing('taobao_wechat_bill_rows', ['record_code' => 'DUPLICATE'], 'business_mirror'); $this->assertSame(0, DB::connection('business_mirror')->table('taobao_wechat_bill_staging')->count()); } public function test_out_of_period_and_inconsistent_amount_rows_are_rejected(): void { $outOfPeriod = $this->xlsx([ $this->row('OUTSIDE', '2026-09-01 00:00:00', '10.00', '0', '10.00'), ]); $badAmount = $this->xlsx([ $this->row('BAD-AMOUNT', '2026-08-10 00:00:00', '10.00', '2.00', '9.00'), ]); $importer = app(TaobaoWechatSettlementBillImporter::class); foreach ([ [$outOfPeriod, '入账时间超出指定账期'], [$badAmount, '收入、支出与金额不一致'], ] as [$path, $message]) { try { $importer->import($path, '2026-08-01', '2026-08-31', 1, '10.00', '0.00', 100); $this->fail($message . '时必须拒绝导入。'); } catch (RuntimeException $exception) { $this->assertStringContainsString($message, $exception->getMessage()); } } $this->assertSame(0, DB::connection('business_mirror')->table('taobao_wechat_bill_rows')->count()); $this->assertSame(0, DB::connection('business_mirror')->table('taobao_wechat_bill_staging')->count()); } public function test_blank_entry_type_is_rejected(): void { $path = $this->xlsx([ $this->row('BLANK-ENTRY-TYPE', '2026-08-10 00:00:00', '10.00', '0', '10.00', ''), ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('缺少入账类型'); app(TaobaoWechatSettlementBillImporter::class)->import( $path, '2026-08-01', '2026-08-31', 1, '10.00', '0.00', 100, ); } private function createTables(): void { $schema = Schema::connection('business_mirror'); $schema->create('taobao_wechat_bill_imports', function (Blueprint $table): void { $table->uuid('generation')->primary(); $table->string('source_filename'); $table->char('source_sha256', 64)->unique(); $table->date('period_start'); $table->date('period_end'); $table->string('status', 32)->default('running'); $table->unsignedBigInteger('expected_row_count'); $table->decimal('expected_income_total', 20, 2); $table->decimal('expected_expense_total', 20, 2); $table->unsignedBigInteger('row_count')->default(0); $table->unsignedBigInteger('excluded_rows')->default(0); $table->unsignedInteger('store_count')->default(0); $table->unsignedInteger('bill_item_count')->default(0); $table->json('entry_type_counts')->nullable(); $table->decimal('income_total', 20, 2)->default(0); $table->decimal('expense_total', 20, 2)->default(0); $table->decimal('net_total', 20, 2)->default(0); $table->string('last_error_code', 120)->nullable(); $table->timestamp('completed_at')->nullable(); $table->timestamps(); }); $schema->create('taobao_wechat_bill_staging', function (Blueprint $table): void { $table->bigIncrements('id'); $table->uuid('generation'); $this->billColumns($table); $table->unique(['generation', 'record_code']); }); $schema->create('taobao_wechat_bill_rows', function (Blueprint $table): void { $table->string('record_code', 191)->primary(); $this->billColumns($table, false); $table->uuid('source_generation'); $table->char('source_sha256', 64); $table->timestamp('imported_at'); }); } private function billColumns(Blueprint $table, bool $includeRecordCode = true): void { if ($includeRecordCode) { $table->string('record_code', 191); } $table->string('store_name')->nullable(); $table->string('custom_remark')->nullable(); $table->string('bill_item')->nullable(); $table->string('taobao_order_no', 191)->nullable(); $table->string('online_order_no', 191)->nullable(); $table->string('pay_serial_no')->nullable(); $table->string('business_description', 1000)->nullable(); $table->string('entry_type')->nullable(); $table->dateTime('occurred_at'); $table->decimal('income_amount', 20, 2)->default(0); $table->decimal('expense_amount', 20, 2)->default(0); $table->decimal('signed_amount', 20, 2)->default(0); $table->string('remark', 1000)->nullable(); $table->dateTime('jst_pulled_at')->nullable(); $table->string('source_modified_by')->nullable(); $table->unsignedBigInteger('source_row_number'); } private function insertActiveRow(string $recordCode, string $occurredAt): void { DB::connection('business_mirror')->table('taobao_wechat_bill_rows')->insert([ 'record_code' => $recordCode, 'store_name' => '原有店铺', 'bill_item' => '原有流水', 'entry_type' => '收入', 'occurred_at' => $occurredAt, 'income_amount' => 1, 'expense_amount' => 0, 'signed_amount' => 1, 'source_row_number' => 1, 'source_generation' => 'existing-generation', 'source_sha256' => str_repeat('a', 64), 'imported_at' => '2026-09-01 00:00:00', ]); } /** @return list */ private function row( string $recordCode, string $occurredAt, string $income, string $expense, string $signed, string $entryType = '收入', string $billItem = '技术服务费', ): array { return [ $recordCode, '淘系微信-测试店铺', '', $billItem, 'ONLINE-' . $recordCode, 'TB-' . $recordCode, 'PAY-' . $recordCode, $occurredAt, $entryType, $income, $expense, $signed, '业务描述', '-', '2026-09-07 17:07:15', 'RPA', ]; } /** @param list> $rows */ private function xlsx(array $rows, bool $includeSignedAmount = true): string { $temporary = tempnam(sys_get_temp_dir(), 'taobao-wechat-authoritative-'); $path = $temporary . '.xlsx'; rename($temporary, $path); $spreadsheet = new Spreadsheet(); $headers = [ '记录编码', '店铺', '自定义备注', '账单项目', '线上单号', '淘宝订单编号', '支付流水号', '入账时间', '入账类型', '收入金额', '支出金额', '金额', '业务描述', '备注', 'JST拉取时间', '修改人', ]; if (! $includeSignedAmount) { array_splice($headers, 11, 1); $rows = array_map(static function (array $row): array { array_splice($row, 11, 1); return $row; }, $rows); } $spreadsheet->getActiveSheet()->fromArray(array_merge([$headers], $rows)); (new Xlsx($spreadsheet))->save($path); $spreadsheet->disconnectWorksheets(); $this->files[] = $path; return $path; } }