'线上单号', 'document_no' => '单据编号', 'merchant_order_no' => '商户订单号', 'document_type' => '单据类型', 'product_id' => '商品编码', 'product_name' => '商品名称', 'cost_happened_at' => '费用发生时间', 'cost_name' => '费用项', 'currency' => '币种', 'settle_remark' => '钱包结算备注', 'shop_no' => '店铺号', 'jd_shop_no' => '京东门店编码', 'brand_shop_no' => '品牌门店编号', 'shop_name' => '门店名称', 'account_code' => '账户代码', ]; 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['cost_happened_at'] = $this->dateTime($r['费用发生时间']); $row = [ 'id' => 'shengsuan-jd:' . $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), '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'], '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_daily_bill_rows')->insert($batch); $batch = []; } } if ($count !== $expectedCount || $income !== $expectedIn || $expense !== $expectedOut) { throw new InvalidArgumentException('笔数或收支合计不一致,未启用此明细。'); } if ($batch !== []) $db->table('jd_daily_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'), ]; // 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(); 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 decimal(int $value): string { return intdiv($value, 100) . '.' . str_pad((string) ($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; } }