接入胜算京东日账单至京东流水 #201

Merged
qiuxiang merged 2 commits from feature/qiuxiang-jd-daily-bills into main 2026-09-18 14:27:40 +08:00
10 changed files with 466 additions and 25 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\JdDailyBillImporter;
use Throwable;
class ImportJdDailyBills extends Command
{
protected $signature = 'business-mirror:import-jd-daily
{path : 胜算京东日账单完整月份 UTF-8 CSV} {month : YYYY-MM}
{--count= : 核对笔数} {--income= : 核对收入} {--expense= : 核对支出}';
protected $description = '核对并启用胜算京东日账单月份镜像,保留旧版本';
public function handle(JdDailyBillImporter $importer): int
{
$lock = Cache::lock('jd-daily-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,51 @@
<?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_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');
}
};

View File

@ -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 <CSV路径> <YYYY-MM> --count=<笔数> --income=<收入> --expense=<支出>
```
导入分批写入共享业务镜像 `jd_daily_bill_rows`,精确核对笔数、金额、月份、币种、时间格式及记录编码唯一性。全部成功后才原子更新 `jd_daily_bill_periods` 的有效版本;失败清理本次未启用版本,保留旧数据。状态保存源文件 SHA256 和导入时间。重复导入不会叠加流水。
回滚时在同一互斥锁下恢复备份的月份状态(`active_generation`),旧版本行保留。首次接入回滚只撤销该月份状态,保留原始文件和镜像用于核查。代码回滚沿用发布脚本保留的上一版本镜像。
验收核对源端和镜像笔数、收支,验证真实页面首页、翻页、店铺和月份筛选、全量导出及耗时。后续自动同步只能在确认完整官方来源及稳定游标后接入,不能拿部分旧表填补未导入期间。

View File

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

View File

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

View File

@ -0,0 +1,87 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use RuntimeException;
class JdDailyBillRepository
{
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));
$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' => 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'),
'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'));
}
}

View File

@ -0,0 +1,128 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
class JdDailyBillImporter
{
public const PAYLOAD_FIELDS = [
'online_order_no' => '线上单号', '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;
}
}

View File

@ -18,12 +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">发生时间</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">{{ 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>
<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="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>
@ -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();},

View File

@ -0,0 +1,111 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\Repositories\JdDailyBillRepository;
use Modules\EcommerceFinance\Services\JdDailyBillImporter;
use PHPUnit\Framework\TestCase;
class JdDailyBillTest 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_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',
]);
}
}

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'].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);