From 55125539177bf52ce6e387e2522fd016f0461768 Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Thu, 5 Nov 2026 19:13:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=AC=E4=B8=9C=E9=92=B1=E5=8C=85?= =?UTF-8?q?=E6=8C=89=E8=B4=A6=E6=9C=9F=E8=AE=A1=E7=AE=97=E6=9C=9F=E5=88=9D?= =?UTF-8?q?=E5=92=8C=E6=9C=9F=E6=9C=AB=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 期初余额取所选月份开始前聚水潭京东钱包每店铺账户最后一笔余额;期末余额统一按期初加收入减支出计算,并增加余额查询索引与定向测试。 --- ...00000_index_jd_wallet_opening_balances.php | 44 ++++++ docs/京东钱包接入.md | 2 +- .../Http/Controllers/AccountBills.php | 10 +- .../StoreReceivingJdWalletBillRepository.php | 144 +++++++++++++++++- .../js/components/business/AccountBills.vue | 21 ++- .../EcommerceFinance/JdWalletBillTest.php | 82 ++++++++-- tests/Unit/View/AccountBillsTableTest.php | 17 +++ 7 files changed, 299 insertions(+), 21 deletions(-) create mode 100644 database/migrations/2026_11_05_100000_index_jd_wallet_opening_balances.php diff --git a/database/migrations/2026_11_05_100000_index_jd_wallet_opening_balances.php b/database/migrations/2026_11_05_100000_index_jd_wallet_opening_balances.php new file mode 100644 index 00000000..86b5c3e4 --- /dev/null +++ b/database/migrations/2026_11_05_100000_index_jd_wallet_opening_balances.php @@ -0,0 +1,44 @@ +connection()); + if (! $schema->hasTable(self::TABLE) || $schema->hasIndex(self::TABLE, self::INDEX)) { + return; + } + + $schema->table(self::TABLE, static function (Blueprint $table): void { + $table->index( + ['generation', 'store_name', 'account_name', 'occurred_at', 'record_code'], + self::INDEX, + ); + }); + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable(self::TABLE) || ! $schema->hasIndex(self::TABLE, self::INDEX)) { + return; + } + + $schema->table( + self::TABLE, + static fn (Blueprint $table) => $table->dropIndex(self::INDEX), + ); + } + + private function connection(): string + { + return (string) config('upstream_mirrors.connection', 'business_mirror'); + } +}; diff --git a/docs/京东钱包接入.md b/docs/京东钱包接入.md index 9398ff1c..807846df 100644 --- a/docs/京东钱包接入.md +++ b/docs/京东钱包接入.md @@ -12,6 +12,6 @@ php artisan business-mirror:import-jd-wallet --count 导入前备份受影响镜像与月份状态;首次建表记录原表不存在。原件及无损 CSV 仅放私有目录,编号保存为文本。按批写入待启用版本,验证完整月份、笔数、收支、币种、重复编号和日期后原子启用,保留上一版本供回滚。失败不会启用部分数据。 -每条“账户余额”保持原始值,可为负数。汇总“账户余额合计(最新)”取当前筛选结果中每个“店铺 + 账户名称”的最新流水,按费用结算时间、数值记录编码消歧后求和;不把流水余额逐笔相加,也不把全表最后一笔当所有账户余额。此口径不补齐筛选范围内没有流水的账户。 +每条“账户余额”保持原始值,可为负数。页面“期初余额”取当前账期开始前,聚水潭京东钱包每个“店铺 + 账户名称”最后一笔流水余额之和;同一费用结算时间按数值记录编码消歧。首个导入月份没有上月流水时,按当月首笔余额减首笔收入加首笔支出倒推期初。“期末余额”不再取筛选结果的最新快照,统一按“期初余额 + 收入合计 - 支出合计”计算。切换月份时,期初截止日随所选账期变化。 回滚时使用同一锁恢复旧月份状态的 `active_generation`,原镜像行保留。首次接入可撤销该月份启用状态;不删除上游或其他业务数据。上线核对实际页面、首页/次页/末页、店铺与日期筛选、收支及全量导出。 diff --git a/modules/EcommerceFinance/Http/Controllers/AccountBills.php b/modules/EcommerceFinance/Http/Controllers/AccountBills.php index ede106d6..03a6cf88 100644 --- a/modules/EcommerceFinance/Http/Controllers/AccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AccountBills.php @@ -523,7 +523,15 @@ class AccountBills extends Controller 'current_page' => 1, 'last_page' => 1, 'per_page' => $perPage, - 'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0], + 'summary' => [ + 'income' => 0, + 'expense' => 0, + 'opening_balance' => null, + 'opening_balance_date' => null, + 'opening_balance_source' => null, + 'balance' => null, + 'count' => 0, + ], 'columns' => AccountBillColumns::for($billType), ], 503); } diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingJdWalletBillRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingJdWalletBillRepository.php index 197823b2..006399fc 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingJdWalletBillRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingJdWalletBillRepository.php @@ -2,13 +2,17 @@ namespace Modules\EcommerceFinance\Repositories; +use Carbon\CarbonImmutable; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Query\Builder; +use Illuminate\Support\Facades\Cache; use Modules\EcommerceFinance\Services\LedgerRemarkFilter; use RuntimeException; class StoreReceivingJdWalletBillRepository { + private const OPENING_BALANCE_CACHE_SECONDS = 300; + public function __construct(private readonly DatabaseManager $databases) {} public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array @@ -27,12 +31,21 @@ class StoreReceivingJdWalletBillRepository ->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); + $opening = $this->openingBalance($filters, $periods); + $income = round((float) $summary->income, 2); + $expense = round((float) $summary->expense, 2); 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], + 'summary' => [ + 'count' => $total, + 'income' => $income, + 'expense' => $expense, + 'opening_balance' => $opening['balance'], + 'opening_balance_date' => $opening['date'], + 'opening_balance_source' => $opening['source'], + 'balance' => round($opening['balance'] + $income - $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()], @@ -90,12 +103,127 @@ class StoreReceivingJdWalletBillRepository return $q->orderBy($sort, $direction)->orderBy('record_code', $direction); } - private function latestBalanceTotal(array $filters, \Illuminate\Support\Collection $periods): float + /** + * 当前账期开始前,每个店铺账户最后一笔余额之和。 + * + * 首个导入月份没有上月流水时,用当月首笔余额倒推期初, + * 避免把没有上月数据误报成零余额。 + * + * @return array{balance:float,date:?string,source:string} + */ + private function openingBalance(array $filters, \Illuminate\Support\Collection $periods): array { - // 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); + $start = $this->date($filters['start'] ?? null) ?? $this->date($filters['end'] ?? null); + if ($start === null) { + return ['balance' => 0.0, 'date' => null, 'source' => 'none']; + } + + $monthStart = $start->modify('first day of this month')->startOfDay(); + $nextMonthStart = $monthStart->modify('+1 month'); + $cutoff = $monthStart->format('Y-m-d H:i:s'); + $periodEnd = $nextMonthStart->format('Y-m-d H:i:s'); + $store = trim((string) ($filters['store_name'] ?? '')); + $generations = $periods->pluck('active_generation')->filter()->sort()->values()->all(); + $cacheKey = 'jd-wallet-opening-balance:v1:' . hash('sha256', implode('|', [ + $cutoff, + $store, + implode(',', $generations), + ])); + + $opening = Cache::remember( + $cacheKey, + self::OPENING_BALANCE_CACHE_SECONDS, + function () use ($generations, $cutoff, $periodEnd, $store): array { + $latestRows = $this->rankedBalances( + $generations, + $store, + '0000-01-01 00:00:00', + $cutoff, + 'desc', + ); + if ($latestRows->isNotEmpty()) { + return [ + 'balance' => round((float) $latestRows->sum('balance'), 2), + 'source' => 'previous_period', + ]; + } + + $firstRows = $this->rankedBalances( + $generations, + $store, + $cutoff, + $periodEnd, + 'asc', + ); + if ($firstRows->isEmpty()) { + return ['balance' => 0.0, 'source' => 'none']; + } + + return [ + 'balance' => round((float) $firstRows->sum( + static fn (object $row): float => (float) $row->balance + - (float) $row->income + + (float) $row->expense, + ), 2), + 'source' => 'first_row_estimate', + ]; + }, + ); + + return [ + 'balance' => (float) $opening['balance'], + 'date' => $monthStart->modify('-1 day')->format('Y-m-d'), + 'source' => (string) $opening['source'], + ]; + } + + /** + * @param list $generations + * @return \Illuminate\Support\Collection + */ + private function rankedBalances( + array $generations, + string $store, + string $from, + ?string $before, + string $direction, + ): \Illuminate\Support\Collection { + $query = $this->db()->table('jd_wallet_bill_rows') + ->whereIn('generation', $generations) + ->whereNotNull('balance') + ->where('occurred_at', '>=', $from) + ->when($before !== null, fn (Builder $query) => $query->where('occurred_at', '<', $before)) + ->when($store !== '', fn (Builder $query) => $query->where('store_name', $store)) + ->select(['store_name', 'account_name', 'occurred_at', 'income', 'expense', 'balance', 'record_code']); + + $ranked = $query->selectRaw( + 'ROW_NUMBER() OVER (PARTITION BY store_name, account_name ORDER BY occurred_at ' + . $direction . ', LENGTH(record_code) ' . $direction . ', record_code ' . $direction + . ') AS balance_rank', + ); + + return $this->db()->query() + ->fromSub($ranked, 'ranked_balances') + ->where('balance_rank', 1) + ->get(); + } + + private function date(mixed $value): ?CarbonImmutable + { + $date = trim((string) $value); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) { + return null; + } + + try { + $parsed = CarbonImmutable::createFromFormat('!Y-m-d', $date); + } catch (\Throwable) { + return null; + } + + return $parsed instanceof CarbonImmutable && $parsed->format('Y-m-d') === $date + ? $parsed + : null; } private function db(): \Illuminate\Database\Connection diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index 721858d8..5988a304 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -6,6 +6,7 @@ 'tw-stats--promotion-only': isPromotionMarketing && !compact, 'tw-stats--promotion-compact': compact, 'tw-stats--taobao-wechat': billType === 'taobao-wechat', + 'tw-stats--jd-wallet': billType === 'jd-wallet', }" >