财务经营报表:新增账单明细页(多选筛选/日历选择/四项汇总/按列导出与进度/收款明细同款分页),科目映射表字段改名财务备注与财务类型
- 账单明细:7 个平台账单分支下推查询,按 active_generation 取当前版本(修正抖音重复计数),跨月大数据量由 73s 降到 1-2s - 筛选:平台/店铺/账单项目/财务类型多选下拉(打字模糊搜索+滚动),日期改日历选择 - 导出:全部筛选结果 CSV 流式下载并按显示列最小取数,带进度轮询;列设置按账号存本机 - 分页:每页 10/20/50,首页/上下页/末页,第 X-Y 条/共 N 条 - 科目映射表:字段改名财务备注/财务类型(导入兼容旧名)
This commit is contained in:
parent
a77d369d15
commit
6af12c030e
@ -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,
|
||||
|
||||
@ -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' => '安装明细'],
|
||||
|
||||
@ -29,10 +29,10 @@ class AccountMappingStore
|
||||
public const LABELS = [
|
||||
'platform' => '平台',
|
||||
'item' => '账单项目',
|
||||
'memo' => '业务备注',
|
||||
'memo' => '财务备注',
|
||||
'goods' => '商品名称关键词',
|
||||
'invoice' => '票据口径',
|
||||
'category' => '大类',
|
||||
'category' => '财务类型',
|
||||
'dAcct' => '借方科目',
|
||||
'dSub' => '借方二级',
|
||||
'cAcct' => '贷方科目',
|
||||
|
||||
@ -25,7 +25,7 @@ class AccountMappingWorkbookImporter
|
||||
'memo' => ['业务备注', '财务备注', '二次备注', '业务备注关键词', '账务备注', '备注'],
|
||||
'goods' => ['商品名称关键词', '商品名称', '商品描述', '货品名称'],
|
||||
'invoice' => ['票据口径', '票据', '票据类型', '发票'],
|
||||
'category' => ['大类', '业务性质', '科目大类', '分类'],
|
||||
'category' => ['财务类型', '大类', '业务性质', '科目大类', '分类'],
|
||||
'basis' => ['方向基准', '基准'],
|
||||
'dAcct' => ['借方科目', '借/贷(借)', '借/贷(借)', '借方'],
|
||||
'dSub' => ['借方二级'],
|
||||
|
||||
639
modules/EcommerceFinance/View/BillingDetailsPageData.php
Normal file
639
modules/EcommerceFinance/View/BillingDetailsPageData.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>';
|
||||
|
||||
@ -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>
|
||||
289
resources/views/business/pages/partials/multi-select.blade.php
Normal file
289
resources/views/business/pages/partials/multi-select.blade.php
Normal 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user