349 lines
14 KiB
PHP
349 lines
14 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\PinduoduoSettlementBillRepository;
|
|
use Modules\EcommerceFinance\Services\PinduoduoSettlementBillImporter;
|
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
|
use RuntimeException;
|
|
use Tests\TestCase;
|
|
|
|
class PinduoduoSettlementBillImporterTest 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_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', '拼多多营销账户'),
|
|
]);
|
|
|
|
$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' => '拼多多营销账户',
|
|
], '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<string> */
|
|
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<list<string>> $rows */
|
|
private function xlsx(array $rows): string
|
|
{
|
|
$temporary = tempnam(sys_get_temp_dir(), 'pdd-authoritative-');
|
|
$path = $temporary . '.xlsx';
|
|
rename($temporary, $path);
|
|
$spreadsheet = new Spreadsheet();
|
|
$spreadsheet->getActiveSheet()->fromArray(array_merge([[
|
|
'记录编码', '店铺', '自定义备注', '账单项目', '商户订单号', '线上单号',
|
|
'财务类型', '业务描述', '类型', '发生时间', '收入金额', '支出金额', '金额',
|
|
'备注', '修改时间', '修改人',
|
|
]], $rows));
|
|
(new Xlsx($spreadsheet))->save($path);
|
|
$spreadsheet->disconnectWorksheets();
|
|
$this->files[] = $path;
|
|
|
|
return $path;
|
|
}
|
|
}
|