feat(order): complete 7.1 order full lifecycle state transitions, 15m/30m/48h auto-timeouts, expert reject/apply-complete, and partial refund arbitration
这个提交包含在:
@@ -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' ? '已部分退款' : '已完成')
|
||||
};
|
||||
}
|
||||
|
||||
// 买家发起退款/售后申诉:订单流转为争议仲裁中,同时写入仲裁工单 (含理由与凭证截图)
|
||||
|
||||
在新工单中引用
屏蔽一个用户