diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 12fd263c..93a4a30b 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -286,6 +286,97 @@ class Pages extends Controller return response()->json(['data' => $payload['data'], 'has_more' => $payload['has_more'] ?? false]); } + /** + * 账单明细导出:按页面同一套筛选条件流式导出 CSV(全部筛选结果)。 + */ + public function exportBillingDetails(Request $request): StreamedResponse + { + $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); + abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'billing-details'), 403, '当前账号没有该页面权限。'); + + $pageData = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class); + $filters = $pageData->exportFilters($request->all()); + $columns = $pageData->exportColumns(array_values(array_filter( + (array) $request->get('columns', []), + static fn ($key): bool => is_string($key) && $key !== '', + ))); + + $cell = static function (array $row, string $key): string { + $income = (float) ($row['income'] ?? 0); + $expense = (float) ($row['expense'] ?? 0); + + return match ($key) { + 'amount' => number_format($income - $expense, 2, '.', ''), + 'income' => $income > 0 ? number_format($income, 2, '.', '') : '', + 'expense' => $expense > 0 ? number_format($expense, 2, '.', '') : '', + default => trim((string) ($row[$key] ?? '')), + }; + }; + $csvText = fn (mixed $value): string => $this->csvText($value); + + // 导出进度:前端带 token 发起下载,导出过程中把已导出行数写进缓存,前端轮询显示进度 + $token = trim((string) $request->get('token', '')); + $token = preg_match('/^[A-Za-z0-9_-]{8,64}$/', $token) === 1 ? $token : ''; + $progressKey = $token === '' ? null : self::billingDetailsProgressKey($token); + $total = $pageData->exportTotal($filters); + $putProgress = static function (array $state) use ($progressKey): void { + if ($progressKey !== null) { + \Illuminate\Support\Facades\Cache::put($progressKey, $state, now()->addMinutes(30)); + } + }; + $putProgress(['status' => 'running', 'rows' => 0, 'total' => $total]); + + return response()->streamDownload(function () use ($pageData, $filters, $columns, $cell, $csvText, $putProgress, $total): void { + $csv = fopen('php://output', 'w'); + if ($csv === false) { + throw new \RuntimeException('无法创建账单明细导出流。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, array_map(static fn (array $definition): string => $definition[0], array_values($columns))); + + $written = 0; + $putProgress(['status' => 'running', 'rows' => 0, 'total' => $total]); + foreach ($pageData->exportRows($filters, $columns) as $row) { + $line = []; + foreach (array_keys($columns) as $key) { + $value = $cell($row, (string) $key); + // 数值列不做公式转义,否则负数会被写成 '-0.47 + $line[] = ($columns[$key][1] ?? '') === 'num' ? $value : $csvText($value); + } + fputcsv($csv, $line); + $written++; + if ($written % 5000 === 0) { + $putProgress(['status' => 'running', 'rows' => $written, 'total' => $total]); + } + } + + fclose($csv); + $putProgress(['status' => 'done', 'rows' => $written, 'total' => max($total, $written)]); + }, '账单明细_' . $filters['start'] . '_' . $filters['end'] . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + + /** 账单明细导出进度(前端每秒轮询一次) */ + public function billingDetailsExportProgress(Request $request): \Illuminate\Http\JsonResponse + { + $token = trim((string) $request->get('token', '')); + abort_unless(preg_match('/^[A-Za-z0-9_-]{8,64}$/', $token) === 1, 400, '导出标识无效。'); + + $state = \Illuminate\Support\Facades\Cache::get(self::billingDetailsProgressKey($token)); + + return response()->json($state ?? ['status' => 'unknown', 'rows' => 0, 'total' => 0], 200, [ + 'Cache-Control' => 'private, no-store', + ]); + } + + private static function billingDetailsProgressKey(string $token): string + { + return 'billing-details-export:' . (int) user()->id . ':' . $token; + } + public function exportInstallationDetails(Request $request): StreamedResponse { $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); @@ -1312,6 +1403,7 @@ class Pages extends Controller 'withdrawal-management' => $this->contentForWithdrawalManagement(), 'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(), 'account-mapping' => $this->contentForAccountMapping(), + 'billing-details' => app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)->page(), 'planting-details' => $this->contentForPlantingDetails(), 'jst-order-labels' => $this->contentForJstOrderLabels(), default => null, diff --git a/config/business_navigation.php b/config/business_navigation.php index 08b4df13..3ae9c9c5 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -723,6 +723,7 @@ $navigation = [ 'icon' => 'analytics', 'pages' => [ ['slug' => 'performance-details', 'title' => '业绩明细'], + ['slug' => 'billing-details', 'title' => '账单明细'], ['slug' => 'product-costs', 'title' => '商品成本'], ['slug' => 'courier-freight', 'title' => '快递运费'], ['slug' => 'installation-details', 'title' => '安装明细'], diff --git a/modules/EcommerceFinance/Services/AccountMappingStore.php b/modules/EcommerceFinance/Services/AccountMappingStore.php index 90fea12b..f3a97306 100644 --- a/modules/EcommerceFinance/Services/AccountMappingStore.php +++ b/modules/EcommerceFinance/Services/AccountMappingStore.php @@ -29,10 +29,10 @@ class AccountMappingStore public const LABELS = [ 'platform' => '平台', 'item' => '账单项目', - 'memo' => '业务备注', + 'memo' => '财务备注', 'goods' => '商品名称关键词', 'invoice' => '票据口径', - 'category' => '大类', + 'category' => '财务类型', 'dAcct' => '借方科目', 'dSub' => '借方二级', 'cAcct' => '贷方科目', diff --git a/modules/EcommerceFinance/Services/AccountMappingWorkbookImporter.php b/modules/EcommerceFinance/Services/AccountMappingWorkbookImporter.php index 9ca17f1f..6e02675b 100644 --- a/modules/EcommerceFinance/Services/AccountMappingWorkbookImporter.php +++ b/modules/EcommerceFinance/Services/AccountMappingWorkbookImporter.php @@ -25,7 +25,7 @@ class AccountMappingWorkbookImporter 'memo' => ['业务备注', '财务备注', '二次备注', '业务备注关键词', '账务备注', '备注'], 'goods' => ['商品名称关键词', '商品名称', '商品描述', '货品名称'], 'invoice' => ['票据口径', '票据', '票据类型', '发票'], - 'category' => ['大类', '业务性质', '科目大类', '分类'], + 'category' => ['财务类型', '大类', '业务性质', '科目大类', '分类'], 'basis' => ['方向基准', '基准'], 'dAcct' => ['借方科目', '借/贷(借)', '借/贷(借)', '借方'], 'dSub' => ['借方二级'], diff --git a/modules/EcommerceFinance/View/BillingDetailsPageData.php b/modules/EcommerceFinance/View/BillingDetailsPageData.php new file mode 100644 index 00000000..598e129e --- /dev/null +++ b/modules/EcommerceFinance/View/BillingDetailsPageData.php @@ -0,0 +1,639 @@ + ['table' => 'taobao_alipay_bill_rows', 'date' => 'occurred_at', 'income' => 'income_amount', 'expense' => 'expense_amount', 'online_order' => 'online_order_no', 'merchant_order' => 'merchant_order_no', 'type' => null, 'periods' => null], + '淘系微信' => ['table' => 'taobao_wechat_bill_rows', 'date' => 'occurred_at', 'income' => 'income_amount', 'expense' => 'expense_amount', 'online_order' => 'online_order_no', 'merchant_order' => 'pay_serial_no', 'type' => null, 'periods' => null], + '拼多多' => ['table' => 'pdd_settlement_bill_rows', 'date' => 'occurred_at', 'income' => 'income_amount', 'expense' => 'expense_amount', 'online_order' => 'online_order_no', 'merchant_order' => 'merchant_order_no', 'type' => 'account_name', 'periods' => null], + '京东账单' => ['table' => 'jd_daily_bill_rows', 'date' => 'occurred_at', 'income' => 'income', 'expense' => 'expense', 'online_order' => null, 'merchant_order' => null, 'type' => null, 'periods' => 'jd_daily_bill_periods'], + '京东钱包' => ['table' => 'jd_wallet_bill_rows', 'date' => 'occurred_at', 'income' => 'income', 'expense' => 'expense', 'online_order' => null, 'merchant_order' => null, 'type' => 'account_name', 'periods' => 'jd_wallet_bill_periods'], + '抖音' => ['table' => 'douyin_authoritative_bills', 'date' => 'occurred_at', 'income' => 'income', 'expense' => 'expense', 'online_order' => null, 'merchant_order' => null, 'type' => null, 'periods' => 'douyin_authoritative_periods'], + '得物' => ['table' => 'dewu_settlement_bill_rows', 'date' => 'bill_date', 'income' => 'income_amount', 'expense' => 'expense_amount', 'online_order' => 'online_order_no', 'merchant_order' => 'order_no', 'type' => null, 'periods' => null], + ]; + + private const PLATFORM_PREFIX = [ + '天猫' => '天猫', '淘宝' => '淘宝', '京东' => '京东', '京喜' => '京喜', '京喜小店' => '京喜', + '拼多多' => '拼多多', '抖音' => '抖音', '头条放心购' => '抖音', '得物' => '得物', + '阿里巴巴' => '阿里巴巴', '新佰广1688' => '阿里巴巴', '淘工厂' => '阿里巴巴', + '唯品会MP' => '唯品会MP', '唯品会JX' => '唯品会JX', '支付宝' => '支付宝', + ]; + + public function page(): array + { + return [ + 'view' => 'business.pages.financial-operations.billing-details', + 'options' => $this->options(), + 'columns' => self::COLUMNS, + ] + $this->data(); + } + + /** + * 页面与导出的列定义(顺序即默认显示顺序):key => [标签, 类型]。 + */ + public const COLUMNS = [ + 'record_code' => ['记录编码', 'text'], + 'store_name' => ['店铺', 'text'], + 'finance_remark' => ['财务备注', 'text'], + 'online_order_no' => ['线上单号', 'text'], + 'amount' => ['金额', 'num'], + 'bill_item' => ['账单项目', 'tag'], + 'merchant_order_no' => ['商户订单号', 'text'], + 'custom_remark' => ['自定义备注', 'text'], + 'finance_type' => ['财务类型', 'tag'], + 'type_text' => ['类型', 'text'], + 'occurred_at' => ['发生时间', 'text'], + 'income' => ['收入金额', 'num'], + 'expense' => ['支出金额', 'num'], + 'remark' => ['备注', 'text'], + ]; + + /** 导出用:按请求解析筛选条件(与页面同一套口径) */ + public function exportFilters(array $input): array + { + return $this->parseFilters($input); + } + + /** 导出用:当前筛选下的总行数(进度百分比用) */ + public function exportTotal(array $filters): int + { + if ($filters === []) { + return 0; + } + + $parts = []; + $bind = []; + foreach (self::SOURCES as $label => $meta) { + $branch = $this->branch($label, $meta, $filters); + foreach ($branch['scopes'] as $scope) { + $parts[] = 'SELECT COUNT(*) AS c FROM ' . $branch['table'] . ' WHERE ' . $scope['where']; + $bind = array_merge($bind, $scope['bind']); + } + } + + $total = 0; + foreach ($this->connection()->select(implode(' UNION ALL ', $parts), $bind) as $row) { + $total += (int) $row->c; + } + + return $total; + } + + /** + * 导出用:按 key 过滤列;不传即全部列。 + * + * @param list $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_PREFIX), + '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'] !== []) { + $parts = []; + foreach ($filters['platform'] as $platform) { + $parts[] = 'store_name LIKE ?'; + $bind[] = $platform . '-%'; + } + $conditions[] = '(' . implode(' OR ', $parts) . ')'; + } + if ($filters['store'] !== []) { + $conditions[] = 'store_name IN (' . $this->literals($filters['store']) . ')'; + } + if ($filters['item'] !== []) { + // 候选取自下拉清单(已去 wx 前缀),这里用同一套归一化表达式比对 + $conditions[] = $this->normalizedItemSql() . ' IN (' . $this->literals($filters['item']) . ')'; + } + if ($filters['keyword'] !== '') { + $keyword = '%' . $filters['keyword'] . '%'; + $haystack = []; + foreach (array_unique(['record_code', $online, $merchant, 'bill_item', $customRemark, $remark]) as $column) { + $haystack[] = $column . ' LIKE ?'; + $bind[] = $keyword; + } + $conditions[] = '(' . implode(' OR ', $haystack) . ')'; + } + if ($filters['type'] !== []) { + $allowed = $this->itemsForTypes($filters['type']); + if ($allowed === []) { + $conditions[] = '1 = 0'; + } else { + // 与页面带出财务备注/类型时一致:wx 前缀的账单项目按去前缀后的名称归类。 + // 项目名以字面量内联:MySQL 对字面量 IN 列表会排序后二分查找, + // 参数化 IN 会逐行线性比对,161 个项目 × 130 万行会退化到 30 秒以上。 + $conditions[] = $this->normalizedItemSql() . ' IN (' . $this->literals($allowed) . ')'; + } + } + + $dateBind = $dateCol === 'bill_date' + ? [$filters['start'], $filters['end']] + : [$filters['start'] . ' 00:00:00', $filters['end'] . ' 23:59:59']; + + $scopes = []; + foreach ($this->generations($meta['periods'] === null ? null : (string) $meta['periods'], $filters['start'], $filters['end']) as $generation) { + $where = []; + $scopeBind = []; + if ($generation !== null) { + $where[] = 'generation = ?'; + $scopeBind[] = $generation; + } + $where[] = "{$dateCol} >= ? AND {$dateCol} <= ?"; + $scopeBind = array_merge($scopeBind, $dateBind); + foreach ($conditions as $condition) { + $where[] = $condition; + } + $scopes[] = ['where' => implode(' AND ', $where), 'bind' => array_merge($scopeBind, $bind)]; + } + + return [ + 'table' => $table, + 'date' => $dateCol, + 'income' => (string) $meta['income'], + 'expense' => (string) $meta['expense'], + 'cols' => $cols, + 'scopes' => $scopes, + ]; + } + + /** @var array> */ + 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); + } +} diff --git a/resources/views/business/pages/financial-operations/billing-details.blade.php b/resources/views/business/pages/financial-operations/billing-details.blade.php new file mode 100644 index 00000000..497c3677 --- /dev/null +++ b/resources/views/business/pages/financial-operations/billing-details.blade.php @@ -0,0 +1,479 @@ +
+ @include('business.pages.partials.upstream-data-styles') + @include('business.pages.partials.multi-select') + @include('business.pages.partials.date-range-picker') + + +

