['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', '支付宝' => '支付宝', ]; /** * 平台筛选:按「账户 / 数据来源」分类,与「店铺收款账户账单」各页签一致。 * 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::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 $keys * @return array */ 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 $filters * @return \Generator> */ 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 $meta * @param array $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,items:list} */ 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 */ 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_FILTERS), 'types' => $this->types(), 'error' => $error, 'status' => $status, ]; } /** * 组装单个平台分支:统一的输出列 + 每个账期版本一份「筛选已下推」的 WHERE。 * * @param array $meta * @param array $filters * @return array{table:string,date:string,income:string,expense:string,cols:string,scopes:list}>} */ 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'] !== []) { // 平台按账户来源筛:先看这个分支被哪几个平台命中,没命中就直接排除整个分支 $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'] !== []) { $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> */ private array $generationCache = []; private function connection(): \Illuminate\Database\Connection { return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); } /** * 筛选值统一成字符串列表(表单提交的多选是数组;空值丢弃)。 * * @return list */ 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 $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 $input * @return array */ 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 $row * @param array $rules * @return array */ 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 */ 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> */ private array $columnCache = []; /** @return list */ 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 */ 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 $types * @return list */ 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); } }