fix: 拆分违约赔付与普通冲减金额 #481

Merged
yueyang merged 1 commits from fix/yueyang-default-compensation-split into main 2026-10-23 21:42:37 +08:00
16 changed files with 288 additions and 135 deletions

View File

@ -13,7 +13,7 @@ class PreviewStoreReceiptDefaultCompensation extends Command
{mapping : 最新科目映射表 xlsx 路径}
{--output= : 输出 JSON 路径;默认写入 storage/app/local/performance-details/default-compensation}';
protected $description = '本地预览:按最新科目映射表生成业绩明细「违约赔付(冲减收入)」金额快照,不写数据库';
protected $description = '人工核对:按指定科目映射表生成「违约赔付(冲减收入)」金额快照,不写数据库';
public function handle(StoreReceiptDefaultCompensationSnapshotBuilder $builder): int
{

View File

@ -115,18 +115,19 @@ class SyncStoreReceiptOrderMatches extends Command
* 「优惠金额」列口径:账单的总账备注命中以下取值。
*
* 总账备注优先取「财务规则 › 科目映射表」的财务备注(与店铺收款账户账单页一致),
* 取不到时回落到 ec_account_ledger_remarks。金额不计入收入金额与差额。
* 取不到时回落到 ec_account_ledger_remarks。金额不计入收入,但计入差额公式。
*/
private const DISCOUNT_REMARKS = [
'优惠收入',
];
/**
* 「冲减金额」列口径:账单的总账备注命中以下取值,不参与收入金额与差额计算。
* 「违约赔付(冲减收入)」是冲减收入的细分项,总额仍计入冲减金额。
*/
/** 「冲减金额」列口径:只认普通冲减收入,不包含违约赔付。 */
private const OFFSET_REMARKS = [
'冲减收入',
];
/** 「违约赔付(冲减收入)」独立列口径,计算差额时单独加回一次。 */
private const DEFAULT_COMPENSATION_REMARKS = [
'违约赔付(冲减收入)',
];
@ -278,16 +279,17 @@ class SyncStoreReceiptOrderMatches extends Command
// 「总账备注」→ 按平台分组的账单项目集合(淘系页签同页混有淘宝/天猫/阿里巴巴店铺,
// 必须连店铺名前缀一起过滤,才能与页面按行取到的总账备注一致)。
// 收入金额只认 LEDGER_REMARKS;优惠/冲减金额各自单独成列,不参与收入与差额。
// 收入金额只认 LEDGER_REMARKS;优惠、冲减、违约赔付各自单独成列。
$baseGroups = $this->ledgerRemarkGroups($platform);
$discountGroups = $this->ledgerRemarkGroups($platform, self::DISCOUNT_REMARKS);
$offsetGroups = $this->ledgerRemarkGroups($platform, self::OFFSET_REMARKS);
$defaultCompensationGroups = $this->ledgerRemarkGroups($platform, self::DEFAULT_COMPENSATION_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::DEFAULT_COMPENSATION_REMARKS),
$this->accountMappingItems($platform, self::REFUND_REMARKS),
)));
// 微信账户页签的账单项目带「wx」前缀(wx限时红包代商家垫付扣回 等),
@ -299,8 +301,12 @@ class SyncStoreReceiptOrderMatches extends Command
return self::FAILURE;
}
$this->info('总账备注命中平台分组:' . count($baseGroups) . ' 组');
if ($discountGroups === [] && $offsetGroups === [] && $refundGroups === [] && $promoItems === []) {
$this->warn('「优惠收入」「冲减收入」「退款」在科目映射表与总账备注映射里都没有对应账单项目,三列将全部为空。');
if ($discountGroups === []
&& $offsetGroups === []
&& $defaultCompensationGroups === []
&& $refundGroups === []
&& $promoItems === []) {
$this->warn('「优惠收入」「冲减收入」「违约赔付(冲减收入)」「退款」都没有对应账单项目,相关列将全部为空。');
}
$select = ['store_name', 'bill_item', $orderColumn, $timeColumn, $amountColumn];
@ -355,10 +361,10 @@ class SyncStoreReceiptOrderMatches extends Command
$billQuery->whereIn('generation', $generations);
$this->info('活动代:' . count($generations) . ' 个');
}
$billQuery->where(function ($group) use ($baseGroups, $discountGroups, $offsetGroups, $refundGroups, $promoItems): void {
$billQuery->where(function ($group) use ($baseGroups, $discountGroups, $offsetGroups, $defaultCompensationGroups, $refundGroups, $promoItems): void {
LedgerRemarkFilter::applyGroups(
$group,
array_merge($baseGroups, $discountGroups, $offsetGroups, $refundGroups),
array_merge($baseGroups, $discountGroups, $offsetGroups, $defaultCompensationGroups, $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 . '%'),
@ -380,9 +386,11 @@ class SyncStoreReceiptOrderMatches extends Command
[
'groups' => $groups, 'promoOnlyGroups' => $promoOnlyGroups,
'paymentTotal' => $paymentTotal, 'incomeTotal' => $incomeTotal,
'discountTotal' => $discountTotal, 'offsetTotal' => $offsetTotal, 'refundTotal' => $refundTotal,
'discountTotal' => $discountTotal, 'offsetTotal' => $offsetTotal,
'defaultCompensationTotal' => $defaultCompensationTotal, 'refundTotal' => $refundTotal,
'moneyRemarkRows' => $moneyRemarkRows, 'attachedDiscount' => $attachedDiscount,
'attachedOffset' => $attachedOffset, 'unattachedRows' => $unattachedRows, 'billCount' => $billCount,
'attachedOffset' => $attachedOffset, 'attachedDefaultCompensation' => $attachedDefaultCompensation,
'unattachedRows' => $unattachedRows, 'billCount' => $billCount,
] = $this->aggregateBills(
$this->streamBillRows($billQuery->orderBy($timeColumn)), $source,
$orderColumn, $timeColumn, $amountColumn, $expenseColumn, $realStoreColumn,
@ -400,7 +408,12 @@ class SyncStoreReceiptOrderMatches extends Command
$plantingUnreimbursedTotal = 0.0;
$plantingUnreimbursedRows = 0;
$this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行');
$this->line(' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 2) . ',退款金额合计:' . number_format($refundTotal, 2));
$this->line(
' 优惠金额合计:' . number_format($discountTotal, 2)
. ',冲减金额合计:' . number_format($offsetTotal, 2)
. ',违约赔付(冲减收入)合计:' . number_format($defaultCompensationTotal, 2)
. ',退款金额合计:' . number_format($refundTotal, 2)
);
foreach ($groups as $group) {
if (! empty($group['planting_seen'])) {
$plantingRows++;
@ -431,11 +444,16 @@ class SyncStoreReceiptOrderMatches extends Command
number_format($plantingUnreimbursedTotal, 2),
));
$this->line(sprintf(
' 已挂订单行:优惠 %s / 冲减 %s;未挂到订单行:%s(金额 %s,已补成「无收款账单 / 账单无单号」行)',
' 已挂订单行:优惠 %s / 冲减 %s / 违约赔付 %s;未挂到订单行:%s(金额 %s,已补成「无收款账单 / 账单无单号」行)',
number_format($attachedDiscount, 2),
number_format($attachedOffset, 2),
number_format($attachedDefaultCompensation, 2),
number_format($unattachedRows) . ' 行',
number_format(($discountTotal + $offsetTotal) - ($attachedDiscount + $attachedOffset), 2),
number_format(
($discountTotal + $offsetTotal + $defaultCompensationTotal)
- ($attachedDiscount + $attachedOffset + $attachedDefaultCompensation),
2
),
));
unset($plantingCores);
if ((bool) $this->option('amounts-only')) {
@ -712,9 +730,14 @@ class SyncStoreReceiptOrderMatches extends Command
$remark = self::REMARK_MULTI_PAY;
}
// 差额口径:(收入金额 + 优惠金额 + 冲减金额)− 比对基准金额(默认订单实付金额)。
// 优惠/冲减属于同一订单的账单,把它们加回来,差额才只反映真正对不上的部分。
$promoAmount = round((float) ($group['discount_amount'] ?? 0.0) + (float) ($group['offset_amount'] ?? 0.0), 2);
// 差额口径:(收入 + 优惠 + 冲减 + 违约赔付)− 比对基准金额。
// 三类抵减项分别展示,但计算差额时都必须且只能加回一次。
$promoAmount = round(
(float) ($group['discount_amount'] ?? 0.0)
+ (float) ($group['offset_amount'] ?? 0.0)
+ (float) ($group['default_compensation_amount'] ?? 0.0),
2
);
$difference = $basisAmount === null ? null : round($income + $promoAmount - $basisAmount, 2);
// 双口径兜底:账单(收入+优惠+冲减)能对上「卖家实收」时,按实收核对——
@ -801,6 +824,9 @@ 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,
'default_compensation_amount' => ! empty($group['default_compensation_seen'])
? round((float) $group['default_compensation_amount'], 2)
: null,
'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null,
'planting_amount' => ! empty($group['planting_seen']) ? $income : null,
'planting_label' => self::plantingLabel($group),
@ -855,21 +881,20 @@ class SyncStoreReceiptOrderMatches extends Command
$this->line(' 订单侧实付金额合计:' . number_format($paidTotal, 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')
->orWhereNotNull('default_compensation_amount')
->orWhereNotNull('refund_amount');
})
->count();
if ($promoItems !== [] && $promoFilled === 0 && $moneyRemarkRows > 0) {
$this->error(sprintf(
'重建后自检不通过:%s %s 的「优惠金额 / 冲减金额 / 退款金额」三列一行都没写,但本月账单里有 %d 行命中优惠/冲减/退款(科目映射表里有 %d 个相关账单项目)。'
'重建后自检不通过:%s %s 的优惠、冲减、违约赔付、退款金额一行都没写,但本月账单里有 %d 行命中了相关备注(科目映射表里有 %d 个相关账单项目)。'
. '请确认使用最新代码(优惠金额逻辑见 c8a035a1)重跑,或补跑 store-receipt:order-match:sync --amounts-only。',
$month,
(string) $source['label'],
@ -987,8 +1012,8 @@ class SyncStoreReceiptOrderMatches extends Command
}
/**
* 只回填「优惠金额 / 冲减金额 / 种菜金额」三列:不重建业绩明细、不查订单侧,
* 先清空该账期该平台的三列,再按 row_key 更新镜像里已存在的行。
* 只回填优惠、冲减、违约赔付、退款、种菜等金额列:不重建业绩明细、不查订单侧,
* 先清空该账期该平台的相关列,再按 row_key 更新镜像里已存在的行。
*
* @param array<string,mixed> $source
* @param array<string,array<string,mixed>> $groups
@ -1018,6 +1043,7 @@ class SyncStoreReceiptOrderMatches extends Command
->update([
'discount_amount' => null,
'offset_amount' => null,
'default_compensation_amount' => null,
'refund_amount' => null,
'planting_amount' => null,
'planting_label' => null,
@ -1025,12 +1051,14 @@ class SyncStoreReceiptOrderMatches extends Command
$discounts = [];
$offsets = [];
$defaultCompensations = [];
$refunds = [];
$plantings = [];
$plantingLabels = [];
foreach ($groups as $group) {
if (empty($group['discount_seen'])
&& empty($group['offset_seen'])
&& empty($group['default_compensation_seen'])
&& empty($group['refund_seen'])
&& empty($group['planting_seen'])
&& empty($group['planting_reimbursement_seen'])
@ -1054,6 +1082,9 @@ class SyncStoreReceiptOrderMatches extends Command
if (! empty($group['offset_seen'])) {
$offsets[$rowKey] = round((float) $group['offset_amount'], 2);
}
if (! empty($group['default_compensation_seen'])) {
$defaultCompensations[$rowKey] = round((float) $group['default_compensation_amount'], 2);
}
if (! empty($group['refund_seen'])) {
$refunds[$rowKey] = round((float) $group['refund_amount'], 2);
}
@ -1069,6 +1100,7 @@ class SyncStoreReceiptOrderMatches extends Command
$this->updateColumnValues($connection, $reportMonth, 'discount_amount', $discounts);
$this->updateColumnValues($connection, $reportMonth, 'offset_amount', $offsets);
$this->updateColumnValues($connection, $reportMonth, 'default_compensation_amount', $defaultCompensations);
$this->updateColumnValues($connection, $reportMonth, 'refund_amount', $refunds);
$this->updateColumnValues($connection, $reportMonth, 'planting_amount', $plantings);
$this->updateColumnValues($connection, $reportMonth, 'planting_label', $plantingLabels);
@ -1104,17 +1136,25 @@ class SyncStoreReceiptOrderMatches extends Command
->where('platform', $platform)
->whereNotNull($basisColumn)
->update([
'difference' => DB::raw('ROUND(income_amount + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0) - ' . $basisColumn . ', 2)'),
'difference' => DB::raw(
'ROUND(income_amount'
. ' + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0)'
. ' + COALESCE(default_compensation_amount, 0)'
. ' - ' . $basisColumn . ', 2)'
),
]);
$this->info(sprintf(
'已回填 %s %s:优惠 %d 行(%s)/ 冲减 %d 行(%s)/ 补「无收款账单/无单号」优惠冲减行 %d 行 / 种菜 %d 行(%s)/ 标签「种菜报销」%d 行 / 标签「种菜未报销」%d 行,镜像原有 %d 行,未重建业绩明细。',
'已回填 %s %s:优惠 %d 行(%s)/ 冲减 %d 行(%s)/ 违约赔付 %d 行(%s)/ 补「无收款账单/无单号」抵减行 %d 行 / 种菜 %d 行(%s)/ 标签「种菜报销」%d 行 / 标签「种菜未报销」%d 行,镜像原有 %d 行,未重建业绩明细。',
$month,
$platform,
count($discounts),
number_format(array_sum($discounts), 2),
count($offsets),
number_format(array_sum($offsets), 2),
count($defaultCompensations),
number_format(array_sum($defaultCompensations), 2),
$promoRows,
count($plantings),
number_format(array_sum($plantings), 2),
@ -1181,6 +1221,7 @@ class SyncStoreReceiptOrderMatches extends Command
$incomeTotal = 0.0;
$discountTotal = 0.0;
$offsetTotal = 0.0;
$defaultCompensationTotal = 0.0;
$refundTotal = 0.0;
/** @var array<string,int> $promoRowsByKey 门店+订单 维度的优惠/冲减账单行数(用于统计未挂单行数) */
$promoRowsByKey = [];
@ -1203,7 +1244,8 @@ class SyncStoreReceiptOrderMatches extends Command
$this->billGoodsName($bill),
);
$isPromoRemark = in_array($remark, self::DISCOUNT_REMARKS, true)
|| in_array($remark, self::OFFSET_REMARKS, true);
|| in_array($remark, self::OFFSET_REMARKS, true)
|| in_array($remark, self::DEFAULT_COMPENSATION_REMARKS, true);
$isRefundRemark = in_array($remark, self::REFUND_REMARKS, true);
$isIncomeRemark = in_array($remark, self::LEDGER_REMARKS, true);
if ($isPromoRemark || $isRefundRemark) {
@ -1213,6 +1255,7 @@ class SyncStoreReceiptOrderMatches extends Command
$isIncomeRemark => 'income',
$isRefundRemark => 'refund',
in_array($remark, self::DISCOUNT_REMARKS, true) => 'discount',
in_array($remark, self::DEFAULT_COMPENSATION_REMARKS, true) => 'default_compensation',
in_array($remark, self::OFFSET_REMARKS, true) => 'offset',
default => null,
};
@ -1243,6 +1286,8 @@ class SyncStoreReceiptOrderMatches extends Command
'discount_seen' => false,
'offset_amount' => 0.0,
'offset_seen' => false,
'default_compensation_amount' => 0.0,
'default_compensation_seen' => false,
'refund_amount' => 0.0,
'refund_seen' => false,
];
@ -1269,6 +1314,8 @@ class SyncStoreReceiptOrderMatches extends Command
'discount_seen' => false,
'offset_amount' => 0.0,
'offset_seen' => false,
'default_compensation_amount' => 0.0,
'default_compensation_seen' => false,
'refund_amount' => 0.0,
'refund_seen' => false,
];
@ -1290,6 +1337,11 @@ class SyncStoreReceiptOrderMatches extends Command
$groups[$key]['refund_seen'] = true;
$groups[$key]['refund_amount'] += $amount;
$refundTotal += $amount;
} elseif ($bucket === 'default_compensation') {
$groups[$key]['default_compensation_seen'] = true;
$groups[$key]['default_compensation_amount'] += $amount;
$defaultCompensationTotal += $amount;
$promoRowsByKey[$key] = ($promoRowsByKey[$key] ?? 0) + 1;
} else {
$groups[$key]['offset_seen'] = true;
$groups[$key]['offset_amount'] += $amount;
@ -1317,9 +1369,11 @@ class SyncStoreReceiptOrderMatches extends Command
// 口径对照:账单口径合计、已挂到订单行的合计、挂不上的行数(账单没有订单号 / 订单没有收款行)。
$attachedDiscount = 0.0;
$attachedOffset = 0.0;
$attachedDefaultCompensation = 0.0;
foreach ($groups as $group) {
$attachedDiscount += (float) $group['discount_amount'];
$attachedOffset += (float) $group['offset_amount'];
$attachedDefaultCompensation += (float) $group['default_compensation_amount'];
}
$unattachedRows = 0;
foreach ($promoRowsByKey as $key => $count) {
@ -1336,7 +1390,9 @@ class SyncStoreReceiptOrderMatches extends Command
return compact(
'groups', 'promoOnlyGroups', 'paymentTotal', 'incomeTotal', 'discountTotal',
'offsetTotal', 'refundTotal', 'moneyRemarkRows', 'attachedDiscount', 'attachedOffset', 'unattachedRows', 'billCount',
'offsetTotal', 'defaultCompensationTotal', 'refundTotal', 'moneyRemarkRows',
'attachedDiscount', 'attachedOffset', 'attachedDefaultCompensation',
'unattachedRows', 'billCount',
);
}
@ -1366,6 +1422,9 @@ 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,
'default_compensation_amount' => ! empty($group['default_compensation_seen'])
? round((float) $group['default_compensation_amount'], 2)
: null,
'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null,
// 补行没有订单侧商品金额,种菜金额同样留空;列集合与顺序必须与主行完全一致,批量插入才不会错列。
'planting_amount' => null,

View File

@ -197,6 +197,19 @@ class Pages extends Controller
];
}
/** 业绩明细三类抵减项的 SQL 合计。 */
private static function receiptAdjustmentSql(): string
{
return 'COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)'
. ' + COALESCE(default_compensation_amount, 0)';
}
/** 业绩明细净收入 SQL:收入加三类抵减项。 */
private static function receiptNetSql(): string
{
return 'COALESCE(income_amount, 0) + (' . self::receiptAdjustmentSql() . ')';
}
/**
* 「订单实付含已退款部分」的聚水潭证据 SQL(仅抖音负差额行计算,CASE 守卫保证其它行不触发子查询)。
*
@ -209,7 +222,7 @@ class Pages extends Controller
*/
private static function receiptRefundEvidenceSql(): array
{
$guard = "platform = '抖音' AND COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) < -0.005";
$guard = "platform = '抖音' AND COALESCE(difference, " . self::receiptNetSql() . ') < -0.005';
$refundWhere = "o.so_id = store_receipt_order_match_rows.online_order_no
AND rf.refund > 0 AND rf.shop_status LIKE '%SUCCESS%' AND COALESCE(rf.status, '') NOT LIKE '%Split%'";
@ -237,7 +250,7 @@ class Pages extends Controller
*/
private static function receiptDewuMarketingEvidenceSql(): array
{
$guard = "platform = '得物' AND COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) < -0.005";
$guard = "platform = '得物' AND COALESCE(difference, " . self::receiptNetSql() . ') < -0.005';
return [
'guard' => $guard,
@ -258,7 +271,7 @@ class Pages extends Controller
*/
private static function receiptJingxiSupplyEvidenceSql(): array
{
$guard = "platform = '京喜账单' AND COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) > 0.005";
$guard = "platform = '京喜账单' AND COALESCE(difference, " . self::receiptNetSql() . ') > 0.005';
// 京喜账单按「活动代」保留多代历史导入:只认与本月重叠的活动代(与同步命令同一口径)。
// 不按代过滤时,旧代 + 新代会把同一笔直营服务费算两遍(例如 3,432 被算成 6,864),
// 供货价规则整月对不上,全部落进「差额待核实」。没有活动代登记的月份保持原口径。
@ -312,7 +325,8 @@ class Pages extends Controller
$label = $basis['label'];
$rules = [];
$effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))';
$adjustment = self::receiptAdjustmentSql();
$effective = 'COALESCE(difference, ' . self::receiptNetSql() . ')';
$rules[$label . '未匹配'] = "match_status = '未匹配'";
$rules[$label . '取数为0(种菜)'] = "match_status = '已匹配' AND planting_amount IS NOT NULL AND ABS(COALESCE($column, 0)) < 0.005";
$rules[$label . '取数为0'] = "match_status = '已匹配' AND planting_amount IS NULL AND ABS(COALESCE($column, 0)) < 0.005";
@ -381,17 +395,20 @@ class Pages extends Controller
// C 方案:这三条是"通用等式档"——口径(卖家实收 / 应付已付)能对上账单的行让位给口径兜底
// (归零 + 口径标识),只有口径也对不上时才显示这三档,避免把口径差标成一堆"未减优惠/冲减"。
$notBasisMarked = self::receiptReasonNotBasisMarkedSql();
$rules[$label . '未减冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(discount_amount, 0))) < 0.005 AND " . $notBasisMarked;
$rules[$label . '未减优惠'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(offset_amount, 0))) < 0.005 AND " . $notBasisMarked;
$rules[$label . '未减冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0)"
. ' + COALESCE(discount_amount, 0) + COALESCE(default_compensation_amount, 0))) < 0.005 AND ' . $notBasisMarked;
$rules[$label . '未减优惠'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0)"
. ' + COALESCE(offset_amount, 0) + COALESCE(default_compensation_amount, 0))) < 0.005 AND ' . $notBasisMarked;
$pddPaymentDiscountGuard = "NOT (platform = '拼多多' AND match_status = '已匹配'"
. ' AND ' . $effective . ' > 0.005'
. ' AND COALESCE(refund_amount, 0) < -0.005'
. ' AND (' . $effective . ' - COALESCE(discount_amount, 0) - COALESCE(offset_amount, 0)) > 0.005'
. ' AND (' . $effective . ' - COALESCE(discount_amount, 0) - COALESCE(offset_amount, 0)) <= 20.005)';
. ' AND (' . $effective . ' - (' . $adjustment . ')) > 0.005'
. ' AND (' . $effective . ' - (' . $adjustment . ')) <= 20.005)';
$rules[$label . '未减优惠和冲减'] = "match_status = '已匹配'"
. ' AND (ABS(COALESCE(' . $column . ', 0) - COALESCE(income_amount, 0)) < 0.005'
. ' OR ((ABS(COALESCE(discount_amount, 0)) > 0.005 OR ABS(COALESCE(offset_amount, 0)) > 0.005)'
. ' AND ABS(' . $effective . ' - (COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))) < 0.02'
. ' OR ((ABS(COALESCE(discount_amount, 0)) > 0.005 OR ABS(COALESCE(offset_amount, 0)) > 0.005'
. ' OR ABS(COALESCE(default_compensation_amount, 0)) > 0.005)'
. ' AND ABS(' . $effective . ' - (' . $adjustment . ')) < 0.02'
. ' AND ' . $pddPaymentDiscountGuard . '))'
. ' AND ' . $notBasisMarked;
@ -429,14 +446,14 @@ class Pages extends Controller
. ') ELSE 0 END = 1) OR ' . $taoRefundHit . ' OR ' . $pddFullRefundHit
. ' OR (ABS(COALESCE(refund_amount, 0)) > 0.005'
. ' AND (ABS(' . $effective . ' - COALESCE(refund_amount, 0)) < 0.02'
. ' OR ((ABS(COALESCE(discount_amount, 0)) > 0.005 OR ABS(COALESCE(offset_amount, 0)) > 0.005)'
. ' AND ABS(' . $effective . ' - (COALESCE(refund_amount, 0)'
. ' + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))) < 0.02))'
. ' OR ((ABS(COALESCE(discount_amount, 0)) > 0.005 OR ABS(COALESCE(offset_amount, 0)) > 0.005'
. ' OR ABS(COALESCE(default_compensation_amount, 0)) > 0.005)'
. ' AND ABS(' . $effective . ' - (COALESCE(refund_amount, 0) + (' . $adjustment . '))) < 0.02))'
. ' AND ' . $notBasisMarked . ')';
// 拼多多部分退款:账单收入高于聚水潭买家实付,且扣除优惠/冲减后仍有正向小额差额,
// 与「多多支付立减」等平台支付类补贴一致;只标注原因,不改差额和收入。
$pddPaymentDiscount = '(' . $effective . ' - COALESCE(discount_amount, 0) - COALESCE(offset_amount, 0))';
$pddPaymentDiscount = '(' . $effective . ' - (' . $adjustment . '))';
$rules['差额=平台支付立减'] = "platform = '拼多多'"
. " AND match_status = '已匹配'"
. ' AND ' . $effective . ' > 0.005'
@ -453,6 +470,7 @@ class Pages extends Controller
. ' AND ' . $effective . ' < -0.005'
. ' AND ABS(COALESCE(discount_amount, 0)) < 0.005'
. ' AND ABS(COALESCE(offset_amount, 0)) < 0.005'
. ' AND ABS(COALESCE(default_compensation_amount, 0)) < 0.005'
. ' AND ABS(COALESCE(refund_amount, 0)) < 0.005'
. ' AND COALESCE(paid_amount, 0) > COALESCE(income_amount, 0) + 0.005'
. ' AND ' . $notBasisMarked;
@ -477,7 +495,7 @@ class Pages extends Controller
*/
private static function receiptDifferenceRemarkIndexSql(): string
{
$effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))';
$effective = 'COALESCE(difference, ' . self::receiptNetSql() . ')';
$sql = 'CASE WHEN ABS(' . $effective . ') <= 0.005 THEN 0';
$index = 1;
foreach (self::receiptDifferenceRemarkBaseRules() as $condition) {
@ -586,7 +604,7 @@ class Pages extends Controller
/** 未命中复杂原因时,差额的默认口径。 */
private static function receiptDefaultDifferenceSql(): string
{
return 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))';
return 'COALESCE(difference, ' . self::receiptNetSql() . ')';
}
/**
@ -614,7 +632,10 @@ class Pages extends Controller
$label = $basis['label'];
$difference = $row->difference === null
? (float) ($row->income_amount ?? 0) + (float) ($row->discount_amount ?? 0) + (float) ($row->offset_amount ?? 0)
? (float) ($row->income_amount ?? 0)
+ (float) ($row->discount_amount ?? 0)
+ (float) ($row->offset_amount ?? 0)
+ (float) ($row->default_compensation_amount ?? 0)
: (float) $row->difference;
if (abs($difference) < 0.005) {
@ -630,6 +651,8 @@ class Pages extends Controller
$income = (float) ($row->income_amount ?? 0);
$discount = (float) ($row->discount_amount ?? 0);
$offset = (float) ($row->offset_amount ?? 0);
$defaultCompensation = (float) ($row->default_compensation_amount ?? 0);
$adjustment = $discount + $offset + $defaultCompensation;
if (abs($product) < 0.005) {
return ($row->planting_amount ?? null) !== null ? $label . '取数为0(种菜)' : $label . '取数为0';
@ -697,10 +720,10 @@ class Pages extends Controller
['订单实付按卖家实收核对', '订单实付按应付/已付核对'],
true
);
if (! $basisMarked && abs($product - ($income + $discount)) < 0.005) {
if (! $basisMarked && abs($product - ($income + $discount + $defaultCompensation)) < 0.005) {
return $label . '未减冲减';
}
if (! $basisMarked && abs($product - ($income + $offset)) < 0.005) {
if (! $basisMarked && abs($product - ($income + $offset + $defaultCompensation)) < 0.005) {
return $label . '未减优惠';
}
if (! $basisMarked && abs($product - $income) < 0.005) {
@ -746,7 +769,7 @@ class Pages extends Controller
}
// 拼多多部分退款:差额扣除优惠/冲减后,剩余部分与平台支付立减一致。
$pddPaymentDiscount = $difference - $discount - $offset;
$pddPaymentDiscount = $difference - $adjustment;
if (($row->platform ?? '') === '拼多多'
&& ! $basisMarked
&& $difference > 0.005
@ -770,6 +793,7 @@ class Pages extends Controller
&& self::is1688StoreName((string) ($row->store_name ?? ''))
&& abs($discount) < 0.005
&& abs($offset) < 0.005
&& abs($defaultCompensation) < 0.005
&& abs((float) ($row->refund_amount ?? 0)) < 0.005
&& (float) ($row->paid_amount ?? 0) > $income + 0.005) {
return '订单实付含平台红包补贴';
@ -782,9 +806,9 @@ class Pages extends Controller
$refund = (float) ($row->refund_amount ?? 0);
$hasRefund = abs($refund) > 0.005;
$hasDiscountOffset = abs($discount) > 0.005 || abs($offset) > 0.005;
$hasDiscountOffset = abs($discount) > 0.005 || abs($offset) > 0.005 || abs($defaultCompensation) > 0.005;
if ($hasRefund && $hasDiscountOffset
&& abs($difference - ($refund + $discount + $offset)) < 0.02) {
&& abs($difference - ($refund + $adjustment)) < 0.02) {
return '订单实付含已退款部分';
}
if ($hasRefund && abs($difference - $refund) < 0.02) {
@ -792,7 +816,7 @@ class Pages extends Controller
}
if (
$hasDiscountOffset
&& abs($difference - ($discount + $offset)) < 0.02
&& abs($difference - $adjustment) < 0.02
) {
return '订单实付未减优惠和冲减';
}
@ -1329,8 +1353,6 @@ class Pages extends Controller
$unmatchedReasonFilter = $this->receiptOrderMatchFilterValues(request('unmatched_reason'));
$differenceState = $this->receiptOrderMatchFilterValues(request('difference_state'));
$orderNo = trim((string) request('online_order_no', ''));
$defaultCompensationAmounts = self::localReceiptDefaultCompensationAmounts($month);
// 闭包里要用 $this->applyReceiptRemarkFilter()(差异原因备注按公式判定),所以不能声明为 static
$applyFilters = function ($query) use ($platformFilter, $storeFilter, $orderNo, $statusFilter, $remarkFilter, $unmatchedReasonFilter, $differenceState): void {
if ($platformFilter !== []) {
@ -1375,6 +1397,7 @@ class Pages extends Controller
SUM(income_amount) as income_total,
SUM(discount_amount) as discount_total,
SUM(offset_amount) as offset_total,
SUM(default_compensation_amount) as default_compensation_total,
SUM(refund_amount) as refund_total,
SUM(planting_amount) as planting_total,
SUM(product_amount) as goods_total,
@ -1411,6 +1434,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),
'default_compensation_total' => round((float) ($agg->default_compensation_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),
@ -1481,9 +1505,8 @@ class Pages extends Controller
// 差额异常备注列:只展示「差额为什么产生」的原因备注;未匹配原因单独一列;
// 未匹配行(差额为空)按比对基准金额=0 计入差额。
$items = $items->map(static function (object $row) use ($defaultCompensationAmounts): object {
$items = $items->map(static function (object $row): object {
$rawRemark = trim((string) ($row->remark ?? ''));
$row->default_compensation_amount = $defaultCompensationAmounts[(string) ($row->row_key ?? '')] ?? null;
// 未匹配原因列:未匹配行照旧;「订单实付按卖家实收核对」这类口径标识在已匹配行同样展示。
$row->unmatched_reason = $rawRemark === ''
? null
@ -1493,7 +1516,10 @@ class Pages extends Controller
if ($row->difference === null) {
$row->difference = round(
(float) ($row->income_amount ?? 0) + (float) ($row->discount_amount ?? 0) + (float) ($row->offset_amount ?? 0),
(float) ($row->income_amount ?? 0)
+ (float) ($row->discount_amount ?? 0)
+ (float) ($row->offset_amount ?? 0)
+ (float) ($row->default_compensation_amount ?? 0),
2
);
}
@ -1656,44 +1682,6 @@ class Pages extends Controller
return array_keys($clean);
}
/**
* 本地预览快照:读取按最新科目映射表生成的「违约赔付(冲减收入)」金额。
* 文件不存在或内容无效时返回空数组,不影响正式页面。
*
* @return array<string,float>
*/
private static function localReceiptDefaultCompensationAmounts(?string $month): array
{
static $snapshots = [];
$month = substr(trim((string) $month), 0, 7);
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
return [];
}
if (isset($snapshots[$month])) {
return $snapshots[$month];
}
$path = storage_path('app/local/performance-details/default-compensation/' . $month . '.json');
if (! is_file($path)) {
return $snapshots[$month] = [];
}
$decoded = json_decode((string) file_get_contents($path), true);
if (! is_array($decoded) || ! is_array($decoded['amounts'] ?? null)) {
return $snapshots[$month] = [];
}
$amounts = [];
foreach ($decoded['amounts'] as $rowKey => $amount) {
if (is_string($rowKey) && $rowKey !== '' && is_numeric($amount)) {
$amounts[$rowKey] = round((float) $amount, 2);
}
}
return $snapshots[$month] = $amounts;
}
/**
* 业绩明细的账期集合与当前账期:未指定月份时取镜像里最新的月份。
*
@ -2133,8 +2121,6 @@ class Pages extends Controller
];
[, $month] = $this->receiptOrderMatchMonths();
$defaultCompensationAmounts = self::localReceiptDefaultCompensationAmounts($month);
$query = DB::connection($connection)->table('store_receipt_order_match_rows')
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
->when($month !== null, static fn ($builder) => $builder->where('report_month', $month));
@ -2201,7 +2187,7 @@ class Pages extends Controller
$filename = '店铺收款匹配订单明细_' . ($month ?? 'all') . '.csv';
return response()->streamDownload(function () use ($query, $selected, $map, $defaultCompensationAmounts): void {
return response()->streamDownload(function () use ($query, $selected, $map): void {
// 单表几十万行:用游标一次查询顺序吐出行,避免 chunk 的 OFFSET 反复重跑
// (29 万行时会退化成几百次排序查询,导出要几分钟)。
@set_time_limit(0);
@ -2214,9 +2200,7 @@ class Pages extends Controller
$line = [];
foreach ($selected as $key) {
$format = $map[$key][2];
$value = $key === 'default_compensation_amount'
? ($defaultCompensationAmounts[(string) ($row->row_key ?? '')] ?? '')
: ($row->{$map[$key][0]} ?? '');
$value = $row->{$map[$key][0]} ?? '';
// 「差额异常备注」列与页面一致:只写差额产生的原因
if ($key === 'remark') {
$value = self::receiptDifferenceRemark($row) ?? '';
@ -2225,7 +2209,10 @@ class Pages extends Controller
// 注意:上面取值用了 ?? '',空差值是空字符串而不是 null,这里两种都要认。
if ($key === 'difference' && ($value === null || $value === '')) {
$value = round(
(float) ($row->income_amount ?? 0) + (float) ($row->discount_amount ?? 0) + (float) ($row->offset_amount ?? 0),
(float) ($row->income_amount ?? 0)
+ (float) ($row->discount_amount ?? 0)
+ (float) ($row->offset_amount ?? 0)
+ (float) ($row->default_compensation_amount ?? 0),
2
);
}
@ -2557,7 +2544,8 @@ class Pages extends Controller
->where('report_month', $reportMonth)
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
->selectRaw('platform, store_name, SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)) as income')
. ' + COALESCE(offset_amount, 0) + COALESCE(default_compensation_amount, 0)'
. ' + COALESCE(refund_amount, 0)) as income')
->groupBy('platform', 'store_name')
->get();
foreach ($incomeRows as $row) {

View File

@ -252,7 +252,7 @@ $navigation = [
'directory' => [
'icon' => 'query_stats',
'status' => '共享镜像',
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按店铺和发生日期逐日汇总后落到主体期间。',
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 违约赔付(冲减收入) + 退款金额」合计,按店铺和发生日期逐日汇总后落到主体期间。',
'empty' => '店铺维度报表尚未完成共享数据导入。',
'fields' => [
['name' => 'erp_store_name', 'label' => 'ERP系统店铺名称'],
@ -822,7 +822,7 @@ $navigation = [
'directory' => [
'icon' => 'receipt_long',
'status' => '共享镜像',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注)、差额=直营补贴款(待开票结算)(仅京喜账单·京喜自营工厂店负差额且无退款(出类拔萃等 10 家已逐店核验,其余工厂店按实付≈商品金额的供货价特征识别):京东直营补贴款待开票结算时标注)、差额=直营补贴款(订单退款已冲回)(同批店负差额但订单已退款:补贴款已随退款冲回)、差额=订单实付包含商家承担优惠和以旧换新国补(仅京东账单·索菲亚客厅/办公/书房/功能、亿家达全屋、朴美家居店负差额、且该单含国补(聚水潭应付−账单收入>0):差额为商家承担优惠(店铺东券/单品折扣等)与以旧换新国补之和,已用营销对账逐单核实)、差额=订单实付包含商家承担优惠(同店负差额但不含国补的单:差额即商家承担优惠)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)(以上三档等式对带口径标识的行让位给口径兜底)、订单实付含已退款部分(抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注;淘系支付宝/微信负差额:差额=退款合计、或退款合计=实付−收入(收款缺口)、或整单全退(退款合计≈实付)时同样标注(退款取聚水潭已确认记录));以上具体规则均未命中且相关金额在 2 分钱内精确对平时,退款及其与优惠/冲减的组合复用「订单实付含已退款部分」,优惠与冲减组合复用「订单实付未减优惠和冲减」,不新增同义备注;没有可复用的原备注时继续标「差额待核实」。都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」「违约赔付(冲减收入)」分别取对应总账备注的账单净额,不计入收入金额;「退款金额」只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额,只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额+违约赔付(冲减收入))−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的抵减项会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定;具体规则均未命中且相关金额在 2 分钱内精确对平时,退款及其与抵减项的组合复用「订单实付含已退款部分」,优惠、冲减与违约赔付组合复用「订单实付未减优惠和冲减」;没有可复用的原备注时继续标「差额待核实」。都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。',
'fields' => [
['name' => 'platform', 'label' => '平台'],

View File

@ -9,7 +9,8 @@ return [
*
* 两个口径的取数链完全一致(同店同号 → 店铺名不一致 → 同号多条 → 空店铺兜底),
* 只是把「订单侧金额」从商品金额换成订单实付金额;差额公式仍是
* (收入金额 + 优惠金额 + 冲减金额)− 订单侧金额,退款金额单独列示、不参与差额。
* (收入金额 + 优惠金额 + 冲减金额 + 违约赔付(冲减收入))− 订单侧金额,
* 退款金额单独列示、不参与差额。
*/
'difference_basis' => env('PERFORMANCE_DETAILS_DIFFERENCE_BASIS', 'paid'),
];

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 业绩明细把「违约赔付(冲减收入)」从普通「冲减收入」中拆开。
*
* 金额仍按账单行「收入 - 支出」保存,负数表示净扣减;差额和收入汇总会
* 分别计入本列,避免只保留在 offset_amount 里造成口径混淆。
*/
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', 'default_compensation_amount')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->decimal('default_compensation_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', 'default_compensation_amount')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('default_compensation_amount');
});
}
};

