CaiWuXiTong/modules/EcommerceFinance/Repositories/StoreReceivingJdWalletBillRepository.php
qiuxiang 3e202ca9bf feat: 京东钱包按账期计算期初和期末余额 (#901)
期初余额取所选月份开始前聚水潭京东钱包每店铺账户最后一笔余额;期末余额统一按期初加收入减支出计算,并增加余额查询索引与定向测试。

> 开工时标题使用 `WIP: <任务名称>` 并先填写“任务占用与边界”。Codex 完成独立合并前自审后补齐其余内容并移除 `WIP:`。

## 任务占用与边界

- 任务名称:
- 实际开发者:
- 任务来源/团队分工:
- 目标结果:
- 预计修改路径:
- 将写入的公共契约(路由、Controller 方法、Repository、表/视图、`source_key`、`scope`、权限名、菜单键或共享组件):
- 仅复用、明确不修改的公共契约:
- 数据源、表/视图和字段:
- 上游 `source_key` / `scope`:
- 权限名、菜单键或共享组件:
- 明确不修改的范围:

## 开工碰撞检查

- [ ] Codex 已执行 `scripts/codex-task-preflight.ps1`
- [ ] 已检查最新 `main` 和所有远程任务分支
- [ ] 已搜索主线既有页面、接口、Repository 和数据查询
- [ ] 已把“将写入”和“仅复用、不修改”的公共契约分开;仅复用项不作为单独阻断条件
- [ ] 未发现实际写入重叠;或已在下方写明相关分支、具体重叠项和协调结论

相关分支、重叠项与协调结论:

## 实际改动

- 完成内容:
- Codex 实际修改的文件:
- 新增或变更的公共契约:
- 与原计划的差异:

## 验证证据

- 正常流程:
- 空数据/错误输入/无权限场景:
- 受影响旧功能:
- 自动测试及结果:
- 实际页面、接口或只读业务查询结果:

## 风险检查

- [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复
- [ ] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权
- [ ] 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物
- [ ] 前端改动符合 `DESIGN.md`;无前端改动则写“不适用”

高风险影响、回滚和授权状态:

## 合并前复查

- [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查
- [ ] PR 不再是 `WIP:`,所有测试结果已经补齐
- [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 `origin/main...任务分支` 的完整 diff
- [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果
- [ ] 合并顺序不会覆盖其他未合并任务
- [ ] 已授权当前任务的 Codex 执行 Squash merge、验证 `main` 并清理任务分支
- [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权

Reviewed-on: #901
2026-11-05 19:34:02 +08:00

234 lines
10 KiB
PHP

<?php
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
{
$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);
}
$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' => $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()],
];
}
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]);
}
LedgerRemarkFilter::itemsWhereIn($q, (array) ($filters['bill_items'] ?? []), 'bill_item');
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);
}
/**
* 当前账期开始前,每个店铺账户最后一笔余额之和。
*
* 首个导入月份没有上月流水时,用当月首笔余额倒推期初,
* 避免把没有上月数据误报成零余额。
*
* @return array{balance:float,date:?string,source:string}
*/
private function openingBalance(array $filters, \Illuminate\Support\Collection $periods): array
{
$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<string> $generations
* @return \Illuminate\Support\Collection<int,object>
*/
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
{
return $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror'));
}
}