diff --git a/app/Console/Commands/ImportJdWalletBills.php b/app/Console/Commands/ImportJdWalletBills.php new file mode 100644 index 00000000..61f7ea44 --- /dev/null +++ b/app/Console/Commands/ImportJdWalletBills.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_160000_create_jd_wallet_bill_mirror_tables.php b/database/migrations/2026_09_18_160000_create_jd_wallet_bill_mirror_tables.php new file mode 100644 index 00000000..43c36389 --- /dev/null +++ b/database/migrations/2026_09_18_160000_create_jd_wallet_bill_mirror_tables.php @@ -0,0 +1,53 @@ +hasTable('jd_wallet_bill_periods')) { + $schema->create('jd_wallet_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_wallet_bill_rows')) { + $schema->create('jd_wallet_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->decimal('balance', 20, 2); + $t->string('account_name'); + $t->primary(['generation', 'record_code'], 'jd_wallet_pk'); + $t->index(['generation', 'occurred_at', 'record_code'], 'jd_wallet_time'); + $t->index(['generation', 'occurred_at', 'income', 'expense'], 'jd_wallet_summary'); + $t->index(['generation', 'store_name', 'occurred_at'], 'jd_wallet_shop'); + $t->index(['generation', 'bill_item', 'occurred_at'], 'jd_wallet_item'); + }); + } + } + + public function down(): void + { + $schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror')); + $schema->dropIfExists('jd_wallet_bill_rows'); + $schema->dropIfExists('jd_wallet_bill_periods'); + } +}; diff --git a/docs/京东钱包接入.md b/docs/京东钱包接入.md new file mode 100644 index 00000000..9398ff1c --- /dev/null +++ b/docs/京东钱包接入.md @@ -0,0 +1,17 @@ +# 京东钱包流水 + +“京东网银钱包账户”读取胜算“平台原始账单(新)→ 京东商城 → 京东钱包”的完整月份原始明细。费用结算时间决定月份,保留流水号、交易订单号、账户名称、商户订单号、交易类型、账户余额、记账请求号等原始字段,与京东日账单独立存储。 + +当前通过完整月份原始导出校准,不代表自动增量接口已经接通。页面显示已接入月份和导入时间。镜像为 `jd_wallet_bill_rows`,月份有效版本及来源摘要为 `jd_wallet_bill_periods`。 + +生产节点在 `/run/caiwu-upstream-sync.lock` 内执行: + +```bash +php artisan business-mirror:import-jd-wallet --count=<核对笔数> --income=<核对收入> --expense=<核对支出> +``` + +导入前备份受影响镜像与月份状态;首次建表记录原表不存在。原件及无损 CSV 仅放私有目录,编号保存为文本。按批写入待启用版本,验证完整月份、笔数、收支、币种、重复编号和日期后原子启用,保留上一版本供回滚。失败不会启用部分数据。 + +每条“账户余额”保持原始值,可为负数。汇总“账户余额合计(最新)”取当前筛选结果中每个“店铺 + 账户名称”的最新流水,按费用结算时间、数值记录编码消歧后求和;不把流水余额逐笔相加,也不把全表最后一笔当所有账户余额。此口径不补齐筛选范围内没有流水的账户。 + +回滚时使用同一锁恢复旧月份状态的 `active_generation`,原镜像行保留。首次接入可撤销该月份启用状态;不删除上游或其他业务数据。上线核对实际页面、首页/次页/末页、店铺与日期筛选、收支及全量导出。 diff --git a/modules/EcommerceFinance/AccountBillColumns.php b/modules/EcommerceFinance/AccountBillColumns.php index af2fc854..c0bd07f8 100644 --- a/modules/EcommerceFinance/AccountBillColumns.php +++ b/modules/EcommerceFinance/AccountBillColumns.php @@ -83,31 +83,23 @@ 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.doc_no', 'num'], - ['订单编号', 'payload.order_no_2', 'num'], - ['商户订单号', 'payload.merchant_order_no', 'num'], - ['单据类型', 'payload.doc_type', 'text'], - ['商品编码', 'payload.goods_code', 'num'], - ['商品名称', 'payload.goods_name', 'text'], + ['流水号', 'payload.serial_no', 'num'], + ['交易订单号', 'order_no', 'num'], + ['账户名称', 'payload.account_name', 'text'], ['费用结算时间', 'occurred_at', 'date'], - ['费用发生时间', 'payload.fee_occurred_at', 'date'], - ['费用项', 'payload.fee_item', 'text'], + ['账单日期', 'payload.bill_date', 'date'], + ['商户订单号', 'payload.merchant_order_no', 'num'], + ['交易备注', 'payload.tx_remark', 'text'], + ['交易类型', 'payload.tx_type', 'text'], ['收入金额', 'income', 'money_in'], ['支出金额', 'expense', 'money_out'], + ['账户余额', 'balance', 'balance'], ['币种', 'payload.currency', 'text'], - ['钱包结算备注', 'payload.wallet_remark', 'text'], - ['备注', 'remark', 'text'], - ['店铺号', 'payload.store_no', 'num'], - ['京东门店编码', 'payload.jd_store_code', 'num'], - ['品牌门店编号', 'payload.brand_store_no', 'num'], - ['门店名称', 'payload.store_name_2', 'text'], - ['账户代码', 'payload.account_code', 'num'], + ['记账请求号', 'payload.book_request_no', 'num'], + ['业务订单号', 'payload.business_order_no', 'num'], ['修改时间', 'updated_at', 'date'], ['修改人', 'modifier', 'text'], ], diff --git a/modules/EcommerceFinance/Http/Controllers/AccountBills.php b/modules/EcommerceFinance/Http/Controllers/AccountBills.php index 5fa35053..7b550a21 100644 --- a/modules/EcommerceFinance/Http/Controllers/AccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AccountBills.php @@ -8,6 +8,7 @@ use Illuminate\Routing\Controller; use Modules\EcommerceFinance\AccountBillColumns; use Modules\EcommerceFinance\Models\AccountBill; use Modules\EcommerceFinance\Repositories\JdDailyBillRepository; +use Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingDewuBillMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository; @@ -30,7 +31,7 @@ class AccountBills extends Controller abort(404, '未知的账户类型。'); } - if (in_array($billType, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions'], true)) { + if (in_array($billType, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions', 'jd-wallet'], true)) { return $request->boolean('export') ? $this->export($request, $billType) : $this->mirrorIndex($request, $billType); @@ -119,7 +120,7 @@ class AccountBills extends Controller abort_unless(AccountBillColumns::has($bill_type), 404, '未知的账户类型。'); - if (in_array($bill_type, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions'], true)) { + if (in_array($bill_type, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions', 'jd-wallet'], true)) { return $this->exportMirror($request, $bill_type); } @@ -143,7 +144,7 @@ class AccountBills extends Controller { $this->authorizeStoreReceivingBills(); - abort_unless(in_array($bill_type, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions'], true), 404, '该账户类型没有可用的筛选选项。'); + abort_unless(in_array($bill_type, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions', 'jd-wallet'], true), 404, '该账户类型没有可用的筛选选项。'); $label = $this->mirrorLabel($bill_type); @@ -152,7 +153,7 @@ class AccountBills extends Controller $this->mirrorRepository($bill_type)->filterOptions() ); } catch (RuntimeException $exception) { - if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready'], true)) { + if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready', 'jd_wallet_bill_mirror_not_ready'], true)) { throw $exception; } @@ -272,7 +273,7 @@ class AccountBills extends Controller $perPage, ); } catch (RuntimeException $exception) { - if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready'], true)) { + if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready', 'jd_wallet_bill_mirror_not_ready'], true)) { throw $exception; } @@ -323,7 +324,7 @@ class AccountBills extends Controller $rows = $this->mirrorRepository($billType) ->exportRows($filters, $sort, $direction); } catch (RuntimeException $exception) { - if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready'], true)) { + if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready', 'jd_wallet_bill_mirror_not_ready'], true)) { throw $exception; } @@ -366,6 +367,7 @@ class AccountBills extends Controller { return match ($billType) { 'jd-transactions' => app(JdDailyBillRepository::class), + 'jd-wallet' => app(StoreReceivingJdWalletBillRepository::class), 'pinduoduo' => app(StoreReceivingPinduoduoBillMirrorRepository::class), 'dewu' => app(StoreReceivingDewuBillMirrorRepository::class), default => app(StoreReceivingDouyinBillMirrorRepository::class), @@ -376,6 +378,7 @@ class AccountBills extends Controller { return match ($billType) { 'jd-transactions' => '京东日账单', + 'jd-wallet' => '京东钱包', 'pinduoduo' => '拼多多', 'dewu' => '得物', default => '抖音', diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingJdWalletBillRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingJdWalletBillRepository.php new file mode 100644 index 00000000..53c31705 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/StoreReceivingJdWalletBillRepository.php @@ -0,0 +1,103 @@ +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)); + // Skip deep pages using narrow indexed keys, then load JSON only for the visible rows. + $keys = $this->ordered($query, $sort, $direction)->forPage($page, $perPage)->get(['generation', 'record_code']); + $rows = []; + foreach ($keys->groupBy('generation') as $generation => $group) { + $records = $this->db()->table('jd_wallet_bill_rows')->where('generation', $generation) + ->whereIn('record_code', $group->pluck('record_code')->all())->get(['record_code', 'row_data']); + foreach ($records as $record) $rows[$generation . ':' . $record->record_code] = json_decode($record->row_data, true, 512, JSON_THROW_ON_ERROR); + } + $balance = $this->latestBalanceTotal($filters, $periods); + return [ + 'data' => $keys->map(fn ($key) => $rows[$key->generation . ':' . $key->record_code])->all(), + 'total' => $total, 'current_page' => $page, 'last_page' => max(1, (int) ceil($total / $perPage)), 'per_page' => $perPage, + 'summary' => ['count' => $total, 'income' => round((float) $summary->income, 2), 'expense' => round((float) $summary->expense, 2), + 'balance' => $balance], + '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_wallet_bill_periods')) throw new RuntimeException('jd_wallet_bill_mirror_not_ready'); + $periods = $this->db()->table('jd_wallet_bill_periods')->orderBy('period_start')->get(); + if ($periods->isEmpty()) throw new RuntimeException('jd_wallet_bill_mirror_not_ready'); + return $periods; + } + + private function query(array $filters, \Illuminate\Support\Collection $periods): Builder + { + $q = $this->db()->table('jd_wallet_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', 'balance'], true) ? $sort : 'occurred_at'; + $direction = $direction === 'asc' ? 'asc' : 'desc'; + return $q->orderBy($sort, $direction)->orderBy('record_code', $direction); + } + + private function latestBalanceTotal(array $filters, \Illuminate\Support\Collection $periods): float + { + // Balances are snapshots: sum only the latest row per shop/account within the selected results. + $ranked = $this->query($filters, $periods)->select('balance') + ->selectRaw('ROW_NUMBER() OVER (PARTITION BY store_name, account_name ORDER BY occurred_at DESC, LENGTH(record_code) DESC, record_code DESC) AS balance_rank'); + return round((float) $this->db()->query()->fromSub($ranked, 'latest')->where('balance_rank', 1)->sum('balance'), 2); + } + + private function db(): \Illuminate\Database\Connection + { + return $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror')); + } +} diff --git a/modules/EcommerceFinance/Services/JdWalletBillImporter.php b/modules/EcommerceFinance/Services/JdWalletBillImporter.php new file mode 100644 index 00000000..8902030e --- /dev/null +++ b/modules/EcommerceFinance/Services/JdWalletBillImporter.php @@ -0,0 +1,134 @@ + '线上单号', '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): 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['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'), + ]; + // Publish only reconciled complete months; keep the preceding generation for rollback. + $db->transaction(fn () => $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; + } +} diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index 887de580..b0a3f5e5 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -18,13 +18,13 @@ -
{{ billType === 'jd-transactions' ? '费用结算时间' : '发生时间' }}
至
+
{{ ['jd-transactions','jd-wallet'].includes(billType) ? '费用结算时间' : '发生时间' }}
至
-
来源:{{ sync.source }} · 已接入月份:{{ (sync.periods || []).join('、') }} · 导入时间:{{ sync.last_succeeded_at }}
+
来源:{{ sync.source }} · 已接入月份:{{ (sync.periods || []).join('、') }} · 导入时间:{{ sync.last_succeeded_at }}
{{ sourceGroups.length ? '账单明细' : columnsTitle }}{{ total }} 条
@@ -60,11 +60,11 @@ export default { 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','jd-transactions'].indexOf(this.billType)!==-1;}, + usesMirrorOptions(){return ['douyin','pinduoduo','dewu','jd-transactions','jd-wallet'].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:this.billType==='jd-transactions'?'收支净额':'期末余额(最新)',value:this.money(this.summary.balance),note:this.billType==='jd-transactions'?'按当前筛选':'最新一条记录',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'?'收支净额':(this.billType==='jd-wallet'?'账户余额合计(最新)':'期末余额(最新)'),value:this.money(this.summary.balance),note:this.billType==='jd-transactions'?'按当前筛选':(this.billType==='jd-wallet'?'筛选结果中每个店铺账户最新一笔':'最新一条记录'),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();}, diff --git a/tests/Unit/EcommerceFinance/JdWalletBillTest.php b/tests/Unit/EcommerceFinance/JdWalletBillTest.php new file mode 100644 index 00000000..cf43efe1 --- /dev/null +++ b/tests/Unit/EcommerceFinance/JdWalletBillTest.php @@ -0,0 +1,126 @@ +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_160000_create_jd_wallet_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(StoreReceivingJdWalletBillRepository::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(-5.03, $first['summary']['balance']); + $this->assertSame('2000000000000000001', $first['data'][0]['record_code']); + $this->assertSame('10100126080000000000000001', $first['data'][0]['payload']['serial_no']); + $this->assertSame('2026-07-31 10:00:00', $first['data'][0]['payload']['bill_date']); + $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->assertContains('balance', array_column(AccountBillColumns::for('jd-wallet')['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_wallet_bill_periods')->value('active_generation')); + $this->assertSame(1, DB::connection('business_mirror')->table('jd_wallet_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_wallet_bill_rows')->count()); + $this->assertSame(12.0, app(StoreReceivingJdWalletBillRepository::class)->paginate([], 'occurred_at', 'asc', 1, 10)['summary']['income']); + } + + public function test_uninitialized_mirror_is_not_reported_as_empty_success(): void + { + $this->expectExceptionMessage('jd_wallet_bill_mirror_not_ready'); + app(StoreReceivingJdWalletBillRepository::class)->paginate([], 'occurred_at', 'asc', 1, 10); + } + + public function test_balances_use_latest_row_per_shop_and_account_and_keep_negative_values(): void + { + $rows = [ + array_replace($this->row('9', '10.00'), ['账户余额' => '100.00']), + array_replace($this->row('10', '20.00'), ['账户余额' => '-5.03']), + array_replace($this->row('11', '0.00', '2.00'), ['店铺' => '另一店', '账户余额' => '20.00']), + array_replace($this->row('12', '0.00'), ['账户名称' => '另一账户', '账户余额' => '7.00']), + ]; + $this->import($rows, 4, '30.00', '2.00'); + $repo = app(StoreReceivingJdWalletBillRepository::class); + $this->assertSame(21.97, $repo->paginate([], 'income', 'asc', 1, 1)['summary']['balance']); + $this->assertSame(1.97, $repo->paginate(['store_name' => '测试京东店'], 'income', 'desc', 1, 10)['summary']['balance']); + $this->assertSame(0.0, $repo->paginate(['start' => '2026-09-01'], 'occurred_at', 'desc', 1, 10)['summary']['balance']); + } + + 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(JdWalletBillImporter::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(JdWalletBillImporter::PAYLOAD_FIELDS), ''), [ + '记录编码' => $code, '店铺' => '测试京东店', '账单项目' => '货款', '交易订单号' => '3606404000000001', + '流水号' => '10100126080000000000000001', '费用结算时间' => '2026-08-31 23:59:59', + '账单日期' => '2026-07-31 10:00:00', '收入金额' => $income, '支出金额' => $expense, '币种' => 'CNY', '账户余额' => '-5.03', '账户名称' => '测试账户', + ]); + } +} diff --git a/tests/Unit/View/AccountBillsTableTest.php b/tests/Unit/View/AccountBillsTableTest.php index e7d9db2c..9c0ac911 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','jd-transactions'].indexOf(this.billType)!==-1", $source); + $this->assertStringContainsString("['douyin','pinduoduo','dewu','jd-transactions','jd-wallet'].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);