['费用']]; /** * 各平台账单表;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 $keys * @return array */ 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 $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 $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, * items:list * }>, * 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, * items:array * }> $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, * items:array * }> $totals * @return list, * items:list * }> */ 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 */ 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,non_invoice_expense:array}> */ 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> */ 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 $items * @return list */ 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,counts:array}> */ 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 $filters * @return array,counts:array}> */ 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,counts:array}> */ private function emptyFacets(): array { return [ 'platform' => ['values' => [], 'counts' => []], 'store' => ['values' => [], 'counts' => []], 'item' => ['values' => [], 'counts' => []], 'type' => ['values' => [], 'counts' => []], 'remark' => ['values' => [], 'counts' => []], ]; } /** @param array $filters */ private function withoutFilter(array $filters, string $key): array { if ($key === 'keyword') { $filters['keyword'] = ''; } else { $filters[$key] = []; } return $filters; } /** * 平台联动:按「账户/数据来源」分支 + 店铺前缀统计行数。 * * @param array $filters * @return array{values:list,counts:array} */ 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 $filters * @return array */ /** 分支 → 该分支可能命中的科目映射表平台 */ 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 $filters * @return array> 分支标签 → [取值 => 行数] */ 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 $counts * @return array */ /** * 平台筛选值(账户口径)→ 科目映射表里的平台取值。 * * @param list $labels * @return list */ 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 $rules * @return array> */ 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 $itemCounts * @param array> $itemIndex * @param list $platforms 平台筛选(为空表示不限平台) * @return array */ 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 $counts * @param list $order * @return array{values:list,counts:array} */ 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 $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'] : "''"; // 科目映射表按业务备注/商品名称区分时要用到这些列(没有的列统一留空) $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> */ 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'] ?? []), '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 $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'] = (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 $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 $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 */ 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, 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 */ 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 科目映射表里出现过的「财务备注」取值 */ 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 $remarks * @return list */ 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 $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); } }