*/ 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_channels_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'), $this->row('REC-EXPENSE', '2026-08-02 12:00:00', '0', '25.00', '退货退款订单', '退款'), $this->row('REC-ZERO', '2026-08-03 12:00:00', '0', '0'), $this->row('REC-FEE', '2026-08-04 12:00:00', '0', '3.00', '扣减其他费用明细'), ]); $importer = app(DewuSettlementBillImporter::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['channel_counts']); $this->assertSame('100.00', $result['income_total']); $this->assertSame('28.00', $result['expense_total']); $this->assertSame('72.00', $result['net_total']); $this->assertDatabaseMissing('dewu_settlement_bill_rows', ['record_code' => 'OLD-IN-PERIOD'], 'business_mirror'); $this->assertDatabaseHas('dewu_settlement_bill_rows', ['record_code' => 'KEEP-OUTSIDE-PERIOD'], 'business_mirror'); $this->assertDatabaseHas('dewu_settlement_bill_rows', [ 'record_code' => 'REC-FEE', 'channel' => '扣减其他费用明细', 'signed_amount' => -3, ], 'business_mirror'); $this->assertDatabaseHas('dewu_settlement_bill_rows', [ 'record_code' => 'REC-INCOME', 'signed_amount' => 100, ], 'business_mirror'); $repository = app(DewuSettlementBillRepository::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('dewu_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'), ]); try { app(DewuSettlementBillImporter::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('dewu_settlement_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror'); $this->assertDatabaseMissing('dewu_settlement_bill_rows', ['record_code' => 'NEW-ROW'], 'business_mirror'); $this->assertSame('failed', DB::connection('business_mirror')->table('dewu_settlement_bill_imports')->value('status')); $this->assertSame(2, DB::connection('business_mirror')->table('dewu_settlement_bill_imports')->value('expected_row_count')); $this->assertSame(1, DB::connection('business_mirror')->table('dewu_settlement_bill_imports')->value('row_count')); $this->assertSame(0, DB::connection('business_mirror')->table('dewu_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'), $this->row('DUPLICATE', '2026-08-17 12:00:00', '20.00', '0'), ]); $failure = null; try { app(DewuSettlementBillImporter::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('dewu_settlement_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror'); $this->assertDatabaseMissing('dewu_settlement_bill_rows', ['record_code' => 'DUPLICATE'], 'business_mirror'); $this->assertSame(0, DB::connection('business_mirror')->table('dewu_settlement_bill_staging')->count()); } public function test_out_of_period_and_missing_bill_date_rows_are_rejected(): void { $outOfPeriod = $this->xlsx([ $this->row('OUTSIDE', '2026-09-01 00:00:00', '10.00', '0'), ]); $missingDate = $this->xlsx([ $this->row('MISSING-DATE', '', '10.00', '0'), ]); $importer = app(DewuSettlementBillImporter::class); foreach ([ [$outOfPeriod, '账单时间超出指定账期'], [$missingDate, '缺少可用于账期核验'], ] 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('dewu_settlement_bill_rows')->count()); $this->assertSame(0, DB::connection('business_mirror')->table('dewu_settlement_bill_staging')->count()); } public function test_blank_channel_is_rejected(): void { $path = $this->xlsx([ $this->row('BLANK-CHANNEL', '2026-08-10 00:00:00', '10.00', '0', ''), ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('缺少渠道'); app(DewuSettlementBillImporter::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('dewu_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_rows')->default(0); $table->unsignedInteger('store_count')->default(0); $table->unsignedInteger('bill_item_count')->default(0); $table->json('channel_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('dewu_settlement_bill_staging', function (Blueprint $table): void { $table->bigIncrements('id'); $table->uuid('generation'); $this->billColumns($table); $table->unique(['generation', 'record_code']); }); $schema->create('dewu_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('order_no', 191)->nullable(); $table->string('online_order_no', 191)->nullable(); $table->string('custom_remark')->nullable(); $table->string('bill_item')->nullable(); $table->string('store_name')->nullable(); $table->string('order_type')->nullable(); $table->string('goods_name', 1000)->nullable(); $table->string('goods_no', 191)->nullable(); $table->string('quantity', 64)->nullable(); $table->string('channel')->nullable(); $table->string('business_type')->nullable(); $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('online_real_store')->nullable(); $table->dateTime('order_created_at')->nullable(); $table->dateTime('paid_refunded_at')->nullable(); $table->dateTime('ship_return_at')->nullable(); $table->dateTime('bill_date'); $table->string('settle_status')->nullable(); $table->string('pay_period')->nullable(); $table->dateTime('pulled_at')->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('dewu_settlement_bill_rows')->insert([ 'record_code' => $recordCode, 'store_name' => '原有店铺', 'bill_item' => '原有流水', 'channel' => '得物', 'ship_return_at' => $occurredAt, 'bill_date' => $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 $channel = '得物', string $billItem = '技术服务费', ): array { return [ $recordCode, 'ORDER-' . $recordCode, 'ONLINE-' . $recordCode, '', $billItem, '得物-测试店铺', '普通订单', '测试商品', 'SKU-' . $recordCode, '1', $channel, $income !== '0' ? '收入' : '支出', $income, $expense, '得物-测试店铺', '2026-08-01 08:00:00', '', $occurredAt, '已结算', '2026-08', '2026-09-01 08:00:00', '2026-09-07 17:07:15', 'RPA', ]; } /** @param list> $rows */ private function xlsx(array $rows): string { $temporary = tempnam(sys_get_temp_dir(), 'dewu-authoritative-'); $path = $temporary . '.xlsx'; rename($temporary, $path); $spreadsheet = new Spreadsheet(); $headers = [ '记录编码', '订单号', '线上单号', '自定义备注', '账单项目', '店铺', '订单类型', '商品名称', '商品货号', '数量', '渠道', '业务类型', '收入金额', '支出金额', '线上单号对应实际店铺', '订单创建时间', '支付/退款时间', '发货/退货时间', '结算状态', '收款账期', '拉取时间', '修改时间', '修改人', ]; $spreadsheet->getActiveSheet()->fromArray(array_merge([$headers], $rows)); (new Xlsx($spreadsheet))->save($path); $spreadsheet->disconnectWorksheets(); $this->files[] = $path; return $path; } }