374 lines
15 KiB
PHP
374 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\DewuSettlementBillRepository;
|
|
use Modules\EcommerceFinance\Services\DewuSettlementBillImporter;
|
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
|
use RuntimeException;
|
|
use Tests\TestCase;
|
|
|
|
class DewuSettlementBillImporterTest 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_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<string> */
|
|
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<list<string>> $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;
|
|
}
|
|
}
|