mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 13:31:04 +00:00
fix(售后): 完善售后商品金额与申请表单
- 未发货订单仅允许仅退款,可用类型与提交校验同时限制 - 售后商品卡展示规格原价;申请页展示商品分摊实付+运费 - 顶部仅保留订单编号,退款金额/退还积分移动到售后类型上方 - 退款金额标签统一为退款金额,旧售后快照回填原价
This commit is contained in:
parent
6b1f41dddb
commit
944774bd44
@ -120,7 +120,7 @@ class AfterSaleServices extends BaseService
|
||||
|
||||
return [
|
||||
'allowed' => true,
|
||||
'types' => $rule['available_types'] ?? [],
|
||||
'types' => $this->filterTypesByDelivery($rule['available_types'] ?? [], $center),
|
||||
'deadline' => $rule['deadline'] ?? 0,
|
||||
'max_num' => $totalRefundable,
|
||||
// 用户实际支付的运费作为可退款金额的一部分下发,前端用于展示退款上限。
|
||||
@ -131,6 +131,21 @@ class AfterSaleServices extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按发货状态收敛售后类型
|
||||
* 未发货订单(delivery_time=0)只能申请仅退款,避免用户选择退货退款/换货等需要先收货的类型。
|
||||
* @param array $types 规则声明的可用类型
|
||||
* @param array $center 中台订单
|
||||
* @return array
|
||||
*/
|
||||
protected function filterTypesByDelivery(array $types, array $center): array
|
||||
{
|
||||
if ((int)($center['delivery_time'] ?? 0) <= 0) {
|
||||
return ['refund_only'];
|
||||
}
|
||||
return array_values($types);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载订单商品级可退数量
|
||||
* 查询订单的全部 order_items,聚合已申请售后单的 refund_num,计算每个 cart_id 的剩余可退数量
|
||||
@ -236,6 +251,7 @@ class AfterSaleServices extends BaseService
|
||||
// 组装返回
|
||||
$deductionIntegral = $integralUnitPrice > 0 ? [] : $this->allocateDeductionIntegral($center, $orderItems);
|
||||
$paidAmounts = $this->allocatePaidAmounts($center, $orderItems);
|
||||
$isActivityOrder = !in_array((string)($center['activity_type'] ?? ''), ['', 'normal'], true);
|
||||
$result = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$cartId = (int)($item['cart_id'] ?? 0);
|
||||
@ -264,6 +280,10 @@ class AfterSaleServices extends BaseService
|
||||
'refunded_num' => $refundedNum,
|
||||
'refundable_num' => $refundableNum,
|
||||
'true_price' => (string)($item['true_price'] ?? $item['price'] ?? '0.00'),
|
||||
// 商品信息卡展示规格原价;活动订单展示活动价(true_price 为等级/会员优惠后单价)
|
||||
'original_price' => $isActivityOrder
|
||||
? (string)($item['true_price'] ?? $item['price'] ?? '0.00')
|
||||
: (string)($item['price'] ?? $item['attr_info']['price'] ?? $item['true_price'] ?? '0.00'),
|
||||
// 分摊后的整行实付现金(商品原价 - 优惠券/积分抵扣/促销等优惠),退款现金上限按此累计
|
||||
'paid_amount_total' => $paidAmounts[$cartId] ?? '0.00',
|
||||
// 积分参考价:非积分订单为 0,仅积分活动订单有值
|
||||
@ -794,6 +814,57 @@ class AfterSaleServices extends BaseService
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为售后商品快照补充原价
|
||||
* 旧售后单快照只保存 true_price(等级/会员优惠后单价),详情商品卡需展示规格原价;
|
||||
* 新快照已带 original_price,只有缺失时才查订单明细回填。
|
||||
* @param array $snapshot 售后商品快照
|
||||
* @param string $orderNo 订单号
|
||||
* @return array
|
||||
*/
|
||||
protected function enrichSnapshotOriginalPrice(array $snapshot, string $orderNo): array
|
||||
{
|
||||
$items = $snapshot['items'] ?? [];
|
||||
if (!is_array($items) || empty($items)) {
|
||||
return $snapshot;
|
||||
}
|
||||
$needEnrich = false;
|
||||
foreach ($items as $item) {
|
||||
if (!isset($item['original_price']) || $item['original_price'] === '') {
|
||||
$needEnrich = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$needEnrich || $orderNo === '') {
|
||||
return $snapshot;
|
||||
}
|
||||
$center = $this->centerServices->getOne(['order_no' => $orderNo]);
|
||||
if (empty($center)) {
|
||||
return $snapshot;
|
||||
}
|
||||
if (is_object($center)) {
|
||||
$center = $center->toArray();
|
||||
}
|
||||
$orderItems = $this->centerServices->loadOrderItems($center);
|
||||
$itemMap = [];
|
||||
foreach ($orderItems as $orderItem) {
|
||||
$itemMap[(string)($orderItem['cart_id'] ?? '')] = $orderItem;
|
||||
}
|
||||
$isActivityOrder = !in_array((string)($center['activity_type'] ?? ''), ['', 'normal'], true);
|
||||
foreach ($items as &$item) {
|
||||
if (isset($item['original_price']) && $item['original_price'] !== '') {
|
||||
continue;
|
||||
}
|
||||
$orderItem = $itemMap[(string)($item['cart_id'] ?? '')] ?? [];
|
||||
$item['original_price'] = $isActivityOrder
|
||||
? (string)($orderItem['true_price'] ?? $item['true_price'] ?? '0.00')
|
||||
: (string)($orderItem['price'] ?? $orderItem['attr_info']['price'] ?? $item['true_price'] ?? '0.00');
|
||||
}
|
||||
unset($item);
|
||||
$snapshot['items'] = $items;
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取售后详情
|
||||
* 含售后单信息、时间线、用户可操作按钮,前端详情模式据此渲染
|
||||
@ -813,6 +884,16 @@ class AfterSaleServices extends BaseService
|
||||
// 构造时间线(按售后类型不同节点不同,终态特殊处理)
|
||||
$timeline = $this->buildTimeline($afterSale);
|
||||
|
||||
// 旧售后快照只有 true_price(优惠后),从订单商品明细回填原价供商品卡展示
|
||||
$productSnapshot = $afterSale['product_snapshot'] ?? [];
|
||||
if (is_string($productSnapshot)) {
|
||||
$productSnapshot = json_decode($productSnapshot, true) ?: [];
|
||||
}
|
||||
$productSnapshot = $this->enrichSnapshotOriginalPrice(
|
||||
is_array($productSnapshot) ? $productSnapshot : [],
|
||||
(string)($afterSale['order_no'] ?? '')
|
||||
);
|
||||
|
||||
// 获取用户可操作按钮(由产品类型 Handler 提供)
|
||||
$productType = $afterSale['product_type'] ?? 'normal';
|
||||
$handler = AfterSaleRuleRegistry::getProvider('product_type', $productType);
|
||||
@ -837,7 +918,7 @@ class AfterSaleServices extends BaseService
|
||||
'remark' => $afterSale['remark'] ?? '',
|
||||
'reject_reason' => $afterSale['reject_reason'] ?? '',
|
||||
'evidence_images' => $afterSale['evidence_images'] ?? [],
|
||||
'product_snapshot' => $afterSale['product_snapshot'] ?? [],
|
||||
'product_snapshot' => $productSnapshot,
|
||||
// v8.1.1 商品级售后:返回 cart_ids 和 refund_num,供前端展示售后范围
|
||||
'cart_ids' => $afterSale['cart_ids'] ?? [],
|
||||
'refund_num' => (int)($afterSale['refund_num'] ?? 0),
|
||||
@ -1038,7 +1119,8 @@ class AfterSaleServices extends BaseService
|
||||
if (!$rule['allow']) {
|
||||
throw new \RuntimeException($rule['reject_reason'] ?: '当前订单不支持售后');
|
||||
}
|
||||
if (!empty($rule['available_types']) && !in_array($type, $rule['available_types'])) {
|
||||
$availableTypes = $this->filterTypesByDelivery($rule['available_types'] ?? [], $center);
|
||||
if (!empty($availableTypes) && !in_array($type, $availableTypes, true)) {
|
||||
throw new \RuntimeException('当前订单不支持该售后类型');
|
||||
}
|
||||
|
||||
|
||||
@ -741,14 +741,15 @@ class OrderCenterUserServices extends OrderCenterServices
|
||||
|
||||
// 构建 _status,兼容旧版格式
|
||||
$data['_status'] = $this->buildRefundOrderStatus($detail);
|
||||
// 判断是否支持退货:由各产品类型 Handler 独立声明(supportsReturn),中台不硬编码产品类型
|
||||
// 判断是否支持退货:未发货订单只能仅退款;已发货再由产品类型 Handler 声明是否支持退货
|
||||
try {
|
||||
$productTypeRegistry = app()->make(ProductTypeRegistry::class);
|
||||
$productType = (string)($detail['product_type'] ?? '');
|
||||
if ($productType === '') throw new ApiException('订单产品类型缺失');
|
||||
$data['_status']['_is_back'] = $productTypeRegistry->get($productType)->supportsReturn();
|
||||
$isShipped = (int)($detail['delivery_time'] ?? 0) > 0;
|
||||
$data['_status']['_is_back'] = $isShipped && $productTypeRegistry->get($productType)->supportsReturn();
|
||||
} catch (\Throwable $e) {
|
||||
// Registry 异常时兜底支持退货,保证退款页可正常渲染
|
||||
// Registry 异常时降级为仅退款,避免未发货订单出现退货入口
|
||||
$data['_status']['_is_back'] = false;
|
||||
}
|
||||
$data['cartInfo'] = $data['cartInfo'] ?? $cartInfo;
|
||||
|
||||
@ -147,7 +147,7 @@ abstract class BaseAfterSaleHandler implements AfterSaleHandlerInterface
|
||||
if (in_array($type, ['refund_only', 'return_refund'])) {
|
||||
$fields[] = [
|
||||
'key' => 'refund_price',
|
||||
'label' => '申请退款金额',
|
||||
'label' => '退款金额',
|
||||
'type' => 'price',
|
||||
'required' => true,
|
||||
'placeholder' => '请输入退款金额',
|
||||
|
||||
@ -28,13 +28,9 @@
|
||||
<!-- 订单信息卡 -->
|
||||
<view class="order-card">
|
||||
<view class="order-row">
|
||||
<text class="label">{{ $t('订单号') }}</text>
|
||||
<text class="label">{{ $t('订单编号') }}</text>
|
||||
<text class="value">{{ orderNo }}</text>
|
||||
</view>
|
||||
<view class="order-row">
|
||||
<text class="label">{{ $t('订单金额') }}</text>
|
||||
<text class="value price">¥{{ orderDetail.pay_price || '0.00' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品列表卡(商品级售后选择功能暂时关闭,默认全量售后) -->
|
||||
@ -58,7 +54,7 @@
|
||||
<view class="goods-name">{{ item.store_name }}</view>
|
||||
<view class="goods-sku" v-if="item.sku">{{ $t('规格') }}:{{ item.sku }}</view>
|
||||
<view class="goods-bottom">
|
||||
<text class="goods-price">¥{{ item.true_price }}</text>
|
||||
<text class="goods-price">¥{{ getProductPaidAmount(item) }}</text>
|
||||
<view class="goods-right">
|
||||
<!-- 选中时显示数量步进器,未选中显示购买数量 -->
|
||||
<view class="num-stepper" v-if="itemSelectionEnabled && selectedCartIds.includes(item.cart_id)" @tap.stop>
|
||||
@ -90,6 +86,26 @@
|
||||
</view>
|
||||
|
||||
<view class="list">
|
||||
<!-- 退款金额/退还积分置顶,位于售后类型上方 -->
|
||||
<view class="item acea-row row-between-wrapper" v-if="refundPriceField">
|
||||
<view>{{ $t('退款金额') }}</view>
|
||||
<input
|
||||
class="num price-input"
|
||||
type="digit"
|
||||
v-model="formValues.refund_price"
|
||||
:placeholder="$t(refundPriceField.placeholder || '')"
|
||||
/>
|
||||
</view>
|
||||
<view class="item acea-row row-between-wrapper" v-if="refundIntegralField">
|
||||
<view>{{ $t('退还积分') }}</view>
|
||||
<input
|
||||
class="num"
|
||||
type="number"
|
||||
v-model="formValues.refund_integral"
|
||||
:placeholder="$t(refundIntegralField.placeholder || '')"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 售后类型选择 -->
|
||||
<view class="item acea-row row-between-wrapper">
|
||||
<view>{{ $t('售后类型') }}</view>
|
||||
@ -102,7 +118,7 @@
|
||||
</view>
|
||||
|
||||
<!-- 动态表单字段(由后端 form_config 下发) -->
|
||||
<block v-for="field in formFields" :key="field.key">
|
||||
<block v-for="field in otherFields" :key="field.key">
|
||||
<!-- 退款金额 -->
|
||||
<view class="item acea-row row-between-wrapper" v-if="field.type === 'price'">
|
||||
<view>{{ $t(field.label) }}</view>
|
||||
@ -205,7 +221,7 @@
|
||||
<text class="value">{{ detail.after_sale_no }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="label">{{ $t('订单号') }}</text>
|
||||
<text class="label">{{ $t('订单编号') }}</text>
|
||||
<text class="value" @tap="copyText(detail.order_no)">{{ detail.order_no }}</text>
|
||||
</view>
|
||||
<view class="info-row" v-if="detail.refund_price > 0">
|
||||
@ -254,7 +270,7 @@
|
||||
<view class="snapshot-name">{{ item.store_name }}</view>
|
||||
<view class="snapshot-sku" v-if="item.sku">{{ $t('规格') }}:{{ item.sku }}</view>
|
||||
<view class="snapshot-bottom">
|
||||
<text class="snapshot-price">¥{{ item.true_price }}</text>
|
||||
<text class="snapshot-price">¥{{ item.original_price || item.true_price }}</text>
|
||||
<text class="snapshot-num">x{{ item.cart_num }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@ -468,6 +484,12 @@ const pendingAction = ref(null);
|
||||
// ===== computed =====
|
||||
/** 售后类型选项文案列表(优先后端 type_text,前端映射 fallback) */
|
||||
const typeOptions = computed(() => availableTypes.value.map(type => i18n.global.t(AFTER_SALE_TYPE_TEXT[type] || type)));
|
||||
/** 退款金额字段(置顶展示) */
|
||||
const refundPriceField = computed(() => formFields.value.find(field => field.key === 'refund_price') || null);
|
||||
/** 退还积分字段(紧跟退款金额展示) */
|
||||
const refundIntegralField = computed(() => formFields.value.find(field => field.key === 'refund_integral') || null);
|
||||
/** 其余动态字段(退款金额/积分已单独置顶,避免重复渲染) */
|
||||
const otherFields = computed(() => formFields.value.filter(field => !['refund_price', 'refund_integral'].includes(field.key)));
|
||||
/** 状态样式类(用于详情页顶部横幅配色) */
|
||||
const statusClass = computed(() => {
|
||||
const status = detail.value.status || '';
|
||||
@ -768,6 +790,20 @@ function uploadImage(fieldKey, max) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品实际付款金额(单价):商品分摊实付 + 按实付占比分摊的运费
|
||||
* @param {Object} item 可售后商品
|
||||
* @return {string} 两位小数金额
|
||||
*/
|
||||
function getProductPaidAmount(item) {
|
||||
const cartNum = Number(item.cart_num || 1) || 1;
|
||||
const rowTotal = rowPaidTotal(item);
|
||||
const totalPaid = refundableItems.value.reduce((sum, row) => sum + rowPaidTotal(row), 0);
|
||||
const payPostage = Number(orderDetail.value.pay_postage || 0);
|
||||
const postageShare = totalPaid > 0 && rowTotal > 0 ? payPostage * rowTotal / totalPaid : 0;
|
||||
return ((rowTotal + postageShare) / cartNum).toFixed(2);
|
||||
}
|
||||
|
||||
/** 构建售后商品快照(v8.1.1 按 cart_id 选择范围) */
|
||||
function buildSnapshot() {
|
||||
const items = selectedItems.value.map(item => ({
|
||||
@ -778,6 +814,7 @@ function buildSnapshot() {
|
||||
sku: item.sku,
|
||||
cart_num: refundNumMap.value[item.cart_id] || 0, // 售后数量(部分数量支持)
|
||||
true_price: item.true_price,
|
||||
original_price: item.original_price || item.true_price,
|
||||
}));
|
||||
return {
|
||||
product_type: orderDetail.value.product_type || 'normal',
|
||||
@ -1050,7 +1087,9 @@ defineExpose({
|
||||
padding: 8rpx 0;
|
||||
|
||||
.label {
|
||||
color: #999;
|
||||
color: #333;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
@ -1065,6 +1104,7 @@ defineExpose({
|
||||
color: #e93323;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user