- 账单明细:7 个平台账单分支下推查询,按 active_generation 取当前版本(修正抖音重复计数),跨月大数据量由 73s 降到 1-2s - 筛选:平台/店铺/账单项目/财务类型多选下拉(打字模糊搜索+滚动),日期改日历选择 - 导出:全部筛选结果 CSV 流式下载并按显示列最小取数,带进度轮询;列设置按账号存本机 - 分页:每页 10/20/50,首页/上下页/末页,第 X-Y 条/共 N 条 - 科目映射表:字段改名财务备注/财务类型(导入兼容旧名)
640 lines
28 KiB
PHP
640 lines
28 KiB
PHP
<?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);
|
||
}
|
||
}
|