*/ 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_accounts_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-MARKETING', '2026-08-04 12:00:00', '0', '3.00', '-3.00', '拼多多营销账户'), ], false); $importer = app(PinduoduoSettlementBillImporter::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_account_rows']); $this->assertSame(1, $result['store_count']); $this->assertSame(2, $result['bill_item_count']); $this->assertSame([ '拼多多货款账户' => 3, '拼多多营销账户' => 1, ], $result['account_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('pdd_settlement_bill_rows', ['record_code' => 'OLD-IN-PERIOD'], 'business_mirror'); $this->assertDatabaseHas('pdd_settlement_bill_rows', ['record_code' => 'KEEP-OUTSIDE-PERIOD'], 'business_mirror'); $this->assertDatabaseHas('pdd_settlement_bill_rows', [ 'record_code' => 'REC-MARKETING', 'account_name' => '拼多多营销账户', 'signed_amount' => -3, ], 'business_mirror'); $this->assertDatabaseHas('pdd_settlement_bill_rows', [ 'record_code' => 'REC-INCOME', 'signed_amount' => 100, ], 'business_mirror'); $repository = app(PinduoduoSettlementBillRepository::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('pdd_settlement_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_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(PinduoduoSettlementBillImporter::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('pdd_settlement_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror'); $this->assertDatabaseMissing('pdd_settlement_bill_rows', ['record_code' => 'NEW-ROW'], 'business_mirror'); $this->assertSame('failed', DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->value('status')); $this->assertSame(2, DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->value('expected_row_count')); $this->assertSame(1, DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->value('row_count')); $this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_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(PinduoduoSettlementBillImporter::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('pdd_settlement_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror'); $this->assertDatabaseMissing('pdd_settlement_bill_rows', ['record_code' => 'DUPLICATE'], 'business_mirror'); $this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_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(PinduoduoSettlementBillImporter::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('pdd_settlement_bill_rows')->count()); $this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_staging')->count()); } public function test_blank_account_type_is_rejected(): void { $path = $this->xlsx([ $this->row('BLANK-ACCOUNT', '2026-08-10 00:00:00', '10.00', '0', '10.00', ''), ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('缺少账户类型'); app(PinduoduoSettlementBillImporter::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('pdd_settlement_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_account_rows')->default(0); $table->unsignedInteger('store_count')->default(0); $table->unsignedInteger('bill_item_count')->default(0); $table->json('account_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('pdd_settlement_bill_staging', function (Blueprint $table): void { $table->bigIncrements('id'); $table->uuid('generation'); $this->billColumns($table); $table->unique(['generation', 'record_code']); }); $schema->create('pdd_settlement_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('merchant_order_no', 191)->nullable(); $table->string('online_order_no', 191)->nullable(); $table->string('business_type')->nullable(); $table->string('business_description', 1000)->nullable(); $table->string('account_name')->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('source_updated_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('pdd_settlement_bill_rows')->insert([ 'record_code' => $recordCode, 'store_name' => '原有店铺', 'bill_item' => '原有流水', 'account_name' => PinduoduoSettlementBillImporter::ACCOUNT_NAME, '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 $account = PinduoduoSettlementBillImporter::ACCOUNT_NAME, string $billItem = '技术服务费', ): array { return [ $recordCode, '拼多多-测试店铺', '', $billItem, 'ORDER-' . $recordCode, 'ONLINE-' . $recordCode, $billItem, '001|' . $billItem, $account, $occurredAt, $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(), 'pdd-authoritative-'); $path = $temporary . '.xlsx'; rename($temporary, $path); $spreadsheet = new Spreadsheet(); $headers = [ '记录编码', '店铺', '自定义备注', '账单项目', '商户订单号', '线上单号', '财务类型', '业务描述', '类型', '发生时间', '收入金额', '支出金额', '金额', '备注', '修改时间', '修改人', ]; if (! $includeSignedAmount) { array_splice($headers, 12, 1); $rows = array_map(static function (array $row): array { array_splice($row, 12, 1); return $row; }, $rows); } $spreadsheet->getActiveSheet()->fromArray(array_merge([$headers], $rows)); (new Xlsx($spreadsheet))->save($path); $spreadsheet->disconnectWorksheets(); $this->files[] = $path; return $path; } }