支持京东账单月中截止日期并保护后续账单 #241

Merged
qiuxiang merged 1 commits from fix/qiuxiang-jd-import-through into main 2026-09-22 16:48:35 +08:00
3 changed files with 46 additions and 8 deletions

View File

@ -10,8 +10,8 @@ use Throwable;
class ImportJdDailyBills extends Command
{
protected $signature = 'business-mirror:import-jd-daily
{path : 胜算京东日账单完整月份 UTF-8 CSV} {month : YYYY-MM}
{--count= : 核对笔数} {--income= : 核对收入} {--expense= : 核对支出}';
{path : 胜算京东日账单指定月份月初至截止日期 UTF-8 CSV} {month : YYYY-MM}
{--through= : 截止日期 YYYY-MM-DD,默认月末} {--count= : 核对笔数} {--income= : 核对收入} {--expense= : 核对支出}';
protected $description = '核对并启用胜算京东日账单月份镜像,保留旧版本';
@ -24,7 +24,7 @@ class ImportJdDailyBills extends Command
}
try {
$result = $importer->import((string) $this->argument('path'), (string) $this->argument('month'),
(int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense'));
(int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense'), $this->option('through'));
$this->info('京东日账单已启用,笔数:' . $result['row_count']);
return self::SUCCESS;
} catch (Throwable $e) {

View File

@ -24,7 +24,7 @@ class JdDailyBillImporter
public function __construct(private readonly DatabaseManager $databases) {}
public function import(string $path, string $month, int $expectedCount, string $expectedIncome, string $expectedExpense): array
public function import(string $path, string $month, int $expectedCount, string $expectedIncome, string $expectedExpense, ?string $through = null): array
{
if (! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) || $expectedCount < 1) {
throw new InvalidArgumentException('必须指定完整月份及核对笔数。');
@ -33,6 +33,13 @@ class JdDailyBillImporter
$expectedOut = $this->cents($expectedExpense);
$start = $month . '-01';
$end = Carbon::parse($start)->addMonth()->format('Y-m-d');
if ($through !== null) {
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $through);
if (! $date || $date->format('Y-m-d') !== $through || $through < $start || $through >= $end) {
throw new InvalidArgumentException('截止日期必须是指定月份内的有效日期。');
}
$end = $date->modify('+1 day')->format('Y-m-d');
}
$file = fopen($path, 'rb');
if ($file === false) throw new RuntimeException('无法读取京东日账单明细。');
$db = $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror'));
@ -95,8 +102,14 @@ class JdDailyBillImporter
'row_count' => $count, 'income' => $this->decimal($income), 'expense' => $this->decimal($expense),
'imported_at' => now()->format('Y-m-d H:i:s'),
];
// Publish only reconciled complete months; keep the preceding generation for rollback.
$db->transaction(fn () => $db->table('jd_daily_bill_periods')->updateOrInsert(['period_start' => $start], $state));
// Publish only the reconciled date range; keep the preceding generation for rollback.
$db->transaction(function () use ($db, $start, $end, $state): void {
$previous = $db->table('jd_daily_bill_periods')->where('period_start', $start)->lockForUpdate()->first();
if ($previous !== null && $previous->period_end > $end) {
throw new InvalidArgumentException('截止日期早于已导入范围,未替换现有账单。');
}
$db->table('jd_daily_bill_periods')->updateOrInsert(['period_start' => $start], $state);
});
return $state;
} catch (Throwable $e) {
$db->table('jd_daily_bill_rows')->where('generation', $generation)->delete();

View File

@ -90,7 +90,32 @@ class JdDailyBillTest extends TestCase
app(JdDailyBillRepository::class)->paginate([], 'occurred_at', 'asc', 1, 10);
}
private function import(array $rows, int $count, string $income, string $expense): array
public function test_cutoff_validates_rows_and_refuses_to_shorten_existing_coverage(): void
{
$row = array_replace($this->row('123', '10.00'), ['费用结算时间' => '2026-08-21 23:59:59']);
$initial = $this->import([$row], 1, '10.00', '0.00', '2026-08-21');
$this->assertSame('2026-08-22', $initial['period_end']);
foreach (['2026-08-20', '2026-09-21', '2026-08-32'] as $cutoff) {
try {
$this->import([$row], 1, '10.00', '0.00', $cutoff);
$this->fail('Invalid cutoff accepted');
} catch (\InvalidArgumentException $e) {
$this->assertSame($initial['active_generation'], DB::connection('business_mirror')->table('jd_daily_bill_periods')->value('active_generation'));
}
}
$full = $this->import([$row, $this->row('124', '2.00')], 2, '12.00', '0.00');
try {
$this->import([$row], 1, '10.00', '0.00', '2026-08-21');
$this->fail('Existing later records would be lost');
} catch (\InvalidArgumentException $e) {
$repo = app(JdDailyBillRepository::class);
$this->assertSame(2, $repo->paginate([], 'occurred_at', 'asc', 1, 10)['total']);
$this->assertSame(2.0, $repo->paginate(['start' => '2026-08-22'], 'occurred_at', 'asc', 1, 10)['summary']['income']);
$this->assertSame($full['active_generation'], DB::connection('business_mirror')->table('jd_daily_bill_periods')->value('active_generation'));
}
}
private function import(array $rows, int $count, string $income, string $expense, ?string $through = null): array
{
$path = tempnam(sys_get_temp_dir(), 'jd-daily-');
$this->files[] = $path;
@ -98,7 +123,7 @@ class JdDailyBillTest extends TestCase
fputcsv($file, array_keys($rows[0]));
foreach ($rows as $row) fputcsv($file, array_values($row));
fclose($file);
return app(JdDailyBillImporter::class)->import($path, '2026-08', $count, $income, $expense);
return app(JdDailyBillImporter::class)->import($path, '2026-08', $count, $income, $expense, $through);
}
private function row(string $code, string $income, string $expense = '0.00'): array