CaiWuXiTong/tests/Feature/Console/TaobaoWechatSettlementBillImporterTest.php
yueyang e7ca3026a6 feat: 导入淘系微信权威账单 (#260)
保留现有表头,新增受控幂等导入、页面查询和完整账单校验。
2026-09-24 10:29:59 +08:00

389 lines
15 KiB
PHP

<?php
namespace Tests\Feature\Console;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\TaobaoWechatSettlementBillRepository;
use Modules\EcommerceFinance\Services\TaobaoWechatSettlementBillImporter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use RuntimeException;
use Tests\TestCase;
class TaobaoWechatSettlementBillImporterTest extends TestCase
{
/** @var list<string> */
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<string> */
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<list<string>> $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;
}
}