业绩明细:新增「退款金额」列,按总账备注=退款取数

- 新增列「退款金额」:店铺收款账户账单里总账备注=退款的金额(收入侧 − 支出侧),
  与优惠金额/冲减金额同一口径,单独列示、不参与差额计算;统计卡、导出字段、
  字段结构说明同步补充
- 同步命令支持账单表的 expense 列(京东账单/京喜账单/京东网银钱包/抖音用 expense,
  淘系/拼多多/得物用 expense_amount),此前只认 expense_amount,
  导致这些平台支出侧的退款、扣回金额被当成 0
- 重建后自检改为「本月账单确有优惠/冲减/退款行、但三列一行都没写」才报错,
  并把退款金额纳入判定,避免无数据月份误报
- 新增迁移:store_receipt_order_match_rows.refund_amount
This commit is contained in:
yueyang 2026-10-10 13:40:58 +08:00
parent 153b823ab8
commit 107c883eb2
5 changed files with 118 additions and 20 deletions

View File

@ -123,6 +123,11 @@ class SyncStoreReceiptOrderMatches extends Command
'冲减收入',
];
/** 「退款金额」列口径:账单的总账备注命中以下取值,单独列示,不参与收入金额与差额计算。 */
private const REFUND_REMARKS = [
'退款',
];
private const TARGET_TABLE = 'store_receipt_order_match_rows';
private const STATUS_MATCHED = '已匹配';
@ -205,11 +210,13 @@ class SyncStoreReceiptOrderMatches extends Command
$baseGroups = $this->ledgerRemarkGroups($platform);
$discountGroups = $this->ledgerRemarkGroups($platform, self::DISCOUNT_REMARKS);
$offsetGroups = $this->ledgerRemarkGroups($platform, self::OFFSET_REMARKS);
$refundGroups = $this->ledgerRemarkGroups($platform, self::REFUND_REMARKS);
// 总账备注现在优先取科目映射表的财务备注,因此这两列的候选账单项目
// 也要按科目映射表补一遍,否则「优惠收入 / 冲减收入」永远取不到项目。
// 也要按科目映射表补一遍,否则「优惠收入 / 冲减收入 / 退款」永远取不到项目。
$promoItems = array_values(array_unique(array_merge(
$this->accountMappingItems($platform, self::DISCOUNT_REMARKS),
$this->accountMappingItems($platform, self::OFFSET_REMARKS),
$this->accountMappingItems($platform, self::REFUND_REMARKS),
)));
// 微信账户页签的账单项目带「wx」前缀(wx限时红包代商家垫付扣回 等),
// 科目映射表用的是不带前缀的名字,这里两种写法都带上。
@ -220,14 +227,22 @@ class SyncStoreReceiptOrderMatches extends Command
return self::FAILURE;
}
$this->info('总账备注命中平台分组:' . count($baseGroups) . ' 组');
if ($discountGroups === [] && $offsetGroups === [] && $promoItems === []) {
$this->warn('「优惠收入」「冲减收入」在科目映射表与总账备注映射里都没有对应账单项目,两列将全部为空。');
if ($discountGroups === [] && $offsetGroups === [] && $refundGroups === [] && $promoItems === []) {
$this->warn('「优惠收入」「冲减收入」「退款」在科目映射表与总账备注映射里都没有对应账单项目,三列将全部为空。');
}
$select = ['store_name', 'bill_item', $orderColumn, $timeColumn, $amountColumn];
// 「冲减金额」按行金额(收入侧 − 支出侧)取数,账单表有支出列时一并取出。
// 淘系/拼多多/得物的支出列叫 expense_amount,京东系列(京东账单/京喜/京东网银钱包)与抖音叫 expense,
// 两种写法都要认,否则支出侧的退款/扣回会被当成 0。
$expenseColumn = '';
if (Schema::connection($connection)->hasColumn($billTable, 'expense_amount')) {
$select[] = 'expense_amount';
$expenseColumn = 'expense_amount';
} elseif (Schema::connection($connection)->hasColumn($billTable, 'expense')) {
$expenseColumn = 'expense';
}
if ($expenseColumn !== '') {
$select[] = $expenseColumn;
}
if ($realStoreColumn !== '') {
$select[] = $realStoreColumn;
@ -268,10 +283,10 @@ class SyncStoreReceiptOrderMatches extends Command
$billQuery->whereIn('generation', $generations);
$this->info('活动代:' . count($generations) . ' 个');
}
$billQuery->where(function ($group) use ($baseGroups, $discountGroups, $offsetGroups, $promoItems): void {
$billQuery->where(function ($group) use ($baseGroups, $discountGroups, $offsetGroups, $refundGroups, $promoItems): void {
LedgerRemarkFilter::applyGroups(
$group,
array_merge($baseGroups, $discountGroups, $offsetGroups),
array_merge($baseGroups, $discountGroups, $offsetGroups, $refundGroups),
'bill_item',
static fn ($sub, string $prefix) => $sub->where('store_name', 'like', $prefix . '%'),
static fn ($sub, string $prefix) => $sub->where('store_name', 'not like', $prefix . '%'),
@ -298,6 +313,7 @@ class SyncStoreReceiptOrderMatches extends Command
$incomeTotal = 0.0;
$discountTotal = 0.0;
$offsetTotal = 0.0;
$refundTotal = 0.0;
// 种菜订单号:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号(或的关系)。
$plantingCores = $this->plantingOrderCores();
$plantingTotal = 0.0;
@ -309,6 +325,9 @@ class SyncStoreReceiptOrderMatches extends Command
$incomeOverlapTotal = 0.0;
/** @var array<string,int> $promoRowsByKey 门店+订单 维度的优惠/冲减账单行数(用于统计未挂单行数) */
$promoRowsByKey = [];
// 本月账单里真正命中「优惠收入 / 冲减收入 / 退款」的行数(重建后自检用:
// 只有账单里确实有这些行,却一行都没写进结果表,才算异常)。
$moneyRemarkRows = 0;
foreach ($bills as $bill) {
$storeName = (string) ($bill->store_name ?? '');
$billItem = (string) ($bill->bill_item ?? '');
@ -324,14 +343,19 @@ class SyncStoreReceiptOrderMatches extends Command
);
$isPromoRemark = in_array($remark, self::DISCOUNT_REMARKS, true)
|| in_array($remark, self::OFFSET_REMARKS, true);
$isRefundRemark = in_array($remark, self::REFUND_REMARKS, true);
$isIncomeRemark = in_array($legacyRemark, self::LEDGER_REMARKS, true);
if ($isIncomeRemark && $isPromoRemark) {
if ($isPromoRemark || $isRefundRemark) {
$moneyRemarkRows++;
}
if ($isIncomeRemark && ($isPromoRemark || $isRefundRemark)) {
// 已经在收入金额里的行:不重复计入优惠/冲减列,单独提示金额。
$incomeOverlapRows++;
$incomeOverlapTotal += (float) $bill->{$amountColumn};
}
$bucket = match (true) {
$isIncomeRemark => 'income',
$isRefundRemark => 'refund',
in_array($remark, self::DISCOUNT_REMARKS, true) => 'discount',
in_array($remark, self::OFFSET_REMARKS, true) => 'offset',
default => null,
@ -360,12 +384,15 @@ class SyncStoreReceiptOrderMatches extends Command
'discount_seen' => false,
'offset_amount' => 0.0,
'offset_seen' => false,
'refund_amount' => 0.0,
'refund_seen' => false,
];
}
// 三列统一按「行金额=收入侧 − 支出侧」取数(负数表示净扣减)。
// 得物/天猫/淘系这类平台把「卖家承担优惠 / 扣回」记在支出侧,只取收入侧会漏掉扣减。
$amount = (float) $bill->{$amountColumn} - (float) ($bill->expense_amount ?? 0);
$amount = (float) $bill->{$amountColumn}
- ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0);
if ($bucket === 'income') {
$groups[$key]['payment_count']++;
$groups[$key]['income_amount'] += $amount;
@ -376,6 +403,10 @@ class SyncStoreReceiptOrderMatches extends Command
$groups[$key]['discount_amount'] += $amount;
$discountTotal += $amount;
$promoRowsByKey[$key] = ($promoRowsByKey[$key] ?? 0) + 1;
} elseif ($bucket === 'refund') {
$groups[$key]['refund_seen'] = true;
$groups[$key]['refund_amount'] += $amount;
$refundTotal += $amount;
} else {
$groups[$key]['offset_seen'] = true;
$groups[$key]['offset_amount'] += $amount;
@ -417,10 +448,11 @@ class SyncStoreReceiptOrderMatches extends Command
$orderColumn,
$timeColumn,
$amountColumn,
$expenseColumn,
$realStoreColumn,
);
$this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行');
$this->line(' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 2));
$this->line(' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 2) . ',退款金额合计:' . number_format($refundTotal, 2));
foreach ($groups as $group) {
if (! empty($group['planting_seen'])) {
$plantingRows++;
@ -712,6 +744,7 @@ class SyncStoreReceiptOrderMatches extends Command
'income_amount' => $income,
'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,
'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null,
'planting_amount' => ! empty($group['planting_seen']) ? $income : null,
'product_amount' => $productAmount,
'paid_amount' => $paidAmount,
@ -754,22 +787,25 @@ class SyncStoreReceiptOrderMatches extends Command
$this->line(' 订单侧商品金额合计:' . number_format($goodsTotal, 2));
$this->line(' 差额合计:' . number_format($differenceTotal, 2));
// 重建后自检:映射表里有「优惠收入 / 冲减收入」的账单项目,但重建出来的行两列一行都没写,
// 说明这次重建用的代码或参数不对(历史上出现过用旧代码重建、把优惠金额整列写成空),
// 这里显式报错,避免静默丢列后没人发现。
// 重建后自检:本月账单里确实存在「优惠收入 / 冲减收入 / 退款」的账单行,但重建出来的行
// 三列一行都没写,说明这次重建用的代码或参数不对(历史上出现过用旧代码重建、把优惠金额
// 整列写成空),这里显式报错,避免静默丢列后没人发现。
$promoFilled = (int) DB::connection($connection)->table(self::TARGET_TABLE)
->where('report_month', $reportMonth)
->where('platform', (string) $source['label'])
->where(function ($query): void {
$query->whereNotNull('discount_amount')->orWhereNotNull('offset_amount');
$query->whereNotNull('discount_amount')
->orWhereNotNull('offset_amount')
->orWhereNotNull('refund_amount');
})
->count();
if ($promoItems !== [] && $promoFilled === 0) {
if ($promoItems !== [] && $promoFilled === 0 && $moneyRemarkRows > 0) {
$this->error(sprintf(
'重建后自检不通过:%s %s 的「优惠金额 / 冲减金额」两列一行都没写,但科目映射表里有 %d 个相关账单项目。'
'重建后自检不通过:%s %s 的「优惠金额 / 冲减金额 / 退款金额」三列一行都没写,但本月账单里有 %d 行命中优惠/冲减/退款(科目映射表里有 %d 个相关账单项目)。'
. '请确认使用最新代码(优惠金额逻辑见 c8a035a1)重跑,或补跑 store-receipt:order-match:sync --amounts-only。',
$month,
(string) $source['label'],
$moneyRemarkRows,
count($promoItems),
));
@ -849,13 +885,14 @@ class SyncStoreReceiptOrderMatches extends Command
$target()
->where('report_month', $reportMonth)
->where('platform', $platform)
->update(['discount_amount' => null, 'offset_amount' => null, 'planting_amount' => null]);
->update(['discount_amount' => null, 'offset_amount' => null, 'refund_amount' => null, 'planting_amount' => null]);
$discounts = [];
$offsets = [];
$refunds = [];
$plantings = [];
foreach ($groups as $group) {
if (empty($group['discount_seen']) && empty($group['offset_seen']) && empty($group['planting_seen'])) {
if (empty($group['discount_seen']) && empty($group['offset_seen']) && empty($group['refund_seen']) && empty($group['planting_seen'])) {
continue;
}
@ -870,6 +907,9 @@ class SyncStoreReceiptOrderMatches extends Command
if (! empty($group['offset_seen'])) {
$offsets[$rowKey] = round((float) $group['offset_amount'], 2);
}
if (! empty($group['refund_seen'])) {
$refunds[$rowKey] = round((float) $group['refund_amount'], 2);
}
if (! empty($group['planting_seen'])) {
$plantings[$rowKey] = round((float) $group['income_amount'], 2);
}
@ -877,6 +917,7 @@ class SyncStoreReceiptOrderMatches extends Command
$this->updateAmountColumn($connection, $reportMonth, 'discount_amount', $discounts);
$this->updateAmountColumn($connection, $reportMonth, 'offset_amount', $offsets);
$this->updateAmountColumn($connection, $reportMonth, 'refund_amount', $refunds);
$this->updateAmountColumn($connection, $reportMonth, 'planting_amount', $plantings);
// A 方案:本月本平台的补行(payment_count=0 的行)整体重建,保证幂等。
@ -947,6 +988,7 @@ class SyncStoreReceiptOrderMatches extends Command
string $orderColumn,
string $timeColumn,
string $amountColumn,
string $expenseColumn,
string $realStoreColumn,
): array {
$result = [];
@ -961,7 +1003,8 @@ class SyncStoreReceiptOrderMatches extends Command
$this->billGoodsName($bill),
);
$type = in_array($remark, self::DISCOUNT_REMARKS, true) ? 'discount'
: (in_array($remark, self::OFFSET_REMARKS, true) ? 'offset' : null);
: (in_array($remark, self::OFFSET_REMARKS, true) ? 'offset'
: (in_array($remark, self::REFUND_REMARKS, true) ? 'refund' : null));
if ($type === null) {
continue;
}
@ -994,14 +1037,20 @@ class SyncStoreReceiptOrderMatches extends Command
'discount_seen' => false,
'offset_amount' => 0.0,
'offset_seen' => false,
'refund_amount' => 0.0,
'refund_seen' => false,
];
}
// 与主流程一致:优惠/冲减都按「收入侧 − 支出侧」取数。
$amount = (float) $bill->{$amountColumn} - (float) ($bill->expense_amount ?? 0);
$amount = (float) $bill->{$amountColumn}
- ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0);
if ($type === 'discount') {
$result[$key]['discount_seen'] = true;
$result[$key]['discount_amount'] += $amount;
} elseif ($type === 'refund') {
$result[$key]['refund_seen'] = true;
$result[$key]['refund_amount'] += $amount;
} else {
$result[$key]['offset_seen'] = true;
$result[$key]['offset_amount'] += $amount;
@ -1036,6 +1085,7 @@ class SyncStoreReceiptOrderMatches extends Command
'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,
'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null,
// 补行没有订单侧商品金额,种菜金额同样留空;列集合与顺序必须与主行完全一致,批量插入才不会错列。
'planting_amount' => null,
'product_amount' => null,

View File

@ -747,6 +747,7 @@ class Pages extends Controller
SUM(income_amount) as income_total,
SUM(discount_amount) as discount_total,
SUM(offset_amount) as offset_total,
SUM(refund_amount) as refund_total,
SUM(planting_amount) as planting_total,
SUM(product_amount) as goods_total,
SUM(COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))) as difference_total,
@ -769,6 +770,7 @@ class Pages extends Controller
'income_total' => round((float) ($agg->income_total ?? 0), 2),
'discount_total' => round((float) ($agg->discount_total ?? 0), 2),
'offset_total' => round((float) ($agg->offset_total ?? 0), 2),
'refund_total' => round((float) ($agg->refund_total ?? 0), 2),
'planting_total' => round((float) ($agg->planting_total ?? 0), 2),
'goods_total' => round((float) ($agg->goods_total ?? 0), 2),
'difference_total' => round((float) ($agg->difference_total ?? 0), 2),
@ -1110,6 +1112,7 @@ class Pages extends Controller
'income_amount' => ['income_amount', '收入金额', 'money'],
'discount_amount' => ['discount_amount', '优惠金额', 'money'],
'offset_amount' => ['offset_amount', '冲减金额', 'money'],
'refund_amount' => ['refund_amount', '退款金额', 'money'],
'planting_amount' => ['planting_amount', '种菜金额', 'money'],
'product_amount' => ['product_amount', '商品金额', 'money'],
'paid_amount' => ['paid_amount', '订单实付金额', 'money'],

View File

@ -791,7 +791,7 @@ $navigation = [
'directory' => [
'icon' => 'receipt_long',
'status' => '共享镜像',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−商品金额,未匹配到商品金额的行按商品金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:商品金额未匹配(账单无单号 / 单号格式异常 / 订单表无此单 / 订单无商品金额 / 无收款账单,未匹配到商品金额);商品金额未加种菜(商品金额=0 且是种菜单)、商品金额取数为0(商品金额=0 但不是种菜单)、商品金额未减冲减(商品金额=收入+优惠 → 差额=冲减金额)、商品金额未减优惠(商品金额=收入+冲减 → 差额=优惠金额)、商品金额未减优惠和冲减(商品金额=收入 → 差额=优惠+冲减);都不满足则留空。',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−商品金额,未匹配到商品金额的行按商品金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:商品金额未匹配(账单无单号 / 单号格式异常 / 订单表无此单 / 订单无商品金额 / 无收款账单,未匹配到商品金额);商品金额未加种菜(商品金额=0 且是种菜单)、商品金额取数为0(商品金额=0 但不是种菜单)、商品金额未减冲减(商品金额=收入+优惠 → 差额=冲减金额)、商品金额未减优惠(商品金额=收入+冲减 → 差额=优惠金额)、商品金额未减优惠和冲减(商品金额=收入 → 差额=优惠+冲减);都不满足则留空。',
'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。',
'fields' => [
['name' => 'platform', 'label' => '平台'],
@ -801,6 +801,7 @@ $navigation = [
['name' => 'income_amount', 'label' => '收入金额', 'kind' => 'number'],
['name' => 'discount_amount', 'label' => '优惠金额', 'kind' => 'number'],
['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'],
['name' => 'refund_amount', 'label' => '退款金额', 'kind' => 'number'],
['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'],
['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'],
['name' => 'paid_amount', 'label' => '订单实付金额', 'kind' => 'number'],

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 业绩明细新增「退款金额」列:账单总账备注=退款的金额(收入侧 − 支出侧),
* 与「优惠金额 / 冲减金额」同一套取数口径,单独列示、不参与差额计算。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| $schema->hasColumn('store_receipt_order_match_rows', 'refund_amount')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->decimal('refund_amount', 20, 4)->nullable()->after('offset_amount');
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'refund_amount')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('refund_amount');
});
}
};

View File

@ -1094,6 +1094,7 @@
<span class="receipt-match-summary__item"><em>账单收入合计</em><strong>{{ number_format((float) $receiptStats['income_total'], 2) }}</strong></span>
<span class="receipt-match-summary__item"><em>优惠金额合计</em><strong>{{ number_format((float) ($receiptStats['discount_total'] ?? 0), 2) }}</strong></span>
<span class="receipt-match-summary__item"><em>冲减金额合计</em><strong>{{ number_format((float) ($receiptStats['offset_total'] ?? 0), 2) }}</strong></span>
<span class="receipt-match-summary__item"><em>退款金额合计</em><strong>{{ number_format((float) ($receiptStats['refund_total'] ?? 0), 2) }}</strong></span>
<span class="receipt-match-summary__item"><em>种菜金额合计</em><strong>{{ number_format((float) ($receiptStats['planting_total'] ?? 0), 2) }}</strong></span>
<span class="receipt-match-summary__item"><em>订单商品金额合计</em><strong>{{ number_format((float) $receiptStats['goods_total'], 2) }}</strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--lead"><em>差额合计</em><strong>{{ number_format((float) $receiptStats['difference_total'], 2) }}</strong></span>