feat: 接入胜算京东钱包至店铺网银钱包账户
This commit is contained in:
parent
1c08c61b90
commit
4c06e35389
37
app/Console/Commands/ImportJdWalletBills.php
Normal file
37
app/Console/Commands/ImportJdWalletBills.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
17
docs/京东钱包接入.md
Normal file
17
docs/京东钱包接入.md
Normal 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`,原镜像行保留。首次接入可撤销该月份启用状态;不删除上游或其他业务数据。上线核对实际页面、首页/次页/末页、店铺与日期筛选、收支及全量导出。
|
||||
@ -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'],
|
||||
],
|
||||
|
||||
@ -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 => '抖音',
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
134
modules/EcommerceFinance/Services/JdWalletBillImporter.php
Normal file
134
modules/EcommerceFinance/Services/JdWalletBillImporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();},
|
||||
|
||||
126
tests/Unit/EcommerceFinance/JdWalletBillTest.php
Normal file
126
tests/Unit/EcommerceFinance/JdWalletBillTest.php
Normal 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', '账户名称' => '测试账户',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user