CaiWuXiTong/modules/EcommerceFinance/View/BillingDetailsPageData.php
yueyang 6af12c030e 财务经营报表:新增账单明细页(多选筛选/日历选择/四项汇总/按列导出与进度/收款明细同款分页),科目映射表字段改名财务备注与财务类型
- 账单明细:7 个平台账单分支下推查询,按 active_generation 取当前版本(修正抖音重复计数),跨月大数据量由 73s 降到 1-2s

- 筛选:平台/店铺/账单项目/财务类型多选下拉(打字模糊搜索+滚动),日期改日历选择

- 导出:全部筛选结果 CSV 流式下载并按显示列最小取数,带进度轮询;列设置按账号存本机

- 分页:每页 10/20/50,首页/上下页/末页,第 X-Y 条/共 N 条

- 科目映射表:字段改名财务备注/财务类型(导入兼容旧名)
2026-10-06 15:35:16 +08:00

640 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* 财务经营报表 › 账单明细。
*
* 数据源与「账单流水数据 › 店铺收款账户账单」同源(各平台已导入账单表),
* 财务备注 / 财务类型 由科目映射表按「平台 + 账单项目(wx 去前缀)」带出。
*
* 查询按「分支下推」组织:每个平台各自带日期和筛选走自己的索引;
* 京东账单 / 京东钱包 / 抖音 再按 periods 表的 active_generation 拆成单版本分支,
* 避免同一账期多版本重复计数,同时让每个分支都能用 (generation, occurred_at, record_code)
* 索引直接排序取前 N 行,不再把整月数据物化成临时表再排序。
*/
class BillingDetailsPageData
{
/**
* 各平台账单表;periods 非空表示该表按 active_generation 区分版本,必须用 periods 表锁定当前版本。
*/
private const SOURCES = [
'淘系支付宝' => ['table' => 'taobao_alipay_bill_rows', 'date' => 'occurred_at', 'income' => 'income_amount', 'expense' => 'expense_amount', 'online_order' => 'online_order_no', 'merchant_order' => 'merchant_order_no', 'type' => null, 'periods' => null],
'淘系微信' => ['table' => 'taobao_wechat_bill_rows', 'date' => 'occurred_at', 'income' => 'income_amount', 'expense' => 'expense_amount', 'online_order' => 'online_order_no', 'merchant_order' => 'pay_serial_no', 'type' => null, 'periods' => null],
'拼多多' => ['table' => 'pdd_settlement_bill_rows', 'date' => 'occurred_at', 'income' => 'income_amount', 'expense' => 'expense_amount', 'online_order' => 'online_order_no', 'merchant_order' => 'merchant_order_no', 'type' => 'account_name', 'periods' => null],
'京东账单' => ['table' => 'jd_daily_bill_rows', 'date' => 'occurred_at', 'income' => 'income', 'expense' => 'expense', 'online_order' => null, 'merchant_order' => null, 'type' => null, 'periods' => 'jd_daily_bill_periods'],
'京东钱包' => ['table' => 'jd_wallet_bill_rows', 'date' => 'occurred_at', 'income' => 'income', 'expense' => 'expense', 'online_order' => null, 'merchant_order' => null, 'type' => 'account_name', 'periods' => 'jd_wallet_bill_periods'],
'抖音' => ['table' => 'douyin_authoritative_bills', 'date' => 'occurred_at', 'income' => 'income', 'expense' => 'expense', 'online_order' => null, 'merchant_order' => null, 'type' => null, 'periods' => 'douyin_authoritative_periods'],
'得物' => ['table' => 'dewu_settlement_bill_rows', 'date' => 'bill_date', 'income' => 'income_amount', 'expense' => 'expense_amount', 'online_order' => 'online_order_no', 'merchant_order' => 'order_no', 'type' => null, 'periods' => null],
];
private const PLATFORM_PREFIX = [
'天猫' => '天猫', '淘宝' => '淘宝', '京东' => '京东', '京喜' => '京喜', '京喜小店' => '京喜',
'拼多多' => '拼多多', '抖音' => '抖音', '头条放心购' => '抖音', '得物' => '得物',
'阿里巴巴' => '阿里巴巴', '新佰广1688' => '阿里巴巴', '淘工厂' => '阿里巴巴',
'唯品会MP' => '唯品会MP', '唯品会JX' => '唯品会JX', '支付宝' => '支付宝',
];
public function page(): array
{
return [
'view' => 'business.pages.financial-operations.billing-details',
'options' => $this->options(),
'columns' => self::COLUMNS,
] + $this->data();
}
/**
* 页面与导出的列定义(顺序即默认显示顺序):key => [标签, 类型]。
*/
public const COLUMNS = [
'record_code' => ['记录编码', 'text'],
'store_name' => ['店铺', 'text'],
'finance_remark' => ['财务备注', 'text'],
'online_order_no' => ['线上单号', 'text'],
'amount' => ['金额', 'num'],
'bill_item' => ['账单项目', 'tag'],
'merchant_order_no' => ['商户订单号', 'text'],
'custom_remark' => ['自定义备注', 'text'],
'finance_type' => ['财务类型', 'tag'],
'type_text' => ['类型', 'text'],
'occurred_at' => ['发生时间', 'text'],
'income' => ['收入金额', 'num'],
'expense' => ['支出金额', 'num'],
'remark' => ['备注', 'text'],
];
/** 导出用:按请求解析筛选条件(与页面同一套口径) */
public function exportFilters(array $input): array
{
return $this->parseFilters($input);
}
/** 导出用:当前筛选下的总行数(进度百分比用) */
public function exportTotal(array $filters): int
{
if ($filters === []) {
return 0;
}
$parts = [];
$bind = [];
foreach (self::SOURCES as $label => $meta) {
$branch = $this->branch($label, $meta, $filters);
foreach ($branch['scopes'] as $scope) {
$parts[] = 'SELECT COUNT(*) AS c FROM ' . $branch['table'] . ' WHERE ' . $scope['where'];
$bind = array_merge($bind, $scope['bind']);
}
}
$total = 0;
foreach ($this->connection()->select(implode(' UNION ALL ', $parts), $bind) as $row) {
$total += (int) $row->c;
}
return $total;
}
/**
* 导出用:按 key 过滤列;不传即全部列。
*
* @param list<string> $keys
* @return array<string,array{0:string,1:string}>
*/
public function exportColumns(array $keys): array
{
if ($keys === []) {
return self::COLUMNS;
}
$wanted = array_flip(array_map(static fn ($key): string => trim((string) $key), $keys));
$columns = [];
foreach (self::COLUMNS as $key => $definition) {
if (isset($wanted[$key])) {
$columns[$key] = $definition;
}
}
return $columns === [] ? self::COLUMNS : $columns;
}
/**
* 导出用:逐分支流式产出「页面同款」行(财务备注/财务类型已按科目映射表带出)。
*
* @param array<string,mixed> $filters
* @return \Generator<array<string,string>>
*/
public function exportRows(array $filters, array $columns = []): \Generator
{
$connection = $this->connection();
$rules = $this->rules();
foreach (self::SOURCES as $label => $meta) {
$branch = $this->branch($label, $meta, $filters);
// 只查导出要用的列:备注这类长文本不查,从镜像库搬的数据量能少好几倍
$select = $columns === [] ? $branch['cols'] : $this->exportSelectList($meta, $columns);
foreach ($branch['scopes'] as $scope) {
// 用游标(非缓冲查询)逐行产出,避免上百万行一次性读进内存
foreach ($connection->cursor(
'SELECT ' . $select . ' FROM ' . $branch['table'] . ' WHERE ' . $scope['where']
. ' ORDER BY ' . $branch['date'] . ' DESC, record_code DESC',
$scope['bind']
) as $raw) {
yield $this->presentRow((array) $raw, $rules);
}
}
}
}
/**
* 按导出列拼最小查询列表(别名与页面列一致)。
*
* @param array<string,mixed> $meta
* @param array<string,array{0:string,1:string}> $columns
*/
private function exportSelectList(array $meta, array $columns): string
{
$table = (string) $meta['table'];
$has = fn (string $column): bool => in_array($column, $this->columns($table), true);
$pick = static fn (?string $column): ?string => $column !== null && $column !== '' ? $column : null;
$want = array_flip(array_keys($columns));
$finance = isset($want['finance_remark']) || isset($want['finance_type']);
$select = [];
$add = static function (string $alias, ?string $expr) use (&$select): void {
if ($expr === null || isset($select[$alias])) {
return;
}
$select[$alias] = $expr === $alias ? $alias : $expr . ' AS ' . $alias;
};
if (isset($want['record_code'])) { $add('record_code', 'record_code'); }
if (isset($want['store_name']) || $finance) { $add('store_name', 'store_name'); }
if (isset($want['bill_item']) || $finance) { $add('bill_item', 'bill_item'); }
if (isset($want['online_order_no'])) { $add('online_order_no', $pick(is_string($meta['online_order']) ? (string) $meta['online_order'] : null)); }
if (isset($want['merchant_order_no'])) { $add('merchant_order_no', $pick(is_string($meta['merchant_order']) ? (string) $meta['merchant_order'] : null)); }
if (isset($want['custom_remark'])) { $add('custom_remark', $has('custom_remark') ? 'custom_remark' : null); }
if (isset($want['remark'])) { $add('remark', $has('remark') ? 'remark' : null); }
if (isset($want['type_text'])) { $add('type_text', $pick(is_string($meta['type']) ? (string) $meta['type'] : null)); }
if (isset($want['occurred_at'])) { $add('occurred_at', (string) $meta['date']); }
if (isset($want['income']) || isset($want['amount'])) { $add('income', (string) $meta['income']); }
if (isset($want['expense']) || isset($want['amount'])) { $add('expense', (string) $meta['expense']); }
// 一列都没选到时至少取主键列,保证 SQL 合法
return $select === [] ? 'record_code' : implode(', ', $select);
}
/**
* 筛选下拉候选:店铺与账单项目(账单项目统一成去 wx 前缀后的名称,与带出财务备注/类型同一口径)。
* 镜像每天只会新增数据,候选清单按 6 小时缓存,避免每次开页都做一次索引扫描。
*
* @return array{stores:list<string>,items:list<string>}
*/
public function options(): array
{
try {
return cache()->remember('billing-details-filter-options-v1', now()->addHours(6), function (): array {
$stores = [];
$items = [];
foreach (self::SOURCES as $meta) {
$table = (string) $meta['table'];
try {
foreach ($this->connection()->select('SELECT DISTINCT store_name FROM ' . $table) as $row) {
$store = trim((string) $row->store_name);
if ($store !== '') { $stores[$store] = true; }
}
foreach ($this->connection()->select('SELECT DISTINCT bill_item FROM ' . $table) as $row) {
$item = $this->normalizeItem((string) $row->bill_item);
if ($item !== '') { $items[$item] = true; }
}
} catch (Throwable) {
continue;
}
}
$stores = array_keys($stores);
$items = array_keys($items);
sort($stores, SORT_NATURAL | SORT_FLAG_CASE);
sort($items, SORT_NATURAL | SORT_FLAG_CASE);
return ['stores' => $stores, 'items' => $items];
});
} catch (Throwable $exception) {
report($exception);
return ['stores' => [], 'items' => []];
}
}
/** @return array<string,mixed> */
public function data(?array $input = null): array
{
$input ??= request()->all();
$filters = $this->parseFilters($input);
$rows = [];
$total = 0;
$income = 0.0;
$expense = 0.0;
$status = null;
$error = null;
$limit = $this->perPage($input);
$page = max((int) ($input['page'] ?? request('page', 1)), 1);
$offset = ($page - 1) * $limit;
try {
$conn = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
// 每个平台一个分支,日期与筛选已下推到分支内,可各自命中 (日期, record_code) 索引
$branches = [];
foreach (self::SOURCES as $label => $meta) {
$branches[] = $this->branch($label, $meta, $filters);
}
// 汇总:按分支聚合并,避免把整月数据物化成临时表
$summaryParts = [];
$summaryBind = [];
foreach ($branches as $branch) {
foreach ($branch['scopes'] as $scope) {
$summaryParts[] = 'SELECT COUNT(*) AS c, COALESCE(SUM(' . $branch['income'] . '),0) AS inc, '
. 'COALESCE(SUM(' . $branch['expense'] . '),0) AS exp FROM ' . $branch['table']
. ' WHERE ' . $scope['where'];
$summaryBind = array_merge($summaryBind, $scope['bind']);
}
}
foreach ($conn->select(implode(' UNION ALL ', $summaryParts), $summaryBind) as $part) {
$total += (int) $part->c;
$income += (float) $part->inc;
$expense += (float) $part->exp;
}
// 明细:每个分支先用索引取自己的前 offset+limit 行,再合并排序,避免全量排序
if ($total > 0 && $offset < $total) {
$need = $offset + $limit;
$rowParts = [];
$rowBind = [];
foreach ($branches as $branch) {
foreach ($branch['scopes'] as $scope) {
$rowParts[] = '(SELECT ' . $branch['cols'] . ' FROM ' . $branch['table']
. ' WHERE ' . $scope['where']
. ' ORDER BY ' . $branch['date'] . ' DESC, record_code DESC LIMIT ' . $need . ')';
$rowBind = array_merge($rowBind, $scope['bind']);
}
}
$rows = $conn->select(
implode(' UNION ALL ', $rowParts) . ' ORDER BY occurred_at DESC, record_code DESC LIMIT ' . $limit . ' OFFSET ' . $offset,
$rowBind
);
}
} catch (Throwable $exception) {
report($exception);
$error = '账单明细数据暂时无法读取:' . $exception->getMessage();
}
$rules = $this->rules();
$rows = array_map(fn ($row): array => $this->presentRow((array) $row, $rules), $rows);
return [
'filters' => $filters,
'rows' => new LengthAwarePaginator($rows, $total, $limit, $page, [
'path' => request()->url(),
'query' => request()->except('page'),
]),
'summary' => ['rows' => $total, 'income' => round($income, 2), 'expense' => round($expense, 2), 'net' => round($income - $expense, 2)],
'platforms' => array_keys(self::PLATFORM_PREFIX),
'types' => $this->types(),
'error' => $error,
'status' => $status,
];
}
/**
* 组装单个平台分支:统一的输出列 + 每个账期版本一份「筛选已下推」的 WHERE。
*
* @param array<string,mixed> $meta
* @param array<string,string> $filters
* @return array{table:string,date:string,income:string,expense:string,cols:string,scopes:list<array{where:string,bind:list<mixed>}>}
*/
private function branch(string $label, array $meta, array $filters): array
{
$table = (string) $meta['table'];
$dateCol = (string) $meta['date'];
$has = fn (string $column): bool => in_array($column, $this->columns($table), true);
// 各平台账单表列不完全一致(京东/抖音没有 custom_remark、remark),缺的列统一留空
$customRemark = $has('custom_remark') ? 'custom_remark' : "''";
$remark = $has('remark') ? 'remark' : "''";
$online = is_string($meta['online_order']) && $has((string) $meta['online_order']) ? (string) $meta['online_order'] : "''";
$merchant = is_string($meta['merchant_order']) && $has((string) $meta['merchant_order']) ? (string) $meta['merchant_order'] : "''";
$typeCol = is_string($meta['type']) && $has((string) $meta['type']) ? (string) $meta['type'] : "''";
$cols = "'{$label}' AS source, record_code, store_name, {$customRemark} AS custom_remark, {$online} AS online_order_no, "
. "bill_item, {$merchant} AS merchant_order_no, {$typeCol} AS type_text, {$dateCol} AS occurred_at, "
. "{$meta['income']} AS income, {$meta['expense']} AS expense, {$remark} AS remark";
// 公共筛选(不含日期)逐条下推到分支,让各表尽量用自己的索引
$conditions = [];
$bind = [];
if ($filters['platform'] !== []) {
$parts = [];
foreach ($filters['platform'] as $platform) {
$parts[] = 'store_name LIKE ?';
$bind[] = $platform . '-%';
}
$conditions[] = '(' . implode(' OR ', $parts) . ')';
}
if ($filters['store'] !== []) {
$conditions[] = 'store_name IN (' . $this->literals($filters['store']) . ')';
}
if ($filters['item'] !== []) {
// 候选取自下拉清单(已去 wx 前缀),这里用同一套归一化表达式比对
$conditions[] = $this->normalizedItemSql() . ' IN (' . $this->literals($filters['item']) . ')';
}
if ($filters['keyword'] !== '') {
$keyword = '%' . $filters['keyword'] . '%';
$haystack = [];
foreach (array_unique(['record_code', $online, $merchant, 'bill_item', $customRemark, $remark]) as $column) {
$haystack[] = $column . ' LIKE ?';
$bind[] = $keyword;
}
$conditions[] = '(' . implode(' OR ', $haystack) . ')';
}
if ($filters['type'] !== []) {
$allowed = $this->itemsForTypes($filters['type']);
if ($allowed === []) {
$conditions[] = '1 = 0';
} else {
// 与页面带出财务备注/类型时一致:wx 前缀的账单项目按去前缀后的名称归类。
// 项目名以字面量内联:MySQL 对字面量 IN 列表会排序后二分查找,
// 参数化 IN 会逐行线性比对,161 个项目 × 130 万行会退化到 30 秒以上。
$conditions[] = $this->normalizedItemSql() . ' IN (' . $this->literals($allowed) . ')';
}
}
$dateBind = $dateCol === 'bill_date'
? [$filters['start'], $filters['end']]
: [$filters['start'] . ' 00:00:00', $filters['end'] . ' 23:59:59'];
$scopes = [];
foreach ($this->generations($meta['periods'] === null ? null : (string) $meta['periods'], $filters['start'], $filters['end']) as $generation) {
$where = [];
$scopeBind = [];
if ($generation !== null) {
$where[] = 'generation = ?';
$scopeBind[] = $generation;
}
$where[] = "{$dateCol} >= ? AND {$dateCol} <= ?";
$scopeBind = array_merge($scopeBind, $dateBind);
foreach ($conditions as $condition) {
$where[] = $condition;
}
$scopes[] = ['where' => implode(' AND ', $where), 'bind' => array_merge($scopeBind, $bind)];
}
return [
'table' => $table,
'date' => $dateCol,
'income' => (string) $meta['income'],
'expense' => (string) $meta['expense'],
'cols' => $cols,
'scopes' => $scopes,
];
}
/** @var array<string,list<string|null>> */
private array $generationCache = [];
private function connection(): \Illuminate\Database\Connection
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
/**
* 筛选值统一成字符串列表(表单提交的多选是数组;空值丢弃)。
*
* @return list<string>
*/
private function values(mixed $raw): array
{
$list = is_array($raw) ? $raw : [$raw];
$values = [];
foreach ($list as $value) {
$value = trim((string) $value);
if ($value !== '' && ! in_array($value, $values, true)) {
$values[] = $value;
}
}
return array_slice($values, 0, 500);
}
/**
* IN 列表以字面量内联(已按驱动转义)。
* MySQL 对字面量 IN 会排序后二分查找,参数化 IN 会逐行线性比对;
* 财务类型动辄上百个项目 × 上百万行,参数化会退化到 30 秒以上。
*
* @param list<string> $values
*/
private function literals(array $values): string
{
$pdo = $this->connection()->getPdo();
return implode(',', array_map(static fn (string $value): string => $pdo->quote($value), array_values(array_unique($values))));
}
/** 账单项目归一化:去掉 wx 前缀(与页面带出财务备注/类型时一致) */
private function normalizeItem(string $item): string
{
$item = trim($item);
return preg_match('/^wx/i', $item) === 1 ? ltrim(substr($item, 2)) : $item;
}
/**
* 解析筛选条件(页面与导出共用同一套口径)。
*
* @param array<string,mixed> $input
* @return array<string,mixed>
*/
private function parseFilters(array $input): array
{
$now = now();
$month = trim((string) ($input['month'] ?? '')) ?: $now->format('Y-m');
$start = trim((string) ($input['start'] ?? '')) ?: $month . '-01';
$end = trim((string) ($input['end'] ?? '')) ?: date('Y-m-t', strtotime($start));
// 日期只接受 Y-m-d,避免拼进 SQL 的字符串被注入
$start = preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) === 1 ? $start : $now->format('Y-m-01');
$end = preg_match('/^\d{4}-\d{2}-\d{2}$/', $end) === 1 ? $end : date('Y-m-t', strtotime($start));
if ($end < $start) { $end = $start; }
return [
'month' => substr($start, 0, 7),
'start' => $start,
'end' => $end,
'platform' => $this->values($input['platform'] ?? []),
'store' => $this->values($input['store'] ?? []),
'item' => $this->values($input['item'] ?? []),
'type' => $this->values($input['type'] ?? []),
'keyword' => trim((string) ($input['keyword'] ?? '')),
'per_page' => $this->perPage($input),
];
}
/** 每页条数:只接受 10 / 20 / 50,缺省 20 */
private function perPage(array $input): int
{
$size = (int) ($input['per_page'] ?? 20);
return in_array($size, [10, 20, 50], true) ? $size : 20;
}
/**
* 行展示口径:带出平台、财务备注、财务类型(页面与导出一致)。
*
* @param array<string,mixed> $row
* @param array<string,array{memo:string,category:string,catch:bool}> $rules
* @return array<string,mixed>
*/
private function presentRow(array $row, array $rules): array
{
$shop = (string) ($row['store_name'] ?? '');
$prefix = $shop === '' ? '' : explode('-', $shop)[0];
$platform = self::PLATFORM_PREFIX[$prefix] ?? '';
$item = trim((string) ($row['bill_item'] ?? ''));
$normalized = $this->normalizeItem($item);
$rule = $rules[$platform . '|' . $normalized] ?? ($rules[$platform . '|' . $item] ?? null);
$row['platform'] = $platform;
$row['finance_remark'] = $rule['memo'] ?? '';
$row['finance_type'] = $rule['category'] ?? '';
$row['matched'] = $rule !== null;
return $row;
}
private function normalizedItemSql(): string
{
return "(CASE WHEN bill_item LIKE 'wx%' THEN LTRIM(SUBSTRING(bill_item, 3)) ELSE bill_item END)";
}
/**
* 取与查询区间有交集的账期的 active_generation(与 JD / 抖音账单页仓储同一口径)。
*
* 只取相交账期,可保证分支数量不随镜像历史累积增长;
* 账期信息不可用时退回 [null],表示该分支不区分版本。
*
* @return list<string|null>
*/
private function generations(?string $periodsTable, string $start, string $end): array
{
if ($periodsTable === null) {
return [null];
}
$cacheKey = $periodsTable . '|' . $start . '|' . $end;
if (array_key_exists($cacheKey, $this->generationCache)) {
return $this->generationCache[$cacheKey];
}
try {
$connection = $this->connection();
$columns = array_map(static fn ($column): string => (string) $column->Field, $connection->select('SHOW COLUMNS FROM ' . $periodsTable));
$active = $connection->table($periodsTable)->distinct()->orderBy('active_generation');
if (in_array('period_start', $columns, true) && in_array('period_end', $columns, true)) {
$active->where('period_start', '<=', $end)->where('period_end', '>=', $start);
}
$generations = $active->pluck('active_generation')
->filter(static fn ($generation): bool => is_string($generation) && $generation !== '')
->values()
->all();
if ($generations === []) {
// 查询区间与任何账期都不相交时,退回全部在用版本,保证不漏数
$generations = $connection->table($periodsTable)->distinct()->orderBy('active_generation')
->pluck('active_generation')
->filter(static fn ($generation): bool => is_string($generation) && $generation !== '')
->values()
->all();
}
} catch (Throwable) {
$generations = [];
}
return $this->generationCache[$cacheKey] = ($generations === [] ? [null] : $generations);
}
/** @var array<string,list<string>> */
private array $columnCache = [];
/** @return list<string> */
private function columns(string $table): array
{
if (! isset($this->columnCache[$table])) {
try {
$this->columnCache[$table] = array_map(
static fn ($c): string => (string) $c->Field,
DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))->select('SHOW COLUMNS FROM ' . $table)
);
} catch (Throwable) {
$this->columnCache[$table] = [];
}
}
return $this->columnCache[$table];
}
/** 科目映射表:平台|账单项目 → [memo, category] */
private function rules(): array
{
static $cache = null;
if ($cache !== null) { return $cache; }
$cache = [];
$path = storage_path('app/account-mapping/rules.json');
if (! is_file($path)) { return $cache; }
$raw = json_decode((string) file_get_contents($path), true);
foreach (($raw['rules'] ?? $raw) as $rule) {
$key = trim((string) ($rule['platform'] ?? '')) . '|' . trim((string) ($rule['item'] ?? ''));
$prev = $cache[$key] ?? null;
$isCatchAll = trim((string) ($rule['memo'] ?? '')) === '' && trim((string) ($rule['goods'] ?? '')) === '';
if ($prev === null || ($isCatchAll && ! $prev['catch'])) {
$cache[$key] = ['memo' => (string) ($rule['memo'] ?? ''), 'category' => (string) ($rule['category'] ?? ''), 'catch' => $isCatchAll];
}
}
return $cache;
}
/** @return list<string> */
private function types(): array
{
$types = [];
foreach ($this->rules() as $rule) {
if ($rule['category'] !== '' && ! in_array($rule['category'], $types, true)) { $types[] = $rule['category']; }
}
sort($types);
return $types;
}
/** 财务类型 → 该类型下的账单项目集合(用于按类型筛选时保持分页正确) */
private function itemsForType(string $type): array
{
return $this->itemsForTypes([$type]);
}
/**
* 多个财务类型 → 其下所有账单项目的并集。
*
* @param list<string> $types
* @return list<string>
*/
private function itemsForTypes(array $types): array
{
$wanted = array_flip($types);
$items = [];
foreach ($this->rules() as $key => $rule) {
if (isset($wanted[$rule['category']])) {
$items[explode('|', $key, 2)[1]] = true;
}
}
return array_keys($items);
}
}