接入胜算京东钱包至京东网银钱包账户 (#203)

This commit is contained in:
qiuxiang 2026-09-18 15:07:43 +08:00
parent 1c08c61b90
commit 3db5605375
10 changed files with 494 additions and 29 deletions

View File

@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\JdWalletBillImporter;
use Throwable;
class ImportJdWalletBills extends Command
{
protected $signature = 'business-mirror:import-jd-wallet
{path : 胜算京东钱包完整月份 UTF-8 CSV} {month : YYYY-MM}
{--count= : 核对笔数} {--income= : 核对收入} {--expense= : 核对支出}';
protected $description = '核对并启用胜算京东钱包月份镜像,保留旧版本';
public function handle(JdWalletBillImporter $importer): int
{
$lock = Cache::lock('jd-wallet-bill-import', 21600);
if (! $lock->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();
}
}
}

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
if (! $schema->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');
}
};

View File

@ -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 <UTF-8-CSV路径> <YYYY-MM> --count=<核对笔数> --income=<核对收入> --expense=<核对支出>
```
导入前备份受影响镜像与月份状态;首次建表记录原表不存在。原件及无损 CSV 仅放私有目录,编号保存为文本。按批写入待启用版本,验证完整月份、笔数、收支、币种、重复编号和日期后原子启用,保留上一版本供回滚。失败不会启用部分数据。
每条“账户余额”保持原始值,可为负数。汇总“账户余额合计(最新)”取当前筛选结果中每个“店铺 + 账户名称”的最新流水,按费用结算时间、数值记录编码消歧后求和;不把流水余额逐笔相加,也不把全表最后一笔当所有账户余额。此口径不补齐筛选范围内没有流水的账户。
回滚时使用同一锁恢复旧月份状态的 `active_generation`,原镜像行保留。首次接入可撤销该月份启用状态;不删除上游或其他业务数据。上线核对实际页面、首页/次页/末页、店铺与日期筛选、收支及全量导出。

View File

@ -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'],
],

View File

@ -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 => '抖音',

View File

@ -0,0 +1,103 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use RuntimeException;
class StoreReceivingJdWalletBillRepository
{
public function __construct(private readonly DatabaseManager $databases) {}
public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array
{
$periods = $this->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'));
}
}

View File

@ -0,0 +1,134 @@
<?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): 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;
}
}

View File

@ -18,13 +18,13 @@
</select>
<input v-else v-model.trim="filters.bill_item" placeholder="账单项目" />
</label>
<div class="tw-filter-field tw-date"><span class="tw-filter-label">{{ billType === 'jd-transactions' ? '费用结算时间' : '发生时间' }}</span><div class="tw-date-row"><span class="tw-date-control" :class="{ 'has-picker': isDouyin }"><input ref="startDate" v-model="filters.start" type="date" aria-label="开始日期" :max="filters.end || undefined" :aria-invalid="invalidDateRange ? 'true' : 'false'" @change="applyFilters" /><button v-if="isDouyin" type="button" class="tw-date-picker" title="选择开始日期" aria-label="选择开始日期" @click="openDatePicker('startDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span><b>至</b><span class="tw-date-control" :class="{ 'has-picker': isDouyin }"><input ref="endDate" v-model="filters.end" type="date" aria-label="结束日期" :min="filters.start || undefined" :aria-invalid="invalidDateRange ? 'true' : 'false'" @change="applyFilters" /><button v-if="isDouyin" type="button" class="tw-date-picker" title="选择结束日期" aria-label="选择结束日期" @click="openDatePicker('endDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span></div></div>
<div class="tw-filter-field tw-date"><span class="tw-filter-label">{{ ['jd-transactions','jd-wallet'].includes(billType) ? '费用结算时间' : '发生时间' }}</span><div class="tw-date-row"><span class="tw-date-control" :class="{ 'has-picker': isDouyin }"><input ref="startDate" v-model="filters.start" type="date" aria-label="开始日期" :max="filters.end || undefined" :aria-invalid="invalidDateRange ? 'true' : 'false'" @change="applyFilters" /><button v-if="isDouyin" type="button" class="tw-date-picker" title="选择开始日期" aria-label="选择开始日期" @click="openDatePicker('startDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span><b>至</b><span class="tw-date-control" :class="{ 'has-picker': isDouyin }"><input ref="endDate" v-model="filters.end" type="date" aria-label="结束日期" :min="filters.start || undefined" :aria-invalid="invalidDateRange ? 'true' : 'false'" @change="applyFilters" /><button v-if="isDouyin" type="button" class="tw-date-picker" title="选择结束日期" aria-label="选择结束日期" @click="openDatePicker('endDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span></div></div>
<label class="tw-keyword">关键词(记录编码 / 订单 / 备注)<input v-model.trim="filters.keyword" placeholder="输入关键词" /></label>
<div class="tw-actions"><button class="tw-button tw-primary" type="submit" :disabled="invalidDateRange">{{ loading ? '查询中…' : '查询' }}</button><button class="tw-button" type="button" @click="resetFilters">重置</button></div>
<p v-if="invalidDateRange" class="tw-filter-error" role="alert">结束日期不能早于开始日期</p>
</form>
<section class="tw-panel" :aria-label="`${columnsTitle || '账户'}账单明细`">
<div v-if="billType==='jd-transactions' && sync" class="tw-note">来源:{{ sync.source }} · 已接入月份:{{ (sync.periods || []).join('、') }} · 导入时间:{{ sync.last_succeeded_at }}</div>
<div v-if="['jd-transactions','jd-wallet'].includes(billType) && sync" class="tw-note">来源:{{ sync.source }} · 已接入月份:{{ (sync.periods || []).join('、') }} · 导入时间:{{ sync.last_succeeded_at }}</div>
<div v-if="loadError" class="tw-alert" role="alert"><span>账单加载失败,请重试</span><button type="button" @click="fetch">重新加载</button></div>
<div class="tw-toolbar">
<div><strong>{{ sourceGroups.length ? '账单明细' : columnsTitle }}</strong><span v-if="!loading" class="tw-count">{{ total }} 条</span></div>
@ -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();},

View File

@ -0,0 +1,126 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository;
use Modules\EcommerceFinance\Services\JdWalletBillImporter;
use PHPUnit\Framework\TestCase;
class JdWalletBillTest extends TestCase
{
private array $files = [];
protected function setUp(): void
{
parent::setUp();
$app = require dirname(__DIR__, 3) . '/bootstrap/app.php';
$app->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', '账户名称' => '测试账户',
]);
}
}

View File

@ -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);