feat(order): complete 7.1 order full lifecycle state transitions, 15m/30m/48h auto-timeouts, expert reject/apply-complete, and partial refund arbitration
这个提交包含在:
@@ -62,7 +62,7 @@
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="仲裁决议" :width="230" fixed="right">
|
||||
<a-table-column title="仲裁决议" :width="280" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space v-if="record.status === '待平台仲裁'">
|
||||
<a-button
|
||||
@@ -73,6 +73,14 @@
|
||||
>
|
||||
全额退款
|
||||
</a-button>
|
||||
<a-button
|
||||
type="outline"
|
||||
status="danger"
|
||||
size="small"
|
||||
@click="openPartialModal(record)"
|
||||
>
|
||||
部分退款
|
||||
</a-button>
|
||||
<a-button
|
||||
type="outline"
|
||||
status="warning"
|
||||
@@ -95,6 +103,28 @@
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:visible="partialModalVisible"
|
||||
title="客服判定部分退款"
|
||||
@ok="submitPartialRefund"
|
||||
@cancel="partialModalVisible = false"
|
||||
>
|
||||
<a-form :model="partialForm" layout="vertical">
|
||||
<a-form-item label="关联订单号">
|
||||
<a-input :model-value="currentArbRecord?.orderNo || ''" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="部分退款金额 (元) — 剩余款项将自动解冻划入大神账户">
|
||||
<a-input-number
|
||||
v-model="partialForm.refundAmount"
|
||||
:min="1"
|
||||
:max="Number(currentArbRecord?.orderAmount || 9999)"
|
||||
:precision="2"
|
||||
placeholder="请输入退还给买家的部分金额"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -105,6 +135,11 @@
|
||||
|
||||
const loading = ref(false);
|
||||
const rawList = ref<any[]>([]);
|
||||
const partialModalVisible = ref(false);
|
||||
const currentArbRecord = ref<any>(null);
|
||||
const partialForm = ref({
|
||||
refundAmount: 20,
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
@@ -120,13 +155,32 @@
|
||||
}
|
||||
};
|
||||
|
||||
const openPartialModal = (record: any) => {
|
||||
currentArbRecord.value = record;
|
||||
const total = Number(record.orderAmount || 60);
|
||||
partialForm.value.refundAmount = Number((total * 0.5).toFixed(2)) || 20;
|
||||
partialModalVisible.value = true;
|
||||
};
|
||||
|
||||
const submitPartialRefund = async () => {
|
||||
if (!currentArbRecord.value) return;
|
||||
await handleArbitrate(
|
||||
currentArbRecord.value.id,
|
||||
'refund_partial',
|
||||
Number(partialForm.value.refundAmount || 20)
|
||||
);
|
||||
partialModalVisible.value = false;
|
||||
};
|
||||
|
||||
const handleArbitrate = async (
|
||||
id: string,
|
||||
decision: 'refund_full' | 'reject' | 'deduct_credit'
|
||||
decision: 'refund_full' | 'refund_partial' | 'reject' | 'deduct_credit',
|
||||
refundAmount?: number
|
||||
) => {
|
||||
try {
|
||||
const res: any = await axios.post(`/api/arbitrations/${id}/handle`, {
|
||||
decision,
|
||||
refundAmount,
|
||||
});
|
||||
Message.success(res.data?.message || '仲裁执行成功');
|
||||
fetchData();
|
||||
|
||||
@@ -39,11 +39,16 @@
|
||||
<a-select v-model="statusFilter" placeholder="订单流转状态" allow-clear>
|
||||
<a-option value="ALL">全部状态</a-option>
|
||||
<a-option value="待支付">待支付</a-option>
|
||||
<a-option value="待接单">待接单</a-option>
|
||||
<a-option value="已接单">已接单</a-option>
|
||||
<a-option value="已到场核销">已到场核销</a-option>
|
||||
<a-option value="履约中">履约中</a-option>
|
||||
<a-option value="待确认">待确认</a-option>
|
||||
<a-option value="已完成">已完成</a-option>
|
||||
<a-option value="争议仲裁中">争议仲裁中</a-option>
|
||||
<a-option value="已全额退款">已全额退款</a-option>
|
||||
<a-option value="已部分退款">已部分退款</a-option>
|
||||
<a-option value="已取消">已取消</a-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
@@ -242,9 +242,22 @@ export function formatOrderData(raw) {
|
||||
|
||||
let statusKey = raw.status || 'IN_SERVICE';
|
||||
if (statusKey === '待支付' || statusKey === '待付款') statusKey = 'PENDING_PAY';
|
||||
else if (statusKey === '已到场核销' || statusKey === '履约中' || statusKey === '服务中') statusKey = 'IN_SERVICE';
|
||||
else if (
|
||||
statusKey === '待接单' ||
|
||||
statusKey === '已接单' ||
|
||||
statusKey === '已到场核销' ||
|
||||
statusKey === '履约中' ||
|
||||
statusKey === '服务中' ||
|
||||
statusKey === '待确认'
|
||||
) statusKey = 'IN_SERVICE';
|
||||
else if (statusKey === '已完成') statusKey = 'COMPLETED';
|
||||
else if (statusKey === '争议仲裁中' || statusKey === '退款中' || statusKey === '已退款' || statusKey === '已全额退款') statusKey = 'REFUNDING';
|
||||
else if (
|
||||
statusKey === '争议仲裁中' ||
|
||||
statusKey === '退款中' ||
|
||||
statusKey === '已退款' ||
|
||||
statusKey === '已全额退款' ||
|
||||
statusKey === '已部分退款'
|
||||
) statusKey = 'REFUNDING';
|
||||
else if (statusKey === '已取消') statusKey = 'CANCELLED';
|
||||
|
||||
const companionAvatar = raw.expertAvatar || raw.expert_avatar || raw.avatar ||
|
||||
@@ -265,6 +278,7 @@ export function formatOrderData(raw) {
|
||||
serviceTitle: raw.serviceTitle || raw.service_title || `${game} · 专业陪伴服务`,
|
||||
quantity: Number(raw.durationHours || raw.duration_hours || raw.quantity || 2),
|
||||
totalAmount: Number(raw.amount || raw.totalAmount || 0),
|
||||
refundAmount: Number(raw.refundAmount || raw.refund_amount || 0),
|
||||
status: statusKey,
|
||||
rawStatus: raw.status || '履约中',
|
||||
meetVenue: raw.location || raw.meetVenue || '北京市公开商圈',
|
||||
@@ -332,7 +346,33 @@ export async function payOrder(orderId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认验收订单
|
||||
* 大神拒单自动退款 (待接单 -> 已退款)
|
||||
*/
|
||||
export async function rejectOrder(orderId, payload = {}) {
|
||||
try {
|
||||
const res = await http.post(`/orders/${orderId}/reject`, payload);
|
||||
return formatOrderData(res);
|
||||
} catch (err) {
|
||||
console.warn('[API] 拒单接口网络异常:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 大神点击申请完成服务 (服务中 -> 待确认)
|
||||
*/
|
||||
export async function applyCompleteOrder(orderId, payload = {}) {
|
||||
try {
|
||||
const res = await http.post(`/orders/${orderId}/apply-complete`, payload);
|
||||
return formatOrderData(res);
|
||||
} catch (err) {
|
||||
console.warn('[API] 申请完成服务网络异常:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 买家确认验收订单 (待确认 -> 已完成)
|
||||
*/
|
||||
export async function completeOrder(orderId) {
|
||||
try {
|
||||
|
||||
@@ -300,7 +300,10 @@
|
||||
<text class="pool-desc">{{ order.desc }}</text>
|
||||
<view class="pool-foot">
|
||||
<text class="pool-time">倒计时: {{ order.countdown }}s</text>
|
||||
<button class="btn-grab btn-active" @click="grabOrder(order)">立即抢单</button>
|
||||
<view class="pool-btn-group">
|
||||
<button class="btn-reject btn-active" @click="handleRejectOrder(order)">拒单</button>
|
||||
<button class="btn-grab btn-active" @click="grabOrder(order)">立即抢单</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -404,6 +407,8 @@ import {
|
||||
getOrdersPool,
|
||||
getExpertStats,
|
||||
claimOrder,
|
||||
rejectOrder,
|
||||
applyCompleteOrder,
|
||||
completeOrder as completeOrderApi,
|
||||
updateCompanionProfile,
|
||||
getExpertRevenueTrends,
|
||||
@@ -763,13 +768,37 @@ const grabOrder = async (order) => {
|
||||
});
|
||||
} catch (_) {}
|
||||
uni.showToast({
|
||||
title: `抢单成功!已接单 ¥${order.price}`,
|
||||
title: '抢单成功已接单',
|
||||
icon: 'success'
|
||||
});
|
||||
poolOrders.value = poolOrders.value.filter(o => o.id !== order.id);
|
||||
loadWorkspaceOrders();
|
||||
};
|
||||
|
||||
const handleRejectOrder = (order) => {
|
||||
uni.showModal({
|
||||
title: '确认拒单并退款',
|
||||
content: '拒单后系统将自动将该订单款项原路全额退还给买家,确定拒单吗?',
|
||||
confirmColor: '#EF4444',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await rejectOrder(order.id, {
|
||||
expertId: expert.value.id || 101,
|
||||
reason: '大神当前档期冲突拒单'
|
||||
});
|
||||
} catch (_) {}
|
||||
poolOrders.value = poolOrders.value.filter(o => o.id !== order.id);
|
||||
uni.showToast({
|
||||
title: '已拒单并自动退款',
|
||||
icon: 'none'
|
||||
});
|
||||
loadWorkspaceOrders();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const chatWithBuyer = () => {
|
||||
uni.showActionSheet({
|
||||
@@ -798,15 +827,19 @@ const chatWithBuyer = () => {
|
||||
const completeOrder = (id) => {
|
||||
uni.showModal({
|
||||
title: '确认提交服务验收',
|
||||
content: '服务已完成?提交后将完成服务结算并自动解冻订单资金打入您的佣金钱包。',
|
||||
content: '服务已完成?提交后订单将进入【待确认】状态,买家确认或48小时后自动解冻资金结算至您的佣金钱包。',
|
||||
confirmColor: '#10B981',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await completeOrderApi(id);
|
||||
} catch (_) {}
|
||||
await applyCompleteOrder(id, { expertId: expert.value.id || 101 });
|
||||
} catch (_) {
|
||||
try {
|
||||
await completeOrderApi(id);
|
||||
} catch (_) {}
|
||||
}
|
||||
activeOrders.value = activeOrders.value.filter(o => o.id !== id);
|
||||
uni.showToast({ title: '已验收结算并入账!', icon: 'success' });
|
||||
uni.showToast({ title: '已提交待买家确认', icon: 'success' });
|
||||
loadWorkspaceOrders();
|
||||
}
|
||||
}
|
||||
@@ -1698,6 +1731,26 @@ const switchBackToPlayer = () => {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pool-btn-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
color: #F87171;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 28rpx;
|
||||
height: 56rpx;
|
||||
line-height: 56rpx;
|
||||
padding: 0 22rpx;
|
||||
margin: 0;
|
||||
&::after { display: none; }
|
||||
}
|
||||
|
||||
.btn-grab {
|
||||
background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
|
||||
color: #FFF;
|
||||
@@ -1709,6 +1762,7 @@ const switchBackToPlayer = () => {
|
||||
padding: 0 28rpx;
|
||||
margin: 0;
|
||||
box-shadow: 0 4rpx 16rpx rgba(99, 102, 241, 0.4);
|
||||
&::after { display: none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,8 +466,11 @@ const goToExplore = () => {
|
||||
};
|
||||
|
||||
const getStatusText = (status, order) => {
|
||||
if (order && order.rawStatus === '已退款') return '已全额退款';
|
||||
if (order && (order.rawStatus === '已退款' || order.rawStatus === '已全额退款')) return '已全额退款';
|
||||
if (order && order.rawStatus === '已部分退款') return '已部分退款';
|
||||
if (order && order.rawStatus === '争议仲裁中') return '争议仲裁中';
|
||||
if (order && order.rawStatus === '待接单') return '待接单';
|
||||
if (order && order.rawStatus === '待确认') return '待确认验收';
|
||||
const map = {
|
||||
PENDING_PAY: '待付款',
|
||||
IN_SERVICE: '服务中',
|
||||
|
||||
@@ -38,13 +38,16 @@ export interface CompanionOrder {
|
||||
gameDetails?: GameDetails;
|
||||
offlineDetails?: OfflineDetails;
|
||||
offlineVerifyCode?: string;
|
||||
paidAt?: Date;
|
||||
checkedInAt?: Date;
|
||||
finishAppliedAt?: Date;
|
||||
completedAt?: Date;
|
||||
disputeReason?: string;
|
||||
disputedAt?: Date;
|
||||
arbitratedAt?: Date;
|
||||
arbitratedBy?: number;
|
||||
arbitrationDecision?: 'REFUND' | 'RELEASE';
|
||||
arbitrationDecision?: 'REFUND' | 'PARTIAL_REFUND' | 'RELEASE';
|
||||
refundAmountCents?: number;
|
||||
arbitrationReason?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -93,6 +96,7 @@ export class CompanionOrderService {
|
||||
throw new Error(`订单状态 [${order.status}] 不允许支付`);
|
||||
}
|
||||
|
||||
order.paidAt = new Date();
|
||||
order.status = OrderStatus.PENDING_ACCEPT;
|
||||
return order;
|
||||
}
|
||||
@@ -148,6 +152,7 @@ export class CompanionOrderService {
|
||||
throw new Error(`订单当前状态 [${order.status}] 不可拒单`);
|
||||
}
|
||||
|
||||
order.refundAmountCents = order.totalAmountCents;
|
||||
order.status = OrderStatus.REFUNDED;
|
||||
return order;
|
||||
}
|
||||
@@ -188,6 +193,7 @@ export class CompanionOrderService {
|
||||
throw new Error(`订单当前状态 [${order.status}] 无法申请完工结算`);
|
||||
}
|
||||
|
||||
order.finishAppliedAt = new Date();
|
||||
order.status = OrderStatus.PENDING_CONFIRM;
|
||||
return order;
|
||||
}
|
||||
@@ -226,7 +232,8 @@ export class CompanionOrderService {
|
||||
arbitrate(params: {
|
||||
orderId: string;
|
||||
operatorAdminId: number;
|
||||
decision: 'REFUND' | 'RELEASE';
|
||||
decision: 'REFUND' | 'PARTIAL_REFUND' | 'RELEASE';
|
||||
refundAmountCents?: number;
|
||||
reason?: string;
|
||||
roleManager?: AuthRoleManager;
|
||||
}): CompanionOrder {
|
||||
@@ -250,20 +257,73 @@ export class CompanionOrderService {
|
||||
throw new Error(`订单当前状态 [${order.status}] 非争议仲裁中,不可执行仲裁`);
|
||||
}
|
||||
|
||||
if (params.decision === 'PARTIAL_REFUND') {
|
||||
const partial = Number(params.refundAmountCents || 0);
|
||||
if (partial <= 0 || partial > order.totalAmountCents) {
|
||||
throw new Error('部分退款金额必须大于0且不超过订单总金额');
|
||||
}
|
||||
order.refundAmountCents = partial;
|
||||
} else if (params.decision === 'REFUND') {
|
||||
order.refundAmountCents = order.totalAmountCents;
|
||||
}
|
||||
|
||||
order.arbitratedAt = new Date();
|
||||
order.arbitratedBy = params.operatorAdminId;
|
||||
order.arbitrationDecision = params.decision;
|
||||
order.arbitrationReason = params.reason;
|
||||
|
||||
if (params.decision === 'REFUND') {
|
||||
if (params.decision === 'REFUND' || params.decision === 'PARTIAL_REFUND') {
|
||||
order.status = OrderStatus.REFUNDED;
|
||||
} else {
|
||||
order.completedAt = new Date();
|
||||
order.status = OrderStatus.COMPLETED;
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描并执行订单生命周期超时流转:
|
||||
* 1. 待付款超过 15 分钟未支付 -> 已取消 (CANCELLED)
|
||||
* 2. 待接单超过 30 分钟未接单 -> 已退款 (REFUNDED)
|
||||
* 3. 待确认超过 48 小时未确认 -> 已完成 (COMPLETED)
|
||||
*/
|
||||
processOrderTimeouts(now: Date = new Date()): {
|
||||
cancelledIds: string[];
|
||||
refundedIds: string[];
|
||||
autoCompletedIds: string[];
|
||||
} {
|
||||
const nowMs = now.getTime();
|
||||
const cancelledIds: string[] = [];
|
||||
const refundedIds: string[] = [];
|
||||
const autoCompletedIds: string[] = [];
|
||||
|
||||
for (const order of this.orders.values()) {
|
||||
if (order.status === OrderStatus.PENDING_PAYMENT) {
|
||||
if (nowMs - order.createdAt.getTime() >= 15 * 60 * 1000) {
|
||||
order.status = OrderStatus.CANCELLED;
|
||||
cancelledIds.push(order.id);
|
||||
}
|
||||
} else if (order.status === OrderStatus.PENDING_ACCEPT) {
|
||||
const baseTime = (order.paidAt || order.createdAt).getTime();
|
||||
if (nowMs - baseTime >= 30 * 60 * 1000) {
|
||||
order.status = OrderStatus.REFUNDED;
|
||||
order.refundAmountCents = order.totalAmountCents;
|
||||
refundedIds.push(order.id);
|
||||
}
|
||||
} else if (order.status === OrderStatus.PENDING_CONFIRM && !order.offlineVerifyCode) {
|
||||
const baseTime = (order.finishAppliedAt || order.createdAt).getTime();
|
||||
if (nowMs - baseTime >= 48 * 60 * 60 * 1000) {
|
||||
order.status = OrderStatus.COMPLETED;
|
||||
order.completedAt = now;
|
||||
autoCompletedIds.push(order.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { cancelledIds, refundedIds, autoCompletedIds };
|
||||
}
|
||||
|
||||
getAllOrders(): CompanionOrder[] {
|
||||
return Array.from(this.orders.values());
|
||||
}
|
||||
|
||||
@@ -128,7 +128,10 @@ export async function initDatabase(): Promise<void> {
|
||||
"ALTER TABLE orders ADD COLUMN verify_code VARCHAR(8) AFTER location",
|
||||
"ALTER TABLE orders ADD COLUMN remark VARCHAR(255) AFTER verify_code",
|
||||
"ALTER TABLE orders ADD COLUMN pay_type VARCHAR(32) DEFAULT 'WECHAT' AFTER status",
|
||||
"ALTER TABLE orders ADD COLUMN coupon_discount DECIMAL(10,2) DEFAULT 0.00 AFTER amount"
|
||||
"ALTER TABLE orders ADD COLUMN coupon_discount DECIMAL(10,2) DEFAULT 0.00 AFTER amount",
|
||||
"ALTER TABLE orders ADD COLUMN paid_at DATETIME NULL AFTER created_at",
|
||||
"ALTER TABLE orders ADD COLUMN finish_applied_at DATETIME NULL AFTER paid_at",
|
||||
"ALTER TABLE orders ADD COLUMN refund_amount DECIMAL(10,2) DEFAULT 0.00 AFTER coupon_discount"
|
||||
];
|
||||
for (const colSql of extraOrderCols) {
|
||||
try {
|
||||
|
||||
@@ -820,8 +820,72 @@ export class MysqlRepository {
|
||||
};
|
||||
}
|
||||
|
||||
// 扫描并执行订单超时自动流转 (15分钟待付款自动取消 / 30分钟待接单自动退款 / 48小时待确认自动完成结算)
|
||||
async processOrderTimeouts(): Promise<{ cancelledCount: number; refundedCount: number; completedCount: number }> {
|
||||
let cancelledCount = 0;
|
||||
let refundedCount = 0;
|
||||
let completedCount = 0;
|
||||
|
||||
try {
|
||||
// 1. 15分钟超时未支付 -> 自动流转为已取消
|
||||
const [cancelRes]: any = await pool.query(`
|
||||
UPDATE orders
|
||||
SET status = '已取消'
|
||||
WHERE status IN ('待支付', '待付款')
|
||||
AND created_at <= DATE_SUB(NOW(), INTERVAL 15 MINUTE)
|
||||
`);
|
||||
cancelledCount = Number(cancelRes?.affectedRows || 0);
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
// 2. 30分钟超时未接单 -> 自动流转为已全额退款并退还买家余额
|
||||
const [expiredAcceptRows]: any = await pool.query(`
|
||||
SELECT * FROM orders
|
||||
WHERE status = '待接单'
|
||||
AND COALESCE(paid_at, created_at) <= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
|
||||
`);
|
||||
for (const ord of expiredAcceptRows || []) {
|
||||
const [upd]: any = await pool.query(
|
||||
"UPDATE orders SET status = '已全额退款', refund_amount = amount WHERE id = ? AND status = '待接单'",
|
||||
[ord.id]
|
||||
);
|
||||
if (upd && upd.affectedRows === 1) {
|
||||
refundedCount += 1;
|
||||
const buyerId = Number(ord.buyer_id || 1001);
|
||||
const refundAmt = Number(ord.amount || 0);
|
||||
if (buyerId > 0 && refundAmt > 0) {
|
||||
await pool.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [refundAmt, buyerId]);
|
||||
const ts = Date.now();
|
||||
const rand = Math.floor(100 + Math.random() * 900);
|
||||
await pool.query(
|
||||
`INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
|
||||
VALUES (?, ?, ?, '超时未接单自动退款', ?, 'IN', ?, ?, NOW())`,
|
||||
[`fin_tmo_${ts}_${rand}`, `TO${ts}${rand}`, buyerId, refundAmt, `超时未接单退款 (${ord.order_no || ord.id})`, String(ord.id)]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
// 3. 48小时待确认超时 -> 自动确认完成并解冻结算至大神钱包
|
||||
const [expiredConfirmRows]: any = await pool.query(`
|
||||
SELECT id FROM orders
|
||||
WHERE status = '待确认'
|
||||
AND COALESCE(finish_applied_at, created_at) <= DATE_SUB(NOW(), INTERVAL 48 HOUR)
|
||||
`);
|
||||
for (const ord of expiredConfirmRows || []) {
|
||||
await this.completeOrderWithSettlement(String(ord.id));
|
||||
completedCount += 1;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return { cancelledCount, refundedCount, completedCount };
|
||||
}
|
||||
|
||||
// 订单相关
|
||||
async getOrders(): Promise<any[]> {
|
||||
await this.processOrderTimeouts().catch(() => {});
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
@@ -838,11 +902,14 @@ export class MysqlRepository {
|
||||
duration_hours as durationHours,
|
||||
amount,
|
||||
COALESCE(coupon_discount, 0.00) as couponDiscount,
|
||||
COALESCE(refund_amount, 0.00) as refundAmount,
|
||||
COALESCE(pay_type, 'WECHAT') as payType,
|
||||
location,
|
||||
verify_code as verifyCode,
|
||||
remark,
|
||||
status,
|
||||
paid_at as paidAt,
|
||||
finish_applied_at as finishAppliedAt,
|
||||
created_at as createdAt
|
||||
FROM orders
|
||||
ORDER BY created_at DESC
|
||||
@@ -920,8 +987,8 @@ export class MysqlRepository {
|
||||
companion_name, expert_avatar, track, service_title,
|
||||
expense_burden, hourly_rate, duration_hours, amount,
|
||||
coupon_discount, pay_type,
|
||||
location, verify_code, remark, status, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
location, verify_code, remark, status, paid_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待支付' || status === '待付款' ? 'NULL' : 'NOW()'}, NOW())
|
||||
`, [
|
||||
id, buyerId, expertId, orderNo, clientName,
|
||||
companionName, expertAvatar, track, serviceTitle,
|
||||
@@ -974,11 +1041,78 @@ export class MysqlRepository {
|
||||
if (newStatus === '已完成') {
|
||||
return this.completeOrderWithSettlement(id);
|
||||
}
|
||||
await pool.query('UPDATE orders SET status = ? WHERE id = ? OR order_no = ?', [newStatus, id, id]);
|
||||
if (newStatus === '待接单' || newStatus === '履约中' || newStatus === '服务中') {
|
||||
await pool.query('UPDATE orders SET status = ?, paid_at = COALESCE(paid_at, NOW()) WHERE id = ? OR order_no = ?', [newStatus, id, id]);
|
||||
} else if (newStatus === '待确认') {
|
||||
await pool.query('UPDATE orders SET status = ?, finish_applied_at = NOW() WHERE id = ? OR order_no = ?', [newStatus, id, id]);
|
||||
} else {
|
||||
await pool.query('UPDATE orders SET status = ? WHERE id = ? OR order_no = ?', [newStatus, id, id]);
|
||||
}
|
||||
const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [id, id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口)
|
||||
async applyCompleteOrder(orderId: string): Promise<any> {
|
||||
await pool.query(
|
||||
"UPDATE orders SET status = '待确认', finish_applied_at = NOW() WHERE id = ? OR order_no = ?",
|
||||
[orderId, orderId]
|
||||
);
|
||||
const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [orderId, orderId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// 大神拒单自动全额退款 (待接单 -> 已退款,资金退回买家钱包余额)
|
||||
async rejectOrder(orderId: string, data?: { expertId?: number; reason?: string }): Promise<any> {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const [rows]: any = await conn.query('SELECT * FROM orders WHERE id = ? OR order_no = ? FOR UPDATE', [orderId, orderId]);
|
||||
if (!rows || rows.length === 0) {
|
||||
throw new Error('订单不存在');
|
||||
}
|
||||
const order = rows[0];
|
||||
if (['已完成', '已取消', '已退款', '已全额退款'].includes(order.status)) {
|
||||
throw new Error(`当前订单状态【${order.status}】不可拒单`);
|
||||
}
|
||||
|
||||
const rejectNote = data?.reason ? ` [大神拒单: ${data.reason}]` : ' [大神拒单自动退款]';
|
||||
await conn.query(
|
||||
"UPDATE orders SET status = '已全额退款', refund_amount = amount, remark = CONCAT(COALESCE(remark, ''), ?) WHERE id = ?",
|
||||
[rejectNote.slice(0, 60), order.id]
|
||||
);
|
||||
|
||||
const buyerId = Number(order.buyer_id || 1001);
|
||||
const refundAmount = Number(order.amount || 0);
|
||||
if (buyerId > 0 && refundAmount > 0) {
|
||||
await conn.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [refundAmount, buyerId]);
|
||||
const ts = Date.now();
|
||||
const rand = Math.floor(100 + Math.random() * 900);
|
||||
await conn.query(
|
||||
`INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
|
||||
VALUES (?, ?, ?, '大神拒单自动退款', ?, 'IN', ?, ?, NOW())`,
|
||||
[
|
||||
`fin_rej_${ts}_${rand}`,
|
||||
`RJ${ts}${rand}`,
|
||||
buyerId,
|
||||
refundAmount,
|
||||
`大神拒单退款 (${order.order_no || order.id})`,
|
||||
String(order.id)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
await conn.commit();
|
||||
const [updated]: any = await pool.query('SELECT * FROM orders WHERE id = ?', [order.id]);
|
||||
return updated[0] || order;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
async completeOrderWithSettlement(id: string): Promise<any> {
|
||||
await pool.query("UPDATE orders SET status = '已完成' WHERE id = ? OR order_no = ?", [id, id]);
|
||||
const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [id, id]);
|
||||
@@ -1948,23 +2082,28 @@ export class MysqlRepository {
|
||||
async getArbitrations(): Promise<any[]> {
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT
|
||||
id, order_id as orderId, order_no as orderNo,
|
||||
client_name as clientName, companion_name as companionName,
|
||||
reason, user_evidence as userEvidence, status, created_at as createdAt
|
||||
FROM arbitrations
|
||||
ORDER BY created_at DESC
|
||||
a.id, a.order_id as orderId, a.order_no as orderNo,
|
||||
a.client_name as clientName, a.companion_name as companionName,
|
||||
a.reason, a.user_evidence as userEvidence, a.status, a.created_at as createdAt,
|
||||
COALESCE(o.amount, 0.00) as orderAmount,
|
||||
COALESCE(o.refund_amount, 0.00) as refundAmount
|
||||
FROM arbitrations a
|
||||
LEFT JOIN orders o ON a.order_id = o.id
|
||||
ORDER BY a.created_at DESC
|
||||
`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async handleArbitration(id: string, decision: string): Promise<any> {
|
||||
async handleArbitration(id: string, decision: string, refundAmountInput?: number): Promise<any> {
|
||||
const conn = await pool.getConnection();
|
||||
let shouldSettleOrderId = '';
|
||||
let finalRefundAmount = 0;
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
let statusText = '申诉已驳回';
|
||||
if (decision === 'refund_full') statusText = '已全额退款用户';
|
||||
if (decision === 'refund_partial') statusText = '已部分退款用户';
|
||||
if (decision === 'deduct_credit') statusText = '已扣减大神信用分';
|
||||
|
||||
await conn.query('UPDATE arbitrations SET status = ? WHERE id = ?', [statusText, id]);
|
||||
@@ -1976,12 +2115,13 @@ export class MysqlRepository {
|
||||
const order = ordRows && ordRows[0];
|
||||
|
||||
if (decision === 'refund_full' && order) {
|
||||
const refundAmount = Number(order.amount || 0);
|
||||
finalRefundAmount = refundAmount;
|
||||
// 1. 更新订单为已全额退款
|
||||
await conn.query('UPDATE orders SET status = "已全额退款" WHERE id = ?', [order.id]);
|
||||
await conn.query('UPDATE orders SET status = "已全额退款", refund_amount = ? WHERE id = ?', [refundAmount, order.id]);
|
||||
|
||||
// 2. 将订单金额真实退回买家钱包余额
|
||||
const buyerId = Number(order.buyer_id || 1001);
|
||||
const refundAmount = Number(order.amount || 0);
|
||||
if (buyerId > 0 && refundAmount > 0) {
|
||||
await conn.query('UPDATE users SET balance = balance + ? WHERE id = ?', [refundAmount, buyerId]);
|
||||
|
||||
@@ -2000,6 +2140,42 @@ export class MysqlRepository {
|
||||
const altId = expertId > 100 && expertId < 200 ? expertId - 100 : expertId;
|
||||
await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.2) WHERE id IN (?, ?) OR user_id = ?', [expertId, altId, expertId]);
|
||||
}
|
||||
} else if (decision === 'refund_partial' && order) {
|
||||
const totalOrderAmt = Number(order.amount || 0);
|
||||
const partialAmt = refundAmountInput !== undefined && Number(refundAmountInput) > 0
|
||||
? Math.min(totalOrderAmt, Number(Number(refundAmountInput).toFixed(2)))
|
||||
: Number((totalOrderAmt * 0.5).toFixed(2));
|
||||
finalRefundAmount = partialAmt;
|
||||
|
||||
await conn.query('UPDATE orders SET status = "已部分退款", refund_amount = ? WHERE id = ?', [partialAmt, order.id]);
|
||||
|
||||
const buyerId = Number(order.buyer_id || 1001);
|
||||
if (buyerId > 0 && partialAmt > 0) {
|
||||
await conn.query('UPDATE users SET balance = balance + ? WHERE id = ?', [partialAmt, buyerId]);
|
||||
const recId = `fin_pref_${Date.now()}`;
|
||||
const recNo = `PTK${Date.now()}`;
|
||||
await conn.query(`
|
||||
INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
|
||||
VALUES (?, ?, ?, '仲裁部分退款', ?, 'IN', ?, ?, NOW())
|
||||
`, [recId, recNo, buyerId, partialAmt, `平台仲裁部分退款 (${order.order_no})`, String(order.id)]);
|
||||
}
|
||||
|
||||
// 剩余未退部分解冻结算给大神
|
||||
const remainAmt = Number(Math.max(0, totalOrderAmt - partialAmt).toFixed(2));
|
||||
if (remainAmt > 0) {
|
||||
const expertUserId = Number(order.expert_id || 101);
|
||||
const expertIncome = Number((remainAmt * 0.9).toFixed(2));
|
||||
await conn.query(
|
||||
'UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ? OR nickname = ?',
|
||||
[expertIncome, expertUserId, order.companion_name || '']
|
||||
);
|
||||
const setId = `fin_pset_${Date.now()}`;
|
||||
const setNo = `PJS${Date.now()}`;
|
||||
await conn.query(`
|
||||
INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
|
||||
VALUES (?, ?, ?, '仲裁部分结算', ?, 'IN', ?, ?, NOW())
|
||||
`, [setId, setNo, expertUserId, expertIncome, order.client_name || '买家客户', String(order.id)]);
|
||||
}
|
||||
} else if (order) {
|
||||
// 驳回退款申诉或仅扣减大神信用分:订单恢复为已完成并进入完单结算闭环
|
||||
await conn.query('UPDATE orders SET status = "已完成" WHERE id = ?', [order.id]);
|
||||
@@ -2030,8 +2206,14 @@ export class MysqlRepository {
|
||||
|
||||
let statusText = '申诉已驳回';
|
||||
if (decision === 'refund_full') statusText = '已全额退款用户';
|
||||
if (decision === 'refund_partial') statusText = '已部分退款用户';
|
||||
if (decision === 'deduct_credit') statusText = '已扣减大神信用分';
|
||||
return { id, status: statusText, orderStatus: decision === 'refund_full' ? '已全额退款' : '已完成' };
|
||||
return {
|
||||
id,
|
||||
status: statusText,
|
||||
refundAmount: finalRefundAmount,
|
||||
orderStatus: decision === 'refund_full' ? '已全额退款' : (decision === 'refund_partial' ? '已部分退款' : '已完成')
|
||||
};
|
||||
}
|
||||
|
||||
// 买家发起退款/售后申诉:订单流转为争议仲裁中,同时写入仲裁工单 (含理由与凭证截图)
|
||||
|
||||
@@ -1265,17 +1265,38 @@ app.post('/api/orders/:id/cancel', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 支付订单
|
||||
// 支付订单 (支持流转为待接单或履约中)
|
||||
app.post('/api/orders/:id/pay', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const order = await repo.updateOrderStatus(String(req.params.id), '履约中');
|
||||
res.json({ code: 20000, data: order, message: '支付成功,服务已进入履约中' });
|
||||
const targetStatus = req.body?.targetStatus || '履约中';
|
||||
const order = await repo.updateOrderStatus(String(req.params.id), targetStatus);
|
||||
res.json({ code: 20000, data: order, message: `支付成功,订单已进入${targetStatus}` });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 确认完成验收
|
||||
// 大神拒单自动全额退款 (待接单 -> 已退款)
|
||||
app.post('/api/orders/:id/reject', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const order = await repo.rejectOrder(String(req.params.id), req.body);
|
||||
res.json({ code: 20000, data: order, message: '已拒单,订单款项已自动原路退还买家' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口)
|
||||
app.post('/api/orders/:id/apply-complete', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const order = await repo.applyCompleteOrder(String(req.params.id));
|
||||
res.json({ code: 20000, data: order, message: '已发起完成服务申请,等待买家确认验收(48小时自动确认)' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 买家确认完成验收 (待确认 -> 已完成,触发资金解冻结算)
|
||||
app.post('/api/orders/:id/complete', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const order = await repo.updateOrderStatus(String(req.params.id), '已完成');
|
||||
@@ -1285,6 +1306,16 @@ app.post('/api/orders/:id/complete', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 扫描处理超时订单 (15m未支付自动取消 / 30m未接单自动退款 / 48h待确认自动完成)
|
||||
app.post('/api/orders/process-timeouts', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await repo.processOrderTimeouts();
|
||||
res.json({ code: 20000, data: result, message: '订单超时自动流转扫描完毕' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 申请退款/售后 (写入仲裁工单:退款理由 + 详细说明 + 凭证截图)
|
||||
app.post('/api/orders/:id/refund', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -1374,13 +1405,13 @@ app.get('/api/arbitrations', async (_req: Request, res: Response) => {
|
||||
app.post('/api/arbitrations/:id/handle', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const arbId = String(req.params.id);
|
||||
const { decision, operatorRole } = req.body; // 'refund_full' | 'reject' | 'deduct_credit'
|
||||
const { decision, refundAmount, operatorRole } = req.body; // 'refund_full' | 'refund_partial' | 'reject' | 'deduct_credit'
|
||||
const headerRole = req.headers['x-user-role'];
|
||||
const callerRole = operatorRole || headerRole || 'admin';
|
||||
if (callerRole === 'user' || callerRole === 'expert' || callerRole === 'PLAYER' || callerRole === 'EXPERT') {
|
||||
return res.status(403).json({ code: 40300, message: '权限不足:买家与大神不可越权执行管理员仲裁' });
|
||||
}
|
||||
const result = await repo.handleArbitration(arbId, decision);
|
||||
const result = await repo.handleArbitration(arbId, decision, refundAmount !== undefined ? Number(refundAmount) : undefined);
|
||||
res.json({ code: 20000, data: result, message: `仲裁处理完毕: ${result.status}` });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
|
||||
@@ -248,4 +248,89 @@ describe('CompanionOrderService (TDD 测试套件: 线上游戏与同城陪玩·
|
||||
}).toThrow('无权核销该订单');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 7.1 陪玩服务全生命周期超时自动流转与部分退款仲裁闭环', () => {
|
||||
it('待付款订单超过 15 分钟未支付自动流转为已取消 (CANCELLED)', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: 1001,
|
||||
expertId: 2001,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '王者双排',
|
||||
hours: 1,
|
||||
totalAmountCents: 3500,
|
||||
});
|
||||
|
||||
const future16Min = new Date(order.createdAt.getTime() + 16 * 60 * 1000);
|
||||
const res = orderService.processOrderTimeouts(future16Min);
|
||||
expect(res.cancelledIds).toContain(order.id);
|
||||
expect(order.status).toBe(OrderStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it('待接单订单超过 30 分钟大神未接单自动流转为已退款 (REFUNDED)', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: 1001,
|
||||
expertId: 2001,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '王者双排',
|
||||
hours: 1,
|
||||
totalAmountCents: 3500,
|
||||
});
|
||||
orderService.payOrder(order.id, 1001);
|
||||
expect(order.status).toBe(OrderStatus.PENDING_ACCEPT);
|
||||
|
||||
const future31Min = new Date((order.paidAt || order.createdAt).getTime() + 31 * 60 * 1000);
|
||||
const res = orderService.processOrderTimeouts(future31Min);
|
||||
expect(res.refundedIds).toContain(order.id);
|
||||
expect(order.status).toBe(OrderStatus.REFUNDED);
|
||||
expect(order.refundAmountCents).toBe(3500);
|
||||
});
|
||||
|
||||
it('待确认订单超过 48 小时买家未确认自动流转为已完成 (COMPLETED)', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: 1001,
|
||||
expertId: 2001,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '王者双排',
|
||||
hours: 2,
|
||||
totalAmountCents: 7000,
|
||||
});
|
||||
orderService.payOrder(order.id, 1001);
|
||||
orderService.expertAccept(order.id, 2001);
|
||||
orderService.expertApplyFinish(order.id, 2001);
|
||||
expect(order.status).toBe(OrderStatus.PENDING_CONFIRM);
|
||||
|
||||
const future49Hours = new Date((order.finishAppliedAt || order.createdAt).getTime() + 49 * 60 * 60 * 1000);
|
||||
const res = orderService.processOrderTimeouts(future49Hours);
|
||||
expect(res.autoCompletedIds).toContain(order.id);
|
||||
expect(order.status).toBe(OrderStatus.COMPLETED);
|
||||
});
|
||||
|
||||
it('争议仲裁支持客服判定部分退款 (PARTIAL_REFUND) 并流转为已退款', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: 1001,
|
||||
expertId: 2001,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '王者三排',
|
||||
hours: 2,
|
||||
totalAmountCents: 10000,
|
||||
});
|
||||
orderService.payOrder(order.id, 1001);
|
||||
orderService.expertAccept(order.id, 2001);
|
||||
orderService.expertApplyFinish(order.id, 2001);
|
||||
orderService.buyerDispute(order.id, 1001, '只打了一半时长申请部分退款');
|
||||
|
||||
const arbOrder = orderService.arbitrate({
|
||||
orderId: order.id,
|
||||
operatorAdminId: 9001,
|
||||
decision: 'PARTIAL_REFUND',
|
||||
refundAmountCents: 5000,
|
||||
reason: '核实已完成1小时对局,判定退还买家一半费用',
|
||||
});
|
||||
|
||||
expect(arbOrder.status).toBe(OrderStatus.REFUNDED);
|
||||
expect(arbOrder.arbitrationDecision).toBe('PARTIAL_REFUND');
|
||||
expect(arbOrder.refundAmountCents).toBe(5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户