148 lines
8.1 KiB
PHP
148 lines
8.1 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Services;
|
|
|
|
use Illuminate\Database\DatabaseManager;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Str;
|
|
use InvalidArgumentException;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
class JdWalletBillImporter
|
|
{
|
|
public const PAYLOAD_FIELDS = [
|
|
'online_order_no' => '线上单号', 'serial_no' => '流水号',
|
|
'tx_order_no' => '交易订单号', 'account_name' => '账户名称',
|
|
'bill_date' => '账单日期', 'merchant_order_no' => '商户订单号',
|
|
'tx_remark' => '交易备注', 'tx_type' => '交易类型', 'currency' => '币种',
|
|
'book_request_no' => '记账请求号', 'business_order_no' => '业务订单号',
|
|
];
|
|
|
|
public function __construct(private readonly DatabaseManager $databases) {}
|
|
|
|
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('必须指定完整月份及核对笔数。');
|
|
}
|
|
$expectedIn = $this->cents($expectedIncome);
|
|
$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'));
|
|
$generation = (string) Str::uuid();
|
|
$count = $income = $expense = 0;
|
|
$batch = [];
|
|
try {
|
|
$headers = fgetcsv($file);
|
|
if (! is_array($headers)) throw new InvalidArgumentException('明细为空。');
|
|
$headers[0] = preg_replace('/^\xEF\xBB\xBF/', '', $headers[0]);
|
|
$required = array_merge(['记录编码', '店铺', '账单项目', '账户余额', '费用结算时间', '收入金额', '支出金额'], array_values(self::PAYLOAD_FIELDS));
|
|
if (count(array_unique($headers)) !== count($headers) || array_diff($required, $headers)) {
|
|
throw new InvalidArgumentException('京东钱包字段缺失或重复。');
|
|
}
|
|
while (($values = fgetcsv($file)) !== false) {
|
|
if ($values === [null]) continue;
|
|
if (count($values) !== count($headers)) throw new InvalidArgumentException('明细列数不一致。');
|
|
$r = array_combine($headers, $values);
|
|
$code = trim($r['记录编码']);
|
|
if (! preg_match('/^\d{1,64}$/', $code)) throw new InvalidArgumentException('记录编码为空或失真。');
|
|
$time = $this->dateTime($r['费用结算时间']);
|
|
if ($time === null || $time < $start || $time >= $end || trim($r['店铺']) === '') {
|
|
throw new InvalidArgumentException('费用结算时间或店铺不符合导入范围。');
|
|
}
|
|
if (trim($r['币种']) !== 'CNY') throw new InvalidArgumentException('不能合并不同币种的账单金额。');
|
|
$in = $this->cents($r['收入金额']);
|
|
$out = $this->cents($r['支出金额']);
|
|
$payload = [];
|
|
foreach (self::PAYLOAD_FIELDS as $key => $label) $payload[$key] = $r[$label] !== '' ? $r[$label] : null;
|
|
$payload['bill_date'] = $this->dateTime($r['账单日期']);
|
|
$balance = $this->signedCents($r['账户余额']);
|
|
$row = [
|
|
'id' => 'shengsuan-jd-wallet:' . $code, 'record_code' => $code,
|
|
'store_name' => trim($r['店铺']), 'bill_item' => $r['账单项目'],
|
|
'order_no' => $r['交易订单号'], 'occurred_at' => $time,
|
|
'income' => $this->decimal($in), 'expense' => $this->decimal($out),
|
|
'balance' => $this->decimal($balance),
|
|
'custom_remark' => $r['自定义备注'] ?? null, 'remark' => $r['交易备注'] ?? null,
|
|
'updated_at' => $this->dateTime($r['修改时间'] ?? ''),
|
|
'modifier' => $r['修改人'] ?? null, 'payload' => $payload,
|
|
];
|
|
$batch[] = [
|
|
'generation' => $generation, 'record_code' => $code, 'store_name' => $row['store_name'],
|
|
'bill_item' => $row['bill_item'], 'order_no' => $row['order_no'], 'occurred_at' => $time,
|
|
'income' => $row['income'], 'expense' => $row['expense'],
|
|
'balance' => $row['balance'], 'account_name' => $r['账户名称'],
|
|
'row_data' => json_encode($row, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
|
];
|
|
$count++; $income += $in; $expense += $out;
|
|
// The generation primary key rejects duplicate source records, including across batches.
|
|
if (count($batch) === 500) {
|
|
$db->table('jd_wallet_bill_rows')->insert($batch);
|
|
$batch = [];
|
|
}
|
|
}
|
|
if ($count !== $expectedCount || $income !== $expectedIn || $expense !== $expectedOut) {
|
|
throw new InvalidArgumentException('笔数或收支合计不一致,未启用此明细。');
|
|
}
|
|
if ($batch !== []) $db->table('jd_wallet_bill_rows')->insert($batch);
|
|
$state = [
|
|
'period_start' => $start, 'period_end' => $end, 'active_generation' => $generation,
|
|
'source_filename' => basename($path), 'source_sha256' => hash_file('sha256', $path),
|
|
'row_count' => $count, 'income' => $this->decimal($income), 'expense' => $this->decimal($expense),
|
|
'imported_at' => now()->format('Y-m-d H:i:s'),
|
|
];
|
|
// Activate only the reconciled range and retain the previous generation for rollback.
|
|
$db->transaction(function () use ($db, $start, $end, $state): void {
|
|
$previous = $db->table('jd_wallet_bill_periods')->where('period_start', $start)->lockForUpdate()->first();
|
|
if ($previous !== null && $previous->period_end > $end) {
|
|
throw new InvalidArgumentException('截止日期早于已导入范围,未替换现有账单。');
|
|
}
|
|
$db->table('jd_wallet_bill_periods')->updateOrInsert(['period_start' => $start], $state);
|
|
});
|
|
return $state;
|
|
} catch (Throwable $e) {
|
|
$db->table('jd_wallet_bill_rows')->where('generation', $generation)->delete();
|
|
throw $e;
|
|
} finally {
|
|
fclose($file);
|
|
}
|
|
}
|
|
|
|
private function cents(string $value): int
|
|
{
|
|
if (! preg_match('/^\d{1,14}(?:\.\d{1,2})?$/', trim($value))) throw new InvalidArgumentException('金额必须为精确到分的非负数。');
|
|
[$whole, $fraction] = array_pad(explode('.', trim($value), 2), 2, '');
|
|
return (int) $whole * 100 + (int) str_pad($fraction, 2, '0');
|
|
}
|
|
|
|
private function signedCents(string $value): int
|
|
{
|
|
$value = trim($value);
|
|
return str_starts_with($value, '-') ? -$this->cents(substr($value, 1)) : $this->cents($value);
|
|
}
|
|
|
|
private function decimal(int $value): string
|
|
{
|
|
return ($value < 0 ? '-' : '') . intdiv(abs($value), 100) . '.' . str_pad((string) (abs($value) % 100), 2, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
private function dateTime(string $value): ?string
|
|
{
|
|
if (trim($value) === '') return null;
|
|
$date = \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value);
|
|
if (! $date || $date->format('Y-m-d H:i:s') !== $value) throw new InvalidArgumentException('账单时间格式无效。');
|
|
return $value;
|
|
}
|
|
}
|