财务经营报表:新增账单明细页(多选筛选/日历选择/四项汇总/按列导出与进度/收款明细同款分页),科目映射表字段改名财务备注与财务类型

- 账单明细:7 个平台账单分支下推查询,按 active_generation 取当前版本(修正抖音重复计数),跨月大数据量由 73s 降到 1-2s

- 筛选:平台/店铺/账单项目/财务类型多选下拉(打字模糊搜索+滚动),日期改日历选择

- 导出:全部筛选结果 CSV 流式下载并按显示列最小取数,带进度轮询;列设置按账号存本机

- 分页:每页 10/20/50,首页/上下页/末页,第 X-Y 条/共 N 条

- 科目映射表:字段改名财务备注/财务类型(导入兼容旧名)
This commit is contained in:
yueyang 2026-10-06 15:35:16 +08:00
parent a77d369d15
commit 6af12c030e
10 changed files with 1840 additions and 8 deletions

View File

@ -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,

View File

@ -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' => '安装明细'],

View File

@ -29,10 +29,10 @@ class AccountMappingStore
public const LABELS = [
'platform' => '平台',
'item' => '账单项目',
'memo' => '业务备注',
'memo' => '财务备注',
'goods' => '商品名称关键词',
'invoice' => '票据口径',
'category' => '大类',
'category' => '财务类型',
'dAcct' => '借方科目',
'dSub' => '借方二级',
'cAcct' => '贷方科目',

View File

@ -25,7 +25,7 @@ class AccountMappingWorkbookImporter
'memo' => ['业务备注', '财务备注', '二次备注', '业务备注关键词', '账务备注', '备注'],
'goods' => ['商品名称关键词', '商品名称', '商品描述', '货品名称'],
'invoice' => ['票据口径', '票据', '票据类型', '发票'],
'category' => ['大类', '业务性质', '科目大类', '分类'],
'category' => ['财务类型', '大类', '业务性质', '科目大类', '分类'],
'basis' => ['方向基准', '基准'],
'dAcct' => ['借方科目', '借/贷(借)', '借/贷(借)', '借方'],
'dSub' => ['借方二级'],

View File

@ -0,0 +1,639 @@
<?php
namespace Modules\EcommerceFinance\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* 财务经营报表 › 账单明细。
*
* 数据源与「账单流水数据 › 店铺收款账户账单」同源(各平台已导入账单表),
* 财务备注 / 财务类型 由科目映射表按「平台 + 账单项目(wx 去前缀)」带出。
*
* 查询按「分支下推」组织:每个平台各自带日期和筛选走自己的索引;
* 京东账单 / 京东钱包 / 抖音 再按 periods 表的 active_generation 拆成单版本分支,
* 避免同一账期多版本重复计数,同时让每个分支都能用 (generation, occurred_at, record_code)
* 索引直接排序取前 N 行,不再把整月数据物化成临时表再排序。
*/
class BillingDetailsPageData
{
/**
* 各平台账单表;periods 非空表示该表按 active_generation 区分版本,必须用 periods 表锁定当前版本。
*/
private const SOURCES = [
'淘系支付宝' => ['table' => 'taobao_alipay_bill_rows', 'date' => 'occurred_at', 'income' => 'income_amount', 'expense' => 'expense_amount', 'online_order' => 'online_order_no', 'merchant_order' => 'merchant_order_no', 'type' => null, 'periods' => null],
'淘系微信' => ['table' => 'taobao_wechat_bill_rows', 'date' => 'occurred_at', 'income' => 'income_amount', 'expense' => 'expense_amount', 'online_order' => 'online_order_no', 'merchant_order' => 'pay_serial_no', 'type' => null, 'periods' => null],
'拼多多' => ['table' => 'pdd_settlement_bill_rows', 'date' => 'occurred_at', 'income' => 'income_amount', 'expense' => 'expense_amount', 'online_order' => 'online_order_no', 'merchant_order' => 'merchant_order_no', 'type' => 'account_name', 'periods' => null],
'京东账单' => ['table' => 'jd_daily_bill_rows', 'date' => 'occurred_at', 'income' => 'income', 'expense' => 'expense', 'online_order' => null, 'merchant_order' => null, 'type' => null, 'periods' => 'jd_daily_bill_periods'],
'京东钱包' => ['table' => 'jd_wallet_bill_rows', 'date' => 'occurred_at', 'income' => 'income', 'expense' => 'expense', 'online_order' => null, 'merchant_order' => null, 'type' => 'account_name', 'periods' => 'jd_wallet_bill_periods'],
'抖音' => ['table' => 'douyin_authoritative_bills', 'date' => 'occurred_at', 'income' => 'income', 'expense' => 'expense', 'online_order' => null, 'merchant_order' => null, 'type' => null, 'periods' => 'douyin_authoritative_periods'],
'得物' => ['table' => 'dewu_settlement_bill_rows', 'date' => 'bill_date', 'income' => 'income_amount', 'expense' => 'expense_amount', 'online_order' => 'online_order_no', 'merchant_order' => 'order_no', 'type' => null, 'periods' => null],
];
private const PLATFORM_PREFIX = [
'天猫' => '天猫', '淘宝' => '淘宝', '京东' => '京东', '京喜' => '京喜', '京喜小店' => '京喜',
'拼多多' => '拼多多', '抖音' => '抖音', '头条放心购' => '抖音', '得物' => '得物',
'阿里巴巴' => '阿里巴巴', '新佰广1688' => '阿里巴巴', '淘工厂' => '阿里巴巴',
'唯品会MP' => '唯品会MP', '唯品会JX' => '唯品会JX', '支付宝' => '支付宝',
];
public function page(): array
{
return [
'view' => 'business.pages.financial-operations.billing-details',
'options' => $this->options(),
'columns' => self::COLUMNS,
] + $this->data();
}
/**
* 页面与导出的列定义(顺序即默认显示顺序):key => [标签, 类型]。
*/
public const COLUMNS = [
'record_code' => ['记录编码', 'text'],
'store_name' => ['店铺', 'text'],
'finance_remark' => ['财务备注', 'text'],
'online_order_no' => ['线上单号', 'text'],
'amount' => ['金额', 'num'],
'bill_item' => ['账单项目', 'tag'],
'merchant_order_no' => ['商户订单号', 'text'],
'custom_remark' => ['自定义备注', 'text'],
'finance_type' => ['财务类型', 'tag'],
'type_text' => ['类型', 'text'],
'occurred_at' => ['发生时间', 'text'],
'income' => ['收入金额', 'num'],
'expense' => ['支出金额', 'num'],
'remark' => ['备注', 'text'],
];
/** 导出用:按请求解析筛选条件(与页面同一套口径) */
public function exportFilters(array $input): array
{
return $this->parseFilters($input);
}
/** 导出用:当前筛选下的总行数(进度百分比用) */
public function exportTotal(array $filters): int
{
if ($filters === []) {
return 0;
}
$parts = [];
$bind = [];
foreach (self::SOURCES as $label => $meta) {
$branch = $this->branch($label, $meta, $filters);
foreach ($branch['scopes'] as $scope) {
$parts[] = 'SELECT COUNT(*) AS c FROM ' . $branch['table'] . ' WHERE ' . $scope['where'];
$bind = array_merge($bind, $scope['bind']);
}
}
$total = 0;
foreach ($this->connection()->select(implode(' UNION ALL ', $parts), $bind) as $row) {
$total += (int) $row->c;
}
return $total;
}
/**
* 导出用:按 key 过滤列;不传即全部列。
*
* @param list<string> $keys
* @return array<string,array{0:string,1:string}>
*/
public function exportColumns(array $keys): array
{
if ($keys === []) {
return self::COLUMNS;
}
$wanted = array_flip(array_map(static fn ($key): string => trim((string) $key), $keys));
$columns = [];
foreach (self::COLUMNS as $key => $definition) {
if (isset($wanted[$key])) {
$columns[$key] = $definition;
}
}
return $columns === [] ? self::COLUMNS : $columns;
}
/**
* 导出用:逐分支流式产出「页面同款」行(财务备注/财务类型已按科目映射表带出)。
*
* @param array<string,mixed> $filters
* @return \Generator<array<string,string>>
*/
public function exportRows(array $filters, array $columns = []): \Generator
{
$connection = $this->connection();
$rules = $this->rules();
foreach (self::SOURCES as $label => $meta) {
$branch = $this->branch($label, $meta, $filters);
// 只查导出要用的列:备注这类长文本不查,从镜像库搬的数据量能少好几倍
$select = $columns === [] ? $branch['cols'] : $this->exportSelectList($meta, $columns);
foreach ($branch['scopes'] as $scope) {
// 用游标(非缓冲查询)逐行产出,避免上百万行一次性读进内存
foreach ($connection->cursor(
'SELECT ' . $select . ' FROM ' . $branch['table'] . ' WHERE ' . $scope['where']
. ' ORDER BY ' . $branch['date'] . ' DESC, record_code DESC',
$scope['bind']
) as $raw) {
yield $this->presentRow((array) $raw, $rules);
}
}
}
}
/**
* 按导出列拼最小查询列表(别名与页面列一致)。
*
* @param array<string,mixed> $meta
* @param array<string,array{0:string,1:string}> $columns
*/
private function exportSelectList(array $meta, array $columns): string
{
$table = (string) $meta['table'];
$has = fn (string $column): bool => in_array($column, $this->columns($table), true);
$pick = static fn (?string $column): ?string => $column !== null && $column !== '' ? $column : null;
$want = array_flip(array_keys($columns));
$finance = isset($want['finance_remark']) || isset($want['finance_type']);
$select = [];
$add = static function (string $alias, ?string $expr) use (&$select): void {
if ($expr === null || isset($select[$alias])) {
return;
}
$select[$alias] = $expr === $alias ? $alias : $expr . ' AS ' . $alias;
};
if (isset($want['record_code'])) { $add('record_code', 'record_code'); }
if (isset($want['store_name']) || $finance) { $add('store_name', 'store_name'); }
if (isset($want['bill_item']) || $finance) { $add('bill_item', 'bill_item'); }
if (isset($want['online_order_no'])) { $add('online_order_no', $pick(is_string($meta['online_order']) ? (string) $meta['online_order'] : null)); }
if (isset($want['merchant_order_no'])) { $add('merchant_order_no', $pick(is_string($meta['merchant_order']) ? (string) $meta['merchant_order'] : null)); }
if (isset($want['custom_remark'])) { $add('custom_remark', $has('custom_remark') ? 'custom_remark' : null); }
if (isset($want['remark'])) { $add('remark', $has('remark') ? 'remark' : null); }
if (isset($want['type_text'])) { $add('type_text', $pick(is_string($meta['type']) ? (string) $meta['type'] : null)); }
if (isset($want['occurred_at'])) { $add('occurred_at', (string) $meta['date']); }
if (isset($want['income']) || isset($want['amount'])) { $add('income', (string) $meta['income']); }
if (isset($want['expense']) || isset($want['amount'])) { $add('expense', (string) $meta['expense']); }
// 一列都没选到时至少取主键列,保证 SQL 合法
return $select === [] ? 'record_code' : implode(', ', $select);
}
/**
* 筛选下拉候选:店铺与账单项目(账单项目统一成去 wx 前缀后的名称,与带出财务备注/类型同一口径)。
* 镜像每天只会新增数据,候选清单按 6 小时缓存,避免每次开页都做一次索引扫描。
*
* @return array{stores:list<string>,items:list<string>}
*/
public function options(): array
{
try {
return cache()->remember('billing-details-filter-options-v1', now()->addHours(6), function (): array {
$stores = [];
$items = [];
foreach (self::SOURCES as $meta) {
$table = (string) $meta['table'];
try {
foreach ($this->connection()->select('SELECT DISTINCT store_name FROM ' . $table) as $row) {
$store = trim((string) $row->store_name);
if ($store !== '') { $stores[$store] = true; }
}
foreach ($this->connection()->select('SELECT DISTINCT bill_item FROM ' . $table) as $row) {
$item = $this->normalizeItem((string) $row->bill_item);
if ($item !== '') { $items[$item] = true; }
}
} catch (Throwable) {
continue;
}
}
$stores = array_keys($stores);
$items = array_keys($items);
sort($stores, SORT_NATURAL | SORT_FLAG_CASE);
sort($items, SORT_NATURAL | SORT_FLAG_CASE);
return ['stores' => $stores, 'items' => $items];
});
} catch (Throwable $exception) {
report($exception);
return ['stores' => [], 'items' => []];
}
}
/** @return array<string,mixed> */
public function data(?array $input = null): array
{
$input ??= request()->all();
$filters = $this->parseFilters($input);
$rows = [];
$total = 0;
$income = 0.0;
$expense = 0.0;
$status = null;
$error = null;
$limit = $this->perPage($input);
$page = max((int) ($input['page'] ?? request('page', 1)), 1);
$offset = ($page - 1) * $limit;
try {
$conn = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
// 每个平台一个分支,日期与筛选已下推到分支内,可各自命中 (日期, record_code) 索引
$branches = [];
foreach (self::SOURCES as $label => $meta) {
$branches[] = $this->branch($label, $meta, $filters);
}
// 汇总:按分支聚合并,避免把整月数据物化成临时表
$summaryParts = [];
$summaryBind = [];
foreach ($branches as $branch) {
foreach ($branch['scopes'] as $scope) {
$summaryParts[] = 'SELECT COUNT(*) AS c, COALESCE(SUM(' . $branch['income'] . '),0) AS inc, '
. 'COALESCE(SUM(' . $branch['expense'] . '),0) AS exp FROM ' . $branch['table']
. ' WHERE ' . $scope['where'];
$summaryBind = array_merge($summaryBind, $scope['bind']);
}
}
foreach ($conn->select(implode(' UNION ALL ', $summaryParts), $summaryBind) as $part) {
$total += (int) $part->c;
$income += (float) $part->inc;
$expense += (float) $part->exp;
}
// 明细:每个分支先用索引取自己的前 offset+limit 行,再合并排序,避免全量排序
if ($total > 0 && $offset < $total) {
$need = $offset + $limit;
$rowParts = [];
$rowBind = [];
foreach ($branches as $branch) {
foreach ($branch['scopes'] as $scope) {
$rowParts[] = '(SELECT ' . $branch['cols'] . ' FROM ' . $branch['table']
. ' WHERE ' . $scope['where']
. ' ORDER BY ' . $branch['date'] . ' DESC, record_code DESC LIMIT ' . $need . ')';
$rowBind = array_merge($rowBind, $scope['bind']);
}
}
$rows = $conn->select(
implode(' UNION ALL ', $rowParts) . ' ORDER BY occurred_at DESC, record_code DESC LIMIT ' . $limit . ' OFFSET ' . $offset,
$rowBind
);
}
} catch (Throwable $exception) {
report($exception);
$error = '账单明细数据暂时无法读取:' . $exception->getMessage();
}
$rules = $this->rules();
$rows = array_map(fn ($row): array => $this->presentRow((array) $row, $rules), $rows);
return [
'filters' => $filters,
'rows' => new LengthAwarePaginator($rows, $total, $limit, $page, [
'path' => request()->url(),
'query' => request()->except('page'),
]),
'summary' => ['rows' => $total, 'income' => round($income, 2), 'expense' => round($expense, 2), 'net' => round($income - $expense, 2)],
'platforms' => array_keys(self::PLATFORM_PREFIX),
'types' => $this->types(),
'error' => $error,
'status' => $status,
];
}
/**
* 组装单个平台分支:统一的输出列 + 每个账期版本一份「筛选已下推」的 WHERE。
*
* @param array<string,mixed> $meta
* @param array<string,string> $filters
* @return array{table:string,date:string,income:string,expense:string,cols:string,scopes:list<array{where:string,bind:list<mixed>}>}
*/
private function branch(string $label, array $meta, array $filters): array
{
$table = (string) $meta['table'];
$dateCol = (string) $meta['date'];
$has = fn (string $column): bool => in_array($column, $this->columns($table), true);
// 各平台账单表列不完全一致(京东/抖音没有 custom_remark、remark),缺的列统一留空
$customRemark = $has('custom_remark') ? 'custom_remark' : "''";
$remark = $has('remark') ? 'remark' : "''";
$online = is_string($meta['online_order']) && $has((string) $meta['online_order']) ? (string) $meta['online_order'] : "''";
$merchant = is_string($meta['merchant_order']) && $has((string) $meta['merchant_order']) ? (string) $meta['merchant_order'] : "''";
$typeCol = is_string($meta['type']) && $has((string) $meta['type']) ? (string) $meta['type'] : "''";
$cols = "'{$label}' AS source, record_code, store_name, {$customRemark} AS custom_remark, {$online} AS online_order_no, "
. "bill_item, {$merchant} AS merchant_order_no, {$typeCol} AS type_text, {$dateCol} AS occurred_at, "
. "{$meta['income']} AS income, {$meta['expense']} AS expense, {$remark} AS remark";
// 公共筛选(不含日期)逐条下推到分支,让各表尽量用自己的索引
$conditions = [];
$bind = [];
if ($filters['platform'] !== []) {
$parts = [];
foreach ($filters['platform'] as $platform) {
$parts[] = 'store_name LIKE ?';
$bind[] = $platform . '-%';
}
$conditions[] = '(' . implode(' OR ', $parts) . ')';
}
if ($filters['store'] !== []) {
$conditions[] = 'store_name IN (' . $this->literals($filters['store']) . ')';
}
if ($filters['item'] !== []) {
// 候选取自下拉清单(已去 wx 前缀),这里用同一套归一化表达式比对
$conditions[] = $this->normalizedItemSql() . ' IN (' . $this->literals($filters['item']) . ')';
}
if ($filters['keyword'] !== '') {
$keyword = '%' . $filters['keyword'] . '%';
$haystack = [];
foreach (array_unique(['record_code', $online, $merchant, 'bill_item', $customRemark, $remark]) as $column) {
$haystack[] = $column . ' LIKE ?';
$bind[] = $keyword;
}
$conditions[] = '(' . implode(' OR ', $haystack) . ')';
}
if ($filters['type'] !== []) {
$allowed = $this->itemsForTypes($filters['type']);
if ($allowed === []) {
$conditions[] = '1 = 0';
} else {
// 与页面带出财务备注/类型时一致:wx 前缀的账单项目按去前缀后的名称归类。
// 项目名以字面量内联:MySQL 对字面量 IN 列表会排序后二分查找,
// 参数化 IN 会逐行线性比对,161 个项目 × 130 万行会退化到 30 秒以上。
$conditions[] = $this->normalizedItemSql() . ' IN (' . $this->literals($allowed) . ')';
}
}
$dateBind = $dateCol === 'bill_date'
? [$filters['start'], $filters['end']]
: [$filters['start'] . ' 00:00:00', $filters['end'] . ' 23:59:59'];
$scopes = [];
foreach ($this->generations($meta['periods'] === null ? null : (string) $meta['periods'], $filters['start'], $filters['end']) as $generation) {
$where = [];
$scopeBind = [];
if ($generation !== null) {
$where[] = 'generation = ?';
$scopeBind[] = $generation;
}
$where[] = "{$dateCol} >= ? AND {$dateCol} <= ?";
$scopeBind = array_merge($scopeBind, $dateBind);
foreach ($conditions as $condition) {
$where[] = $condition;
}
$scopes[] = ['where' => implode(' AND ', $where), 'bind' => array_merge($scopeBind, $bind)];
}
return [
'table' => $table,
'date' => $dateCol,
'income' => (string) $meta['income'],
'expense' => (string) $meta['expense'],
'cols' => $cols,
'scopes' => $scopes,
];
}
/** @var array<string,list<string|null>> */
private array $generationCache = [];
private function connection(): \Illuminate\Database\Connection
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
/**
* 筛选值统一成字符串列表(表单提交的多选是数组;空值丢弃)。
*
* @return list<string>
*/
private function values(mixed $raw): array
{
$list = is_array($raw) ? $raw : [$raw];
$values = [];
foreach ($list as $value) {
$value = trim((string) $value);
if ($value !== '' && ! in_array($value, $values, true)) {
$values[] = $value;
}
}
return array_slice($values, 0, 500);
}
/**
* IN 列表以字面量内联(已按驱动转义)。
* MySQL 对字面量 IN 会排序后二分查找,参数化 IN 会逐行线性比对;
* 财务类型动辄上百个项目 × 上百万行,参数化会退化到 30 秒以上。
*
* @param list<string> $values
*/
private function literals(array $values): string
{
$pdo = $this->connection()->getPdo();
return implode(',', array_map(static fn (string $value): string => $pdo->quote($value), array_values(array_unique($values))));
}
/** 账单项目归一化:去掉 wx 前缀(与页面带出财务备注/类型时一致) */
private function normalizeItem(string $item): string
{
$item = trim($item);
return preg_match('/^wx/i', $item) === 1 ? ltrim(substr($item, 2)) : $item;
}
/**
* 解析筛选条件(页面与导出共用同一套口径)。
*
* @param array<string,mixed> $input
* @return array<string,mixed>
*/
private function parseFilters(array $input): array
{
$now = now();
$month = trim((string) ($input['month'] ?? '')) ?: $now->format('Y-m');
$start = trim((string) ($input['start'] ?? '')) ?: $month . '-01';
$end = trim((string) ($input['end'] ?? '')) ?: date('Y-m-t', strtotime($start));
// 日期只接受 Y-m-d,避免拼进 SQL 的字符串被注入
$start = preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) === 1 ? $start : $now->format('Y-m-01');
$end = preg_match('/^\d{4}-\d{2}-\d{2}$/', $end) === 1 ? $end : date('Y-m-t', strtotime($start));
if ($end < $start) { $end = $start; }
return [
'month' => substr($start, 0, 7),
'start' => $start,
'end' => $end,
'platform' => $this->values($input['platform'] ?? []),
'store' => $this->values($input['store'] ?? []),
'item' => $this->values($input['item'] ?? []),
'type' => $this->values($input['type'] ?? []),
'keyword' => trim((string) ($input['keyword'] ?? '')),
'per_page' => $this->perPage($input),
];
}
/** 每页条数:只接受 10 / 20 / 50,缺省 20 */
private function perPage(array $input): int
{
$size = (int) ($input['per_page'] ?? 20);
return in_array($size, [10, 20, 50], true) ? $size : 20;
}
/**
* 行展示口径:带出平台、财务备注、财务类型(页面与导出一致)。
*
* @param array<string,mixed> $row
* @param array<string,array{memo:string,category:string,catch:bool}> $rules
* @return array<string,mixed>
*/
private function presentRow(array $row, array $rules): array
{
$shop = (string) ($row['store_name'] ?? '');
$prefix = $shop === '' ? '' : explode('-', $shop)[0];
$platform = self::PLATFORM_PREFIX[$prefix] ?? '';
$item = trim((string) ($row['bill_item'] ?? ''));
$normalized = $this->normalizeItem($item);
$rule = $rules[$platform . '|' . $normalized] ?? ($rules[$platform . '|' . $item] ?? null);
$row['platform'] = $platform;
$row['finance_remark'] = $rule['memo'] ?? '';
$row['finance_type'] = $rule['category'] ?? '';
$row['matched'] = $rule !== null;
return $row;
}
private function normalizedItemSql(): string
{
return "(CASE WHEN bill_item LIKE 'wx%' THEN LTRIM(SUBSTRING(bill_item, 3)) ELSE bill_item END)";
}
/**
* 取与查询区间有交集的账期的 active_generation(与 JD / 抖音账单页仓储同一口径)。
*
* 只取相交账期,可保证分支数量不随镜像历史累积增长;
* 账期信息不可用时退回 [null],表示该分支不区分版本。
*
* @return list<string|null>
*/
private function generations(?string $periodsTable, string $start, string $end): array
{
if ($periodsTable === null) {
return [null];
}
$cacheKey = $periodsTable . '|' . $start . '|' . $end;
if (array_key_exists($cacheKey, $this->generationCache)) {
return $this->generationCache[$cacheKey];
}
try {
$connection = $this->connection();
$columns = array_map(static fn ($column): string => (string) $column->Field, $connection->select('SHOW COLUMNS FROM ' . $periodsTable));
$active = $connection->table($periodsTable)->distinct()->orderBy('active_generation');
if (in_array('period_start', $columns, true) && in_array('period_end', $columns, true)) {
$active->where('period_start', '<=', $end)->where('period_end', '>=', $start);
}
$generations = $active->pluck('active_generation')
->filter(static fn ($generation): bool => is_string($generation) && $generation !== '')
->values()
->all();
if ($generations === []) {
// 查询区间与任何账期都不相交时,退回全部在用版本,保证不漏数
$generations = $connection->table($periodsTable)->distinct()->orderBy('active_generation')
->pluck('active_generation')
->filter(static fn ($generation): bool => is_string($generation) && $generation !== '')
->values()
->all();
}
} catch (Throwable) {
$generations = [];
}
return $this->generationCache[$cacheKey] = ($generations === [] ? [null] : $generations);
}
/** @var array<string,list<string>> */
private array $columnCache = [];
/** @return list<string> */
private function columns(string $table): array
{
if (! isset($this->columnCache[$table])) {
try {
$this->columnCache[$table] = array_map(
static fn ($c): string => (string) $c->Field,
DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))->select('SHOW COLUMNS FROM ' . $table)
);
} catch (Throwable) {
$this->columnCache[$table] = [];
}
}
return $this->columnCache[$table];
}
/** 科目映射表:平台|账单项目 → [memo, category] */
private function rules(): array
{
static $cache = null;
if ($cache !== null) { return $cache; }
$cache = [];
$path = storage_path('app/account-mapping/rules.json');
if (! is_file($path)) { return $cache; }
$raw = json_decode((string) file_get_contents($path), true);
foreach (($raw['rules'] ?? $raw) as $rule) {
$key = trim((string) ($rule['platform'] ?? '')) . '|' . trim((string) ($rule['item'] ?? ''));
$prev = $cache[$key] ?? null;
$isCatchAll = trim((string) ($rule['memo'] ?? '')) === '' && trim((string) ($rule['goods'] ?? '')) === '';
if ($prev === null || ($isCatchAll && ! $prev['catch'])) {
$cache[$key] = ['memo' => (string) ($rule['memo'] ?? ''), 'category' => (string) ($rule['category'] ?? ''), 'catch' => $isCatchAll];
}
}
return $cache;
}
/** @return list<string> */
private function types(): array
{
$types = [];
foreach ($this->rules() as $rule) {
if ($rule['category'] !== '' && ! in_array($rule['category'], $types, true)) { $types[] = $rule['category']; }
}
sort($types);
return $types;
}
/** 财务类型 → 该类型下的账单项目集合(用于按类型筛选时保持分页正确) */
private function itemsForType(string $type): array
{
return $this->itemsForTypes([$type]);
}
/**
* 多个财务类型 → 其下所有账单项目的并集。
*
* @param list<string> $types
* @return list<string>
*/
private function itemsForTypes(array $types): array
{
$wanted = array_flip($types);
$items = [];
foreach ($this->rules() as $key => $rule) {
if (isset($wanted[$rule['category']])) {
$items[explode('|', $key, 2)[1]] = true;
}
}
return array_keys($items);
}
}

