From 3375942d6f84c1e9e842ba7b4e8519d8e5947b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=98=8A=E5=A4=A9?= <442384644@qq.com> Date: Tue, 13 Oct 2026 18:07:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=94=AE=E5=90=8E):=20=E9=80=80=E6=AC=BE?= =?UTF-8?q?=E7=8E=B0=E9=87=91=E4=B8=8A=E9=99=90=E6=8C=89=E5=88=86=E6=91=8A?= =?UTF-8?q?=E5=AE=9E=E4=BB=98=E5=8F=A3=E5=BE=84=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 商品行实付=实付金额-实付运费,按原价占比分摊到行并累计取整,优惠券/积分抵扣部分不再折现退还 - 部分数量退款按整数分差取额,运费分摊与积分冲正权重同步实付口径 - 移动端申请页退款上限与后端同算法 --- .../app/services/order/AfterSaleServices.php | 120 ++++++++++++++++-- .../dynamic-pages/engines/AfterSaleEngine.vue | 47 +++++-- 2 files changed, 145 insertions(+), 22 deletions(-) diff --git a/backend/app/services/order/AfterSaleServices.php b/backend/app/services/order/AfterSaleServices.php index e6acb97ff..6db0a0086 100644 --- a/backend/app/services/order/AfterSaleServices.php +++ b/backend/app/services/order/AfterSaleServices.php @@ -106,7 +106,8 @@ class AfterSaleServices extends BaseService $hasIntegral = false; foreach ($items as $item) { $totalRefundable += (int)($item['refundable_num'] ?? 0); - if ((float)($item['true_price'] ?? 0) > 0) { + // 现金维度看分摊后的实付金额(全额券/纯积分抵扣单实付为 0 时不可退现金) + if ((float)($item['paid_amount_total'] ?? $item['true_price'] ?? 0) > 0) { $hasCash = true; } if ((int)($item['refundable_integral'] ?? 0) > 0) { @@ -234,6 +235,7 @@ class AfterSaleServices extends BaseService // 组装返回 $deductionIntegral = $integralUnitPrice > 0 ? [] : $this->allocateDeductionIntegral($center, $orderItems); + $paidAmounts = $this->allocatePaidAmounts($center, $orderItems); $result = []; foreach ($orderItems as $item) { $cartId = (int)($item['cart_id'] ?? 0); @@ -262,6 +264,8 @@ class AfterSaleServices extends BaseService 'refunded_num' => $refundedNum, 'refundable_num' => $refundableNum, 'true_price' => (string)($item['true_price'] ?? $item['price'] ?? '0.00'), + // 分摊后的整行实付现金(商品原价 - 优惠券/积分抵扣/促销等优惠),退款现金上限按此累计 + 'paid_amount_total' => $paidAmounts[$cartId] ?? '0.00', // 积分参考价:非积分订单为 0,仅积分活动订单有值 'integral_unit_price' => $integralUnitPrice, 'deduction_integral_total' => $deductionIntegral[$cartId] ?? 0, @@ -331,9 +335,94 @@ class AfterSaleServices extends BaseService - (int)bcdiv(bcmul((string)$total, (string)$before, 0), (string)$quantity, 0); } + /** + * 将订单商品实付现金按原价占比分摊到行,累计取整保证合计等于实付总额。 + * 商品实付现金 = 实付金额 - 实付运费,优惠券/积分抵扣/促销等优惠已在该口径中扣除, + * 避免退款上限按商品原价累计而超过用户实际支付的现金(积分抵扣部分以退还积分维度返还)。 + * @param array $center 中台订单 + * @param array $items 商品快照 + * @return array cart_id => 整行实付现金(字符串,2 位) + */ + protected function allocatePaidAmounts(array $center, array $items): array + { + $paidGoods = bcsub((string)($center['pay_price'] ?? '0'), (string)($center['pay_postage'] ?? '0'), 2); + if (bccomp($paidGoods, '0', 2) < 0) { + $paidGoods = '0.00'; + } + $weights = []; + foreach ($items as $item) { + $cid = (int)($item['cart_id'] ?? 0); + $weights[$cid] = bcmul((string)($item['true_price'] ?? $item['price'] ?? '0'), (string)($item['cart_num'] ?? 1), 4); + } + $total = array_reduce($weights, fn ($sum, $weight) => bcadd($sum, $weight, 4), '0'); + if (bccomp($total, '0', 4) <= 0) { + return array_fill_keys(array_map('intval', array_keys($weights)), '0.00'); + } + // 实付现金异常大于商品原价合计时按原价封顶 + if (bccomp($paidGoods, $total, 4) > 0) { + $paidGoods = $total; + } + $allocated = '0'; + $cumulative = '0'; + $result = []; + foreach ($weights as $cid => $weight) { + $cumulative = bcadd($cumulative, $weight, 4); + $target = bcdiv(bcmul($paidGoods, $cumulative, 4), $total, 2); + $result[$cid] = bcsub($target, $allocated, 2); + $allocated = $target; + } + return $result; + } + + /** + * 计算选中数量应退的实付现金,逐次退款累计取整(与 deductionIntegralForQuantity 同模式)。 + * @param string $rowTotal 整行实付现金 + * @param int $quantity 整行数量 + * @param int $refunded 已占用数量 + * @param int $selected 本次数量 + * @return string + */ + protected function paidAmountForQuantity(string $rowTotal, int $quantity, int $refunded, int $selected): string + { + if ($quantity <= 0 || bccomp($rowTotal, '0', 2) <= 0) { + return '0.00'; + } + $before = min($quantity, max(0, $refunded)); + $after = min($quantity, $before + max(0, $selected)); + if ($after <= $before) { + return '0.00'; + } + // 全程整数分运算,避免中途小数截断造成少退;差值除回 100 转元 + $totalCents = (int)bcmul($rowTotal, '100', 0); + $centsAt = fn (int $num): int => intdiv($totalCents * $num, $quantity); + return bcdiv((string)($centsAt($after) - $centsAt($before)), '100', 2); + } + + /** + * 按行取退款现金:有分摊实付数据时按数量累计取额,否则回退商品原价(外部直传原始明细的场景)。 + * @param array $item 商品行(loadRefundableItems 结构或原始明细) + * @param int $quantity 本次数量 + * @return string + */ + protected function rowPaidAmountForQuantity(array $item, int $quantity): string + { + $quantity = max(0, $quantity); + $cartNum = max(1, (int)($item['cart_num'] ?? 0)); + if (isset($item['paid_amount_total'])) { + return $this->paidAmountForQuantity( + (string)$item['paid_amount_total'], + $cartNum, + (int)($item['refunded_num'] ?? 0), + min($quantity, $cartNum) + ); + } + return bcmul((string)($item['true_price'] ?? $item['price'] ?? '0'), (string)min($quantity, $cartNum), 2); + } + /** * 获取订单当前可退现金上限 * 按可退明细逐项累加,避免直接用订单实付金额减已退金额导致和实际 SKU 可退数量不一致。 + * 商品部分按分摊后的实付金额(原价扣减优惠券/积分抵扣/促销)累计,现金上限不会超过实付总额。 */ public function getMaxRefundAmount(string $orderNo): string { @@ -354,7 +443,7 @@ class AfterSaleServices extends BaseService } $maxRefundPrice = bcadd( $maxRefundPrice, - bcmul((string)($item['true_price'] ?? '0.00'), (string)$refundNum, 4), + $this->rowPaidAmountForQuantity($item, $refundNum), 2 ); } @@ -364,9 +453,10 @@ class AfterSaleServices extends BaseService } /** - * 按商品金额占比分摊订单已支付运费。 + * 按商品实付金额占比分摊订单已支付运费。 * * 商品级售后允许部分商品/部分数量退款,不能每次都直接加整笔运费,否则多次售后会重复退款。 + * 权重与现金退款口径一致:优先用分摊后的实付金额,无分摊数据时回退商品原价。 * @param array $center 中台订单 * @param array $allItems 订单全部商品明细,需含 cart_num/true_price * @param array $selectedItems 本次售后商品明细,含 refund_num;省略时使用各商品 refundable_num @@ -383,12 +473,14 @@ class AfterSaleServices extends BaseService $totalProductAmount = 0.0; foreach ($allItems as $item) { $cartId = (int)($item['cart_id'] ?? 0); - $unitPrice = (float)($item['true_price'] ?? $item['price'] ?? 0); $cartNum = max(0, (int)($item['cart_num'] ?? 0)); if ($cartId > 0) { $itemMap[$cartId] = $item; } - $totalProductAmount += $unitPrice * $cartNum; + // 分母用整行实付金额(未被已退数量扣减),保证比例基准是整单 + $totalProductAmount += isset($item['paid_amount_total']) + ? (float)$item['paid_amount_total'] + : (float)($item['true_price'] ?? $item['price'] ?? 0) * $cartNum; } if (empty($selectedItems)) { @@ -399,11 +491,10 @@ class AfterSaleServices extends BaseService foreach ($selectedItems as $item) { $cartId = (int)($item['cart_id'] ?? 0); $detail = $itemMap[$cartId] ?? $item; - $unitPrice = (float)($detail['true_price'] ?? $detail['price'] ?? 0); $quantity = array_key_exists('refund_num', $item) ? (int)$item['refund_num'] : (int)($item['refundable_num'] ?? $item['cart_num'] ?? 0); - $selectedProductAmount += $unitPrice * max(0, $quantity); + $selectedProductAmount += (float)$this->rowPaidAmountForQuantity($detail, $quantity); } if ($selectedProductAmount <= 0) { @@ -439,7 +530,13 @@ class AfterSaleServices extends BaseService continue; } $num = (int)($item['refund_num'] ?? 0); - $weight = max(0, (int)round((float)$detail['true_price'] * 100) * $num); + if (isset($detail['paid_amount_total'])) { + // 实付口径权重:按本次数量占整行数量的比例取整行实付金额 + $cartNum = max(1, (int)($detail['cart_num'] ?? 1)); + $weight = max(0, (int)round((float)$detail['paid_amount_total'] * 100 * $num / $cartNum)); + } else { + $weight = max(0, (int)round((float)$detail['true_price'] * 100) * $num); + } $integralWeight = (int)($detail['selected_integral'] ?? 0); $selected[] = compact('cartId', 'detail', 'num', 'weight', 'integralWeight'); $totalWeight += $weight; @@ -649,7 +746,8 @@ class AfterSaleServices extends BaseService if ((int)($item['refundable_num'] ?? 0) <= 0) { continue; } - if ((float)($item['true_price'] ?? 0) > 0) { + // 现金维度看分摊后的实付金额(原价扣减优惠券/积分抵扣/促销) + if ((float)($item['paid_amount_total'] ?? $item['true_price'] ?? 0) > 0) { $hasCash = true; } if ((int)($item['refundable_integral'] ?? 0) > 0) { @@ -1009,7 +1107,9 @@ class AfterSaleServices extends BaseService $cartIds[] = $cartId; $totalRefundNum += $refundNum; - $maxRefundPrice = bcadd($maxRefundPrice, bcmul((string)$ri['true_price'], (string)$refundNum, 4), 2); + // 现金上限按分摊后的实付金额累计,避免优惠券/积分抵扣/促销等优惠被重复以现金退还 + $lineCash = $this->rowPaidAmountForQuantity($ri, $refundNum); + $maxRefundPrice = bcadd($maxRefundPrice, $lineCash, 2); $selectedItemsForPostage[] = [ 'cart_id' => $cartId, 'refund_num' => $refundNum, diff --git a/frontend/uni-app/src/dynamic-pages/engines/AfterSaleEngine.vue b/frontend/uni-app/src/dynamic-pages/engines/AfterSaleEngine.vue index fc878f10a..d57ac2030 100644 --- a/frontend/uni-app/src/dynamic-pages/engines/AfterSaleEngine.vue +++ b/frontend/uni-app/src/dynamic-pages/engines/AfterSaleEngine.vue @@ -477,24 +477,47 @@ const statusClass = computed(() => { }); /** 选中的商品明细列表(用于展示和构建快照) */ const selectedItems = computed(() => refundableItems.value.filter(item => selectedCartIds.value.includes(item.cart_id))); -/** 选中商品的退款金额上限(商品金额 + 按商品金额比例分摊的实付运费) */ +/** 行实付金额按数量累计取整(与后端 paidAmountForQuantity 同算法,单位:元) */ +function paidAmountForQuantity(rowTotal, quantity, refunded, selected) { + const totalCents = Math.round(Number(rowTotal || 0) * 100); + if (quantity <= 0 || totalCents <= 0) return 0; + const before = Math.min(quantity, Math.max(0, Number(refunded) || 0)); + const after = Math.min(quantity, before + Math.max(0, Number(selected) || 0)); + if (after <= before) return 0; + const centAt = num => Math.floor(totalCents * num / quantity); + return (centAt(after) - centAt(before)) / 100; +} + +/** 行实付现金:优先用后端分摊的实付金额,缺失时回退商品原价(旧数据兜底) */ +function rowPaidTotal(item) { + return item.paid_amount_total !== undefined && item.paid_amount_total !== null + ? Number(item.paid_amount_total) + : Number(item.true_price || 0) * Number(item.cart_num || 0); +} + +/** 选中商品的退款金额上限(行实付金额 + 按实付占比分摊的实付运费;优惠券/积分抵扣不再折现退还) */ const maxRefundPrice = computed(() => { - let selectedProductAmount = 0; - let totalProductAmount = 0; - selectedItems.value.forEach(item => { - const num = refundNumMap.value[item.cart_id] || 0; - selectedProductAmount += Number(item.true_price) * num; - }); + let selectedPaidAmount = 0; + let totalPaidAmount = 0; refundableItems.value.forEach(item => { - totalProductAmount += Number(item.true_price) * Number(item.cart_num || 0); + totalPaidAmount += rowPaidTotal(item); + if (selectedCartIds.value.includes(item.cart_id)) { + const num = refundNumMap.value[item.cart_id] || 0; + selectedPaidAmount += paidAmountForQuantity( + rowPaidTotal(item), + Number(item.cart_num || 0), + Number(item.refunded_num || 0), + num + ); + } }); const payPostage = Number(orderDetail.value.pay_postage || 0); - const postageRefund = selectedProductAmount > 0 && payPostage > 0 - ? (totalProductAmount > 0 - ? payPostage * Math.min(1, selectedProductAmount / totalProductAmount) + const postageRefund = selectedPaidAmount > 0 && payPostage > 0 + ? (totalPaidAmount > 0 + ? payPostage * Math.min(1, selectedPaidAmount / totalPaidAmount) : payPostage) : 0; - return (selectedProductAmount + postageRefund).toFixed(2); + return (selectedPaidAmount + postageRefund).toFixed(2); }); /** 选中商品的退还积分合计,普通抵扣积分按数量累计取整。 */ const maxRefundIntegral = computed(() => {