From 58eb89614823016c78173354775aa6fc2eaa79f1 Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Fri, 18 Sep 2026 13:50:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E8=83=9C?= =?UTF-8?q?=E7=AE=97=E4=BA=AC=E4=B8=9C=E6=97=A5=E8=B4=A6=E5=8D=95=E6=9C=88?= =?UTF-8?q?=E4=BB=BD=E9=95=9C=E5=83=8F=E5=8F=8A=E4=BA=AC=E4=B8=9C=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/ImportJdDailyBills.php | 37 +++++ ...000_create_jd_daily_bill_mirror_tables.php | 51 +++++++ docs/京东日账单接入.md | 21 +++ .../EcommerceFinance/AccountBillColumns.php | 28 ++-- .../Http/Controllers/AccountBills.php | 15 +- .../Repositories/JdDailyBillRepository.php | 87 ++++++++++++ .../Services/JdDailyBillImporter.php | 128 ++++++++++++++++++ .../js/components/business/AccountBills.vue | 9 +- .../Unit/EcommerceFinance/JdDailyBillTest.php | 111 +++++++++++++++ tests/Unit/View/AccountBillsTableTest.php | 2 +- 10 files changed, 465 insertions(+), 24 deletions(-) create mode 100644 app/Console/Commands/ImportJdDailyBills.php create mode 100644 database/migrations/2026_09_18_140000_create_jd_daily_bill_mirror_tables.php create mode 100644 docs/京东日账单接入.md create mode 100644 modules/EcommerceFinance/Repositories/JdDailyBillRepository.php create mode 100644 modules/EcommerceFinance/Services/JdDailyBillImporter.php create mode 100644 tests/Unit/EcommerceFinance/JdDailyBillTest.php diff --git a/app/Console/Commands/ImportJdDailyBills.php b/app/Console/Commands/ImportJdDailyBills.php new file mode 100644 index 00000000..2433acef --- /dev/null +++ b/app/Console/Commands/ImportJdDailyBills.php @@ -0,0 +1,37 @@ +get()) { + $this->error('京东日账单正在导入,请稍后重试。'); + return self::FAILURE; + } + try { + $result = $importer->import((string) $this->argument('path'), (string) $this->argument('month'), + (int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense')); + $this->info('京东日账单已启用,笔数:' . $result['row_count']); + return self::SUCCESS; + } catch (Throwable $e) { + $this->error('京东日账单未启用,保留上一版本。' . ($e instanceof \InvalidArgumentException ? $e->getMessage() : '请检查文件、重复记录及镜像连接。')); + return self::FAILURE; + } finally { + $lock->release(); + } + } +} diff --git a/database/migrations/2026_09_18_140000_create_jd_daily_bill_mirror_tables.php b/database/migrations/2026_09_18_140000_create_jd_daily_bill_mirror_tables.php new file mode 100644 index 00000000..453ad034 --- /dev/null +++ b/database/migrations/2026_09_18_140000_create_jd_daily_bill_mirror_tables.php @@ -0,0 +1,51 @@ +hasTable('jd_daily_bill_periods')) { + $schema->create('jd_daily_bill_periods', function (Blueprint $t): void { + $t->date('period_start')->primary(); + $t->date('period_end'); + $t->uuid('active_generation'); + $t->string('source_filename'); + $t->char('source_sha256', 64); + $t->unsignedInteger('row_count'); + $t->decimal('income', 20, 2); + $t->decimal('expense', 20, 2); + $t->dateTime('imported_at'); + }); + } + if (! $schema->hasTable('jd_daily_bill_rows')) { + $schema->create('jd_daily_bill_rows', function (Blueprint $t): void { + $t->uuid('generation'); + $t->string('record_code', 64); + $t->string('store_name'); + $t->string('bill_item'); + $t->string('order_no', 64)->nullable(); + $t->dateTime('occurred_at'); + $t->decimal('income', 20, 2); + $t->decimal('expense', 20, 2); + $t->json('row_data'); + $t->primary(['generation', 'record_code'], 'jd_daily_pk'); + $t->index(['generation', 'occurred_at', 'record_code'], 'jd_daily_time'); + $t->index(['generation', 'occurred_at', 'income', 'expense'], 'jd_daily_summary'); + $t->index(['generation', 'store_name', 'occurred_at'], 'jd_daily_shop'); + $t->index(['generation', 'bill_item', 'occurred_at'], 'jd_daily_item'); + }); + } + } + + public function down(): void + { + $schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror')); + $schema->dropIfExists('jd_daily_bill_rows'); + $schema->dropIfExists('jd_daily_bill_periods'); + } +}; diff --git a/docs/京东日账单接入.md b/docs/京东日账单接入.md new file mode 100644 index 00000000..9d279fbf --- /dev/null +++ b/docs/京东日账单接入.md @@ -0,0 +1,21 @@ +# 京东日账单 + +店铺收款账户账单的“京东流水”展示胜算“平台原始账单(新)→ 京东商城 → 京东日账单”,以费用结算时间筛选。保留原始记录编码、单据编号、商品、费用发生时间和费用结算时间;日账单没有账户余额,页面第三项汇总为收支净额。 + +当前来源是胜算完整月份原始导出,尚未接入自动增量接口。页面标明已接入月份与导入时间,不将未导入月份解释为源端无数据。旧库 `jd_billFromShop2024` 尚未通过完整性核对,不作为正式来源。 + +## 同步及回滚 + +1. 在胜算按费用结算时间查询完整月份,记录笔数及收入、支出合计;下载该查询的全部异步导出分片。 +2. 将原始明细无损转为 UTF-8 CSV,长编号保持文本,金额保持两位小数。原件、CSV、核对证据仅存放私有目录。 +3. 备份 `jd_daily_bill_periods` 和受影响月份旧版本;首次接入记录两表原先不存在。生产同步节点持有 `/run/caiwu-upstream-sync.lock` 后运行: + +```bash +php artisan business-mirror:import-jd-daily --count=<笔数> --income=<收入> --expense=<支出> +``` + +导入分批写入共享业务镜像 `jd_daily_bill_rows`,精确核对笔数、金额、月份、币种、时间格式及记录编码唯一性。全部成功后才原子更新 `jd_daily_bill_periods` 的有效版本;失败清理本次未启用版本,保留旧数据。状态保存源文件 SHA256 和导入时间。重复导入不会叠加流水。 + +回滚时在同一互斥锁下恢复备份的月份状态(`active_generation`),旧版本行保留。首次接入回滚只撤销该月份状态,保留原始文件和镜像用于核查。代码回滚沿用发布脚本保留的上一版本镜像。 + +验收核对源端和镜像笔数、收支,验证真实页面首页、翻页、店铺和月份筛选、全量导出及耗时。后续自动同步只能在确认完整官方来源及稳定游标后接入,不能拿部分旧表填补未导入期间。 diff --git a/modules/EcommerceFinance/AccountBillColumns.php b/modules/EcommerceFinance/AccountBillColumns.php index 568bc088..af2fc854 100644 --- a/modules/EcommerceFinance/AccountBillColumns.php +++ b/modules/EcommerceFinance/AccountBillColumns.php @@ -118,26 +118,28 @@ class AccountBillColumns 'columns' => [ ['记录编码', 'record_code', 'num'], ['店铺', 'store_name', 'text'], - ['财务备注', 'finance_remark', 'text'], - ['订单', 'order_no', 'num'], - ['金额', 'amount', 'money'], ['自定义备注', 'custom_remark', 'text'], ['账单项目', 'bill_item', 'tag'], ['线上单号', 'payload.online_order_no', 'num'], - ['流水号', 'payload.serial_no', 'num'], - ['交易订单号', 'payload.tx_order_no', 'num'], - ['账户名称', 'payload.account_name', 'text'], - ['费用结算时间', 'occurred_at', 'date'], - ['账单日期', 'payload.bill_date', 'date'], + ['单据编号', 'payload.document_no', 'num'], + ['订单编号', 'order_no', 'num'], ['商户订单号', 'payload.merchant_order_no', 'num'], - ['交易备注', 'payload.tx_remark', 'text'], - ['交易类型', 'payload.tx_type', 'text'], + ['单据类型', 'payload.document_type', 'text'], + ['商品编码', 'payload.product_id', 'num'], + ['商品名称', 'payload.product_name', 'text'], + ['费用结算时间', 'occurred_at', 'date'], + ['费用发生时间', 'payload.cost_happened_at', 'date'], + ['费用项', 'payload.cost_name', 'text'], ['收入金额', 'income', 'money_in'], ['支出金额', 'expense', 'money_out'], - ['账户余额', 'balance', 'balance'], ['币种', 'payload.currency', 'text'], - ['记账请求号', 'payload.book_request_no', 'num'], - ['业务订单号', 'payload.business_order_no', 'num'], + ['钱包结算备注', 'payload.settle_remark', 'text'], + ['备注', 'remark', 'text'], + ['店铺号', 'payload.shop_no', 'num'], + ['京东门店编码', 'payload.jd_shop_no', 'num'], + ['品牌门店编号', 'payload.brand_shop_no', 'num'], + ['门店名称', 'payload.shop_name', 'text'], + ['账户代码', 'payload.account_code', 'text'], ['修改时间', 'updated_at', 'date'], ['修改人', 'modifier', 'text'], ], diff --git a/modules/EcommerceFinance/Http/Controllers/AccountBills.php b/modules/EcommerceFinance/Http/Controllers/AccountBills.php index aa9df058..5fa35053 100644 --- a/modules/EcommerceFinance/Http/Controllers/AccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AccountBills.php @@ -7,6 +7,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Modules\EcommerceFinance\AccountBillColumns; use Modules\EcommerceFinance\Models\AccountBill; +use Modules\EcommerceFinance\Repositories\JdDailyBillRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingDewuBillMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository; @@ -29,7 +30,7 @@ class AccountBills extends Controller abort(404, '未知的账户类型。'); } - if (in_array($billType, ['douyin', 'pinduoduo', 'dewu'], true)) { + if (in_array($billType, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions'], true)) { return $request->boolean('export') ? $this->export($request, $billType) : $this->mirrorIndex($request, $billType); @@ -118,7 +119,7 @@ class AccountBills extends Controller abort_unless(AccountBillColumns::has($bill_type), 404, '未知的账户类型。'); - if (in_array($bill_type, ['douyin', 'pinduoduo', 'dewu'], true)) { + if (in_array($bill_type, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions'], true)) { return $this->exportMirror($request, $bill_type); } @@ -142,7 +143,7 @@ class AccountBills extends Controller { $this->authorizeStoreReceivingBills(); - abort_unless(in_array($bill_type, ['douyin', 'pinduoduo', 'dewu'], true), 404, '该账户类型没有可用的筛选选项。'); + abort_unless(in_array($bill_type, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions'], true), 404, '该账户类型没有可用的筛选选项。'); $label = $this->mirrorLabel($bill_type); @@ -151,7 +152,7 @@ class AccountBills extends Controller $this->mirrorRepository($bill_type)->filterOptions() ); } catch (RuntimeException $exception) { - if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') { + if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready'], true)) { throw $exception; } @@ -271,7 +272,7 @@ class AccountBills extends Controller $perPage, ); } catch (RuntimeException $exception) { - if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') { + if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready'], true)) { throw $exception; } @@ -322,7 +323,7 @@ class AccountBills extends Controller $rows = $this->mirrorRepository($billType) ->exportRows($filters, $sort, $direction); } catch (RuntimeException $exception) { - if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') { + if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready'], true)) { throw $exception; } @@ -364,6 +365,7 @@ class AccountBills extends Controller private function mirrorRepository(string $billType): object { return match ($billType) { + 'jd-transactions' => app(JdDailyBillRepository::class), 'pinduoduo' => app(StoreReceivingPinduoduoBillMirrorRepository::class), 'dewu' => app(StoreReceivingDewuBillMirrorRepository::class), default => app(StoreReceivingDouyinBillMirrorRepository::class), @@ -373,6 +375,7 @@ class AccountBills extends Controller private function mirrorLabel(string $billType): string { return match ($billType) { + 'jd-transactions' => '京东日账单', 'pinduoduo' => '拼多多', 'dewu' => '得物', default => '抖音', diff --git a/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php b/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php new file mode 100644 index 00000000..0d8e3818 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php @@ -0,0 +1,87 @@ +periods(); + $query = $this->query($filters, $periods); + $summary = (clone $query)->selectRaw('COUNT(*) AS count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first(); + $total = (int) $summary->count; + $page = max(1, $page); + $perPage = min(100, max(1, $perPage)); + $rows = $this->ordered($query, $sort, $direction)->forPage($page, $perPage)->pluck('row_data'); + return [ + 'data' => $rows->map(fn ($r) => json_decode($r, true, 512, JSON_THROW_ON_ERROR))->all(), + 'total' => $total, 'current_page' => $page, 'last_page' => max(1, (int) ceil($total / $perPage)), 'per_page' => $perPage, + 'summary' => ['count' => $total, 'income' => (float) $summary->income, 'expense' => (float) $summary->expense, + 'balance' => round((float) $summary->income - (float) $summary->expense, 2)], + 'sync' => ['status' => 'imported', 'source' => '胜算原始导出', + 'last_succeeded_at' => $periods->max('imported_at'), + 'periods' => $periods->map(fn ($p) => substr($p->period_start, 0, 7))->values()->all()], + ]; + } + + public function exportRows(array $filters, string $sort, string $direction): iterable + { + $query = $this->ordered($this->query($filters, $this->periods()), $sort, $direction); + return $this->stream($query); + } + + private function stream(Builder $query): iterable + { + foreach ($query->select('row_data')->cursor() as $row) yield json_decode($row->row_data, true, 512, JSON_THROW_ON_ERROR); + } + + public function filterOptions(): array + { + $q = $this->query([], $this->periods()); + return [ + 'shops' => (clone $q)->distinct()->orderBy('store_name')->pluck('store_name')->all(), + 'bill_items' => (clone $q)->distinct()->orderBy('bill_item')->pluck('bill_item')->all(), + ]; + } + + private function periods(): \Illuminate\Support\Collection + { + if (! $this->db()->getSchemaBuilder()->hasTable('jd_daily_bill_periods')) throw new RuntimeException('jd_daily_bill_mirror_not_ready'); + $periods = $this->db()->table('jd_daily_bill_periods')->orderBy('period_start')->get(); + if ($periods->isEmpty()) throw new RuntimeException('jd_daily_bill_mirror_not_ready'); + return $periods; + } + + private function query(array $filters, \Illuminate\Support\Collection $periods): Builder + { + $q = $this->db()->table('jd_daily_bill_rows')->whereIn('generation', $periods->pluck('active_generation')->all()); + foreach (['store_name', 'bill_item'] as $field) { + if (($filters[$field] ?? '') !== '') $q->where($field, $filters[$field]); + } + if (($filters['start'] ?? '') !== '') $q->where('occurred_at', '>=', $filters['start'] . ' 00:00:00'); + if (($filters['end'] ?? '') !== '') $q->where('occurred_at', '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00'); + if (($filters['keyword'] ?? '') !== '') { + $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['keyword']) . '%'; + $q->where(fn (Builder $sub) => $sub->where('record_code', 'like', $like)->orWhere('order_no', 'like', $like)->orWhere('row_data', 'like', $like)); + } + return $q; + } + + private function ordered(Builder $q, string $sort, string $direction): Builder + { + $sort = in_array($sort, ['occurred_at', 'record_code', 'income', 'expense'], true) ? $sort : 'occurred_at'; + $direction = $direction === 'asc' ? 'asc' : 'desc'; + return $q->orderBy($sort, $direction)->orderBy('record_code', $direction); + } + + private function db(): \Illuminate\Database\Connection + { + return $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror')); + } +} diff --git a/modules/EcommerceFinance/Services/JdDailyBillImporter.php b/modules/EcommerceFinance/Services/JdDailyBillImporter.php new file mode 100644 index 00000000..3a871b5e --- /dev/null +++ b/modules/EcommerceFinance/Services/JdDailyBillImporter.php @@ -0,0 +1,128 @@ + '线上单号', '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): 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'); + $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 reconciled complete months; keep the preceding generation for rollback. + $db->transaction(fn () => $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; + } +} diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index 11f32e02..50afcf2d 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -24,6 +24,7 @@
+
来源:{{ sync.source }} · 已接入月份:{{ (sync.periods || []).join('、') }} · 导入时间:{{ sync.last_succeeded_at }}
{{ sourceGroups.length ? '账单明细' : columnsTitle }}{{ total }} 条
@@ -56,20 +57,20 @@ import TableColumnSettings from './TableColumnSettings.vue'; const SORTABLE = ['occurred_at','income','expense','balance','record_code']; export default { name:'AccountBills', components:{ TableColumnSettings }, props:{ apiBase:{type:String,required:true}, billType:{type:String,required:true}, userKey:{type:[String,Number],required:true} }, - data(){return{loading:false,loadError:false,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:{store_name:'',bill_item:'',start:'',end:'',keyword:''},filterOptions:{shops:[],bill_items:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};}, + data(){return{loading:false,loadError:false,sync:null,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:{store_name:'',bill_item:'',start:'',end:'',keyword:''},filterOptions:{shops:[],bill_items:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};}, computed:{ isDouyin(){return this.billType==='douyin';}, - usesMirrorOptions(){return ['douyin','pinduoduo','dewu'].indexOf(this.billType)!==-1;}, + usesMirrorOptions(){return ['douyin','pinduoduo','dewu','jd-transactions'].indexOf(this.billType)!==-1;}, invalidDateRange(){return Boolean(this.filters.start&&this.filters.end&&this.filters.start>this.filters.end);}, visibleColumns(){return this.configuredColumns.filter(column=>column.visible);}, visibleGroups(){return this.visibleColumns.reduce((groups,column)=>{if(!column.group)return groups;const last=groups[groups.length-1];if(last&&last.label===column.group)last.span+=1;else groups.push({label:column.group,span:1});return groups;},[]);}, - stats(){return[{label:'收入合计',value:this.money(this.summary.income),note:`${this.summary.count} 笔交易`,className:'is-income'},{label:'支出合计',value:this.money(this.summary.expense),note:'按当前筛选',className:'is-expense'},{label:'期末余额(最新)',value:this.money(this.summary.balance),note:'最新一条记录',className:''},{label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}];}, + stats(){return[{label:'收入合计',value:this.money(this.summary.income),note:`${this.summary.count} 笔交易`,className:'is-income'},{label:'支出合计',value:this.money(this.summary.expense),note:'按当前筛选',className:'is-expense'},{label:this.billType==='jd-transactions'?'收支净额':'期末余额(最新)',value:this.money(this.summary.balance),note:this.billType==='jd-transactions'?'按当前筛选':'最新一条记录',className:''},{label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}];}, rangeStart(){return this.total===0?0:(this.currentPage-1)*this.perPage+1;},rangeEnd(){return Math.min(this.currentPage*this.perPage,this.total);} }, mounted(){if(this.usesMirrorOptions)this.fetchFilterOptions();this.fetch();}, methods:{ async fetchFilterOptions(){this.filterOptionsLoading=true;this.filterOptionsError=false;try{const response=await window.axios.get(`${this.apiBase}/filter-options`);const body=response.data||{};this.filterOptions={shops:Array.isArray(body.shops)?body.shops:[],bill_items:Array.isArray(body.bill_items)?body.bill_items:[]};}catch(error){this.filterOptionsError=true;}finally{this.filterOptionsLoading=false;}}, - async fetch(){const requestId=++this.requestSerial;this.loading=true;this.loadError=false;try{const response=await window.axios.get(this.apiBase,{params:{...this.filters,sort:this.sort.key,dir:this.sort.dir,limit:this.perPage,page:this.currentPage}});if(requestId!==this.requestSerial)return;const body=response.data||{};this.rows=body.data||[];this.total=body.total||0;this.currentPage=body.current_page||1;this.lastPage=body.last_page||1;this.perPage=body.per_page||this.perPage;this.summary=body.summary||{income:0,expense:0,balance:0,count:0};if(body.columns){this.columnsTitle=body.columns.title||'';this.sourceGroups=body.columns.groups||[];this.sourceColumns=this.attachGroups(body.columns.columns||body.columns||[],this.sourceGroups);}}catch(error){if(requestId!==this.requestSerial)return;this.rows=[];this.total=0;this.summary={income:0,expense:0,balance:0,count:0};this.loadError=true;}finally{if(requestId===this.requestSerial)this.loading=false;}}, + async fetch(){const requestId=++this.requestSerial;this.loading=true;this.loadError=false;try{const response=await window.axios.get(this.apiBase,{params:{...this.filters,sort:this.sort.key,dir:this.sort.dir,limit:this.perPage,page:this.currentPage}});if(requestId!==this.requestSerial)return;const body=response.data||{};this.sync=body.sync||null;this.rows=body.data||[];this.total=body.total||0;this.currentPage=body.current_page||1;this.lastPage=body.last_page||1;this.perPage=body.per_page||this.perPage;this.summary=body.summary||{income:0,expense:0,balance:0,count:0};if(body.columns){this.columnsTitle=body.columns.title||'';this.sourceGroups=body.columns.groups||[];this.sourceColumns=this.attachGroups(body.columns.columns||body.columns||[],this.sourceGroups);}}catch(error){if(requestId!==this.requestSerial)return;this.rows=[];this.total=0;this.sync=null;this.summary={income:0,expense:0,balance:0,count:0};this.loadError=true;}finally{if(requestId===this.requestSerial)this.loading=false;}}, attachGroups(columns,groups){let index=0;return columns.map((column,columnIndex)=>{while(groups[index]&&columnIndex>=groups.slice(0,index+1).reduce((sum,group)=>sum+Number(group.span||0),0))index+=1;return{...column,key:column.field||`column-${columnIndex}`,group:groups[index]?groups[index].label:''};});}, applyFilters(){if(this.invalidDateRange)return;this.currentPage=1;this.fetch();},resetFilters(){this.filters={store_name:'',bill_item:'',start:'',end:'',keyword:''};this.applyFilters();},changePage(page){this.currentPage=page;this.fetch();this.$nextTick(()=>{if(this.$refs.scroll)this.$refs.scroll.scrollTop=0;});}, openDatePicker(refName){const input=this.$refs[refName];if(!input)return;input.focus();if(typeof input.showPicker==='function'){try{input.showPicker();return;}catch(error){/* Fall through for embedded browsers without picker support. */}}input.click();}, diff --git a/tests/Unit/EcommerceFinance/JdDailyBillTest.php b/tests/Unit/EcommerceFinance/JdDailyBillTest.php new file mode 100644 index 00000000..de7bfe5d --- /dev/null +++ b/tests/Unit/EcommerceFinance/JdDailyBillTest.php @@ -0,0 +1,111 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + config(['database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]); + DB::purge('business_mirror'); + (require database_path('migrations/2026_09_18_140000_create_jd_daily_bill_mirror_tables.php'))->up(); + } + + protected function tearDown(): void + { + foreach ($this->files as $file) unlink($file); + parent::tearDown(); + } + + public function test_complete_month_preserves_identifiers_and_unifies_paging_filters_and_export(): void + { + $rows = [$this->row('2000000000000000001', '40.12'), $this->row('2000000000000000002', '0.00', '2.03')]; + $this->import($rows, 2, '40.12', '2.03'); + $repo = app(JdDailyBillRepository::class); + $filters = ['start' => '2026-08-01', 'end' => '2026-08-31']; + $first = $repo->paginate($filters, 'record_code', 'asc', 1, 1); + $second = $repo->paginate($filters, 'record_code', 'asc', 2, 1); + $this->assertSame(2, $first['total']); + $this->assertSame(40.12, $first['summary']['income']); + $this->assertSame(2.03, $first['summary']['expense']); + $this->assertSame(38.09, $first['summary']['balance']); + $this->assertSame('2000000000000000001', $first['data'][0]['record_code']); + $this->assertSame('10100126080000000000000001', $first['data'][0]['payload']['document_no']); + $this->assertSame('2026-07-31 10:00:00', $first['data'][0]['payload']['cost_happened_at']); + $this->assertSame([$first['data'][0], $second['data'][0]], iterator_to_array($repo->exportRows($filters, 'record_code', 'asc'))); + $this->assertSame(1, $repo->paginate(['keyword' => '2000000000000000002'], 'occurred_at', 'asc', 1, 10)['total']); + $this->assertSame(0, $repo->paginate(['start' => '2026-09-01'], 'occurred_at', 'asc', 1, 10)['total']); + $this->assertSame(2, $repo->paginate(['store_name' => '测试京东店', 'bill_item' => '货款'], 'income', 'desc', 1, 10)['total']); + $this->assertSame(['测试京东店'], $repo->filterOptions()['shops']); + $this->assertSame(['2026-08'], $first['sync']['periods']); + $this->assertNotContains('balance', array_column(AccountBillColumns::for('jd-transactions')['columns'], 'field')); + } + + public function test_invalid_import_does_not_replace_active_month(): void + { + $row = $this->row('2000000000000000001', '10.00'); + $initial = $this->import([$row], 1, '10.00', '0.00'); + $cases = [ + [[$row], 2, '10.00', '0.00'], [[$row], 1, '11.00', '0.00'], + [[$row, $row], 2, '20.00', '0.00'], + [[array_replace($row, ['费用结算时间' => '2026-09-01 00:00:00'])], 1, '10.00', '0.00'], + [[array_replace($row, ['费用结算时间' => '2026-08-32 00:00:00'])], 1, '10.00', '0.00'], + [[array_replace($row, ['收入金额' => '1.005'])], 1, '1.00', '0.00'], + [[array_replace($row, ['币种' => 'USD'])], 1, '10.00', '0.00'], + [[array_replace($row, ['记录编码' => '2E+18'])], 1, '10.00', '0.00'], + ]; + foreach ($cases as $case) { + $caught = false; + try { $this->import(...$case); } catch (\InvalidArgumentException|\Illuminate\Database\QueryException) { $caught = true; } + $this->assertTrue($caught); + $this->assertSame($initial['active_generation'], DB::connection('business_mirror')->table('jd_daily_bill_periods')->value('active_generation')); + $this->assertSame(1, DB::connection('business_mirror')->table('jd_daily_bill_rows')->count()); + } + } + + public function test_reimport_is_atomic_and_keeps_old_version(): void + { + $first = $this->import([$this->row('2000000000000000001', '10.00')], 1, '10.00', '0.00'); + $second = $this->import([$this->row('2000000000000000001', '12.00')], 1, '12.00', '0.00'); + $this->assertNotSame($first['active_generation'], $second['active_generation']); + $this->assertSame(2, DB::connection('business_mirror')->table('jd_daily_bill_rows')->count()); + $this->assertSame(12.0, app(JdDailyBillRepository::class)->paginate([], 'occurred_at', 'asc', 1, 10)['summary']['income']); + } + + public function test_uninitialized_mirror_is_not_reported_as_empty_success(): void + { + $this->expectExceptionMessage('jd_daily_bill_mirror_not_ready'); + app(JdDailyBillRepository::class)->paginate([], 'occurred_at', 'asc', 1, 10); + } + + private function import(array $rows, int $count, string $income, string $expense): array + { + $path = tempnam(sys_get_temp_dir(), 'jd-daily-'); + $this->files[] = $path; + $file = fopen($path, 'wb'); + 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); + } + + private function row(string $code, string $income, string $expense = '0.00'): array + { + return array_replace(array_fill_keys(array_values(JdDailyBillImporter::PAYLOAD_FIELDS), ''), [ + '记录编码' => $code, '店铺' => '测试京东店', '账单项目' => '货款', '订单编号' => '3606404000000001', + '单据编号' => '10100126080000000000000001', '费用结算时间' => '2026-08-31 23:59:59', + '费用发生时间' => '2026-07-31 10:00:00', '收入金额' => $income, '支出金额' => $expense, '币种' => 'CNY', + ]); + } +} diff --git a/tests/Unit/View/AccountBillsTableTest.php b/tests/Unit/View/AccountBillsTableTest.php index 119f2d83..e7d9db2c 100644 --- a/tests/Unit/View/AccountBillsTableTest.php +++ b/tests/Unit/View/AccountBillsTableTest.php @@ -11,7 +11,7 @@ class AccountBillsTableTest extends TestCase $source = file_get_contents(__DIR__ . '/../../../resources/assets/js/components/business/AccountBills.vue'); $this->assertStringContainsString("isDouyin(){return this.billType==='douyin';}", $source); - $this->assertStringContainsString("['douyin','pinduoduo','dewu'].indexOf(this.billType)!==-1", $source); + $this->assertStringContainsString("['douyin','pinduoduo','dewu','jd-transactions'].indexOf(this.billType)!==-1", $source); $this->assertStringContainsString('v-if="usesMirrorOptions && !filterOptionsError"', $source); $this->assertStringContainsString('page-key="store-receiving-bills"', $source); $this->assertStringContainsString(':tab-key="billType"', $source); -- 2.47.2 From ae24935d8f994285688d81248c881c70388c672c Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Fri, 18 Sep 2026 14:28:19 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E6=98=8E=E7=A1=AE=E4=BA=AC=E4=B8=9C?= =?UTF-8?q?=E8=B4=B9=E7=94=A8=E7=BB=93=E7=AE=97=E7=AD=9B=E9=80=89=E4=B8=8E?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E7=B2=BE=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/EcommerceFinance/Repositories/JdDailyBillRepository.php | 2 +- resources/assets/js/components/business/AccountBills.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php b/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php index 0d8e3818..d23d4982 100644 --- a/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php +++ b/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php @@ -22,7 +22,7 @@ class JdDailyBillRepository return [ 'data' => $rows->map(fn ($r) => json_decode($r, true, 512, JSON_THROW_ON_ERROR))->all(), 'total' => $total, 'current_page' => $page, 'last_page' => max(1, (int) ceil($total / $perPage)), 'per_page' => $perPage, - 'summary' => ['count' => $total, 'income' => (float) $summary->income, 'expense' => (float) $summary->expense, + 'summary' => ['count' => $total, 'income' => round((float) $summary->income, 2), 'expense' => round((float) $summary->expense, 2), 'balance' => round((float) $summary->income - (float) $summary->expense, 2)], 'sync' => ['status' => 'imported', 'source' => '胜算原始导出', 'last_succeeded_at' => $periods->max('imported_at'), diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index 50afcf2d..887de580 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -18,7 +18,7 @@ -
发生时间
至
+
{{ billType === 'jd-transactions' ? '费用结算时间' : '发生时间' }}
至
-- 2.47.2