View File

@ -17,7 +17,7 @@ use RuntimeException;
*
* 两列金额口径不同,页面分别展示:
* 销量(period_sales / sales_NN):业绩明细(店铺收款匹配订单明细)的
* 「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按「店铺名称 + 发生日期」
* 「收入金额 + 优惠金额 + 冲减金额 + 违约赔付(冲减收入) + 退款金额」合计,按「店铺名称 + 发生日期」
* 预汇总到 store_report_paid_sales_rows,再按发生日期落到主体期间;
* 收入(rolling_12m_income / income_NN):平台账单日汇总 store_report_daily_rows 的收入,
* 供主体维度经营报表按主体汇总使用。

View File

@ -9,10 +9,10 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
/**
* 本地预览用:直接从最新科目映射表和账单镜像生成违约赔付金额快照。
* 人工核对工具:直接从指定科目映射表和账单镜像生成违约赔付金额快照。
*
* 不写共享数据库,只输出 storage/app/local 下的 JSON。正式接入系统前用于核对
* 「种菜金额」后面的「违约赔付(冲减收入)」列。
* 不写共享数据库。正式页面已直接读取 store_receipt_order_match_rows 的
* default_compensation_amount,本工具仅保留用于离线验算。
*/
class StoreReceiptDefaultCompensationSnapshotBuilder
{

View File

@ -80,7 +80,7 @@ class StoreReportLogic
[
'code' => 'R9',
'object' => '销量口径',
'logic' => '销量=业绩明细(店铺收款匹配订单明细)的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按「店铺名称 + 发生日期」逐日汇总;退款、冲减按账单净额(负数)计入,不再使用订单实付/实收金额。收入列仍按平台账单日汇总,两个口径分开统计、不互相覆盖。',
'logic' => '销量=业绩明细(店铺收款匹配订单明细)的「收入金额 + 优惠金额 + 冲减金额 + 违约赔付(冲减收入) + 退款金额」合计,按「店铺名称 + 发生日期」逐日汇总;退款及各项抵减按账单净额(负数)计入,不再使用订单实付/实收金额。收入列仍按平台账单日汇总,两个口径分开统计、不互相覆盖。',
'source' => 'store_receipt_order_match_rows → store_report_paid_sales_rows',
'priority' => '与业绩明细同口径',
],

View File

@ -448,7 +448,8 @@ class StoreReportMirrorSynchronizer
/**
* 店铺维度报表的「销量」:把业绩明细的
* 「收入金额 + 优惠金额 + 冲减金额 + 退款金额」按「店铺名称 + 发生日期」预汇总。
* 「收入金额 + 优惠金额 + 冲减金额 + 违约赔付(冲减收入) + 退款金额」
* 按「店铺名称 + 发生日期」预汇总。
*
* 业绩明细由 store-receipt:order-match:sync 按「账期 + 平台」整体重写,这里用
* 「行数 + 最近写入时间 + 金额合计」的指纹判断是否变化:没变化就复用当前版本,
@ -502,7 +503,8 @@ class StoreReportMirrorSynchronizer
. ' (generation, store_name, occurred_on, paid_amount, order_count, updated_at)'
. ' SELECT ?, store_name, occurred_date,'
. ' SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), COUNT(*), ?'
. ' + COALESCE(offset_amount, 0) + COALESCE(default_compensation_amount, 0)'
. ' + COALESCE(refund_amount, 0)), COUNT(*), ?'
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE
. " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IS NOT NULL"
. ' GROUP BY store_name, occurred_date',
@ -568,7 +570,8 @@ class StoreReportMirrorSynchronizer
. ' (generation, store_name, occurred_on, paid_amount, order_count, updated_at)'
. ' SELECT ?, store_name, occurred_date,'
. ' SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), COUNT(*), ?'
. ' + COALESCE(offset_amount, 0) + COALESCE(default_compensation_amount, 0)'
. ' + COALESCE(refund_amount, 0)), COUNT(*), ?'
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE
. " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IN ("
. implode(',', array_fill(0, count($dates), '?')) . ') GROUP BY store_name, occurred_date',
@ -593,7 +596,8 @@ class StoreReportMirrorSynchronizer
$row = $this->connection->selectOne(
'SELECT COUNT(*) AS rows_total, MAX(imported_at) AS last_imported_at,'
. ' COALESCE(SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), 0) AS sales_total'
. ' + COALESCE(offset_amount, 0) + COALESCE(default_compensation_amount, 0)'
. ' + COALESCE(refund_amount, 0)), 0) AS sales_total'
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE,
);

View File

@ -1311,6 +1311,7 @@
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>订单实付金额合计</em><strong>{{ number_format((float) ($receiptStats['paid_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>优惠金额合计</em><strong>{{ number_format((float) ($receiptStats['discount_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>冲减金额合计</em><strong>{{ number_format((float) ($receiptStats['offset_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>违约赔付(冲减收入)合计</em><strong>{{ number_format((float) ($receiptStats['default_compensation_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>退款金额合计</em><strong>{{ number_format((float) ($receiptStats['refund_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>种菜金额合计</em><strong>{{ number_format((float) ($receiptStats['planting_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount receipt-match-summary__item--lead"><em>差额合计</em><strong>{{ number_format((float) $receiptStats['difference_total'] / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>

View File

@ -228,11 +228,11 @@ class StoreReportMirrorSyncTest extends TestCase
$mirror->table('store_receipt_order_match_rows')->insert([
$this->perfRow('k1', '2026-08-02', '店铺A', 100.5),
$this->perfRow('k2', '2026-08-02', '店铺A', 9.5),
// 未匹配行没有订单实付、也没有收入,四项金额合计为 0。
// 未匹配行没有订单实付、收入或抵减金额,销量合计为 0。
$this->perfRow('k3', '2026-08-03', '店铺A', null),
$this->perfRow('k4', '2026-08-03', '店铺B', 20),
// 销量=收入 + 优惠 + 冲减 + 退款:100 + 5 + (−3) + (−10) = 92。
$this->perfRow('k6', '2026-08-04', '店铺C', 100, discount: 5, offset: -3, refund: -10),
// 销量=收入 + 优惠 + 冲减 + 违约赔付 + 退款:100 + 5 + (−3) + (−7) + (−10) = 85。
$this->perfRow('k6', '2026-08-04', '店铺C', 100, discount: 5, offset: -3, defaultCompensation: -7, refund: -10),
]);
$upstream = $this->createMock(StoreReportUpstreamRepository::class);
$upstream->method('lifecycleBatch')->willReturn([]);
@ -254,7 +254,7 @@ class StoreReportMirrorSyncTest extends TestCase
], 'business_mirror');
$this->assertDatabaseHas('store_report_paid_sales_rows', [
'generation' => $generation, 'store_name' => '店铺C', 'occurred_on' => '2026-08-04',
'paid_amount' => 92, 'order_count' => 1,
'paid_amount' => 85, 'order_count' => 1,
], 'business_mirror');
$this->assertSame(4, $mirror->table('store_report_paid_sales_rows')->count());
$this->assertDatabaseHas('store_report_import_states', [
@ -286,6 +286,7 @@ class StoreReportMirrorSyncTest extends TestCase
string $importedAt = '2026-10-16 10:00:00',
float $discount = 0,
float $offset = 0,
float $defaultCompensation = 0,
float $refund = 0,
): array
{
@ -295,6 +296,7 @@ class StoreReportMirrorSyncTest extends TestCase
'income_amount' => $income,
'discount_amount' => $discount,
'offset_amount' => $offset,
'default_compensation_amount' => $defaultCompensation,
'refund_amount' => $refund,
'paid_amount' => $income,
'imported_at' => $importedAt,
@ -394,6 +396,7 @@ class StoreReportMirrorSyncTest extends TestCase
$table->decimal('income_amount', 20, 4)->nullable();
$table->decimal('discount_amount', 20, 4)->nullable();
$table->decimal('offset_amount', 20, 4)->nullable();
$table->decimal('default_compensation_amount', 20, 4)->nullable();
$table->decimal('refund_amount', 20, 4)->nullable();
$table->decimal('paid_amount', 20, 4)->nullable();
$table->dateTime('imported_at');

View File

@ -26,6 +26,7 @@ class PerformanceDifferenceRemarkTest extends TestCase
'income_amount' => 0,
'discount_amount' => 0,
'offset_amount' => 0,
'default_compensation_amount' => 0,
'refund_amount' => 0,
'product_amount' => 0,
'paid_amount' => 0,
@ -89,6 +90,7 @@ class PerformanceDifferenceRemarkTest extends TestCase
$this->assertStringContainsString('refund_amount', $rules['订单实付含已退款部分']);
$this->assertStringContainsString('discount_amount', $rules['订单实付未减优惠和冲减']);
$this->assertStringContainsString('offset_amount', $rules['订单实付未减优惠和冲减']);
$this->assertStringContainsString('default_compensation_amount', $rules['订单实付未减优惠和冲减']);
$this->assertArrayNotHasKey('差额含已退款部分', $rules);
$this->assertArrayNotHasKey('差额含优惠/冲减项', $rules);
$this->assertArrayNotHasKey('差额=退款金额(金额对平)', $rules);
@ -151,6 +153,19 @@ class PerformanceDifferenceRemarkTest extends TestCase
]));
}
public function test_default_compensation_is_counted_once_as_a_separate_adjustment(): void
{
$this->assertSame('订单实付未减优惠和冲减', $this->remark([
'platform' => '得物',
'income_amount' => 100.00,
'offset_amount' => -14.00,
'default_compensation_amount' => -25.00,
'product_amount' => 100.00,
'paid_amount' => 100.00,
'difference' => -39.00,
]));
}
public function test_refund_or_discount_cooccurrence_alone_stays_pending(): void
{
$this->assertSame('差额待核实', $this->remark([

View File

@ -10,13 +10,14 @@ use Tests\TestCase;
class StoreReceiptMappingPreviewOverrideTest extends TestCase
{
public function test_default_compensation_is_included_in_offset_remarks(): void
public function test_default_compensation_is_separate_from_offset_remarks(): void
{
$reflection = new ReflectionClass(SyncStoreReceiptOrderMatches::class);
$remarks = $reflection->getConstant('OFFSET_REMARKS');
$offsetRemarks = $reflection->getConstant('OFFSET_REMARKS');
$defaultCompensationRemarks = $reflection->getConstant('DEFAULT_COMPENSATION_REMARKS');
$this->assertContains('冲减收入', $remarks);
$this->assertContains('违约赔付(冲减收入)', $remarks);
$this->assertSame(['冲减收入'], $offsetRemarks);
$this->assertSame(['违约赔付(冲减收入)'], $defaultCompensationRemarks);
}
public function test_local_mapping_override_file_is_used_when_configured(): void

View File

@ -50,6 +50,7 @@ class StoreReceiptMemoryTest extends TestCase
$this->bill('订单收入', '260630-10001', 100, 0, ['occurred_at' => '2026-08-02 10:00:00']),
$this->bill('订单收入', '260630-10001', 25),
$this->bill('冲减收入', '260630-10001', 0, 2),
$this->bill('违约赔付(冲减收入)', '260630-10001', 0, 5),
$this->bill('退款', '260630-10001', 0, 7),
$this->bill('优惠收入', '20002', 4),
$this->bill('退款', '20002', 0, 1),
@ -68,6 +69,7 @@ class StoreReceiptMemoryTest extends TestCase
$this->assertSame(125.0, $paid['income_amount']);
$this->assertSame(-3.0, $paid['discount_amount']);
$this->assertSame(-2.0, $paid['offset_amount']);
$this->assertSame(-5.0, $paid['default_compensation_amount']);
$this->assertSame(-7.0, $paid['refund_amount']);
$this->assertTrue($paid['planting_seen']);
$this->assertSame('260630-10001', $paid['online_order_no']);
@ -75,14 +77,16 @@ class StoreReceiptMemoryTest extends TestCase
$this->assertSame('天猫_测试店', $paid['store_name']);
$this->assertSame(0.0, $result['groups'][$key . '|30003']['income_amount']);
$this->assertSame(3, $result['paymentTotal']);
$this->assertSame(12, $result['billCount']);
$this->assertSame(8, $result['moneyRemarkRows']);
$this->assertSame(13, $result['billCount']);
$this->assertSame(9, $result['moneyRemarkRows']);
$this->assertSame(125.0, $result['incomeTotal']);
$this->assertSame(1.0, $result['discountTotal']);
$this->assertSame(-7.0, $result['offsetTotal']);
$this->assertSame(-5.0, $result['defaultCompensationTotal']);
$this->assertSame(-10.0, $result['refundTotal']);
$this->assertSame(-3.0, $result['attachedDiscount']);
$this->assertSame(-2.0, $result['attachedOffset']);
$this->assertSame(-5.0, $result['attachedDefaultCompensation']);
$this->assertSame(3, $result['unattachedRows']);
$this->assertSame([$key . '|20002', $key . '||冲减收入', $key . '||退款'], array_keys($result['promoOnlyGroups']));
$this->assertSame(4.0, $result['promoOnlyGroups'][$key . '|20002']['discount_amount']);
@ -155,6 +159,36 @@ class StoreReceiptMemoryTest extends TestCase
$this->assertSame(2.0, $first['discount_amount']);
}
public function test_promo_only_row_carries_default_compensation_separately(): void
{
$row = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'promoOnlyRow'))->invoke(
null,
'2026-08-01',
'得物',
[
'order_identity' => '110206461460184356',
'order_core_no' => '110206461460184356',
'shop_key' => '得物-墨尚思',
'bill_item' => '违约赔付',
'occurred_date' => '2026-08-02',
'store_name' => '得物-墨尚思',
'online_order_no' => '110206461460184356',
'discount_seen' => false,
'discount_amount' => 0.0,
'offset_seen' => false,
'offset_amount' => 0.0,
'default_compensation_seen' => true,
'default_compensation_amount' => -25.0,
'refund_seen' => false,
'refund_amount' => 0.0,
],
now(),
);
$this->assertNull($row['offset_amount']);
$this->assertSame(-25.0, $row['default_compensation_amount']);
}
public function test_business_remark_and_goods_name_are_forwarded_once_per_bill(): void
{
$map = $this->createMock(AccountLedgerRemarkMap::class);

View File

@ -32,13 +32,13 @@ class StoreReportPaidSalesIncrementalTest extends TestCase
$table->date('report_month');
$table->date('occurred_date');
$table->string('store_name');
foreach (['income_amount', 'discount_amount', 'offset_amount', 'refund_amount'] as $column) $table->decimal($column, 20, 4)->nullable();
foreach (['income_amount', 'discount_amount', 'offset_amount', 'default_compensation_amount', 'refund_amount'] as $column) $table->decimal($column, 20, 4)->nullable();
});
(require base_path('database/migrations/2026_10_19_171000_track_paid_sales_dirty_dates.php'))->up();
DB::connection('business_mirror')->table('store_receipt_order_match_rows')->insert([
['platform' => 'A', 'report_month' => '2026-10-01', 'occurred_date' => '2026-10-01', 'store_name' => '测试店', 'income_amount' => 100, 'discount_amount' => -5, 'offset_amount' => 0, 'refund_amount' => -10],
['platform' => 'B', 'report_month' => '2026-10-01', 'occurred_date' => '2026-10-01', 'store_name' => '测试店', 'income_amount' => 20, 'discount_amount' => 0, 'offset_amount' => 0, 'refund_amount' => 0],
['platform' => 'A', 'report_month' => '2026-09-01', 'occurred_date' => '2026-09-30', 'store_name' => '测试店', 'income_amount' => 40, 'discount_amount' => 0, 'offset_amount' => 0, 'refund_amount' => 0],
['platform' => 'A', 'report_month' => '2026-10-01', 'occurred_date' => '2026-10-01', 'store_name' => '测试店', 'income_amount' => 100, 'discount_amount' => -5, 'offset_amount' => 0, 'default_compensation_amount' => -7, 'refund_amount' => -10],
['platform' => 'B', 'report_month' => '2026-10-01', 'occurred_date' => '2026-10-01', 'store_name' => '测试店', 'income_amount' => 20, 'discount_amount' => 0, 'offset_amount' => 0, 'default_compensation_amount' => 0, 'refund_amount' => 0],
['platform' => 'A', 'report_month' => '2026-09-01', 'occurred_date' => '2026-09-30', 'store_name' => '测试店', 'income_amount' => 40, 'discount_amount' => 0, 'offset_amount' => 0, 'default_compensation_amount' => 0, 'refund_amount' => 0],
]);
return new StoreReportMirrorSynchronizer(\Mockery::mock(StoreReportUpstreamRepository::class), app('db'));
}
@ -53,7 +53,7 @@ class StoreReportPaidSalesIncrementalTest extends TestCase
$changes->markPeriod('2026-10', 'A');
$db->table('store_receipt_order_match_rows')->where('id', 1)->update(['income_amount' => 120]);
$this->assertTrue($sync->syncPaidSales()['changed']);
$this->assertSame(125.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount'));
$this->assertSame(118.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount'));
$this->assertSame($untouchedId, $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-09-30')->value('id'));
$db->enableQueryLog();
$this->assertFalse($sync->syncPaidSales()['changed']);
@ -71,12 +71,12 @@ class StoreReportPaidSalesIncrementalTest extends TestCase
$changes->markPeriod('2026-10', 'A');
$sync->syncPaidSales();
$this->assertSame(20.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount'));
$this->assertSame(85.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-03')->value('paid_amount'));
$this->assertSame(78.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-03')->value('paid_amount'));
$changes->markPeriod('2026-10', 'B');
$db->table('store_receipt_order_match_rows')->where('platform', 'B')->delete();
$sync->syncPaidSales();
$this->assertSame(0, $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->count());
$this->assertSame(125.0, (float) $db->table('store_report_paid_sales_rows')->sum('paid_amount'));
$this->assertSame(118.0, (float) $db->table('store_report_paid_sales_rows')->sum('paid_amount'));
}
public function test_a_new_revision_is_not_lost_when_an_older_snapshot_is_acknowledged(): void
@ -119,7 +119,7 @@ class StoreReportPaidSalesIncrementalTest extends TestCase
if (str_starts_with($query->sql, 'INSERT INTO store_report_paid_sales_rows')) throw new \RuntimeException('test_aggregate_failure');
});
$this->assertSame('failed', $sync->syncPaidSales()['status']);
$this->assertSame(105.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount'));
$this->assertSame(98.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount'));
$this->assertCount(1, app(StoreReportPaidSalesChanges::class)->pending());
}
}