小程序端核销权益券金额输入限制
CI / verify (pull_request) Has been cancelled

现场取货功能
This commit is contained in:
2026-07-23 00:03:21 +08:00
parent cd206d6baa
commit 0418370c31
5 changed files with 213 additions and 19 deletions
@@ -47,13 +47,25 @@ const STATUS_LABELS: Record<string, string> = {
PENDING_SHIP: '待发货', PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '出库中', OUT_WAREHOUSE: '出库中',
SHIPPING: '配送中', SHIPPING: '配送中',
SHIPPED: '配送中',
PENDING_RECEIVE: '待签收', PENDING_RECEIVE: '待签收',
DELIVERED: '待签收',
COMPLETED: '已完成', COMPLETED: '已完成',
CANCELLED: '已取消', CANCELLED: '已取消',
REFUNDING: '退款中', REFUNDING: '退款中',
REFUNDED: '已退款', REFUNDED: '已退款',
}; };
/** 已付款未完成:可选现场取货并确认收货 */
const ON_SITE_PICKUP_STATUSES = new Set([
'PENDING_SHIP',
'OUT_WAREHOUSE',
'SHIPPING',
'SHIPPED',
'PENDING_RECEIVE',
'DELIVERED',
]);
function fullReceiverAddress(order: OrderDetail) { function fullReceiverAddress(order: OrderDetail) {
const detail = (order.receiverAddress || '').trim(); const detail = (order.receiverAddress || '').trim();
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict] const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
@@ -68,6 +80,8 @@ export default function OrderDetailPage() {
const router = useRouter(); const router = useRouter();
const orderId = router.params.id ?? ''; const orderId = router.params.id ?? '';
const [order, setOrder] = useState<OrderDetail | null>(null); const [order, setOrder] = useState<OrderDetail | null>(null);
const [onSitePickup, setOnSitePickup] = useState(false);
const [confirming, setConfirming] = useState(false);
useEffect(() => { useEffect(() => {
if (!orderId) return; if (!orderId) return;
@@ -76,7 +90,16 @@ export default function OrderDetailPage() {
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')); .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]); }, [orderId]);
const canPay = !!order && order.status === 'PENDING_PAY' && !order.originOrderId; const isReship = !!order?.originOrderId;
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
const canOnSitePickup =
!!order && !isReship && ON_SITE_PICKUP_STATUSES.has(order.status || '');
const canConfirmReceive =
!!order &&
!isReship &&
(['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '') ||
(onSitePickup && canOnSitePickup));
const item = order?.items?.[0]; const item = order?.items?.[0];
const productName = item?.productName || order?.productName || '杜康商品'; const productName = item?.productName || order?.productName || '杜康商品';
const quantity = item?.quantity ?? order?.quantity ?? order?.qty ?? 1; const quantity = item?.quantity ?? order?.quantity ?? order?.qty ?? 1;
@@ -111,10 +134,41 @@ export default function OrderDetailPage() {
Taro.navigateTo({ url: '/pages/customer-service/index' }); Taro.navigateTo({ url: '/pages/customer-service/index' });
} }
async function confirmReceive() {
if (!order || !canConfirmReceive || confirming) return;
const useOnSite =
onSitePickup || !['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
const { confirm } = await Taro.showModal({
title: useOnSite ? '确认现场取货?' : '确认收货?',
content: useOnSite
? '请确认您已在现场拿到商品。确认后订单将完成,好客权益即时可用,无法再安排配送。若尚未取到酒,请勿确认。'
: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
confirmText: '确认收货',
cancelText: '再想想',
});
if (!confirm) return;
setConfirming(true);
try {
const updated = await request<OrderDetail>(`/trade/orders/${order.id}/confirm-receive`, {
method: 'POST',
data: { onSitePickup: useOnSite },
});
setOrder(updated);
setOnSitePickup(false);
toast(useOnSite ? '现场取货已确认,订单完成' : '已确认收货');
} catch (e) {
toast(e instanceof Error ? e.message : '确认收货失败');
} finally {
setConfirming(false);
}
}
const pageClass = [ const pageClass = [
'order-detail-page', 'order-detail-page',
order ? 'order-detail-page--with-actions' : '', order ? 'order-detail-page--with-actions' : '',
canPay ? 'order-detail-page--with-pay' : '', canPay || canConfirmReceive ? 'order-detail-page--with-pay' : '',
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
@@ -171,6 +225,27 @@ export default function OrderDetailPage() {
<Text className="u-muted"></Text> <Text className="u-muted"></Text>
)} )}
</View> </View>
{canOnSitePickup ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<View
className="order-pickup-option"
onClick={() => setOnSitePickup((v) => !v)}
>
<View
className={`order-pickup-check${onSitePickup ? ' order-pickup-check--on' : ''}`}
>
{onSitePickup ? <Text className="order-pickup-check-mark"></Text> : null}
</View>
<View className="order-pickup-copy">
<Text className="order-pickup-title"></Text>
<Text className="order-pickup-desc">
</Text>
</View>
</View>
</View>
) : null}
<View className="order-card"> <View className="order-card">
<Text className="order-card-title"></Text> <Text className="order-card-title"></Text>
<View className="order-row"> <View className="order-row">
@@ -189,7 +264,11 @@ export default function OrderDetailPage() {
</View> </View>
{order ? ( {order ? (
<View className={`order-detail-actionbar${canPay ? ' order-detail-actionbar--with-pay' : ''}`}> <View
className={`order-detail-actionbar${
canPay || canConfirmReceive ? ' order-detail-actionbar--with-pay' : ''
}`}
>
{isWeapp ? ( {isWeapp ? (
<ContactCsButton <ContactCsButton
className="order-detail-cs-btn" className="order-detail-cs-btn"
@@ -219,6 +298,14 @@ export default function OrderDetailPage() {
</View> </View>
</> </>
) : null} ) : null}
{canConfirmReceive ? (
<View
className={`order-confirm-submit${confirming ? ' order-confirm-submit--disabled' : ''}`}
onClick={confirming ? undefined : () => void confirmReceive()}
>
{confirming ? '提交中…' : onSitePickup ? '确认现场取货' : '确认收货'}
</View>
) : null}
</View> </View>
) : null} ) : null}
</PageShell> </PageShell>
+27 -10
View File
@@ -17,17 +17,29 @@ function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
/** 核销金额输入:最多两位小数禁止非法字符 */ /** 核销金额输入:最多两位小数;去掉前导 0禁止非法字符 */
function sanitizeRedeemAmountInput(raw: string): string { function sanitizeRedeemAmountInput(raw: string): string {
let next = raw.replace(/[^\d.]/g, ''); let next = String(raw ?? '').replace(/[^\d.]/g, '');
if (!next) return '';
const firstDot = next.indexOf('.'); const firstDot = next.indexOf('.');
if (firstDot >= 0) { if (firstDot >= 0) {
next = const intRaw = next.slice(0, firstDot).replace(/\D/g, '');
next.slice(0, firstDot + 1) + next.slice(firstDot + 1).replace(/\./g, ''); const decRaw = next
const [intPart, decPart = ''] = next.split('.'); .slice(firstDot + 1)
next = `${intPart}.${decPart.slice(0, 2)}`; .replace(/\D/g, '')
.replace(/\./g, '')
.slice(0, 2);
const intPart = intRaw.replace(/^0+(?=\d)/, '') || '0';
// 正在输入小数点或小数位时保留点
if (decRaw.length > 0 || next.endsWith('.')) {
return `${intPart}.${decRaw}`;
} }
if (next.startsWith('.')) next = `0${next}`; return intPart;
}
// 纯整数:忽略前导 0(保留单个 0)
next = next.replace(/^0+(?=\d)/, '');
return next; return next;
} }
@@ -37,7 +49,7 @@ export default function RedeemPage() {
const initialAmount = router.params.amount ?? ''; const initialAmount = router.params.amount ?? '';
const [balance, setBalance] = useState(0); const [balance, setBalance] = useState(0);
const [couponBalance, setCouponBalance] = useState<number | null>(null); const [couponBalance, setCouponBalance] = useState<number | null>(null);
const [amount, setAmount] = useState(initialAmount); const [amount, setAmount] = useState(() => sanitizeRedeemAmountInput(initialAmount));
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const redeemableMax = couponId ? (couponBalance ?? 0) : balance; const redeemableMax = couponId ? (couponBalance ?? 0) : balance;
@@ -67,7 +79,11 @@ export default function RedeemPage() {
function fillMaxAmount() { function fillMaxAmount() {
if (redeemableMax < MIN_REDEEM_AMOUNT) return; if (redeemableMax < MIN_REDEEM_AMOUNT) return;
setAmount(String(redeemableMax)); setAmount(sanitizeRedeemAmountInput(redeemableMax.toFixed(2)));
}
function onAmountChange(raw: string) {
setAmount(sanitizeRedeemAmountInput(raw));
} }
async function submit() { async function submit() {
@@ -119,7 +135,8 @@ export default function RedeemPage() {
placeholder="输入核销金额" placeholder="输入核销金额"
placeholderClass="redeem-input-placeholder" placeholderClass="redeem-input-placeholder"
value={amount} value={amount}
onInput={(e) => setAmount(sanitizeRedeemAmountInput(e.detail.value))} onInput={(e) => onAmountChange(e.detail.value)}
onBlur={(e) => onAmountChange(e.detail.value)}
style={{ textAlign: 'center' }} style={{ textAlign: 'center' }}
/> />
</View> </View>
+57
View File
@@ -68,6 +68,63 @@
flex-shrink: 0; flex-shrink: 0;
} }
.order-confirm-submit--disabled {
opacity: 0.55;
pointer-events: none;
}
.order-pickup-option {
display: flex;
align-items: flex-start;
gap: 12px;
}
.order-pickup-check {
width: 20px;
height: 20px;
margin-top: 2px;
flex-shrink: 0;
border: 1.5px solid var(--color-outline, #c8c4be);
border-radius: 4px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.order-pickup-check--on {
border-color: var(--color-primary, #8b1a1a);
background: var(--color-primary, #8b1a1a);
}
.order-pickup-check-mark {
color: #fff;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.order-pickup-copy {
flex: 1;
min-width: 0;
}
.order-pickup-title {
display: block;
font-size: 15px;
font-weight: 600;
color: var(--color-on-surface);
margin-bottom: 4px;
}
.order-pickup-desc {
display: block;
font-size: 12px;
line-height: 1.5;
color: var(--color-on-surface-variant, #78716c);
}
.order-card { .order-card {
background: var(--color-card); background: var(--color-card);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -64,8 +64,14 @@ export class TradeController {
} }
@Post(':id/confirm-receive') @Post(':id/confirm-receive')
confirmReceive(@CurrentUser() user: AuthUser, @Param('id') id: string) { confirmReceive(
return this.tradeService.confirmReceive(user.actorId, BigInt(id)); @CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body?: { onSitePickup?: boolean },
) {
return this.tradeService.confirmReceive(user.actorId, BigInt(id), {
onSitePickup: !!body?.onSitePickup,
});
} }
@Post(':id/refund-requests') @Post(':id/refund-requests')
@@ -467,13 +467,38 @@ export class TradeService {
return serializeBigInt(updated); return serializeBigInt(updated);
} }
async confirmReceive(userId: bigint, orderId: bigint) { async confirmReceive(
userId: bigint,
orderId: bigint,
opts?: { onSitePickup?: boolean },
) {
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } }); const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
if (!order) throw new NotFoundException('订单不存在'); if (!order) throw new NotFoundException('订单不存在');
if (order.status !== 'PENDING_RECEIVE') {
const onSitePickup = !!opts?.onSitePickup;
const onSiteEligible = [
'PENDING_SHIP',
'OUT_WAREHOUSE',
'SHIPPING',
'SHIPPED',
'PENDING_RECEIVE',
'DELIVERED',
];
if (onSitePickup) {
if (!onSiteEligible.includes(order.status)) {
throw new BadRequestException('当前状态不可现场取货');
}
} else if (!['PENDING_RECEIVE', 'DELIVERED'].includes(order.status)) {
throw new BadRequestException('当前状态不可确认收货'); throw new BadRequestException('当前状态不可确认收货');
} }
await this.applyStatusTransition(order.id, order.status, 'COMPLETED', 'USER');
await this.applyStatusTransition(
order.id,
order.status,
'COMPLETED',
onSitePickup ? 'USER_ON_SITE' : 'USER',
onSitePickup ? '用户现场取货确认收货' : undefined,
);
return this.getOrder(userId, orderId); return this.getOrder(userId, orderId);
} }
@@ -892,6 +917,7 @@ export class TradeService {
fromStatus: string, fromStatus: string,
targetStatus: string, targetStatus: string,
operator = 'MOCK', operator = 'MOCK',
remark?: string,
) { ) {
const order = await this.prisma.order.findUnique({ where: { id: orderId } }); const order = await this.prisma.order.findUnique({ where: { id: orderId } });
if (!order) return; if (!order) return;
@@ -913,7 +939,7 @@ export class TradeService {
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.order.update({ where: { id: orderId }, data: data as never }); await tx.order.update({ where: { id: orderId }, data: data as never });
if (Object.keys(deliveryData).length) { if (Object.keys(deliveryData).length) {
await tx.orderDelivery.update({ where: { orderId }, data: deliveryData as never }); await tx.orderDelivery.updateMany({ where: { orderId }, data: deliveryData as never });
} }
await tx.commonEvent.create({ await tx.commonEvent.create({
data: buildOrderStatusEvent({ data: buildOrderStatusEvent({
@@ -921,6 +947,7 @@ export class TradeService {
fromStatus: currentStatus, fromStatus: currentStatus,
toStatus: targetStatus, toStatus: targetStatus,
operator, operator,
remark,
}), }),
}); });
}); });