View File

@ -0,0 +1,479 @@
<div class="upstream-data-page billing-details-page" style="padding:0;">
@include('business.pages.partials.upstream-data-styles')
@include('business.pages.partials.multi-select')
@include('business.pages.partials.date-range-picker')
<style>
.pd-stats { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:.75rem; margin-bottom:1rem; }
.pd-stat { padding:.875rem 1rem; border:1px solid #d8d2c7; border-radius:.5rem; background:#fff; }
.pd-stat__label { color:#595959; font-size:.75rem; }
.pd-stat__value { margin-top:.375rem; font-size:1.375rem; font-weight:700; font-variant-numeric:tabular-nums; }
.pd-stat__note { margin-top:.1875rem; color:#595959; font-size:.75rem; }
.pd-stat .is-income { color:#53793e; }
.pd-stat .is-expense { color:#b80000; }
.pd-table-wrap { border:1px solid #d8d2c7; border-radius:.75rem; overflow:auto; background:#fff; }
.pd-table { width:100%; border-collapse:collapse; font-size:.8125rem; }
.pd-table th { background:#1f2937; color:#fff; font-weight:600; text-align:center; padding:.5rem .4rem; white-space:nowrap; }
.pd-table td { border-bottom:1px solid #eee7da; padding:.45rem .5rem; white-space:nowrap; }
.pd-table td.num { text-align:right; }
.pd-tag { display:inline-block; padding:.05rem .4rem; border:1px solid #e0d6c2; border-radius:999px; background:#faf6ee; }
.pd-miss { color:#b04a4a; }
.pd-pager { display:flex; flex-wrap:wrap; align-items:center; gap:.75rem; padding:.75rem 0; font-size:.8125rem; color:#3d3a35; }
.pd-pager__actions { display:flex; align-items:center; gap:.35rem; margin-left:auto; }
.pd-pager__actions label { display:inline-flex; align-items:center; gap:.35rem; color:#595959; }
.pd-pager__actions select { height:1.9rem; padding:0 .35rem; border:1px solid #d8d2c7; border-radius:.4rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; }
.pd-pager__actions a, .pd-pager__actions span.pd-pager__nav, .pd-pager__actions span.pd-pager__page {
display:inline-flex; align-items:center; justify-content:center; min-width:1.9rem; height:1.9rem; padding:0 .45rem;
border:1px solid #d8d2c7; border-radius:.4rem; background:#fff; color:#171717; text-decoration:none;
}
.pd-pager__actions a:hover { background:#f7f0de; }
.pd-pager__actions span.pd-pager__nav { color:#b9b3a8; }
.pd-pager__actions span.pd-pager__page { border:0; background:transparent; }
.pd-wide { flex:1 1 15rem; min-width:12rem; }
.pd-head { display:flex; flex-wrap:wrap; align-items:center; gap:.75rem; margin:.25rem 0 .75rem; }
.pd-head__count { margin-left:.5rem; color:#8a8377; font-size:.75rem; }
.pd-tools { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-left:auto; }
.pd-tools select { height:2.125rem; min-width:16rem; padding:0 .5rem; border:1px solid #d8d2c7; border-radius:.5rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; }
.pd-tools button { display:inline-flex; align-items:center; gap:.25rem; height:2.125rem; padding:0 .875rem; border:1px solid #d8d2c7; border-radius:.5rem; background:#fff; color:#171717; font-size:.8125rem; cursor:pointer; }
.pd-tools button:hover { background:#f7f0de; }
.pd-tools button:disabled { opacity:.5; cursor:not-allowed; }
.pd-colset { position:relative; }
.pd-colset__menu { position:absolute; top:calc(100% + .3rem); right:0; z-index:70; width:18rem; max-height:20rem; overflow:auto; padding:.5rem; border:1px solid #e4dccd; border-radius:8px; background:#fffdf8; box-shadow:0 14px 30px rgba(0,0,0,.14); }
.pd-colset__menu[hidden] { display:none; }
.pd-colset__head { display:flex; align-items:center; justify-content:space-between; margin-bottom:.35rem; font-size:.75rem; color:#595959; }
.pd-colset__item { display:flex; align-items:center; gap:.4rem; padding:.3rem .35rem; border-radius:6px; font-size:.8125rem; color:#3d3a35; cursor:pointer; }
.pd-colset__item:hover { background:#f6f2e9; }
.pd-export-progress { margin:0 0 .75rem; }
.pd-export-progress__bar { height:.5rem; border:1px solid #d8d2c7; border-radius:999px; background:#fff; overflow:hidden; }
.pd-export-progress__bar span { display:block; height:100%; width:0; background:#856632; transition:width .3s ease; }
.pd-export-progress__text { margin-top:.35rem; color:#595959; font-size:.75rem; }
@media (max-width:900px) { .pd-stats { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
@media (max-width:520px) { .pd-stats { grid-template-columns:1fr; } }
</style>
<h2 style="margin:.25rem 0 .5rem;font-size:1.05rem;">账单明细</h2>
<div style="color:#595959;font-size:.8125rem;margin-bottom:.75rem;">
数据源=各平台已导入账单(与「店铺收款账户账单」同源);<b>财务备注</b>、<b>财务类型</b>由科目映射表按「平台+账单项目(wx 自动去前缀)」带出,匹配不到的留空并标红。
</div>
<form class="upstream-data-toolbar" method="get" id="pdFilterForm" autocomplete="off">
{{-- 多选筛选:一个都不勾=全部;勾选结果由 multi-select 写成 name[] 隐藏域随表单提交 --}}
<div class="upstream-data-field cw-ms" id="pdPlatform" data-name="platform"
data-initial="{{ json_encode(array_values($filters['platform']), JSON_UNESCAPED_UNICODE) }}">
<span>平台</span>
<span class="cw-ms__box">
<button type="button" class="cw-ms__btn" data-role="btn" aria-expanded="false">
<span class="cw-ms__label">全部</span><span class="cw-ms__caret">▾</span>
</button>
<span class="cw-ms__panel" data-role="panel" hidden></span>
<span class="cw-ms__values" data-role="values"></span>
</span>
</div>
<div class="upstream-data-field cw-ms pd-wide" id="pdStore" data-name="store"
data-initial="{{ json_encode(array_values($filters['store']), JSON_UNESCAPED_UNICODE) }}">
<span>店铺</span>
<span class="cw-ms__box">
<button type="button" class="cw-ms__btn" data-role="btn" aria-expanded="false">
<span class="cw-ms__label">全部</span><span class="cw-ms__caret">▾</span>
</button>
<span class="cw-ms__panel" data-role="panel" hidden></span>
<span class="cw-ms__values" data-role="values"></span>
</span>
</div>
<div class="upstream-data-field cw-ms pd-wide" id="pdItem" data-name="item"
data-initial="{{ json_encode(array_values($filters['item']), JSON_UNESCAPED_UNICODE) }}">
<span>账单项目</span>
<span class="cw-ms__box">
<button type="button" class="cw-ms__btn" data-role="btn" aria-expanded="false">
<span class="cw-ms__label">全部</span><span class="cw-ms__caret">▾</span>
</button>
<span class="cw-ms__panel" data-role="panel" hidden></span>
<span class="cw-ms__values" data-role="values"></span>
</span>
</div>
<div class="upstream-data-field cw-ms" id="pdType" data-name="type"
data-initial="{{ json_encode(array_values($filters['type']), JSON_UNESCAPED_UNICODE) }}">
<span>财务类型</span>
<span class="cw-ms__box">
<button type="button" class="cw-ms__btn" data-role="btn" aria-expanded="false">
<span class="cw-ms__label">全部</span><span class="cw-ms__caret">▾</span>
</button>
<span class="cw-ms__panel" data-role="panel" hidden></span>
<span class="cw-ms__values" data-role="values"></span>
</span>
</div>
<div class="upstream-data-field upstream-data-field--dates">
<span>发生时间</span>
<div class="upstream-data-date-range">
<span class="upstream-data-date" data-date-picker>
<input id="pdDateFrom" type="text" name="start" value="{{ $filters['start'] }}" readonly
autocomplete="off" placeholder="开始日期" aria-label="开始日期" data-date-input>
<button type="button" class="upstream-data-date__clear" data-date-clear hidden aria-label="清除开始日期">
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
<button type="button" class="upstream-data-date__pick" data-date-toggle aria-label="选择开始日期">
<span class="material-icons-outlined" aria-hidden="true">calendar_today</span>
</button>
<div class="upstream-data-calendar" role="dialog" aria-label="开始日期日历" data-date-calendar hidden></div>
</span>
<b class="upstream-data-date-range__sep">至</b>
<span class="upstream-data-date" data-date-picker>
<input id="pdDateTo" type="text" name="end" value="{{ $filters['end'] }}" readonly
autocomplete="off" placeholder="结束日期" aria-label="结束日期" data-date-input>
<button type="button" class="upstream-data-date__clear" data-date-clear hidden aria-label="清除结束日期">
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
<button type="button" class="upstream-data-date__pick" data-date-toggle aria-label="选择结束日期">
<span class="material-icons-outlined" aria-hidden="true">calendar_today</span>
</button>
<div class="upstream-data-calendar" role="dialog" aria-label="结束日期日历" data-date-calendar hidden></div>
</span>
</div>
</div>
<label class="upstream-data-field pd-wide"><span>关键词</span><input type="search" name="keyword" value="{{ $filters['keyword'] }}" placeholder="记录编码 / 订单号 / 备注"></label>
<div class="upstream-data-actions">
<button type="submit" class="upstream-data-button upstream-data-button--primary">查询</button>
<a class="upstream-data-button" href="{{ request()->url() }}">重置</a>
</div>
</form>
<div class="pd-stats">
<div class="pd-stat">
<div class="pd-stat__label">收入合计</div>
<div class="pd-stat__value is-income">¥{{ number_format($summary['income'], 2) }}</div>
<div class="pd-stat__note">{{ number_format($summary['rows']) }} 笔交易</div>
</div>
<div class="pd-stat">
<div class="pd-stat__label">支出合计</div>
<div class="pd-stat__value is-expense">¥{{ number_format($summary['expense'], 2) }}</div>
<div class="pd-stat__note">按当前筛选</div>
</div>
<div class="pd-stat">
<div class="pd-stat__label">收支差额</div>
<div class="pd-stat__value">¥{{ number_format($summary['net'], 2) }}</div>
<div class="pd-stat__note">收入合计 - 支出合计</div>
</div>
<div class="pd-stat">
<div class="pd-stat__label">交易笔数</div>
<div class="pd-stat__value">{{ number_format($summary['rows']) }}</div>
<div class="pd-stat__note">当前筛选结果</div>
</div>
</div>
@if (! empty($error))
<div style="padding:.75rem 1rem;border:1px solid #edcccc;border-radius:.75rem;background:#fdf3f3;color:#a32d2d;font-size:.8125rem;">{{ $error }}</div>
@else
<div class="pd-head">
<div><strong>账单明细</strong><span class="pd-head__count">{{ number_format($summary['rows']) }} 条</span></div>
<div class="pd-tools">
<select id="pdExportMode" title="选择导出范围" aria-label="导出范围">
<option value="page-visible">当前页(25 条)· 当前显示列</option>
<option value="filtered-visible" selected>全部筛选结果({{ number_format($summary['rows']) }} 条)· 当前显示列</option>
<option value="filtered-all">全部筛选结果({{ number_format($summary['rows']) }} 条)· 全部字段</option>
</select>
<button type="button" id="pdExportBtn"><span class="material-icons-outlined" aria-hidden="true">download</span>导出全部筛选结果</button>
<div class="pd-colset" id="pdColSet">
<button type="button" id="pdColSetBtn" aria-expanded="false" aria-haspopup="dialog">
<span class="material-icons-outlined" aria-hidden="true">view_column</span>列设置
</button>
<div class="pd-colset__menu" id="pdColSetMenu" role="dialog" aria-label="列设置" hidden>
<div class="pd-colset__head"><strong>显示字段</strong><button type="button" id="pdColSetReset">恢复默认</button></div>
<div id="pdColSetList"></div>
</div>
</div>
</div>
</div>
<div class="pd-export-progress" id="pdExportProgress" hidden>
<div class="pd-export-progress__bar"><span id="pdExportBar" style="width:0%"></span></div>
<div class="pd-export-progress__text" id="pdExportText">准备导出…</div>
</div>
<div class="pd-table-wrap">
<table class="pd-table">
<thead>
<tr>
<th data-col="record_code">记录编码</th><th data-col="store_name">店铺</th><th data-col="finance_remark">财务备注</th>
<th data-col="online_order_no">线上单号</th><th data-col="amount">金额</th><th data-col="bill_item">账单项目</th>
<th data-col="merchant_order_no">商户订单号</th><th data-col="custom_remark">自定义备注</th>
<th data-col="finance_type">财务类型</th><th data-col="type_text">类型</th><th data-col="occurred_at">发生时间</th>
<th data-col="income">收入金额</th><th data-col="expense">支出金额</th><th data-col="remark">备注</th>
</tr>
</thead>
<tbody>
@forelse ($rows as $row)
<tr>
<td data-col="record_code">{{ $row['record_code'] }}</td>
<td data-col="store_name">{{ $row['store_name'] }}</td>
<td data-col="finance_remark">{!! $row['finance_remark'] !== '' ? e($row['finance_remark']) : '<span class="pd-miss">—</span>' !!}</td>
<td data-col="online_order_no">{{ $row['online_order_no'] }}</td>
<td data-col="amount" class="num">{{ number_format((float) $row['income'] - (float) $row['expense'], 2) }}</td>
<td data-col="bill_item"><span class="pd-tag">{{ $row['bill_item'] }}</span></td>
<td data-col="merchant_order_no">{{ $row['merchant_order_no'] }}</td>
<td data-col="custom_remark">{{ $row['custom_remark'] }}</td>
<td data-col="finance_type">{!! $row['finance_type'] !== '' ? '<span class="pd-tag">' . e($row['finance_type']) . '</span>' : '<span class="pd-miss">—</span>' !!}</td>
<td data-col="type_text">{{ $row['type_text'] }}</td>
<td data-col="occurred_at">{{ $row['occurred_at'] }}</td>
<td data-col="income" class="num">{{ $row['income'] > 0 ? number_format((float) $row['income'], 2) : '' }}</td>
<td data-col="expense" class="num">{{ $row['expense'] > 0 ? number_format((float) $row['expense'], 2) : '' }}</td>
<td data-col="remark">{{ $row['remark'] }}</td>
</tr>
@empty
<tr><td colspan="14" style="text-align:center;color:#8a8377;padding:1.5rem;">当前条件下没有数据。</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="pd-pager">
<div>第 {{ $rows->firstItem() ?? 0 }} - {{ $rows->lastItem() ?? 0 }} 条 / 共 {{ number_format($rows->total()) }} 条</div>
<div class="pd-pager__actions">
<label>每页
<select id="pdPerPage" aria-label="每页条数">
@foreach ([10, 20, 50] as $size)
<option value="{{ $size }}" @selected((int) $filters['per_page'] === $size)>{{ $size }}</option>
@endforeach
</select>
</label>
@if ($rows->onFirstPage())
<span class="pd-pager__nav" title="第一页">«</span>
<span class="pd-pager__nav" title="上一页">‹</span>
@else
<a href="{{ $rows->url(1) }}" title="第一页">«</a>
<a href="{{ $rows->previousPageUrl() }}" title="上一页">‹</a>
@endif
<span class="pd-pager__page">{{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }}</span>
@if ($rows->hasMorePages())
<a href="{{ $rows->nextPageUrl() }}" title="下一页">›</a>
<a href="{{ $rows->url(max($rows->lastPage(), 1)) }}" title="最后一页">»</a>
@else
<span class="pd-pager__nav" title="下一页">›</span>
<span class="pd-pager__nav" title="最后一页">»</span>
@endif
</div>
</div>
@endif
<script>
(function () {
var api = window.CaiWuMultiSelect;
if (api) {
[
['pdPlatform', @json(array_values($platforms)), '打字找平台(可多选)'],
['pdStore', @json($options['stores'] ?? []), '打字找店铺(可多选)'],
['pdItem', @json($options['items'] ?? []), '打字找账单项目(可多选)'],
['pdType', @json(array_values($types)), '打字找财务类型(可多选)']
].forEach(function (def) {
var el = document.getElementById(def[0]);
if (el) {
api.create(el, { options: def[1], searchable: true, searchPlaceholder: def[2] });
}
});
}
if (window.CaiWuDatePicker) {
window.CaiWuDatePicker.setup(document.getElementById('pdFilterForm'));
}
})();
/* 导出 + 列设置:列设置存在本机(按页面),导出范围与列选择跟当前筛选一致 */
(function () {
var table = document.querySelector('.pd-table');
var modeEl = document.getElementById('pdExportMode');
var exportBtn = document.getElementById('pdExportBtn');
if (!table || !modeEl || !exportBtn) { return; }
var exportBase = @json(route('business.financial-operations.billing-details.export'));
var heads = Array.prototype.slice.call(table.querySelectorAll('thead th[data-col]'));
var keys = heads.map(function (th) { return th.getAttribute('data-col'); });
var labels = heads.map(function (th) { return th.textContent.trim(); });
var storageKey = 'pd-columns:{{ user()->id }}';
var visible = load();
function load() {
try {
var saved = JSON.parse(window.localStorage.getItem(storageKey) || 'null');
if (Object.prototype.toString.call(saved) === '[object Array]' && saved.length === keys.length) {
return saved.map(function (flag) { return flag !== false; });
}
} catch (e) { /* 读取失败按默认全显示 */ }
return keys.map(function () { return true; });
}
function persist() {
try { window.localStorage.setItem(storageKey, JSON.stringify(visible)); } catch (e) { /* 忽略 */ }
}
function applyColumns() {
keys.forEach(function (key, index) {
var show = visible[index] !== false;
Array.prototype.forEach.call(table.querySelectorAll('[data-col="' + key + '"]'), function (cell) {
cell.style.display = show ? '' : 'none';
});
});
}
function renderList() {
var list = document.getElementById('pdColSetList');
if (!list) { return; }
list.innerHTML = keys.map(function (key, index) {
return '<label class="pd-colset__item"><input type="checkbox" data-col-index="' + index + '"'
+ (visible[index] !== false ? ' checked' : '') + '><span>' + labels[index] + '</span></label>';
}).join('');
}
var menu = document.getElementById('pdColSetMenu');
var menuBtn = document.getElementById('pdColSetBtn');
if (menuBtn && menu) {
menuBtn.addEventListener('click', function (event) {
event.stopPropagation();
var open = menu.hidden;
menu.hidden = !open;
menuBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) { renderList(); }
});
menu.addEventListener('click', function (event) {
event.stopPropagation();
var box = event.target.closest('input[data-col-index]');
if (!box) { return; }
visible[Number(box.getAttribute('data-col-index'))] = box.checked;
applyColumns();
persist();
});
document.addEventListener('click', function (event) {
if (menu.hidden) { return; }
if (menu.contains(event.target) || event.target === menuBtn) { return; }
menu.hidden = true;
menuBtn.setAttribute('aria-expanded', 'false');
});
}
var resetBtn = document.getElementById('pdColSetReset');
if (resetBtn) {
resetBtn.addEventListener('click', function (event) {
event.stopPropagation();
visible = keys.map(function () { return true; });
applyColumns();
persist();
renderList();
});
}
function visibleKeys() {
return keys.filter(function (key, index) { return visible[index] !== false; });
}
var progressBox = document.getElementById('pdExportProgress');
var barEl = document.getElementById('pdExportBar');
var textEl = document.getElementById('pdExportText');
var progressUrl = @json(route('business.financial-operations.billing-details.export-progress'));
var pollTimer = null;
function stopPolling() {
if (pollTimer) {
window.clearInterval(pollTimer);
pollTimer = null;
}
}
function watchProgress(token, startedAt) {
stopPolling();
progressBox.hidden = false;
barEl.style.width = '0%';
textEl.textContent = '导出已开始,浏览器会把文件保存到下载目录…';
pollTimer = window.setInterval(function () {
if (Date.now() - startedAt > 30 * 60 * 1000) {
stopPolling();
textEl.textContent = '导出仍在后台进行,进度跟踪已停止(文件仍会下载)。';
return;
}
fetch(progressUrl + '?token=' + encodeURIComponent(token), { headers: { Accept: 'application/json' } })
.then(function (response) { return response.json(); })
.then(function (state) {
if (!state || state.status === 'unknown') { return; }
var rows = Number(state.rows || 0);
var total = Number(state.total || 0);
var percent = total > 0 ? Math.min(100, Math.round(rows * 100 / total)) : 0;
barEl.style.width = percent + '%';
var seconds = Math.round((Date.now() - startedAt) / 1000);
if (state.status === 'done') {
stopPolling();
barEl.style.width = '100%';
textEl.textContent = '导出完成:共 ' + rows.toLocaleString() + ' 行,用时 ' + seconds + ' 秒,文件已保存到下载目录。';
return;
}
if (state.status === 'failed') {
stopPolling();
textEl.textContent = '导出失败:' + (state.error || '未知错误');
return;
}
textEl.textContent = '已导出 ' + rows.toLocaleString() + ' / ' + total.toLocaleString()
+ ' 行(' + percent + '%)· 已用时 ' + seconds + ' 秒';
})
.catch(function () { /* 轮询失败忽略,下一轮再试 */ });
}, 1000);
}
function exportAll(includeColumns) {
var params = new URLSearchParams(window.location.search);
params.delete('page');
params.set('export', '1');
if (includeColumns) {
visibleKeys().forEach(function (key) { params.append('columns[]', key); });
}
var token = 'pd' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
params.set('token', token);
if (progressBox) { watchProgress(token, Date.now()); }
window.location.assign(exportBase + '?' + params.toString());
}
function exportCurrentPage() {
var rows = Array.prototype.slice.call(table.querySelectorAll('tbody tr'));
var lines = [labels.filter(function (label, index) { return visible[index] !== false; })];
rows.forEach(function (tr) {
var cells = Array.prototype.slice.call(tr.querySelectorAll('td'));
if (cells.length !== keys.length) { return; }
lines.push(keys.map(function (key, index) {
if (visible[index] === false) { return null; }
return cells[index].textContent.trim();
}).filter(function (value) { return value !== null; }));
});
var csv = lines.map(function (values) {
return values.map(function (value) {
var text = String(value === null || value === undefined ? '' : value);
if (/^[=+\-@]/.test(text)) { text = "'" + text; }
return '"' + text.replace(/"/g, '""') + '"';
}).join(',');
}).join('\r\n');
var link = document.createElement('a');
link.href = URL.createObjectURL(new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' }));
link.download = '账单明细_当前页.csv';
link.click();
URL.revokeObjectURL(link.href);
}
exportBtn.addEventListener('click', function () {
if (modeEl.value === 'page-visible') {
exportCurrentPage();
return;
}
exportAll(modeEl.value === 'filtered-visible');
});
function syncButtonLabel() {
exportBtn.innerHTML = '<span class="material-icons-outlined" aria-hidden="true">download</span>'
+ (modeEl.value === 'page-visible' ? '导出当前页' : '导出全部筛选结果')
+ (modeEl.value === 'filtered-all' ? '(全部字段)' : '');
}
modeEl.addEventListener('change', syncButtonLabel);
syncButtonLabel();
applyColumns();
// 每页条数:改完回到第 1 页
var perPageEl = document.getElementById('pdPerPage');
if (perPageEl) {
perPageEl.addEventListener('change', function () {
var params = new URLSearchParams(window.location.search);
params.set('per_page', perPageEl.value);
params.delete('page');
window.location.assign(window.location.pathname + '?' + params.toString());
});
}
})();
</script>
</div>

View File

@ -402,7 +402,7 @@
<br>
· 基准为 <span class="am-basis am-basis--in">收入基准</span> 的项目 —— 账单<b>收入</b>时<b>照抄</b>表内方向;账单<b>支出</b>(退款 / 冲减)时<b>借贷互换</b>。
<br>
· 同一个账单项目在业务上不是同一件事时,用<b>业务备注</b>/<b>商品名称关键词</b>区分:业务备注匹配账单的二次备注、账务备注等(<b>202607订单</b> 这类月份式备注不参与匹配),商品名称按包含匹配;两个都填最优先,其次商品名称,再次业务备注,都不填就是兜底规则。
· 同一个账单项目在业务上不是同一件事时,用<b>财务备注</b>/<b>商品名称关键词</b>区分:财务备注匹配账单的二次备注、账务备注等(<b>202607订单</b> 这类月份式备注不参与匹配),商品名称按包含匹配;两个都填最优先,其次商品名称,再次财务备注,都不填就是兜底规则。
<br>
<span style="color:#8a8377;">点行展开看两种收支下的最终分录与校验结果;点「编辑」可直接改这一行;有新增账单项目时用「导入」追加。</span>
</section>
@ -448,7 +448,7 @@
</span>
</div>
<div class="upstream-data-field am-ms" id="amCategory">
<span>大类</span>
<span>财务类型</span>
<span class="am-ms__box">
<button type="button" class="am-ms__btn" data-role="btn" aria-expanded="false">
<span class="am-ms__label">全部</span><span class="am-ms__caret">▾</span>
@ -492,10 +492,10 @@
<th class="am-index" style="width:3.5rem;">序号</th>
<th style="width:6.5rem;">平台</th>
<th style="width:17rem;">账单项目</th>
<th style="width:11rem;">业务备注</th>
<th style="width:11rem;">财务备注</th>
<th style="width:11rem;">商品名称关键词</th>
<th style="width:5rem;">票据</th>
<th style="width:8rem;">大类</th>
<th style="width:8rem;">财务类型</th>
<th style="width:12rem;">借方科目</th>
<th style="width:12rem;">贷方科目</th>
<th style="width:6rem;">方向基准</th>
@ -1255,7 +1255,7 @@
+ '<label class="am-check"><input type="checkbox" id="amOverwrite"> 重复的那些用文件里的内容覆盖现有规则(不勾选则跳过)</label>'
+ dupList
+ '<div style="margin-top:0.75rem;">前 ' + (data.sample || []).length + ' 行预览:</div>'
+ '<table class="am-preview-table"><thead><tr><th>平台</th><th>账单项目</th><th>业务备注</th><th>商品名称</th><th>大类</th><th>借方</th><th>贷方</th><th>基准</th></tr></thead><tbody>' + rows + '</tbody></table>';
+ '<table class="am-preview-table"><thead><tr><th>平台</th><th>账单项目</th><th>财务备注</th><th>商品名称</th><th>财务类型</th><th>借方</th><th>贷方</th><th>基准</th></tr></thead><tbody>' + rows + '</tbody></table>';
var foot = '<button type="button" class="am-btn" id="amImportCancel">取消</button>'
+ '<button type="button" class="am-btn am-btn--primary" id="amImportConfirm">确认导入</button>';

View File

@ -0,0 +1,324 @@
{{--
开始日期 ─ 至 ─ 结束日期 的日历选择控件(与「小额打款」页同一套交互)。
用法:
<div class="upstream-data-field upstream-data-field--dates">
<span>发生时间</span>
<div class="upstream-data-date-range">
<span class="upstream-data-date" data-date-picker>
<input type="text" name="start" readonly data-date-input>
<button type="button" class="upstream-data-date__clear" data-date-clear hidden>…</button>
<button type="button" class="upstream-data-date__pick" data-date-toggle>…</button>
<div class="upstream-data-calendar" data-date-calendar hidden></div>
</span>
<b class="upstream-data-date-range__sep">至</b>
<span class="upstream-data-date" data-date-picker>…</span>
</div>
</div>
window.CaiWuDatePicker.setup(document.getElementById('pdFilterForm'));
说明:样式与脚本目前与退款 › 小额打款工单页保持一致(那页是内联实现),
后续如要合并,把那边也改成引用本 partial。
--}}
<style>
.upstream-data-field--dates { flex: 1 1 20rem; }
.upstream-data-date-range { display: flex; align-items: center; gap: 0.5rem; }
.upstream-data-date { position: relative; display: flex; flex: 1 1 8.5rem; min-width: 0; }
.upstream-data-date input {
flex: 1 1 auto;
width: auto;
min-width: 0;
padding-right: 1.75rem;
border-radius: 0.5rem 0 0 0.5rem;
cursor: pointer;
}
.upstream-data-date__clear {
position: absolute;
top: 0;
right: 2.25rem;
bottom: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
margin: auto 0;
padding: 0;
border: 0;
border-radius: 0.25rem;
background: transparent;
color: var(--vip-content-muted, #595959);
cursor: pointer;
}
.upstream-data-date__clear:hover {
background: rgba(133, 102, 50, 0.12);
color: var(--vip-content-accent-dark, #856632);
}
.upstream-data-date__clear[hidden] { display: none; }
.upstream-data-date__clear .material-icons-outlined { font-size: 0.9375rem; }
.upstream-data-date__pick {
display: inline-flex;
flex: 0 0 2.125rem;
align-items: center;
justify-content: center;
width: 2.125rem;
height: 2.125rem;
padding: 0;
border: 1px solid var(--vip-border, #d8d2c7);
border-left: 0;
border-radius: 0 0.5rem 0.5rem 0;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-accent-dark, #856632);
cursor: pointer;
}
.upstream-data-date__pick:hover,
.upstream-data-date__pick:focus-visible {
background: #f7f0de;
color: #6a5129;
outline: none;
}
.upstream-data-date__pick .material-icons-outlined { font-size: 1.125rem; }
.upstream-data-date-range__sep {
color: var(--vip-content-muted, #595959);
font-weight: 400;
}
.upstream-data-calendar {
position: absolute;
top: calc(100% + 0.35rem);
left: 0;
z-index: 30;
width: 16.5rem;
padding: 0.5rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #ffffff);
box-shadow: 0 4px 8px rgba(23, 23, 23, 0.1);
}
.upstream-data-calendar[hidden] { display: none; }
.upstream-data-calendar__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
margin-bottom: 0.25rem;
}
.upstream-data-calendar__title {
color: var(--vip-content-ink, #171717);
font-size: 0.8125rem;
font-weight: 600;
}
.upstream-data-calendar__nav {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: 0;
border-radius: 0.25rem;
background: transparent;
color: var(--vip-content-accent-dark, #856632);
cursor: pointer;
}
.upstream-data-calendar__nav:hover { background: #f7f0de; }
.upstream-data-calendar__nav .material-icons-outlined { font-size: 1.125rem; }
.upstream-data-calendar__week,
.upstream-data-calendar__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.125rem;
}
.upstream-data-calendar__week span {
padding: 0.25rem 0;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
text-align: center;
}
.upstream-data-calendar__day {
height: 1.75rem;
padding: 0;
border: 1px solid transparent;
border-radius: 0.25rem;
background: transparent;
color: var(--vip-content-ink, #171717);
cursor: pointer;
font: inherit;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.upstream-data-calendar__day:hover { background: #f7f0de; }
.upstream-data-calendar__day.is-outside { color: #b9b3a8; }
.upstream-data-calendar__day.is-today { border-color: var(--vip-content-accent-dark, #856632); }
.upstream-data-calendar__day.is-selected {
border-color: var(--vip-content-accent-dark, #856632);
background: var(--vip-content-accent-dark, #856632);
color: #ffffff;
}
/* 结束日期的日历向右展开会顶出工具栏,改成向左 */
.upstream-data-date:last-of-type .upstream-data-calendar {
right: 0;
left: auto;
}
</style>
<script>
(function () {
if (window.CaiWuDatePicker) { return; }
var closers = [];
var WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
function pad2(number) {
return (number < 10 ? '0' : '') + number;
}
function isoDate(date) {
return date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate());
}
function parseIsoDate(text) {
var match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(text || '').trim());
if (!match) { return null; }
var date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return date.getFullYear() === Number(match[1])
&& date.getMonth() === Number(match[2]) - 1
&& date.getDate() === Number(match[3])
? date
: null;
}
function sameDay(left, right) {
return left.getFullYear() === right.getFullYear()
&& left.getMonth() === right.getMonth()
&& left.getDate() === right.getDate();
}
function setupDatePicker(picker) {
var input = picker.querySelector('[data-date-input]');
var panel = picker.querySelector('[data-date-calendar]');
var toggle = picker.querySelector('[data-date-toggle]');
var clear = picker.querySelector('[data-date-clear]');
if (!input || !panel || !toggle) { return; }
var today = new Date();
var selected = parseIsoDate(input.value);
var anchor = selected || today;
var viewYear = anchor.getFullYear();
var viewMonth = anchor.getMonth();
function isOpen() { return !panel.hidden; }
function syncClear() {
if (clear) { clear.hidden = input.value === ''; }
}
function render() {
var monthStart = new Date(viewYear, viewMonth, 1);
var gridStart = new Date(viewYear, viewMonth, 1 - monthStart.getDay());
var html = '<div class="upstream-data-calendar__head">'
+ '<button type="button" class="upstream-data-calendar__nav" data-calendar-nav="-1" aria-label="上个月">'
+ '<span class="material-icons-outlined" aria-hidden="true">chevron_left</span></button>'
+ '<span class="upstream-data-calendar__title">' + viewYear + ' 年 ' + (viewMonth + 1) + ' 月</span>'
+ '<button type="button" class="upstream-data-calendar__nav" data-calendar-nav="1" aria-label="下个月">'
+ '<span class="material-icons-outlined" aria-hidden="true">chevron_right</span></button>'
+ '</div>'
+ '<div class="upstream-data-calendar__week">'
+ WEEK_LABELS.map(function (label) { return '<span>' + label + '</span>'; }).join('')
+ '</div>'
+ '<div class="upstream-data-calendar__grid">';
for (var offset = 0; offset < 42; offset += 1) {
var day = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + offset);
var classes = 'upstream-data-calendar__day';
if (day.getMonth() !== viewMonth) { classes += ' is-outside'; }
if (sameDay(day, today)) { classes += ' is-today'; }
if (selected && sameDay(day, selected)) { classes += ' is-selected'; }
html += '<button type="button" class="' + classes + '" data-calendar-day="'
+ isoDate(day) + '">' + day.getDate() + '</button>';
}
panel.innerHTML = html + '</div>';
}
function close() {
if (!isOpen()) { return; }
panel.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
}
function open() {
closers.forEach(function (otherClose) { if (otherClose !== close) { otherClose(); } });
if (isOpen()) { return; }
selected = parseIsoDate(input.value);
anchor = selected || new Date();
viewYear = anchor.getFullYear();
viewMonth = anchor.getMonth();
render();
panel.hidden = false;
toggle.setAttribute('aria-expanded', 'true');
}
function shiftMonth(step) {
anchor = new Date(viewYear, viewMonth + step, 1);
viewYear = anchor.getFullYear();
viewMonth = anchor.getMonth();
render();
}
closers.push(close);
toggle.setAttribute('aria-expanded', 'false');
panel.addEventListener('click', function (event) {
// 必须拦住冒泡:翻月会重绘面板,被点的箭头随即脱离 DOM,
// 外层的「点空白处收起」监听会因此误判成点了外面,把日历关掉。
event.stopPropagation();
var nav = event.target.closest('[data-calendar-nav]');
if (nav && panel.contains(nav)) {
shiftMonth(Number(nav.getAttribute('data-calendar-nav')));
return;
}
var day = event.target.closest('[data-calendar-day]');
if (day && panel.contains(day)) {
input.value = day.getAttribute('data-calendar-day');
selected = parseIsoDate(input.value);
syncClear();
close();
}
});
input.addEventListener('click', open);
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault();
open();
}
});
picker.addEventListener('keydown', function (event) {
if (event.key === 'Escape') { close(); }
});
toggle.addEventListener('click', function () {
if (isOpen()) { close(); } else { open(); }
});
if (clear) {
clear.addEventListener('click', function () {
input.value = '';
selected = null;
syncClear();
close();
});
}
document.addEventListener('click', function (event) {
var target = event.target;
// 目标已经不在文档里(点了会被重绘的日历按钮)时,别当成点了外面
if (target && target.isConnected === false) { return; }
if (!picker.contains(target)) { close(); }
});
syncClear();
}
window.CaiWuDatePicker = {
setup: function (scope) {
(scope || document).querySelectorAll('[data-date-picker]').forEach(setupDatePicker);
}
};
})();
</script>

View File

@ -0,0 +1,289 @@
{{--
可打字搜索的多选筛选(按钮 + 复选面板)。
用法:
<div class="upstream-data-field cw-ms" data-name="store" data-initial='["A"]'>
<span>店铺</span>
<span class="cw-ms__box">
<button type="button" class="cw-ms__btn" data-role="btn" aria-expanded="false">
<span class="cw-ms__label">全部</span><span class="cw-ms__caret">▾</span>
</button>
<span class="cw-ms__panel" data-role="panel" hidden></span>
<span class="cw-ms__values" data-role="values"></span>
</span>
</div>
window.CaiWuMultiSelect.create(el, { options: [...], searchable: true });
勾选结果会写成 <input type="hidden" name="store[]"> 随表单提交;一个都不勾=全部。
--}}
<style>
.cw-ms { position: relative; }
.cw-ms__box { position: relative; display: block; }
.cw-ms__btn {
display: flex;
width: 100%;
height: 2.125rem;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 0 0.625rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #fff);
color: var(--vip-content-ink, #171717);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
}
.cw-ms__btn:hover { border-color: #c4b79f; }
.cw-ms__btn[aria-expanded="true"] { border-color: #856632; box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2); }
.cw-ms__btn--on { font-weight: 600; color: #6f5427; }
.cw-ms__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cw-ms__caret { flex: 0 0 auto; font-size: 0.625rem; color: #8a8377; }
.cw-ms__panel {
position: absolute;
top: calc(100% + 0.3rem);
left: 0;
z-index: 70;
min-width: 16rem;
max-height: 16rem;
overflow: auto;
padding: 0.35rem;
background: #fffdf8;
border: 1px solid #e4dccd;
border-radius: 8px;
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.14);
}
.cw-ms__panel[hidden] { display: none; }
.cw-ms__values { display: none; }
.cw-ms__empty { padding: 0.4rem 0.5rem; font-size: 0.75rem; color: #8a8377; }
.cw-ms__search {
width: 100%;
height: 1.9rem;
min-width: 0;
padding: 0 0.5rem;
border: 1px solid #ddd5c7;
border-radius: 0.4rem;
background: #fff;
color: #3d3a35;
font: inherit;
font-size: 0.8125rem;
outline: none;
}
.cw-ms__search:focus { border-color: #856632; box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2); }
.cw-ms__opts { display: block; }
.cw-ms__head { display: flex; gap: 0.35rem; padding: 0.15rem 0.15rem 0.4rem; margin-bottom: 0.25rem; border-bottom: 1px solid #efe8db; }
.cw-ms__head button {
flex: 1 1 auto;
height: auto;
padding: 0.2rem 0.35rem;
border: 1px solid #e4dccd;
border-radius: 6px;
background: #fff;
color: #3d3a35;
font: inherit;
font-size: 0.6875rem;
cursor: pointer;
}
.cw-ms__head button:hover { background: #f6f2e9; }
.cw-ms__opt {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.35rem;
border-radius: 6px;
font-size: 0.8125rem;
color: #3d3a35;
cursor: pointer;
}
.cw-ms__opt:hover { background: #f6f2e9; }
.cw-ms__opt span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cw-ms__panel .cw-ms__opt input {
position: relative;
flex: 0 0 auto;
width: 0.95rem;
height: 0.95rem;
min-width: 0;
margin: 0;
padding: 0;
border: 1px solid #cfc6b4;
border-radius: 4px;
background: #fff;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
}
.cw-ms__panel .cw-ms__opt input:checked { border-color: #856632; background: #856632; }
.cw-ms__panel .cw-ms__opt input:checked::after {
content: '✓';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 0.6875rem;
line-height: 1;
}
</style>
<script>
(function () {
if (window.CaiWuMultiSelect) { return; }
var instances = [];
function esc(value) {
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function create(root, opts) {
opts = opts || {};
var name = root.getAttribute('data-name') || '';
var btn = root.querySelector('[data-role="btn"]');
var labelEl = btn.querySelector('.cw-ms__label');
var panel = root.querySelector('[data-role="panel"]');
var valuesEl = root.querySelector('[data-role="values"]');
var candidates = (opts.options || []).slice();
var selectedList = [];
var keyword = '';
var optsEl = null;
var searchEl = null;
try {
(JSON.parse(root.getAttribute('data-initial') || '[]') || []).forEach(function (v) {
if (selectedList.indexOf(v) < 0) { selectedList.push(String(v)); }
});
} catch (e) { /* 脏数据忽略 */ }
function isSel(v) { return selectedList.indexOf(v) >= 0; }
function listed() {
if (!keyword) { return candidates.slice(); }
return candidates.filter(function (v) { return String(v).toLowerCase().indexOf(keyword) >= 0; });
}
function buildShell() {
if (optsEl) { return; }
panel.innerHTML = (opts.searchable
? '<span class="cw-ms__head"><input type="search" class="cw-ms__search" placeholder="'
+ esc(opts.searchPlaceholder || '打字找关键字') + '"></span>'
: '')
+ '<span class="cw-ms__head">'
+ '<button type="button" data-ms-act="all">全选</button>'
+ '<button type="button" data-ms-act="none">清空</button>'
+ '</span>'
+ '<span class="cw-ms__opts"></span>';
optsEl = panel.querySelector('.cw-ms__opts');
searchEl = panel.querySelector('.cw-ms__search');
if (searchEl) {
searchEl.addEventListener('input', function () {
keyword = searchEl.value.trim().toLowerCase();
renderOptions();
});
}
}
function renderOptions() {
if (!optsEl) { return; }
var items = listed();
if (!items.length) {
optsEl.innerHTML = '<div class="cw-ms__empty">'
+ (keyword ? '没有匹配的项,换个关键字试试' : '暂无可选项') + '</div>';
return;
}
var html = items.slice(0, 300).map(function (v) {
return '<label class="cw-ms__opt"><input type="checkbox" value="' + esc(v) + '"'
+ (isSel(v) ? ' checked' : '') + '><span>' + esc(v) + '</span></label>';
}).join('');
if (items.length > 300) {
html += '<div class="cw-ms__empty">还有 ' + (items.length - 300) + ' 项,再打精确一点</div>';
}
optsEl.innerHTML = html;
}
function sync() {
if (!selectedList.length) {
labelEl.textContent = '全部';
btn.classList.remove('cw-ms__btn--on');
} else if (selectedList.length === 1) {
labelEl.textContent = selectedList[0];
btn.classList.add('cw-ms__btn--on');
} else {
labelEl.textContent = selectedList[0] + ' 等 ' + selectedList.length + ' 项';
btn.classList.add('cw-ms__btn--on');
}
btn.title = selectedList.length ? selectedList.join('、') : '全部';
valuesEl.innerHTML = selectedList.map(function (v) {
return '<input type="hidden" name="' + esc(name) + '[]" value="' + esc(v) + '">';
}).join('');
}
function closePanel() {
panel.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
var api = {
root: root,
values: function () { return selectedList.slice(); },
close: closePanel,
setOptions: function (list) {
candidates = (list || []).slice();
renderOptions();
sync();
}
};
btn.addEventListener('click', function (ev) {
ev.stopPropagation();
if (panel.hidden) {
instances.forEach(function (ms) { if (ms !== api) { ms.close(); } });
buildShell();
renderOptions();
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
if (searchEl) { searchEl.focus(); }
} else {
closePanel();
}
});
panel.addEventListener('click', function (ev) {
ev.stopPropagation();
var act = ev.target.closest('button[data-ms-act]');
if (!act) { return; }
if (act.getAttribute('data-ms-act') === 'all') {
listed().forEach(function (v) { if (!isSel(v)) { selectedList.push(v); } });
} else {
selectedList = [];
keyword = '';
if (searchEl) { searchEl.value = ''; }
}
renderOptions();
sync();
});
panel.addEventListener('change', function (ev) {
var cb = ev.target.closest('input[type="checkbox"]');
if (!cb) { return; }
if (cb.checked) {
if (!isSel(cb.value)) { selectedList.push(cb.value); }
} else {
selectedList = selectedList.filter(function (v) { return v !== cb.value; });
}
sync();
});
instances.push(api);
sync();
return api;
}
document.addEventListener('click', function (ev) {
instances.forEach(function (ms) { if (!ms.root.contains(ev.target)) { ms.close(); } });
});
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape') { instances.forEach(function (ms) { ms.close(); }); }
});
window.CaiWuMultiSelect = { create: create };
})();
</script>

View File

@ -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');