CaiWuXiTong/modules/EcommerceFinance/View/BillingDetailsPageData.php
yueyang c9f5e358c4 费用分类弹窗增加平台汇总
费用分类弹窗增加平台汇总
2026-10-25 14:50:52 +08:00

1749 lines
74 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 Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use Modules\EcommerceFinance\Services\AccountMappingMatcher;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Throwable;
/**
* 财务经营报表 › 账单明细。
*
* 数据源与「账单流水数据 › 店铺收款账户账单」同源(各平台已导入账单表),
* 财务备注 / 财务类型 由科目映射表按「平台 + 账单项目(wx 去前缀)」带出。
*
* 查询按「分支下推」组织:每个平台各自带日期和筛选走自己的索引;
* 京东账单 / 京东钱包 / 抖音 再按 periods 表的 active_generation 拆成单版本分支,
* 避免同一账期多版本重复计数,同时让每个分支都能用 (generation, occurred_at, record_code)
* 索引直接排序取前 N 行,不再把整月数据物化成临时表再排序。
*/
class BillingDetailsPageData
{
private const EXPENSE_FORCED_FILTERS = ['type' => ['费用']];
/**
* 各平台账单表;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', '支付宝' => '支付宝',
];
/** 账单来源页签 → 财务经营分析表使用的平台列。 */
private const ACCOUNT_LABELS = [
'淘系支付宝' => '淘系支付宝',
'淘系微信' => '淘系微信',
'拼多多' => '拼多多',
'京东钱包' => '京东网银钱包',
'抖音' => '抖音',
'得物' => '得物',
];
/**
* 平台筛选:按「账户 / 数据来源」分类,与「店铺收款账户账单」各页签一致。
* source 对应 self::SOURCES 的分支;prefix 非空时再按店铺前缀切分(京东账单 / 京喜账单同属一张表)。
*/
private const PLATFORM_FILTERS = [
'淘系支付宝' => ['source' => '淘系支付宝', 'prefix' => null],
'淘系微信' => ['source' => '淘系微信', 'prefix' => null],
'拼多多' => ['source' => '拼多多', 'prefix' => null],
'京东账单' => ['source' => '京东账单', 'prefix' => '京东'],
'京喜账单' => ['source' => '京东账单', 'prefix' => '京喜'],
'京东网银钱包' => ['source' => '京东钱包', 'prefix' => null],
'抖音' => ['source' => '抖音', 'prefix' => null],
'得物' => ['source' => '得物', 'prefix' => null],
];
public function page(): array
{
return [
'view' => 'business.pages.financial-operations.billing-details',
'options' => $this->options(),
'columns' => self::EXPENSE_COLUMNS,
] + $this->data();
}
/** 费用明细:复用账单明细,但服务端固定财务类型为「费用」。 */
public function expensePage(): array
{
return [
'view' => 'business.pages.financial-operations.billing-details',
'options' => $this->options(),
'columns' => self::COLUMNS,
'pageTitle' => '费用明细',
'expenseOnly' => true,
] + $this->data(null, self::EXPENSE_FORCED_FILTERS);
}
/**
* 页面与导出的列定义(顺序即默认显示顺序):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 const EXPENSE_COLUMNS = [
'platform' => ['平台', 'text'],
'store_name' => ['店铺', 'text'],
'online_order_no' => ['线上单号', 'text'],
'finance_remark' => ['账单费用', 'text'],
'amount' => ['金额(收-支)', 'num'],
];
/** 导出用:按请求解析筛选条件(与页面同一套口径) */
public function exportFilters(array $input, array $forcedFilters = []): array
{
return array_replace($this->parseFilters($input), $forcedFilters);
}
/** 导出用:当前筛选下的总行数(进度百分比用) */
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 $definitions = null): array
{
$definitions ??= self::COLUMNS;
if ($keys === []) {
return $definitions;
}
$wanted = array_flip(array_map(static fn ($key): string => trim((string) $key), $keys));
$columns = [];
foreach ($definitions as $key => $definition) {
if (isset($wanted[$key])) {
$columns[$key] = $definition;
}
}
return $columns === [] ? $definitions : $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 $forcedFilters = []): array
{
$input ??= request()->all();
$filters = array_replace($this->parseFilters($input), $forcedFilters);
$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_FILTERS),
'types' => $this->types(),
'remarks' => $this->remarks(),
'error' => $error,
'status' => $status,
];
}
/**
* 费用明细的账单费用分类汇总:只响应汇总请求,避免拖慢主表首屏。
*
* @return array{
* categories:list<array{
* memo:string,
* invoice_net:float,
* non_invoice_net:float,
* net:float,
* platforms:list<array{platform:string,invoice_net:float,non_invoice_net:float,net:float}>,
* items:list<array{bill_item:string,invoice_net:float,non_invoice_net:float,net:float}>
* }>,
* category_count:int,
* invoice_net:float,
* non_invoice_net:float,
* net:float
* }
*/
public function expenseCategorySummary(?array $input = null, array $forcedFilters = []): array
{
$input ??= request()->all();
$filters = array_replace($this->parseFilters($input), $forcedFilters);
$signature = md5((string) json_encode($filters, JSON_UNESCAPED_UNICODE));
return cache()->remember(
'billing-details-item-summary-v6:' . $signature,
now()->addMinutes(2),
function () use ($filters): array {
$connection = $this->connection();
$rulesByPlatform = $this->expenseRulesByPlatform();
$memoByPlatformItem = $this->expenseMemoByPlatformItem();
$parts = [];
$bind = [];
foreach (self::SOURCES as $label => $meta) {
$branch = $this->branch($label, $meta, $filters);
$platformSql = $this->expensePlatformSql($label);
$invoiceConditions = [];
$nonInvoiceConditions = [];
$memoConditions = [];
foreach (self::BRANCH_PLATFORMS[$label] ?? [] as $platform) {
$invoiceItems = array_keys($rulesByPlatform[$platform]['invoice_expense'] ?? []);
if ($invoiceItems !== []) {
$invoiceConditions[] = '(' . $this->storePrefixCondition($platform)
. ' AND ' . $this->normalizedItemSql()
. ' IN (' . $this->literals($invoiceItems) . '))';
}
$nonInvoiceItems = array_keys($rulesByPlatform[$platform]['non_invoice_expense'] ?? []);
if ($nonInvoiceItems !== []) {
$nonInvoiceConditions[] = '(' . $this->storePrefixCondition($platform)
. ' AND ' . $this->normalizedItemSql()
. ' IN (' . $this->literals($nonInvoiceItems) . '))';
}
$memoItems = [];
foreach ($memoByPlatformItem[$platform] ?? [] as $item => $memo) {
$memoItems[$memo][$item] = true;
}
foreach ($memoItems as $memo => $items) {
$memoConditions[$memo][] = '(' . $this->storePrefixCondition($platform)
. ' AND ' . $this->normalizedItemSql()
. ' IN (' . $this->literals(array_keys($items)) . '))';
}
}
if ($invoiceConditions === [] && $nonInvoiceConditions === [] && $memoConditions === []) {
continue;
}
$memoSql = 'CASE';
foreach ($memoConditions as $memo => $conditions) {
$memoSql .= ' WHEN (' . implode(' OR ', $conditions) . ') THEN ' . $this->literal($memo);
}
$memoSql .= ' ELSE ' . $this->literal('未填写账单费用') . ' END';
$bucketSql = 'CASE';
if ($invoiceConditions !== []) {
$bucketSql .= ' WHEN (' . implode(' OR ', $invoiceConditions)
. ") THEN 'invoice'";
}
if ($nonInvoiceConditions !== []) {
$bucketSql .= ' WHEN (' . implode(' OR ', $nonInvoiceConditions)
. ") THEN 'non_invoice'";
}
$bucketSql .= " ELSE 'unknown' END";
foreach ($branch['scopes'] as $scope) {
$parts[] = 'SELECT ' . $memoSql . ' AS ledger_memo, '
. $platformSql . ' AS platform_label, '
. $bucketSql . ' AS invoice_bucket, bill_item, '
. 'COALESCE(SUM(' . $branch['income'] . '),0) AS inc, '
. 'COALESCE(SUM(' . $branch['expense'] . '),0) AS exp '
. 'FROM ' . $branch['table'] . ' WHERE ' . $scope['where']
. ' GROUP BY ledger_memo, platform_label, invoice_bucket, bill_item';
$bind = array_merge($bind, $scope['bind']);
}
}
$totals = [];
$invoiceNet = 0.0;
$nonInvoiceNet = 0.0;
foreach ($connection->select(implode(' UNION ALL ', $parts), $bind) as $part) {
$bucket = (string) ($part->invoice_bucket ?? '');
if (! in_array($bucket, ['invoice', 'non_invoice'], true)) {
continue;
}
$net = (float) $part->inc - (float) $part->exp;
if ($bucket === 'invoice') {
$invoiceNet += $net;
} else {
$nonInvoiceNet += $net;
}
$this->accumulateLedgerMemoTotal(
$totals,
(string) ($part->ledger_memo ?? ''),
(string) ($part->platform_label ?? ''),
(string) ($part->bill_item ?? ''),
$net,
$bucket,
);
}
$items = $this->sortedLedgerMemoTotals($totals);
return [
'categories' => $items,
'category_count' => count($items),
'invoice_net' => round($invoiceNet, 2),
'non_invoice_net' => round($nonInvoiceNet, 2),
'net' => round($invoiceNet + $nonInvoiceNet, 2),
];
},
);
}
/**
* @param array<string,array{
* invoice_net:float,
* non_invoice_net:float,
* platforms:array<string,array{invoice_net:float,non_invoice_net:float}>,
* items:array<string,array{invoice_net:float,non_invoice_net:float}>
* }> $totals
*/
private function accumulateLedgerMemoTotal(
array &$totals,
string $ledgerMemo,
string $platform,
string $billItem,
float $net,
string $invoiceBucket,
): void {
$ledgerMemo = trim($ledgerMemo) ?: '未填写账单费用';
$platform = trim($platform) ?: '未填写平台';
$billItem = $this->normalizeItem($billItem) ?: '未填写账单项目';
$totals[$ledgerMemo] ??= [
'invoice_net' => 0.0,
'non_invoice_net' => 0.0,
'platforms' => [],
'items' => [],
];
$totals[$ledgerMemo]['platforms'][$platform] ??= ['invoice_net' => 0.0, 'non_invoice_net' => 0.0];
$totals[$ledgerMemo]['items'][$billItem] ??= ['invoice_net' => 0.0, 'non_invoice_net' => 0.0];
$key = $invoiceBucket === 'invoice' ? 'invoice_net' : 'non_invoice_net';
$totals[$ledgerMemo][$key] += $net;
$totals[$ledgerMemo]['platforms'][$platform][$key] += $net;
$totals[$ledgerMemo]['items'][$billItem][$key] += $net;
}
/**
* @param array<string,array{
* invoice_net:float,
* non_invoice_net:float,
* platforms:array<string,array{invoice_net:float,non_invoice_net:float}>,
* items:array<string,array{invoice_net:float,non_invoice_net:float}>
* }> $totals
* @return list<array{
* memo:string,
* invoice_net:float,
* non_invoice_net:float,
* net:float,
* platforms:list<array{platform:string,invoice_net:float,non_invoice_net:float,net:float}>,
* items:list<array{bill_item:string,invoice_net:float,non_invoice_net:float,net:float}>
* }>
*/
private function sortedLedgerMemoTotals(array $totals): array
{
$items = [];
foreach ($totals as $memo => $total) {
$invoiceNet = round((float) $total['invoice_net'], 2);
$nonInvoiceNet = round((float) $total['non_invoice_net'], 2);
$platforms = [];
foreach ($total['platforms'] as $platform => $platformTotal) {
$platformInvoiceNet = round((float) $platformTotal['invoice_net'], 2);
$platformNonInvoiceNet = round((float) $platformTotal['non_invoice_net'], 2);
$platforms[] = [
'platform' => $platform,
'invoice_net' => $platformInvoiceNet,
'non_invoice_net' => $platformNonInvoiceNet,
'net' => round($platformInvoiceNet + $platformNonInvoiceNet, 2),
];
}
usort($platforms, static function (array $left, array $right): int {
if (abs(abs($left['net']) - abs($right['net'])) >= 0.005) {
return abs($right['net']) <=> abs($left['net']);
}
return strnatcasecmp($left['platform'], $right['platform']);
});
$billItems = [];
foreach ($total['items'] as $billItem => $itemTotal) {
$itemInvoiceNet = round((float) $itemTotal['invoice_net'], 2);
$itemNonInvoiceNet = round((float) $itemTotal['non_invoice_net'], 2);
$billItems[] = [
'bill_item' => $billItem,
'invoice_net' => $itemInvoiceNet,
'non_invoice_net' => $itemNonInvoiceNet,
'net' => round($itemInvoiceNet + $itemNonInvoiceNet, 2),
];
}
usort($billItems, static function (array $left, array $right): int {
if (abs($left['net'] - $right['net']) >= 0.005) {
return $left['net'] <=> $right['net'];
}
return strnatcasecmp($left['bill_item'], $right['bill_item']);
});
$items[] = [
'memo' => $memo,
'invoice_net' => $invoiceNet,
'non_invoice_net' => $nonInvoiceNet,
'net' => round($invoiceNet + $nonInvoiceNet, 2),
'platforms' => $platforms,
'items' => $billItems,
];
}
usort($items, static function (array $left, array $right): int {
if (abs($left['net'] - $right['net']) >= 0.005) {
return $left['net'] <=> $right['net'];
}
return strnatcasecmp($left['memo'], $right['memo']);
});
return $items;
}
/**
* 财务类型为「费用」的账单金额,按财务经营分析表的「平台-店铺」及票据口径汇总。
*
* 金额口径为账单「支出 - 收入」,费用返还/退回以负数抵减费用。
* 票据口径 0.06 / 0.09 归入开票费用,不开票归入不开票费用。
*
* @return array<string,array{platform:string,store_name:string,invoice_expense:float,non_invoice_expense:float}>
*/
public function expenseBucketsForMonth(string $reportMonth): array
{
if (preg_match('/^\d{4}-\d{2}$/', $reportMonth) !== 1) {
return [];
}
$filters = $this->parseFilters(['month' => $reportMonth]);
$filters['platform'] = [];
$filters['store'] = [];
$filters['item'] = [];
$filters['type'] = [];
$filters['remark'] = [];
$filters['keyword'] = '';
$connection = $this->connection();
$buckets = [];
$rulesByPlatform = $this->expenseRulesByPlatform();
foreach (self::SOURCES as $label => $meta) {
$branch = $this->branch($label, $meta, $filters);
$conditions = ['invoice_expense' => [], 'non_invoice_expense' => []];
foreach (self::BRANCH_PLATFORMS[$label] ?? [] as $platform) {
foreach ($conditions as $field => $_) {
$items = array_keys($rulesByPlatform[$platform][$field] ?? []);
if ($items === []) {
continue;
}
$conditions[$field][] = [
'sql' => '(' . $this->storePrefixCondition($platform)
. ' AND bill_item IN (' . $this->literals($this->expenseItemVariants($items)) . '))',
'account' => $this->accountLabelForPlatform($label, $platform),
];
}
}
if ($conditions['invoice_expense'] === [] && $conditions['non_invoice_expense'] === []) {
continue;
}
$amount = 'COALESCE(' . $branch['expense'] . ', 0) - COALESCE(' . $branch['income'] . ', 0)';
foreach ($branch['scopes'] as $scope) {
$parts = [];
$bind = [];
foreach ($conditions as $field => $items) {
foreach ($items as $condition) {
$parts[] = 'SELECT ' . $this->literal((string) $condition['account']) . ' AS account_label, store_name, '
. ($field === 'invoice_expense' ? 'COALESCE(SUM(' . $amount . '), 0)' : '0') . ' AS invoice_expense, '
. ($field === 'non_invoice_expense' ? 'COALESCE(SUM(' . $amount . '), 0)' : '0') . ' AS non_invoice_expense '
. 'FROM ' . $branch['table'] . ' WHERE ' . $scope['where']
. ' AND ' . $condition['sql'] . ' GROUP BY store_name';
$bind = array_merge($bind, $scope['bind']);
}
}
$rows = $connection->select(
'SELECT account_label, store_name, '
. 'COALESCE(SUM(invoice_expense), 0) AS invoice_expense, '
. 'COALESCE(SUM(non_invoice_expense), 0) AS non_invoice_expense '
. 'FROM (' . implode(' UNION ALL ', $parts) . ') expense_rows '
. 'GROUP BY account_label, store_name',
$bind,
);
foreach ($rows as $row) {
$store = trim((string) ($row->store_name ?? ''));
$account = trim((string) ($row->account_label ?? ''));
if ($store === '' || $account === '') {
continue;
}
$key = $account . "\0" . $store;
$buckets[$key] ??= [
'platform' => $account,
'store_name' => $store,
'invoice_expense' => 0.0,
'non_invoice_expense' => 0.0,
];
$buckets[$key]['invoice_expense'] = round(
(float) $buckets[$key]['invoice_expense'] + (float) $row->invoice_expense,
2,
);
$buckets[$key]['non_invoice_expense'] = round(
(float) $buckets[$key]['non_invoice_expense'] + (float) $row->non_invoice_expense,
2,
);
}
}
}
foreach ($buckets as &$bucket) {
$bucket['invoice_expense'] = round((float) $bucket['invoice_expense'], 2);
$bucket['non_invoice_expense'] = round((float) $bucket['non_invoice_expense'], 2);
}
unset($bucket);
return $buckets;
}
private function accountLabelForPlatform(string $source, string $platform): string
{
if ($source === '京东账单') {
return $platform === '京喜' ? '京喜账单' : '京东账单';
}
return self::ACCOUNT_LABELS[$source] ?? $source;
}
/**
* 财务类型为「费用」的规则,按业务平台和票据口径分组。
*
* 当前科目映射保证同一平台同一账单项目的「费用」规则票据口径一致;
* 若以后出现同项目既开票又不开票,直接报错,避免静默算错。
*
* @return array<string,array{invoice_expense:array<string,true>,non_invoice_expense:array<string,true>}>
*/
private function expenseRulesByPlatform(): array
{
$itemMeta = [];
$feeRules = [];
foreach (app(AccountMappingStore::class)->all() as $rule) {
$platform = trim((string) ($rule['platform'] ?? ''));
$item = $this->normalizeItem((string) ($rule['item'] ?? ''));
$category = trim((string) ($rule['category'] ?? ''));
$invoice = trim((string) ($rule['invoice'] ?? ''));
if ($platform === '' || $item === '') {
continue;
}
$key = mb_strtolower($platform) . "\0" . mb_strtolower($item);
$itemMeta[$key]['categories'][$category] = true;
$itemMeta[$key]['invoices'][$invoice] = true;
if ($category === '费用') {
$feeRules[] = [$platform, $item, $invoice];
}
}
foreach ($feeRules as [$platform, $item, $invoice]) {
$item = $this->normalizeItem($item);
$meta = $itemMeta[mb_strtolower($platform) . "\0" . mb_strtolower($item)] ?? [];
if (count($meta['categories'] ?? []) > 1 || count($meta['invoices'] ?? []) > 1) {
throw new \RuntimeException(
'费用票据口径无法唯一确定:' . $platform . ' / ' . $item,
);
}
$bucket = match ($invoice) {
'0.06', '0.09' => 'invoice_expense',
'不开票' => 'non_invoice_expense',
default => throw new \RuntimeException(
'费用票据口径未支持:' . $platform . ' / ' . $item . ' / ' . $invoice,
),
};
$groups[$platform][$bucket][$item] = true;
}
return $groups ?? [];
}
/**
* 费用分类汇总使用:平台+账单项目 → 总账备注。
*
* @return array<string,array<string,string>>
*/
private function expenseMemoByPlatformItem(): array
{
$groups = [];
foreach (app(AccountMappingStore::class)->all() as $rule) {
$platform = trim((string) ($rule['platform'] ?? ''));
$item = $this->normalizeItem((string) ($rule['item'] ?? ''));
if ($platform === '' || $item === '' || trim((string) ($rule['category'] ?? '')) !== '费用') {
continue;
}
$memo = trim((string) ($rule['memo'] ?? '')) ?: '未填写账单费用';
$previous = $groups[$platform][$item] ?? null;
if ($previous !== null && $previous !== $memo) {
throw new \RuntimeException('费用分类无法唯一确定:' . $platform . ' / ' . $item);
}
$groups[$platform][$item] = $memo;
}
return $groups;
}
/**
* 科目映射表里的账单项目同时生成带 / 不带 wx 前缀的查找值,
* 保留各账单表的 bill_item 索引,避免对整月数据执行 SUBSTRING。
*
* @param list<string> $items
* @return list<string>
*/
private function expenseItemVariants(array $items): array
{
$variants = [];
foreach ($items as $item) {
$item = trim((string) $item);
if ($item === '') {
continue;
}
$variants[$item] = true;
$variants[preg_match('/^wx/i', $item) === 1 ? $this->normalizeItem($item) : 'wx' . $item] = true;
}
$variants = array_keys($variants);
sort($variants);
return $variants;
}
/**
* 筛选联动:按「除该筛选项以外的其它条件」统计各筛选项的可选值与行数,行数为 0 的取值不返回。
* 结果按筛选条件签名缓存 60 秒,避免连续勾选时反复扫同一批数据。
*
* @return array<string,array{values:list<string>,counts:array<string,int>}>
*/
public function facets(?array $input = null, array $forcedFilters = []): array
{
$input ??= request()->all();
$filters = array_replace($this->parseFilters($input), $forcedFilters);
$signature = md5((string) json_encode($filters, JSON_UNESCAPED_UNICODE));
try {
return cache()->remember(
'billing-details-facets-v1:' . $signature,
now()->addMinutes(5),
fn (): array => $this->computeFacets($filters),
);
} catch (Throwable $exception) {
report($exception);
return $this->emptyFacets();
}
}
/**
* @param array<string,mixed> $filters
* @return array<string,array{values:list<string>,counts:array<string,int>}>
*/
private function computeFacets(array $filters): array
{
$conn = $this->connection();
$rules = $this->rules();
$facets = $this->emptyFacets();
$facets['platform'] = $this->facetPlatform($conn, $this->withoutFilter($filters, 'platform'));
$facets['store'] = $this->shape(
$this->facetCounts($conn, $this->withoutFilter($filters, 'store'), 'store_name'),
$this->options()['stores'] ?? [],
);
// 账单项目按原始 bill_item 分组(命中 bill_item 索引),wx 前缀在 PHP 里归一,避免逐行函数计算拖慢扫描。
$itemByBranch = $this->facetCountsByBranch($conn, $this->withoutFilter($filters, 'item'), 'bill_item');
$itemCounts = [];
foreach ($itemByBranch as $counts) {
foreach ($this->normalizeItemCounts($counts) as $item => $count) {
$itemCounts[$item] = ($itemCounts[$item] ?? 0) + $count;
}
}
$facets['item'] = $this->shape($itemCounts, $this->options()['items'] ?? []);
// 财务类型 / 财务备注:按分支折算(分支内平台集合已知),避免跨平台把同一账单项目的行数重复计入。
$itemIndex = $this->ruleIndexByItem($rules);
$contextPlatforms = $this->rulePlatforms($filters['platform']);
$typeCounts = [];
$remarkCounts = [];
foreach ($itemByBranch as $label => $counts) {
$platforms = self::BRANCH_PLATFORMS[$label] ?? [];
if ($contextPlatforms !== []) {
$platforms = array_values(array_intersect($platforms, $contextPlatforms));
}
$normalized = $this->normalizeItemCounts($counts);
foreach ($this->foldItemCounts($normalized, $itemIndex, 'category', $platforms) as $value => $count) {
$typeCounts[$value] = ($typeCounts[$value] ?? 0) + $count;
}
foreach ($this->foldItemCounts($normalized, $itemIndex, 'memo', $platforms) as $value => $count) {
$remarkCounts[$value] = ($remarkCounts[$value] ?? 0) + $count;
}
}
$facets['type'] = $this->shape($typeCounts, $this->types());
$facets['remark'] = $this->shape($remarkCounts, $this->remarks());
// 未限定平台时,同一账单项目在不同平台可能挂不同备注/类型,行数无法精确拆分:
// 这种情况只给可选值(用于隐藏真的没有数据的取值),不展示可能偏大的行数。
if ($contextPlatforms === []) {
$facets['type']['counts'] = [];
$facets['remark']['counts'] = [];
}
return $facets;
}
/** @return array<string,array{values:list<string>,counts:array<string,int>}> */
private function emptyFacets(): array
{
return [
'platform' => ['values' => [], 'counts' => []],
'store' => ['values' => [], 'counts' => []],
'item' => ['values' => [], 'counts' => []],
'type' => ['values' => [], 'counts' => []],
'remark' => ['values' => [], 'counts' => []],
];
}
/** @param array<string,mixed> $filters */
private function withoutFilter(array $filters, string $key): array
{
if ($key === 'keyword') {
$filters['keyword'] = '';
} else {
$filters[$key] = [];
}
return $filters;
}
/**
* 平台联动:按「账户/数据来源」分支 + 店铺前缀统计行数。
*
* @param array<string,mixed> $filters
* @return array{values:list<string>,counts:array<string,int>}
*/
private function facetPlatform(mixed $conn, array $filters): array
{
$counts = [];
foreach (self::SOURCES as $label => $meta) {
$cases = [];
$labels = [];
foreach (self::PLATFORM_FILTERS as $platformLabel => $mapping) {
if ($mapping['source'] !== $label) {
continue;
}
$cases[] = $mapping['prefix'] === null
? 'COUNT(*)'
: 'SUM(CASE WHEN store_name LIKE ' . $this->likeLiteral((string) $mapping['prefix']) . ' THEN 1 ELSE 0 END)';
$labels[] = $platformLabel;
}
if ($cases === []) {
continue;
}
$select = [];
foreach ($cases as $index => $case) {
$select[] = 'COALESCE(' . $case . ', 0) AS c' . $index;
}
$branch = $this->branch($label, $meta, $filters);
foreach ($branch['scopes'] as $scope) {
$row = $conn->selectOne(
'SELECT ' . implode(', ', $select) . ' FROM ' . $branch['table'] . ' WHERE ' . $scope['where'],
$scope['bind'],
);
foreach ($labels as $index => $platformLabel) {
$counts[$platformLabel] = ($counts[$platformLabel] ?? 0) + (int) ($row->{'c' . $index} ?? 0);
}
}
}
return $this->shape($counts, array_keys(self::PLATFORM_FILTERS));
}
/**
* 单列联动:按该表达式分组统计行数。
*
* @param array<string,mixed> $filters
* @return array<string,int>
*/
/** 分支 → 该分支可能命中的科目映射表平台 */
private const BRANCH_PLATFORMS = [
'淘系支付宝' => ['天猫', '淘宝', '阿里巴巴', '支付宝'],
'淘系微信' => ['天猫', '淘宝'],
'拼多多' => ['拼多多'],
'京东账单' => ['京东', '京喜'],
'京东钱包' => ['京东'],
'抖音' => ['抖音'],
'得物' => ['得物'],
];
private function facetCounts(mixed $conn, array $filters, string $expression): array
{
$counts = [];
foreach ($this->facetCountsByBranch($conn, $filters, $expression) as $branchCounts) {
foreach ($branchCounts as $key => $count) {
$counts[$key] = ($counts[$key] ?? 0) + $count;
}
}
return $counts;
}
/**
* 单列联动(按分支返回):用于只在本分支的平台集合内折算财务类型 / 财务备注。
*
* @param array<string,mixed> $filters
* @return array<string,array<string,int>> 分支标签 → [取值 => 行数]
*/
private function facetCountsByBranch(mixed $conn, array $filters, string $expression): array
{
$result = [];
foreach (self::SOURCES as $label => $meta) {
$branch = $this->branch($label, $meta, $filters);
$parts = [];
$bind = [];
foreach ($branch['scopes'] as $scope) {
$parts[] = 'SELECT ' . $expression . ' AS k, COUNT(*) AS c FROM ' . $branch['table']
. ' WHERE ' . $scope['where'] . ' GROUP BY k';
$bind = array_merge($bind, $scope['bind']);
}
if ($parts === []) {
continue;
}
$counts = [];
foreach ($conn->select(implode(' UNION ALL ', $parts), $bind) as $row) {
$key = trim((string) $row->k);
if ($key !== '') {
$counts[$key] = ($counts[$key] ?? 0) + (int) $row->c;
}
}
$result[$label] = $counts;
}
return $result;
}
/**
* wx 前缀归一:把「wx 前缀」的账单项目并到去前缀后的名称上。
*
* @param array<string,int> $counts
* @return array<string,int>
*/
/**
* 平台筛选值(账户口径)→ 科目映射表里的平台取值。
*
* @param list<string> $labels
* @return list<string>
*/
private function rulePlatforms(array $labels): array
{
$map = [
'淘系支付宝' => ['天猫', '淘宝', '阿里巴巴', '支付宝'],
'淘系微信' => ['天猫', '淘宝'],
'拼多多' => ['拼多多'],
'京东账单' => ['京东'],
'京喜账单' => ['京喜'],
'京东网银钱包' => ['京东'],
'抖音' => ['抖音'],
'得物' => ['得物'],
];
$platforms = [];
foreach ($labels as $label) {
foreach ($map[$label] ?? [$label] as $platform) {
$platforms[$platform] = true;
}
}
return array_keys($platforms);
}
private function normalizeItemCounts(array $counts): array
{
$normalized = [];
foreach ($counts as $item => $count) {
$key = $this->normalizeItem($item);
if ($key === '') {
continue;
}
$normalized[$key] = ($normalized[$key] ?? 0) + $count;
}
return $normalized;
}
/**
* 账单项目 → [平台 => ['memo'=>..,'category'=>..]] 预索引,避免逐项遍历全部规则。
*
* @param array<string,array{memo:string,category:string,invoice:string,catch:bool}> $rules
* @return array<string,array<string,array{memo:string,category:string}>>
*/
private function ruleIndexByItem(array $rules): array
{
$index = [];
foreach ($rules as $key => $rule) {
[$platform, $item] = array_pad(explode('|', $key, 2), 2, '');
if ($platform === '' || $item === '') {
continue;
}
$index[$item][$platform] = ['memo' => $rule['memo'], 'category' => $rule['category']];
}
return $index;
}
/**
* 账单项目行数 → 财务类型 / 财务备注行数。
*
* @param array<string,int> $itemCounts
* @param array<string,array<string,array{memo:string,category:string}>> $itemIndex
* @param list<string> $platforms 平台筛选(为空表示不限平台)
* @return array<string,int>
*/
private function foldItemCounts(array $itemCounts, array $itemIndex, string $field, array $platforms): array
{
$counts = [];
foreach ($itemCounts as $item => $count) {
$values = [];
foreach ($itemIndex[$item] ?? [] as $platform => $rule) {
$value = (string) ($rule[$field] ?? '');
if ($value === '') {
continue;
}
if ($platforms !== [] && ! in_array($platform, $platforms, true)) {
continue;
}
$values[$value] = true;
}
foreach (array_keys($values) as $value) {
$counts[$value] = ($counts[$value] ?? 0) + $count;
}
}
return $counts;
}
/**
* @param array<string,int> $counts
* @param list<string> $order
* @return array{values:list<string>,counts:array<string,int>}
*/
private function shape(array $counts, array $order = []): array
{
$values = [];
foreach ($order as $value) {
if (($counts[$value] ?? 0) > 0) {
$values[] = $value;
}
}
// 行数为 0 的取值不展示(当前已勾选的会由前端补回列表,方便取消)
$rest = [];
foreach ($counts as $value => $count) {
if ($count > 0 && ! in_array($value, $values, true)) {
$rest[] = $value;
}
}
sort($rest, SORT_NATURAL | SORT_FLAG_CASE);
$values = array_merge($values, $rest);
$shaped = [];
foreach ($values as $value) {
$shaped[$value] = (int) $counts[$value];
}
return ['values' => $values, 'counts' => $shaped];
}
private function likeLiteral(string $prefix): string
{
return "'" . str_replace(["\\", "'", '%', '_'], ["\\\\", "''", '\\%', '\\_'], $prefix) . "%'";
}
/**
* 组装单个平台分支:统一的输出列 + 每个账期版本一份「筛选已下推」的 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'] : "''";
// 科目映射表按业务备注/商品名称区分时要用到这些列(没有的列统一留空)
$detailColumns = [];
foreach (['channel', 'business_type', 'order_type', 'second_remark', 'goods_name'] as $detailColumn) {
$detailColumns[] = ($has($detailColumn) ? '`' . $detailColumn . '`' : "''") . ' AS ' . $detailColumn;
}
$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, "
. implode(', ', $detailColumns);
// 公共筛选(不含日期)逐条下推到分支,让各表尽量用自己的索引
$conditions = [];
$bind = [];
if ($filters['platform'] !== []) {
// 平台按账户来源筛:先看这个分支被哪几个平台命中,没命中就直接排除整个分支
$matched = [];
foreach ($filters['platform'] as $platform) {
$mapping = self::PLATFORM_FILTERS[$platform] ?? null;
if ($mapping !== null && $mapping['source'] === $label) {
$matched[] = $mapping;
}
}
if ($matched === []) {
$conditions[] = '1 = 0';
} else {
$parts = [];
foreach ($matched as $mapping) {
if ($mapping['prefix'] === null) {
$parts[] = '1 = 1';
continue;
}
$parts[] = 'store_name LIKE ?';
$bind[] = $mapping['prefix'] . '%';
}
$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'] !== []) {
$conditions[] = $this->platformItemCondition('category', $filters['type'], $label, $table);
}
if (($filters['remark'] ?? []) !== []) {
$conditions[] = $this->platformItemCondition('memo', $filters['remark'], $label, $table);
}
$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'] ?? []),
'remark' => $this->values($input['remark'] ?? []),
'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'] = (string) ($rule['memo'] ?? '');
$row['finance_type'] = (string) ($rule['category'] ?? '');
$row['finance_invoice'] = (string) ($rule['invoice'] ?? '');
$row['matched'] = $rule !== null;
// 同一账单项目多条规则时按业务备注/商品名称关键词取最具体的一条(与店铺收款账户账单页同一口径)
if ($platform !== '' && ($normalized !== '' || $item !== '')) {
$businessRemarks = [];
foreach (['custom_remark', 'channel', 'business_type', 'order_type', 'second_remark', 'remark'] as $column) {
$text = trim((string) ($row[$column] ?? ''));
if ($text !== '') {
$businessRemarks[] = $text;
}
}
$goodsName = trim((string) ($row['goods_name'] ?? ''));
try {
$matched = app(AccountMappingMatcher::class)->match($platform, $normalized !== '' ? $normalized : $item, $businessRemarks, $goodsName);
if ($matched === null && $normalized !== '' && $normalized !== $item) {
$matched = app(AccountMappingMatcher::class)->match($platform, $item, $businessRemarks, $goodsName);
}
} catch (\Throwable) {
$matched = null;
}
if ($matched !== null) {
$row['finance_remark'] = (string) ($matched['rule']['memo'] ?? '');
$row['finance_type'] = (string) ($matched['rule']['category'] ?? '');
$row['finance_invoice'] = (string) ($matched['rule']['invoice'] ?? '');
$row['matched'] = true;
}
}
return $row;
}
private function normalizedItemSql(): string
{
return "(CASE WHEN bill_item LIKE 'wx%' THEN LTRIM(SUBSTRING(bill_item, 3)) ELSE bill_item END)";
}
/**
* 「财务类型 / 财务备注」→ 按平台限定的 SQL 条件。
*
* 同一账单项目在不同平台可能挂不同的财务备注/类型,只按项目名过滤会把其它平台的同名项目一起带出来,
* 所以这里再按店铺前缀限定平台;项目名仍以字面量内联,避免参数化 IN 在千万行上退化成线性比对。
*
* @param string $field category(财务类型)或 memo(财务备注)
* @param list<string> $values
*/
private function platformItemCondition(string $field, array $values, string $branchLabel, string $table = ''): string
{
$branchPlatforms = self::BRANCH_PLATFORMS[$branchLabel] ?? [];
// 财务备注:同一账单项目多条规则时按业务备注/商品名称关键词折算(与账单页签、业绩明细同一口径)
if ($field === 'memo') {
$remarkMap = app(AccountLedgerRemarkMap::class);
$parts = [];
foreach ($values as $value) {
$value = trim((string) $value);
if ($value === '') {
continue;
}
foreach ($branchPlatforms as $platform) {
$plan = $remarkMap->ledgerRemarkPlanForPlatform($platform, $value);
if ($plan['items'] === [] && $plan['conditions'] === []) {
continue;
}
$sub = [];
if ($plan['items'] !== []) {
$sub[] = $this->normalizedItemSql() . ' IN (' . $this->literals($plan['items']) . ')';
}
foreach ($plan['conditions'] as $condition) {
$sub[] = $this->keywordConditionSql($condition, $table);
}
$parts[] = '(' . $this->storePrefixCondition($platform) . ' AND (' . implode(' OR ', $sub) . '))';
}
}
return $parts === [] ? '1 = 0' : '(' . implode(' OR ', $parts) . ')';
}
$wanted = array_flip($values);
$byPlatform = [];
foreach ($this->rules() as $key => $rule) {
$value = (string) ($rule[$field] ?? '');
if ($value === '' || ! isset($wanted[$value])) {
continue;
}
[$platform, $item] = array_pad(explode('|', $key, 2), 2, '');
if ($platform === '' || $item === '') {
continue;
}
$byPlatform[$platform][$item] = true;
}
$parts = [];
foreach ($byPlatform as $platform => $items) {
if ($branchPlatforms !== [] && ! in_array($platform, $branchPlatforms, true)) {
continue;
}
$parts[] = '(' . $this->storePrefixCondition($platform) . ' AND '
. $this->normalizedItemSql() . ' IN (' . $this->literals(array_keys($items)) . '))';
}
return $parts === [] ? '1 = 0' : '(' . implode(' OR ', $parts) . ')';
}
/**
* 关键词条件 → SQL(同一账单项目多条规则时用)。
*
* @param array<string,mixed> $condition
*/
private function keywordConditionSql(array $condition, string $table): string
{
$columns = $table === '' ? [] : $this->columns($table);
$remarkColumns = array_values(array_intersect(
['custom_remark', 'business_type', 'order_type', 'channel', 'second_remark', 'remark'],
$columns,
));
$goodsColumns = array_values(array_intersect(['goods_name', 'product_name'], $columns));
$matches = function (string $memo, string $goods) use ($remarkColumns, $goodsColumns): string {
$parts = [];
$like = function (array $cols, string $keyword): string {
$escaped = addcslashes($keyword, "%_\\");
$items = [];
foreach ($cols as $column) {
$items[] = "COALESCE(`{$column}`, '') LIKE '%" . str_replace("'", "''", $escaped) . "%'";
}
return $items === [] ? '' : '(' . implode(' OR ', $items) . ')';
};
if ($memo !== '') {
$sql = $like($remarkColumns, $memo);
if ($sql !== '') {
$parts[] = $sql;
}
}
if ($goods !== '') {
$sql = $like($goodsColumns, $goods);
if ($sql !== '') {
$parts[] = $sql;
}
}
return $parts === [] ? '' : '(' . implode(' AND ', $parts) . ')';
};
$parts = [$this->normalizedItemSql() . ' = ' . $this->literal((string) $condition['item'])];
if (($condition['negate'] ?? false) === true) {
$others = [];
foreach ((array) ($condition['others'] ?? []) as $other) {
$sql = $matches(trim((string) ($other['memo'] ?? '')), trim((string) ($other['goods'] ?? '')));
if ($sql !== '') {
$others[] = $sql;
}
}
$parts[] = $others === [] ? '1 = 1' : 'NOT (' . implode(' OR ', $others) . ')';
} else {
$sql = $matches(trim((string) ($condition['memo'] ?? '')), trim((string) ($condition['goods'] ?? '')));
$parts[] = $sql === '' ? '1 = 0' : $sql;
}
return '(' . implode(' AND ', $parts) . ')';
}
/** SQL 字符串字面量(精确匹配用,不做 LIKE 加通配)。 */
private function literal(string $value): string
{
return "'" . str_replace(["\\", "'"], ["\\\\", "''"], $value) . "'";
}
/** 科目映射表平台 → 对应的店铺名前缀条件(阿里巴巴含新佰广1688 / 淘工厂等)。 */
private function storePrefixCondition(string $platform): string
{
$parts = [];
foreach (self::PLATFORM_PREFIX as $prefix => $mapped) {
if ($mapped === $platform) {
$parts[] = 'store_name LIKE ' . $this->likeLiteral($prefix);
}
}
return $parts === [] ? '1 = 1' : '(' . implode(' OR ', $parts) . ')';
}
/**
* 账单来源分支 → 费用分类弹窗的平台名称。
*
* 同一来源可能混合多个平台(例如淘系支付宝包含天猫、淘宝、阿里巴巴、支付宝),
* 按店铺名前缀还原,无法识别时保留来源名称。
*/
private function expensePlatformSql(string $branchLabel): string
{
$conditions = [];
foreach (self::BRANCH_PLATFORMS[$branchLabel] ?? [] as $platform) {
$conditions[] = 'WHEN ' . $this->storePrefixCondition($platform)
. ' THEN ' . $this->literal($platform);
}
if ($conditions === []) {
return $this->literal($branchLabel);
}
return 'CASE ' . implode(' ', $conditions)
. ' ELSE ' . $this->literal($branchLabel) . ' 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, invoice](共享数据库,随页面请求实时读取) */
private function rules(): array
{
static $cache = null;
if ($cache !== null) { return $cache; }
$cache = [];
try {
$all = app(AccountMappingStore::class)->all();
} catch (Throwable) {
return $cache;
}
foreach ($all 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'] ?? ''),
'invoice' => (string) ($rule['invoice'] ?? ''),
'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;
}
/** @return list<string> 科目映射表里出现过的「财务备注」取值 */
private function remarks(): array
{
$remarks = [];
foreach ($this->rules() as $rule) {
if ($rule['memo'] !== '' && ! in_array($rule['memo'], $remarks, true)) { $remarks[] = $rule['memo']; }
}
sort($remarks);
return $remarks;
}
/**
* 多个财务备注 → 其下所有账单项目的并集。
*
* @param list<string> $remarks
* @return list<string>
*/
private function itemsForRemarks(array $remarks): array
{
$wanted = array_flip($remarks);
$items = [];
foreach ($this->rules() as $key => $rule) {
if (isset($wanted[$rule['memo']])) {
$items[explode('|', $key, 2)[1]] = true;
}
}
return array_keys($items);
}
/** 财务类型 → 该类型下的账单项目集合(用于按类型筛选时保持分页正确) */
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);
}
}