业绩明细:异常标注三类整理 + 优惠/冲减补行 + 匹配状态/异常标注多选联动筛选

This commit is contained in:
yueyang 2026-10-08 11:46:42 +08:00
parent 627fa966ef
commit 2cf181f1bf
6 changed files with 496 additions and 63 deletions

View File

@ -25,7 +25,7 @@ use Throwable;
* - 订单侧:销售全量按「店铺 + 线上订单号」先聚合求和(一单多品/多子订单在此合并),金额取 goods_count > 0 的有效行
* - 单号清洗:去除日期前缀(如 260630-)、空格、横杠后按核心号匹配
* - 店铺:账单用「线上真实店铺」优先,订单侧用销售全量店铺名,统一把下划线归一为横杠
* - 同一店铺同一订单号多笔收款 → 标注「一单多笔」,商品金额只挂一次
* - 同一店铺同一订单号多笔收款 → 标注「同单多笔收款」,商品金额只挂一次
*/
class SyncStoreReceiptOrderMatches extends Command
{
@ -121,19 +121,30 @@ class SyncStoreReceiptOrderMatches extends Command
private const STATUS_UNMATCHED = '未匹配';
private const REMARK_EMPTY_NO = '空单号';
/**
* 异常标注取值(页面「匹配状态」筛选与导出共用)。
*
* 分三类,命名统一成「哪里出问题」:
* 单号问题:账单无单号 / 单号格式异常
* 订单问题:订单表无此单 / 订单无商品金额
* 匹配提示:店铺名不一致 / 订单侧同号多条 / 同单多笔收款
*/
private const REMARK_EMPTY_NO = '账单无单号';
private const REMARK_BAD_FORMAT = '格式不一致';
private const REMARK_BAD_FORMAT = '单号格式异常';
private const REMARK_NO_ORDER = '订单表无此单';
private const REMARK_NO_AMOUNT = '订单无商品金额';
private const REMARK_SHOP_DIFF = '店铺不一致';
private const REMARK_SHOP_DIFF = '店铺名不一致';
private const REMARK_DUP_ORDER = '订单侧同号多条';
private const REMARK_MULTI_PAY = '一单多笔';
private const REMARK_MULTI_PAY = '同单多笔收款';
/** 优惠/冲减账单所在的订单没有收款行时,补行用这个标注。 */
private const REMARK_PROMO_ONLY = '无收款账单';
public function handle(UpstreamReadOnlyQueryFactory $factory): int
{
@ -271,6 +282,8 @@ class SyncStoreReceiptOrderMatches extends Command
$ledgerMap = $remarkMap->map();
$incomeOverlapRows = 0;
$incomeOverlapTotal = 0.0;
/** @var array<string,int> $promoRowsByKey 门店+订单 维度的优惠/冲减账单行数(用于统计未挂单行数) */
$promoRowsByKey = [];
foreach ($bills as $bill) {
$storeName = (string) ($bill->store_name ?? '');
$billItem = (string) ($bill->bill_item ?? '');
@ -329,10 +342,12 @@ class SyncStoreReceiptOrderMatches extends Command
$groups[$key]['discount_seen'] = true;
$groups[$key]['discount_amount'] += $amount;
$discountTotal += $amount;
$promoRowsByKey[$key] = ($promoRowsByKey[$key] ?? 0) + 1;
} else {
$groups[$key]['offset_seen'] = true;
$groups[$key]['offset_amount'] += $amount;
$offsetTotal += $amount;
$promoRowsByKey[$key] = ($promoRowsByKey[$key] ?? 0) + 1;
}
// 种菜金额只标记「这个订单是不是种菜单」,金额仍取该行的收入金额,
@ -346,6 +361,31 @@ class SyncStoreReceiptOrderMatches extends Command
// 只有优惠/冲减、没有收款的订单号不单独成行。
$groups = array_filter($groups, static fn (array $group): bool => (int) $group['payment_count'] > 0);
// 口径对照:账单口径合计、已挂到订单行的合计、挂不上的行数(账单没有订单号 / 订单没有收款行)。
$attachedDiscount = 0.0;
$attachedOffset = 0.0;
foreach ($groups as $group) {
$attachedDiscount += (float) $group['discount_amount'];
$attachedOffset += (float) $group['offset_amount'];
}
$unattachedRows = 0;
foreach ($promoRowsByKey as $key => $count) {
if (! isset($groups[$key])) {
$unattachedRows += $count;
}
}
// A 方案:挂不到订单行的优惠/冲减也补成行(收入/商品/差额留空),金额不丢。
$promoOnlyGroups = $this->promoOnlyGroups(
$bills,
$groups,
$remarkMap,
$ledgerMap,
$tabPlatform,
$orderColumn,
$timeColumn,
$amountColumn,
$realStoreColumn,
);
$this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行');
$this->line(' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 2));
foreach ($groups as $group) {
@ -360,6 +400,13 @@ class SyncStoreReceiptOrderMatches extends Command
count($plantingCores),
number_format($plantingTotal, 2),
));
$this->line(sprintf(
' 已挂订单行:优惠 %s / 冲减 %s;未挂到订单行:%s(金额 %s,已补成「无收款账单 / 账单无单号」行)',
number_format($attachedDiscount, 2),
number_format($attachedOffset, 2),
number_format($unattachedRows) . ' 行',
number_format(($discountTotal + $offsetTotal) - ($attachedDiscount + $attachedOffset), 2),
));
if ($incomeOverlapRows > 0) {
$this->line(sprintf(
' 提示:%d 笔(%s 元)按旧口径已计入收入金额,未重复计入优惠/冲减列。',
@ -369,7 +416,7 @@ class SyncStoreReceiptOrderMatches extends Command
}
if ((bool) $this->option('amounts-only')) {
return $this->backfillPromoAmounts($month, $source, $groups, $reportMonth);
return $this->backfillPromoAmounts($month, $source, $groups, $promoOnlyGroups, $reportMonth);
}
// 订单侧的单号在不同平台形态不同:淘系/京东是无前缀纯数字,
@ -448,7 +495,7 @@ class SyncStoreReceiptOrderMatches extends Command
$now = now();
$buffer = [];
$imported = 0;
$stats = ['已匹配' => 0, '未匹配' => 0, '一单多笔' => 0];
$stats = ['已匹配' => 0, '未匹配' => 0, self::REMARK_MULTI_PAY => 0];
$remarked = [];
$goodsTotal = 0.0;
$differenceTotal = 0.0;
@ -534,7 +581,7 @@ class SyncStoreReceiptOrderMatches extends Command
$stats[$status] = ($stats[$status] ?? 0) + 1;
if ((int) $group['payment_count'] > 1) {
$stats['一单多笔']++;
$stats[self::REMARK_MULTI_PAY]++;
}
if ($remark !== '') {
$remarked[$remark] = ($remarked[$remark] ?? 0) + 1;
@ -567,6 +614,16 @@ class SyncStoreReceiptOrderMatches extends Command
}
}
// A 方案补行:挂不到订单行的优惠/冲减也写进业绩明细(收入/商品/差额留空)。
foreach ($promoOnlyGroups as $promoGroup) {
$buffer[] = self::promoOnlyRow($reportMonth, (string) $source['label'], $promoGroup, $now);
if (count($buffer) >= 500) {
$target->insertOrIgnore($buffer);
$imported += count($buffer);
$buffer = [];
}
}
if ($buffer !== []) {
$target->insertOrIgnore($buffer);
$imported += count($buffer);
@ -586,6 +643,7 @@ class SyncStoreReceiptOrderMatches extends Command
return self::SUCCESS;
}
/**
* 只回填「种菜金额」:不读账单,直接按业绩明细里已有的「订单号」判断是不是种菜订单,
* 是就把该行的收入金额写进去,不是就清空。适合给已经同步好的账期补这一列。
@ -637,8 +695,9 @@ class SyncStoreReceiptOrderMatches extends Command
*
* @param array<string,mixed> $source
* @param array<string,array<string,mixed>> $groups
* @param array<string,array<string,mixed>> $promoOnlyGroups A 方案要补的行
*/
private function backfillPromoAmounts(string $month, array $source, array $groups, string $reportMonth): int
private function backfillPromoAmounts(string $month, array $source, array $groups, array $promoOnlyGroups, string $reportMonth): int
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$platform = (string) $source['label'];
@ -684,14 +743,37 @@ class SyncStoreReceiptOrderMatches extends Command
$this->updateAmountColumn($connection, $reportMonth, 'offset_amount', $offsets);
$this->updateAmountColumn($connection, $reportMonth, 'planting_amount', $plantings);
// A 方案:本月本平台的补行(payment_count=0 的行)整体重建,保证幂等。
$target()
->where('report_month', $reportMonth)
->where('platform', $platform)
->where('payment_count', 0)
->delete();
$buffer = [];
$promoRows = 0;
foreach ($promoOnlyGroups as $promoGroup) {
$buffer[] = self::promoOnlyRow($reportMonth, $platform, $promoGroup, now());
if (count($buffer) >= 500) {
$target()->insertOrIgnore($buffer);
$promoRows += count($buffer);
$buffer = [];
}
}
if ($buffer !== []) {
$target()->insertOrIgnore($buffer);
$promoRows += count($buffer);
}
$this->info(sprintf(
'已回填 %s %s:优惠 %d 行(%s)/ 冲减 %d 行(%s)/ 种菜 %d 行(%s),镜像原有 %d 行,未重建业绩明细。',
'已回填 %s %s:优惠 %d 行(%s)/ 冲减 %d 行(%s)/ 补「无收款账单/无单号」优惠冲减行 %d 行 / 种菜 %d 行(%s),镜像原有 %d 行,未重建业绩明细。',
$month,
$platform,
count($discounts),
number_format(array_sum($discounts), 2),
count($offsets),
number_format(array_sum($offsets), 2),
$promoRows,
count($plantings),
number_format(array_sum($plantings), 2),
count($existing),
@ -700,6 +782,116 @@ class SyncStoreReceiptOrderMatches extends Command
return self::SUCCESS;
}
/**
* A 方案:把挂不到订单行的优惠/冲减整理成「补行」。
*
* - 有订单号、但该订单没有收款账单(成不了行)→ 一行 = 店铺 + 订单号
* - 账单本身没有订单号 → 一行 = 店铺 + 账单项目(按账期汇总)
*
* @param iterable<int,mixed> $bills
* @param array<string,array<string,mixed>> $groups 已有收款行(已按 payment_count 过滤)
* @param array<string,string> $ledgerMap
* @return array<string,array<string,mixed>>
*/
private function promoOnlyGroups(
iterable $bills,
array $groups,
AccountLedgerRemarkMap $remarkMap,
array $ledgerMap,
?string $tabPlatform,
string $orderColumn,
string $timeColumn,
string $amountColumn,
string $realStoreColumn,
): array {
$result = [];
foreach ($bills as $bill) {
$storeName = (string) ($bill->store_name ?? '');
$billItem = (string) ($bill->bill_item ?? '');
$remark = $remarkMap->remark($storeName, $tabPlatform, $billItem);
$type = in_array($remark, self::DISCOUNT_REMARKS, true) ? 'discount'
: (in_array($remark, self::OFFSET_REMARKS, true) ? 'offset' : null);
if ($type === null) {
continue;
}
// 已经算进「收入金额」白名单的行不重复补。
$legacy = $ledgerMap[$remarkMap->platform($storeName, $tabPlatform) ?? ''][$billItem] ?? null;
if (in_array($legacy, self::LEDGER_REMARKS, true)) {
continue;
}
$rawNo = trim((string) ($bill->{$orderColumn} ?? ''));
$core = self::cleanOrderNo($rawNo);
$realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : '';
$shopKey = self::normalizeShop($realStore !== '' ? $realStore : $storeName);
if ($core !== '' && isset($groups[$shopKey . '|' . $core])) {
// 该订单已有收款行,优惠/冲减已经挂在那一行上。
continue;
}
$key = $core === '' ? $shopKey . '||' . $billItem : $shopKey . '|' . $core;
if (! isset($result[$key])) {
$result[$key] = [
'shop_key' => $shopKey,
'store_name' => $storeName,
'online_order_no' => $rawNo,
'order_core_no' => $core,
'bill_item' => $billItem,
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
'discount_amount' => 0.0,
'discount_seen' => false,
'offset_amount' => 0.0,
'offset_seen' => false,
];
}
$amount = (float) $bill->{$amountColumn};
if ($type === 'discount') {
$result[$key]['discount_seen'] = true;
$result[$key]['discount_amount'] += $amount;
} else {
$result[$key]['offset_seen'] = true;
$result[$key]['offset_amount'] += $amount;
}
}
return $result;
}
/**
* A 方案补行的入库数据:收入/商品/差额留空,只带优惠/冲减金额与原因标注。
*
* @param array<string,mixed> $group
* @return array<string,mixed>
*/
private static function promoOnlyRow(string $reportMonth, string $platform, array $group, mixed $now): array
{
$core = (string) $group['order_core_no'];
$rowKey = $core !== ''
? md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|' . $core)
: md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|promo|' . $group['bill_item']);
return [
'report_month' => $reportMonth,
'platform' => $platform,
'row_key' => $rowKey,
'occurred_date' => $group['occurred_date'],
'store_name' => $group['store_name'],
'online_order_no' => $group['online_order_no'] !== '' ? $group['online_order_no'] : null,
'order_core_no' => $core !== '' ? $core : null,
'payment_count' => 0,
'income_amount' => 0,
'discount_amount' => ! empty($group['discount_seen']) ? round((float) $group['discount_amount'], 2) : null,
'offset_amount' => ! empty($group['offset_seen']) ? round((float) $group['offset_amount'], 2) : null,
'product_amount' => null,
'difference' => null,
'match_status' => self::STATUS_UNMATCHED,
'remark' => $core === '' ? self::REMARK_EMPTY_NO : self::REMARK_PROMO_ONLY,
'imported_at' => $now,
];
}
/**
* 按 row_key 批量更新一列金额:只更新镜像里已有的行,不做插入。
*

View File

@ -107,6 +107,24 @@ class Pages extends Controller
'得物',
];
/** 业绩明细「匹配状态」筛选项:只保留基础匹配状态。 */
private const RECEIPT_STATUS_OPTIONS = [
'已匹配',
'未匹配',
];
/** 业绩明细「异常标注」筛选项,取值与同步命令写入的标注一致。 */
private const RECEIPT_REMARK_OPTIONS = [
'账单无单号',
'单号格式异常',
'订单表无此单',
'订单无商品金额',
'无收款账单',
'店铺名不一致',
'订单侧同号多条',
'同单多笔收款',
];
public function __construct(
private readonly EdgeBandingRepository $edgeBanding,
private readonly InventoryRepository $inventory,
@ -582,21 +600,7 @@ class Pages extends Controller
protected function storeReceiptOrderMatchRows(): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$months = DB::connection($connection)->table('store_receipt_order_match_rows')
->select('report_month')
->distinct()
->orderByDesc('report_month')
->pluck('report_month')
->map(static fn ($month): string => substr((string) $month, 0, 10))
->all();
$requested = substr(trim((string) request('month', '')), 0, 10);
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1) {
// 筛选栏的月份选择器提交 Y-m,这里补成当月 1 号再匹配。
$requested .= '-01';
}
// 明确指定月份时严格按该月份取数(该月没同步就显示空表)。
$month = $requested !== '' ? $requested : ($months[0] ?? null);
[$months, $month] = $this->receiptOrderMatchMonths();
// 每页条数:业绩明细页面可切 10 / 20 / 50,未指定时沿用系统默认(上限 50)。
$requestedLimit = (int) request('per_page', 0);
@ -615,10 +619,10 @@ class Pages extends Controller
// 平台 / 店铺与其它页面一致:可打字搜索的多选(一个都不勾=全部),同时兼容旧的单值链接。
$platformFilter = $this->receiptOrderMatchFilterValues(request('platform'));
$storeFilter = $this->receiptOrderMatchFilterValues(request('store_name'));
[$statusFilter, $remarkFilter] = $this->receiptOrderMatchStatusFilters(request('match_status'), request('remark'));
$orderNo = trim((string) request('online_order_no', ''));
$status = trim((string) request('match_status', ''));
$applyFilters = static function ($query) use ($platformFilter, $storeFilter, $orderNo, $status): void {
$applyFilters = static function ($query) use ($platformFilter, $storeFilter, $orderNo, $statusFilter, $remarkFilter): void {
if ($platformFilter !== []) {
$query->whereIn('platform', $platformFilter);
}
@ -628,13 +632,12 @@ class Pages extends Controller
if ($orderNo !== '') {
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
if ($status !== '') {
// 基础状态(已匹配/未匹配)按状态列过滤,其余按异常标注过滤。
if (in_array($status, ['已匹配', '未匹配'], true)) {
$query->where('match_status', $status);
} else {
$query->where('remark', $status);
}
// 「匹配状态」和「异常标注」是两个独立筛选,各自多选内部是「或」,两者之间是「且」。
if ($statusFilter !== []) {
$query->whereIn('match_status', $statusFilter);
}
if ($remarkFilter !== []) {
$query->whereIn('remark', $remarkFilter);
}
};
@ -708,6 +711,8 @@ class Pages extends Controller
$storeFilter,
array_map('strval', $filterOptions['stores'] ?? []),
)));
$stats['status_options'] = self::RECEIPT_STATUS_OPTIONS;
$stats['remark_options'] = self::RECEIPT_REMARK_OPTIONS;
if ($total === 0) {
return [$empty, $stats];
@ -750,6 +755,126 @@ class Pages extends Controller
return array_keys($clean);
}
/**
* 业绩明细的账期集合与当前账期:未指定月份时取镜像里最新的月份。
*
* @return array{0:list<string>,1:?string} 账期列表(Y-m-d)与当前账期(Y-m-d)
*/
private function receiptOrderMatchMonths(): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$months = DB::connection($connection)->table('store_receipt_order_match_rows')
->select('report_month')
->distinct()
->orderByDesc('report_month')
->pluck('report_month')
->map(static fn ($month): string => substr((string) $month, 0, 10))
->all();
$requested = substr(trim((string) request('month', '')), 0, 10);
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1) {
// 筛选栏的月份选择器提交 Y-m,这里补成当月 1 号再匹配。
$requested .= '-01';
}
// 明确指定月份时严格按该月份取数(该月没同步就显示空表)。
$month = $requested !== '' ? $requested : ($months[0] ?? null);
return [$months, $month];
}
/**
* 归一化「匹配状态 / 异常标注」两组筛选值:状态列与标注列各自独立,
* 同时兼容历史链接把异常标注塞进 match_status 的写法。
*
* @return array{0:list<string>,1:list<string>} 状态值、异常标注值
*/
private function receiptOrderMatchStatusFilters(mixed $statusInput, mixed $remarkInput): array
{
$values = $this->receiptOrderMatchFilterValues($statusInput);
$statusValues = array_values(array_intersect($values, self::RECEIPT_STATUS_OPTIONS));
return [
$statusValues,
array_values(array_unique(array_merge(
array_diff($values, $statusValues),
$this->receiptOrderMatchFilterValues($remarkInput),
))),
];
}
/**
* 业绩明细筛选联动:按「当前其它筛选条件」统计每个筛选项的可选值与行数,
* 行数为 0 的取值不返回(前端不展示)。
*/
public function receiptOrderMatchFilterOptions()
{
[, $month] = $this->receiptOrderMatchMonths();
if ($month === null) {
return response()->json(['month' => null, 'facets' => []]);
}
[$statusValues, $remarkValues] = $this->receiptOrderMatchStatusFilters(request('match_status'), request('remark'));
$filters = [
'platform' => $this->receiptOrderMatchFilterValues(request('platform')),
'store_name' => $this->receiptOrderMatchFilterValues(request('store_name')),
'match_status' => $statusValues,
'remark' => $remarkValues,
];
$orderNo = trim((string) request('online_order_no', ''));
return response()->json([
'month' => substr($month, 0, 7),
'facets' => [
'platform' => $this->receiptOrderMatchFacet($month, 'platform', $filters, $orderNo, self::RECEIPT_PLATFORM_OPTIONS),
'store_name' => $this->receiptOrderMatchFacet($month, 'store_name', $filters, $orderNo),
'match_status' => $this->receiptOrderMatchFacet($month, 'match_status', $filters, $orderNo, self::RECEIPT_STATUS_OPTIONS),
'remark' => $this->receiptOrderMatchFacet($month, 'remark', $filters, $orderNo, self::RECEIPT_REMARK_OPTIONS),
],
]);
}
/**
* 单个筛选项的可选值与行数:应用除该筛选项以外的所有条件。
*
* @param array<string,list<string>> $filters 已归一化的筛选条件(键=列名)
* @param list<string> $order 固定展示顺序,未列出的取值排在后面
* @return array{values:list<string>,counts:array<string,int>}
*/
private function receiptOrderMatchFacet(string $month, string $column, array $filters, string $orderNo, array $order = []): array
{
$query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('store_receipt_order_match_rows')
->where('report_month', $month);
foreach ($filters as $key => $values) {
if ($key === $column || $values === []) {
continue;
}
$query->whereIn($key, $values);
}
if ($orderNo !== '') {
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
$counts = [];
$rows = $query->select($column)
->selectRaw('count(*) as total')
->whereNotNull($column)
->where($column, '!=', '')
->groupBy($column)
->pluck('total', $column);
foreach ($rows as $value => $total) {
$counts[(string) $value] = (int) $total;
}
$ordered = array_values(array_intersect($order, array_keys($counts)));
$rest = array_values(array_diff(array_keys($counts), $ordered));
sort($rest, SORT_NATURAL | SORT_FLAG_CASE);
return ['values' => array_merge($ordered, $rest), 'counts' => $counts];
}
/**
* 业绩明细 → 店铺收款匹配订单明细导出:按当前月份与筛选条件导出 CSV,列序跟随页面显示列。
*/
@ -772,15 +897,7 @@ class Pages extends Controller
'remark' => ['remark', '异常标注', 'text'],
];
$months = DB::connection($connection)->table('store_receipt_order_match_rows')
->select('report_month')->distinct()->orderByDesc('report_month')
->pluck('report_month')
->map(static fn ($month): string => substr((string) $month, 0, 10))->all();
$requested = substr(trim((string) $request->query('month', '')), 0, 10);
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1) {
$requested .= '-01';
}
$month = $requested !== '' ? $requested : ($months[0] ?? null);
[, $month] = $this->receiptOrderMatchMonths();
$query = DB::connection($connection)->table('store_receipt_order_match_rows')
->when($month !== null, static fn ($builder) => $builder->where('report_month', $month));
@ -797,13 +914,13 @@ class Pages extends Controller
if ($orderNo !== '') {
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
$status = trim((string) $request->query('match_status', ''));
if ($status !== '') {
if (in_array($status, ['已匹配', '未匹配'], true)) {
$query->where('match_status', $status);
} else {
$query->where('remark', $status);
}
$statusFilter = $this->receiptOrderMatchFilterValues($request->query('match_status'));
[$statusValues, $remarkValues] = $this->receiptOrderMatchStatusFilters($statusFilter, $request->query('remark'));
if ($statusValues !== []) {
$query->whereIn('match_status', $statusValues);
}
if ($remarkValues !== []) {
$query->whereIn('remark', $remarkValues);
}
$selected = array_values(array_intersect(

View File

@ -789,7 +789,7 @@ $navigation = [
'directory' => [
'icon' => 'receipt_long',
'status' => '共享镜像',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−商品金额,只反映真正对不上的部分。',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−商品金额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品/差额留空):订单没有收款账单标「无收款账单」,账单没有订单号标「账单无单号」。异常标注分三类:单号问题(账单无单号 / 单号格式异常)、订单问题(订单表无此单 / 订单无商品金额 / 无收款账单)、匹配提示(店铺名不一致 / 订单侧同号多条 / 同单多笔收款)。',
'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。',
'fields' => [
['name' => 'platform', 'label' => '平台'],

View File

@ -1393,6 +1393,20 @@
background: #fffdf8;
}
#directory-table-performance-details thead th[data-directory-fixed="start"] { z-index: 5; }
/* 异常标注整理:按问题类型上色,一眼区分「要处理」和「只是提示」 */
.receipt-remark-tag {
display: inline-block;
padding: 0.05rem 0.4rem;
border-radius: 999px;
font-size: 0.72rem;
line-height: 1.5;
white-space: nowrap;
}
.receipt-remark-tag--error { background: #fdeaea; color: #b3261e; }
.receipt-remark-tag--warn { background: #fdf3e3; color: #96690a; }
.receipt-remark-tag--info { background: #eaf1fb; color: #1f5fa8; }
</style>
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="业绩明细筛选查询栏">
{{-- 查询时保留当前每页条数 --}}
@ -1449,15 +1463,28 @@
<span>线上单号:</span>
<input type="text" name="online_order_no" value="{{ request('online_order_no') }}" placeholder="支持包含查询">
</label>
<label class="installation-filter-field">
<div class="installation-filter-field cw-ms" id="rdStatus" data-name="match_status"
data-initial="{{ json_encode(array_values((array) request('match_status', [])), JSON_UNESCAPED_UNICODE) }}">
<span>匹配状态:</span>
<select name="match_status">
<option value="">全部</option>
@foreach (['已匹配', '未匹配', '一单多笔', '空单号', '格式不一致', '订单表无此单', '店铺不一致', '订单侧同号多条'] as $receiptStatusOption)
<option value="{{ $receiptStatusOption }}" @selected((string) request('match_status') === $receiptStatusOption)>{{ $receiptStatusOption }}</option>
@endforeach
</select>
</label>
<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="installation-filter-field cw-ms" id="rdRemark" data-name="remark"
data-initial="{{ json_encode(array_values((array) request('remark', [])), 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="installation-filter-actions">
<button type="submit" class="biz-btn biz-btn-primary">查询</button>
<a href="{{ request()->url() }}" class="biz-btn biz-btn-ghost">清除</a>
@ -1467,17 +1494,98 @@
(function () {
var api = window.CaiWuMultiSelect;
if (!api) { return; }
var widgets = [];
[
['rdPlatform', @json(array_values($receiptStats['platform_options'] ?? [])), '打字找平台(可多选)'],
['rdStore', @json(array_values($receiptStats['store_options'] ?? [])), '打字找店铺(可多选)']
['rdStore', @json(array_values($receiptStats['store_options'] ?? [])), '打字找店铺(可多选)'],
['rdStatus', @json(array_values($receiptStats['status_options'] ?? [])), '打字找匹配状态(可多选)'],
['rdRemark', @json(array_values($receiptStats['remark_options'] ?? [])), '打字找异常标注(可多选)']
].forEach(function (def) {
var el = document.getElementById(def[0]);
if (el) {
api.create(el, { options: def[1], searchable: true, searchPlaceholder: def[2] });
widgets.push(api.create(el, { options: def[1], searchable: true, searchPlaceholder: def[2] }));
}
});
// 筛选联动:勾选后按「其它筛选条件」重新统计各筛选项,行数为 0 的取值不展示;
// 已勾选的值始终保留在列表里,方便取消。
var form = document.querySelector('form[aria-label="业绩明细筛选查询栏"]');
if (!form || !widgets.length) { return; }
var endpoint = @json(route('business.financial-operations.performance-details.filter-options'));
var timer = null;
function currentParams() {
var params = new URLSearchParams();
widgets.forEach(function (widget) {
var name = widget.root.getAttribute('data-name');
(widget.values() || []).forEach(function (value) { params.append(name + '[]', value); });
});
var month = form.querySelector('[name="month"]');
if (month && month.value) { params.set('month', month.value); }
var orderNo = form.querySelector('[name="online_order_no"]');
if (orderNo && orderNo.value.trim()) { params.set('online_order_no', orderNo.value.trim()); }
return params;
}
function refresh() {
window.fetch(endpoint + '?' + currentParams().toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (response) {
return response.ok ? response.json() : null;
}).then(function (data) {
if (!data || !data.facets) { return; }
widgets.forEach(function (widget) {
var facet = data.facets[widget.root.getAttribute('data-name')];
if (!facet || !facet.values) { return; }
var options = facet.values.slice();
(widget.values() || []).forEach(function (value) {
if (options.indexOf(value) < 0) { options.push(value); }
});
widget.setOptions(options, facet.counts || {});
});
}).catch(function () {
// 联动统计失败时保留原有选项,不影响筛选本身。
});
}
function schedule() {
window.clearTimeout(timer);
timer = window.setTimeout(refresh, 250);
}
widgets.forEach(function (widget) {
widget.root.addEventListener('click', schedule);
widget.root.addEventListener('change', schedule);
});
schedule();
})();
</script>
<script>
// 异常标注统一成分类标签:单号问题 / 订单问题需要处理,匹配提示只是提醒。
document.addEventListener('DOMContentLoaded', function () {
var tagClasses = {
'账单无单号': 'receipt-remark-tag--error',
'单号格式异常': 'receipt-remark-tag--error',
'订单表无此单': 'receipt-remark-tag--warn',
'订单无商品金额': 'receipt-remark-tag--warn',
'店铺名不一致': 'receipt-remark-tag--info',
'订单侧同号多条': 'receipt-remark-tag--info',
'同单多笔收款': 'receipt-remark-tag--info'
};
document.querySelectorAll('#directory-table-performance-details td[data-column-key="remark"]').forEach(function (cell) {
var text = cell.textContent.trim();
var cls = tagClasses[text];
if (!cls) { return; }
cell.textContent = '';
var tag = document.createElement('span');
tag.className = 'receipt-remark-tag ' + cls;
tag.textContent = text;
cell.appendChild(tag);
});
});
</script>
@elseif (in_array(($page['slug'] ?? ''), ['inventory-report', 'monthly-weighted-average-price'], true))
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="进销存报表筛选查询栏">
<label class="installation-filter-field">

View File

@ -97,6 +97,9 @@
}
.cw-ms__opt:hover { background: #f6f2e9; }
.cw-ms__opt span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 联动统计:选项右侧显示当前条件下的行数(不传 counts 的页面不显示)。 */
.cw-ms__opt span:first-of-type { flex: 1 1 auto; min-width: 0; }
.cw-ms__opt .cw-ms__count { flex: 0 0 auto; color: #8a8377; font-size: 0.6875rem; font-variant-numeric: tabular-nums; }
.cw-ms__panel .cw-ms__opt input {
position: relative;
flex: 0 0 auto;
@ -145,6 +148,7 @@
var panel = root.querySelector('[data-role="panel"]');
var valuesEl = root.querySelector('[data-role="values"]');
var candidates = (opts.options || []).slice();
var counts = opts.counts || {};
var selectedList = [];
var keyword = '';
var optsEl = null;
@ -157,6 +161,12 @@
} catch (e) { /* 脏数据忽略 */ }
function isSel(v) { return selectedList.indexOf(v) >= 0; }
function countText(v) {
var total = counts[v];
if (total === null || total === undefined) { return ''; }
return String(total).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function listed() {
if (!keyword) { return candidates.slice(); }
return candidates.filter(function (v) { return String(v).toLowerCase().indexOf(keyword) >= 0; });
@ -193,7 +203,8 @@
}
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>';
+ (isSel(v) ? ' checked' : '') + '><span>' + esc(v) + '</span>'
+ '<span class="cw-ms__count">' + esc(countText(v)) + '</span></label>';
}).join('');
if (items.length > 300) {
html += '<div class="cw-ms__empty">还有 ' + (items.length - 300) + ' 项,再打精确一点</div>';
@ -227,8 +238,9 @@
root: root,
values: function () { return selectedList.slice(); },
close: closePanel,
setOptions: function (list) {
setOptions: function (list, nextCounts) {
candidates = (list || []).slice();
if (nextCounts) { counts = nextCounts; }
renderOptions();
sync();
}

View File

@ -180,6 +180,10 @@ Route::get('workspace/product-inventory/monthly-weighted-average-price/export',
->middleware('business.page:financial-operations,performance-details')
->name('business.financial-operations.performance-details.export');
Route::get('workspace/financial-operations/performance-details/filter-options', 'Business\Pages@receiptOrderMatchFilterOptions')
->middleware('business.page:financial-operations,performance-details')
->name('business.financial-operations.performance-details.filter-options');
Route::get('workspace/planting-data/jst-order-labels/export', 'Business\Pages@exportJstOrderLabels')
->middleware('business.page:planting-data,jst-order-labels')
->name('business.planting-data.jst-order-labels.export');