账单明细

+
+ 数据源=各平台已导入账单(与「店铺收款账户账单」同源);财务备注、财务类型由科目映射表按「平台+账单项目(wx 自动去前缀)」带出,匹配不到的留空并标红。 +
+ +
+ {{-- 多选筛选:一个都不勾=全部;勾选结果由 multi-select 写成 name[] 隐藏域随表单提交 --}} +
+ 平台 + + + + + +
+
+ 店铺 + + + + + +
+
+ 账单项目 + + + + + +
+
+ 财务类型 + + + + + +
+
+ 发生时间 +
+ + + + + + + 至 + + + + + + +
+
+ +
+ + 重置 +
+
+ +
+
+
收入合计
+
¥{{ number_format($summary['income'], 2) }}
+
{{ number_format($summary['rows']) }} 笔交易
+
+
+
支出合计
+
¥{{ number_format($summary['expense'], 2) }}
+
按当前筛选
+
+
+
收支差额
+
¥{{ number_format($summary['net'], 2) }}
+
收入合计 - 支出合计
+
+
+
交易笔数
+
{{ number_format($summary['rows']) }}
+
当前筛选结果
+
+
+ + @if (! empty($error)) +
{{ $error }}
+ @else +
+
账单明细{{ number_format($summary['rows']) }} 条
+
+ + +
+ + +
+
+
+ +
+ + + + + + + + + + + + @forelse ($rows as $row) + + + + + + + + + + + + + + + + + @empty + + @endforelse + +
记录编码店铺财务备注线上单号金额账单项目商户订单号自定义备注财务类型类型发生时间收入金额支出金额备注
{{ $row['record_code'] }}{{ $row['store_name'] }}{!! $row['finance_remark'] !== '' ? e($row['finance_remark']) : '—' !!}{{ $row['online_order_no'] }}{{ number_format((float) $row['income'] - (float) $row['expense'], 2) }}{{ $row['bill_item'] }}{{ $row['merchant_order_no'] }}{{ $row['custom_remark'] }}{!! $row['finance_type'] !== '' ? '' . e($row['finance_type']) . '' : '—' !!}{{ $row['type_text'] }}{{ $row['occurred_at'] }}{{ $row['income'] > 0 ? number_format((float) $row['income'], 2) : '' }}{{ $row['expense'] > 0 ? number_format((float) $row['expense'], 2) : '' }}{{ $row['remark'] }}
当前条件下没有数据。
+
+ +
+
第 {{ $rows->firstItem() ?? 0 }} - {{ $rows->lastItem() ?? 0 }} 条 / 共 {{ number_format($rows->total()) }} 条
+
+ + @if ($rows->onFirstPage()) + « + ‹ + @else + « + ‹ + @endif + {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} + @if ($rows->hasMorePages()) + › + » + @else + › + » + @endif +
+
+ @endif + + +
diff --git a/resources/views/business/pages/financial-rules/account-mapping.blade.php b/resources/views/business/pages/financial-rules/account-mapping.blade.php index ddfd3f56..3a56480c 100644 --- a/resources/views/business/pages/financial-rules/account-mapping.blade.php +++ b/resources/views/business/pages/financial-rules/account-mapping.blade.php @@ -402,7 +402,7 @@
· 基准为 收入基准 的项目 —— 账单收入时照抄表内方向;账单支出(退款 / 冲减)时借贷互换。
- · 同一个账单项目在业务上不是同一件事时,用业务备注/商品名称关键词区分:业务备注匹配账单的二次备注、账务备注等(202607订单 这类月份式备注不参与匹配),商品名称按包含匹配;两个都填最优先,其次商品名称,再次业务备注,都不填就是兜底规则。 + · 同一个账单项目在业务上不是同一件事时,用财务备注/商品名称关键词区分:财务备注匹配账单的二次备注、账务备注等(202607订单 这类月份式备注不参与匹配),商品名称按包含匹配;两个都填最优先,其次商品名称,再次财务备注,都不填就是兜底规则。
点行展开看两种收支下的最终分录与校验结果;点「编辑」可直接改这一行;有新增账单项目时用「导入」追加。 @@ -448,7 +448,7 @@
- 大类 + 财务类型 ' + ''; diff --git a/resources/views/business/pages/partials/date-range-picker.blade.php b/resources/views/business/pages/partials/date-range-picker.blade.php new file mode 100644 index 00000000..a8712f85 --- /dev/null +++ b/resources/views/business/pages/partials/date-range-picker.blade.php @@ -0,0 +1,324 @@ +{{-- + 开始日期 ─ 至 ─ 结束日期 的日历选择控件(与「小额打款」页同一套交互)。 + 用法: +
+ 发生时间 +
+ + + + + + + 至 + … +
+
+ window.CaiWuDatePicker.setup(document.getElementById('pdFilterForm')); + 说明:样式与脚本目前与退款 › 小额打款工单页保持一致(那页是内联实现), + 后续如要合并,把那边也改成引用本 partial。 +--}} + + diff --git a/resources/views/business/pages/partials/multi-select.blade.php b/resources/views/business/pages/partials/multi-select.blade.php new file mode 100644 index 00000000..cd090471 --- /dev/null +++ b/resources/views/business/pages/partials/multi-select.blade.php @@ -0,0 +1,289 @@ +{{-- + 可打字搜索的多选筛选(按钮 + 复选面板)。 + 用法: +
+ 店铺 + + + + + +
+ window.CaiWuMultiSelect.create(el, { options: [...], searchable: true }); + 勾选结果会写成 随表单提交;一个都不勾=全部。 +--}} + + diff --git a/routes/admin.php b/routes/admin.php index 82ad242f..4d5c892a 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -331,6 +331,14 @@ Route::get('workspace/financial-operations/installation-details/details/{guid}', Route::get('workspace/financial-operations/installation-details/export', 'Business\Pages@exportInstallationDetails') ->name('business.installation-details.export'); +Route::get('workspace/financial-operations/billing-details/export', 'Business\Pages@exportBillingDetails') + ->middleware('business.page:financial-operations,billing-details') + ->name('business.financial-operations.billing-details.export'); + +Route::get('workspace/financial-operations/billing-details/export-progress', 'Business\Pages@billingDetailsExportProgress') + ->middleware('business.page:financial-operations,billing-details') + ->name('business.financial-operations.billing-details.export-progress'); + Route::get('workspace/order-data/bill-offset/export', 'Business\Pages@exportBillOffset') ->middleware('business.page:order-data,bill-offset') ->name('business.bill-offset.export');