diff --git a/apps/backend/src/db/migrations/134_customer_refund_operations.sql b/apps/backend/src/db/migrations/134_customer_refund_operations.sql new file mode 100644 index 00000000..92ec5c01 --- /dev/null +++ b/apps/backend/src/db/migrations/134_customer_refund_operations.sql @@ -0,0 +1,98 @@ +-- 134_customer_refund_operations.sql +-- +-- 客户退款编排: +-- 1. 平台售后单落库,支持抖音从 refund_RefundCreated 消息自动发现; +-- 2. 一键退款操作记录平台调用、本地退款和业务关闭步骤,保证可重试且不重复退款。 + +INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description) +VALUES ( + 'worker_order.customer_refund', + '客户退款并关闭订单', + '接单管理', + '同意平台用户售后,冲正核销,并关闭订单关联履约、资料和领取任务' +) +ON CONFLICT (permission_key) DO UPDATE SET + permission_name = EXCLUDED.permission_name, + permission_group = EXCLUDED.permission_group, + description = EXCLUDED.description, + updated_at = NOW(); + +-- 客服和运营默认具备前台退款处置能力;高风险管理员仍可在角色权限里收回。 +INSERT INTO admin_role_permissions (role_key, permission_key) +SELECT role_key, 'worker_order.customer_refund' +FROM (VALUES ('admin'), ('operator'), ('support')) AS roles(role_key) +ON CONFLICT DO NOTHING; + +CREATE TABLE IF NOT EXISTS platform_after_sales ( + id BIGSERIAL PRIMARY KEY, + provider TEXT NOT NULL, + platform_order_id TEXT NOT NULL, + sub_order_id TEXT NOT NULL DEFAULT '', + external_after_sale_id TEXT NOT NULL, + local_order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL, + local_order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL, + amount_fen BIGINT NOT NULL DEFAULT 0 CHECK (amount_fen >= 0), + after_sale_status INTEGER, + after_sale_type INTEGER, + refund_status INTEGER, + platform_update_time TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'open' + CHECK (state IN ('open', 'agreed', 'refused', 'closed', 'success', 'failed')), + raw_json JSONB NOT NULL DEFAULT '{}'::jsonb, + applied_at TIMESTAMPTZ, + last_event_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_after_sales_external + ON platform_after_sales(provider, external_after_sale_id); + +CREATE INDEX IF NOT EXISTS idx_platform_after_sales_order + ON platform_after_sales(provider, platform_order_id, state, last_event_at DESC); + +CREATE INDEX IF NOT EXISTS idx_platform_after_sales_local_order + ON platform_after_sales(local_order_id, state, last_event_at DESC) + WHERE local_order_id IS NOT NULL; + +COMMENT ON TABLE platform_after_sales IS + '平台电商售后单:抖音由退款消息落库,供后台自动选择待处理退款;不是打手履约售后单'; +COMMENT ON COLUMN platform_after_sales.platform_update_time IS + '平台售后版本时间;抖音 afterSale.operate 必须回传该值防止版本过期'; + +CREATE TABLE IF NOT EXISTS refund_cancel_operations ( + id BIGSERIAL PRIMARY KEY, + operation_no TEXT NOT NULL UNIQUE, + order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + order_item_id BIGINT NOT NULL REFERENCES order_items(id) ON DELETE CASCADE, + work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL, + provider TEXT NOT NULL, + external_after_sale_id TEXT NOT NULL, + amount_fen BIGINT NOT NULL CHECK (amount_fen > 0), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ( + 'pending', 'platform_approved', 'refund_recorded', 'closed', 'failed' + )), + current_step TEXT NOT NULL DEFAULT 'created', + error_message TEXT NOT NULL DEFAULT '', + result_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_refund_cancel_operations_external + ON refund_cancel_operations(provider, external_after_sale_id, order_item_id); + +CREATE INDEX IF NOT EXISTS idx_refund_cancel_operations_order + ON refund_cancel_operations(order_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_refund_cancel_operations_status + ON refund_cancel_operations(status, updated_at DESC); + +COMMENT ON TABLE refund_cancel_operations IS + '客户退款并关闭订单的幂等编排记录:平台同意、本地退款、业务关闭分步留痕,可安全重试'; + +COMMENT ON COLUMN work_order_shares.cancelled_reason IS + '取消来源:timeout/worker_request/admin/admin_partial/problem/settlement/customer_refund'; diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index d89a7649..12da7a25 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -32,6 +32,10 @@ import { updateAdminWorkOrderPaymentAmount, updateAdminWorkOrderSharing, } from '../../../services/worker-platform/index.js' +import { + executeCustomerRefund, + previewCustomerRefund, +} from '../../../services/finance/customer-refund-service.js' import { buildWorkOrderPaymentAmountAuditData } from '../../../services/worker-platform/work-order-payment-amount.js' import { createJsonHandler, @@ -359,6 +363,40 @@ router.post( ), ) +router.post( + '/worker-platform/orders/:workOrderId/customer-refund/preview', + requireAdminPermission('worker_order.customer_refund'), + createJsonHandler((req) => previewCustomerRefund(String(req.params.workOrderId || '')), { + successMessage: '客户退款预检完成', + errorMessage: '客户退款预检失败', + scope: '[admin/worker-platform/orders/:workOrderId/customer-refund/preview]', + }), +) + +router.post( + '/worker-platform/orders/:workOrderId/customer-refund', + requireAdminPermission('worker_order.customer_refund'), + createJsonHandler( + (req) => + executeCustomerRefund( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession || null, + ), + { + successMessage: '客户退款已完成,订单相关操作已关闭', + errorMessage: '客户退款失败', + scope: '[admin/worker-platform/orders/:workOrderId/customer-refund]', + audit: (req, data) => ({ + action: 'customer_refund_completed', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.delete( '/worker-platform/orders/:workOrderId', requireAdminPermission('worker_order.delete'), diff --git a/apps/backend/src/services/admin/admin-rbac-service.ts b/apps/backend/src/services/admin/admin-rbac-service.ts index e287f055..f286b21c 100644 --- a/apps/backend/src/services/admin/admin-rbac-service.ts +++ b/apps/backend/src/services/admin/admin-rbac-service.ts @@ -21,6 +21,7 @@ export const ADMIN_PERMISSION_KEYS = [ 'worker_order.assign', 'worker_order.return_to_hall', 'worker_order.cancel', + 'worker_order.customer_refund', 'worker_order.cancel_share', 'worker_order.delete', 'worker_order.publish', diff --git a/apps/backend/src/services/finance/customer-refund-service.ts b/apps/backend/src/services/finance/customer-refund-service.ts new file mode 100644 index 00000000..5d649c92 --- /dev/null +++ b/apps/backend/src/services/finance/customer-refund-service.ts @@ -0,0 +1,1222 @@ +import type { PoolClient } from 'pg' + +import { query, withTransaction } from '../../db/client.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import type { AdminSession } from '../admin/admin-auth-service.js' +import type { JsonObject } from '../../types/json.js' +import { + ensureWorkerWalletWithClient, + getWorkerWalletWithClient, +} from '../../repositories/worker-platform/shared.js' +import { getOutstandingDepositAmountWithClient } from '../../repositories/worker-platform/work-order-deposit-repo.js' +import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from '../../repositories/worker-platform/consignment-deposit-repo.js' +import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from '../../repositories/worker-platform/consignment-repo.js' +import { createWorkOrderEventWithClient } from '../../repositories/worker-platform/work-order-event-repo.js' +import { + CLEAR_DELIVERY_RESIDUE_SQL, + RESET_GIFT_FLOW_SQL, +} from '../../repositories/worker-platform/work-order-gift-repo.js' +import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' +import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' +import { listKuaishouIndustryRefunds } from '../platforms/kuaishou-industry/refund-service.js' +import { listKuaishouIndustryShopConfigs } from '../platforms/kuaishou-industry/source-config-service.js' +import { reverseAdminKuaishouIndustryVoucher } from '../admin/kuaishou-industry-admin-service.js' +import { + approveDouyinMashidaAfterSale, + cancelDouyinMashidaCouponVerify, +} from '../platforms/douyin-mashida/gateway-client.js' +import { + findPlatformAfterSaleByExternalId, + findOpenPlatformAfterSale, + markPlatformAfterSaleState, +} from '../platforms/platform-after-sales-service.js' +import { approveAndRecordKuaishouRefund, createManualRefund } from './order-refund-service.js' + +const FIRST_VERSION_STATUSES = new Set([ + 'pending_material', + 'unassigned', + 'open', + 'in_progress', + 'pending_acceptance', + 'problem', +]) + +const DOUYIN_PROVIDER = 'douyin_mashida' +const KUISHOU_PROVIDERS = new Set(['91kaquan', 'kuaishou-91']) + +type RefundContext = { + workOrderId: number + workOrderStatus: string + orderId: number + orderItemId: number + provider: string + platform: string + platformOrderId: string + shopId: string + payStatus: string + orderStatus: string + paidAt: string + remainingFen: number + itemCount: number + workOrderCount: number +} + +type RefundAfterSaleCandidate = { + provider: '91kaquan' | 'douyin_mashida' + externalAfterSaleId: string + platformUpdateTime: string + platformAmountFen: number + state: string + sellerId?: string + refundHandingWay?: number + raw: JsonObject +} + +type RefundCancelOperation = { + id: number + status: 'pending' | 'platform_approved' | 'refund_recorded' | 'closed' | 'failed' + currentStep: string + provider: string + externalAfterSaleId: string + amountFen: number + updatedAt: string +} + +export type CustomerRefundPreview = { + workOrderId: number + orderId: number + provider: string + platform: string + platformOrderId: string + workOrderStatus: string + remainingFen: number + itemCount: number + workOrderCount: number + externalAfterSaleId: string + platformUpdateTime: string + platformAmountFen: number + platformState: string + canExecute: boolean + notes: string[] +} + +export async function previewCustomerRefund( + workOrderId: number | string, +): Promise { + const context = await loadRefundContext(workOrderId) + const resumableOperation = await findResumableRefundCancelOperation(context) + if (resumableOperation?.status === 'closed') { + throw createHttpError('该客户退款已经处理完成,无需重复操作', { + statusCode: 409, + errorCode: 'customer_refund_already_completed', + }) + } + await assertRefundClosureEligible(context.orderId) + const candidate = resumableOperation + ? await buildCandidateFromRefundOperation(context, resumableOperation) + : await findRefundAfterSale(context) + if (!candidate) { + throw createHttpError('抖音没有找到用户售后申请;快手没有查到待处理退款单', { + statusCode: 409, + errorCode: 'customer_refund_after_sale_not_found', + context: { + provider: context.provider, + platformOrderId: context.platformOrderId, + }, + }) + } + const localRefundRecorded = Boolean( + await findRecordedPlatformRefund(context.orderId, candidate.externalAfterSaleId), + ) + if (!localRefundRecorded) { + await assertFreshRefundEligible(context) + } + const effectiveRemainingFen = localRefundRecorded + ? candidate.platformAmountFen || context.remainingFen + : context.remainingFen + const notes: string[] = [] + let canExecute = true + if (resumableOperation) { + notes.push( + resumableOperation.status === 'failed' + ? '检测到上次中断的客户退款,本次会从上次失败步骤继续,不会重复退款' + : '检测到进行中的客户退款,本次会继续完成剩余关闭步骤', + ) + } + if ( + !localRefundRecorded && + candidate.platformAmountFen > 0 && + candidate.platformAmountFen !== context.remainingFen + ) { + canExecute = false + notes.push( + `平台售后金额 ${formatFen(candidate.platformAmountFen)} 与本地剩余可退 ${formatFen(context.remainingFen)} 不一致`, + ) + } + if ( + !localRefundRecorded && + !(await isDouyinAfterSaleAlreadyApproved(candidate, resumableOperation?.status)) && + !candidate.platformUpdateTime && + candidate.provider === DOUYIN_PROVIDER + ) { + canExecute = false + notes.push('抖音售后单缺少 update_time,需重新接收 refund_RefundCreated 消息') + } + if ( + !localRefundRecorded && + candidate.provider === DOUYIN_PROVIDER && + ['closed', 'refused', 'failed'].includes(candidate.state) + ) { + canExecute = false + notes.push('抖音售后单已关闭或不可处理,不能再自动同意退款') + } + return { + workOrderId: context.workOrderId, + orderId: context.orderId, + provider: context.provider, + platform: context.platform, + platformOrderId: context.platformOrderId, + workOrderStatus: context.workOrderStatus, + remainingFen: effectiveRemainingFen, + itemCount: context.itemCount, + workOrderCount: context.workOrderCount, + externalAfterSaleId: candidate.externalAfterSaleId, + platformUpdateTime: candidate.platformUpdateTime, + platformAmountFen: candidate.platformAmountFen, + platformState: candidate.state, + canExecute, + notes, + } +} + +export async function executeCustomerRefund( + workOrderId: number | string, + payload: JsonObject, + session: AdminSession | null, +) { + const context = await loadRefundContext(workOrderId) + const resumableOperation = await findResumableRefundCancelOperation(context) + if (resumableOperation?.status === 'closed') { + return { + idempotent: true, + operationId: resumableOperation.id, + } + } + await assertRefundClosureEligible(context.orderId) + const candidate = resumableOperation + ? await buildCandidateFromRefundOperation(context, resumableOperation) + : await findRefundAfterSale(context) + if (!candidate) { + throw createHttpError('没有找到可操作的用户售后申请', { + statusCode: 409, + errorCode: 'customer_refund_after_sale_not_found', + }) + } + let localRefundRecorded = Boolean( + await findRecordedPlatformRefund(context.orderId, candidate.externalAfterSaleId), + ) + if (!localRefundRecorded) { + await assertFreshRefundEligible(context) + } + if ( + !localRefundRecorded && + candidate.platformAmountFen > 0 && + candidate.platformAmountFen !== context.remainingFen + ) { + throw createHttpError('平台售后金额与本地可退金额不一致,请先人工核对', { + statusCode: 409, + errorCode: 'customer_refund_amount_mismatch', + context: { + platformAmountFen: candidate.platformAmountFen, + remainingFen: context.remainingFen, + }, + }) + } + if ( + !localRefundRecorded && + candidate.provider === DOUYIN_PROVIDER && + ['closed', 'refused', 'failed'].includes(candidate.state) + ) { + throw createHttpError('抖音售后单已关闭或不可处理,不能再自动同意退款', { + statusCode: 409, + errorCode: 'customer_refund_after_sale_not_open', + }) + } + const reason = String(payload.reason || '').trim() || '客户退款' + const note = String(payload.note || '') + .trim() + .slice(0, 500) + const operation = await beginRefundCancelOperation({ + context, + candidate, + actorUserId: session?.userId || null, + }) + if (operation.status === 'closed') { + return { + idempotent: true, + operationId: operation.id, + } + } + + localRefundRecorded = Boolean( + await findRecordedPlatformRefund(context.orderId, candidate.externalAfterSaleId), + ) + if (!localRefundRecorded) { + try { + if (candidate.provider === '91kaquan') { + if (!candidate.sellerId) { + throw createHttpError('未找到快手店铺授权,无法自动同意退款', { + statusCode: 409, + errorCode: 'customer_refund_kuaishou_seller_missing', + }) + } + await approveAndRecordKuaishouRefund( + { + orderId: context.orderId, + orderItemId: context.orderItemId, + amountFen: context.remainingFen, + platformRefundId: candidate.externalAfterSaleId, + sellerId: candidate.sellerId, + refundHandingWay: candidate.refundHandingWay, + status: 10, + negotiateStatus: 1, + reason, + note, + refundedAt: nowIso(), + }, + session, + ) + } else { + if (!(await isDouyinAfterSaleAlreadyApproved(candidate, operation.previousStatus))) { + await approveDouyinMashidaAfterSale({ + afterSaleId: candidate.externalAfterSaleId, + updateTime: candidate.platformUpdateTime, + reason, + remark: note, + }) + await markPlatformAfterSaleState({ + provider: DOUYIN_PROVIDER, + externalAfterSaleId: candidate.externalAfterSaleId, + state: 'agreed', + }) + await updateRefundCancelOperation(operation.id, { + status: 'platform_approved', + currentStep: 'platform_approved', + errorMessage: '', + }) + } + await createManualRefund( + { + orderId: context.orderId, + amountFen: context.remainingFen, + reason, + note, + refundedAt: nowIso(), + source: 'platform', + externalRef: candidate.externalAfterSaleId, + }, + session, + ) + } + localRefundRecorded = true + await updateRefundCancelOperation(operation.id, { + status: 'refund_recorded', + currentStep: 'refund_recorded', + errorMessage: '', + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await updateRefundCancelOperation(operation.id, { + status: 'failed', + currentStep: 'platform_or_refund', + errorMessage: message, + }) + throw error + } + } + + try { + const closed = await closeRefundedOrderOperationally({ + context, + candidate, + reason, + actorName: session?.displayName || session?.username || '', + }) + await updateRefundCancelOperation(operation.id, { + status: 'closed', + currentStep: 'closed', + errorMessage: '', + result: closed, + completedAt: nowIso(), + }) + return { + idempotent: false, + operationId: operation.id, + localRefundRecorded, + ...closed, + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await updateRefundCancelOperation(operation.id, { + status: 'failed', + currentStep: 'close_operational_state', + errorMessage: message, + }) + throw createHttpError( + `平台退款已完成,本地退款已记录,但业务关闭失败:${message}。可再次点击继续重试`, + { + statusCode: 502, + errorCode: 'customer_refund_close_failed', + }, + ) + } +} + +async function loadRefundContext(workOrderIdValue: number | string): Promise { + const workOrderId = normalizeId(workOrderIdValue, '工单 ID') + const result = await query<{ + work_order_id: number + work_order_status: string + order_id: number + order_item_id: number + provider: string + platform: string + platform_order_id: string + shop_id: string + pay_status: string + order_status: string + paid_at: string + remaining_fen: number | string + item_count: number + work_order_count: number + }>( + ` + SELECT + wo.id AS work_order_id, + wo.status AS work_order_status, + o.id AS order_id, + wo.order_item_id, + o.provider, + o.platform, + o.platform_order_id, + o.shop_id, + o.pay_status, + o.order_status, + COALESCE(o.paid_at, o.created_at) AS paid_at, + COALESCE(( + SELECT SUM(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)) + - COALESCE(SUM(( + SELECT SUM(r.amount_fen) + FROM order_refunds r + WHERE r.order_item_id = oi.id AND r.status = 'confirmed' + )), 0) + FROM order_items oi + WHERE oi.order_id = o.id + ), 0)::bigint AS remaining_fen, + (SELECT COUNT(*)::int FROM order_items oi WHERE oi.order_id = o.id) AS item_count, + (SELECT COUNT(*)::int FROM work_orders w WHERE w.order_id = o.id) AS work_order_count + FROM work_orders wo + JOIN orders o ON o.id = wo.order_id + WHERE wo.id = $1 + LIMIT 1 + `, + [workOrderId], + ) + const row = result.rows[0] + if (!row) { + throw createHttpError('工单不存在', { + statusCode: 404, + errorCode: 'customer_refund_work_order_not_found', + }) + } + if (Number(row.item_count || 0) !== 1) { + throw createHttpError('第一版客户退款只支持单订单项订单', { + statusCode: 409, + errorCode: 'customer_refund_multi_item_not_supported', + }) + } + return { + workOrderId: Number(row.work_order_id), + workOrderStatus: row.work_order_status, + orderId: Number(row.order_id), + orderItemId: Number(row.order_item_id), + provider: row.provider, + platform: row.platform, + platformOrderId: row.platform_order_id, + shopId: row.shop_id, + payStatus: row.pay_status, + orderStatus: row.order_status, + paidAt: row.paid_at, + remainingFen: Number(row.remaining_fen || 0), + itemCount: Number(row.item_count || 0), + workOrderCount: Number(row.work_order_count || 0), + } +} + +async function assertFreshRefundEligible(context: RefundContext) { + if (!FIRST_VERSION_STATUSES.has(context.workOrderStatus)) { + throw createHttpError('当前工单状态不支持客户退款一键关闭', { + statusCode: 409, + errorCode: 'customer_refund_work_order_status_invalid', + context: { status: context.workOrderStatus }, + }) + } + if (context.payStatus === 'refunded') { + throw createHttpError('订单已退款,不能重复操作', { + statusCode: 409, + errorCode: 'customer_refund_already_refunded', + }) + } + if (context.remainingFen <= 0) { + throw createHttpError('订单没有可退金额', { + statusCode: 409, + errorCode: 'customer_refund_nothing_left', + }) + } +} + +async function findRefundAfterSale( + context: RefundContext, +): Promise { + if (KUISHOU_PROVIDERS.has(context.provider)) { + return findKuaishouAfterSale(context) + } + if (context.provider === DOUYIN_PROVIDER) { + const afterSale = await findOpenPlatformAfterSale({ + provider: DOUYIN_PROVIDER, + localOrderId: context.orderId, + }) + return afterSale + ? { + provider: DOUYIN_PROVIDER, + externalAfterSaleId: afterSale.externalAfterSaleId, + platformUpdateTime: afterSale.platformUpdateTime, + platformAmountFen: afterSale.amountFen, + state: afterSale.state, + raw: afterSale.raw, + } + : null + } + throw createHttpError('当前平台暂未接入客户退款自动审核', { + statusCode: 501, + errorCode: 'customer_refund_platform_not_supported', + context: { provider: context.provider }, + }) +} + +async function findKuaishouAfterSale( + context: RefundContext, +): Promise { + const configs = listKuaishouIndustryShopConfigs() + const matchedShop = configs.find( + (shop) => + shop.enabled !== false && + String(shop.shopId || '').trim() === String(context.shopId || '').trim(), + ) + const sellerId = String(matchedShop?.sellerId || '').trim() + if (!sellerId) { + throw createHttpError('未找到快手店铺授权,无法自动查询售后单', { + statusCode: 409, + errorCode: 'customer_refund_kuaishou_seller_missing', + }) + } + const now = Date.now() + const paidAt = new Date(context.paidAt).getTime() + // 快手接口要求单次查询时间窗不超过 1 天;待退款通常会在 48 小时内处理完。 + const earliest = Math.max( + Number.isFinite(paidAt) ? paidAt : now - 48 * 60 * 60 * 1000, + now - 48 * 60 * 60 * 1000, + ) + const rowsById = new Map() + for (let windowEnd = now; windowEnd > earliest; ) { + const windowBegin = Math.max(earliest, windowEnd - 24 * 60 * 60 * 1000) + const result = await listKuaishouIndustryRefunds({ + sellerId, + orderId: context.platformOrderId, + beginTime: windowBegin, + endTime: windowEnd, + type: 8, + status: 10, + negotiateStatus: 1, + queryType: 2, + pageSize: 100, + }) + if (!result.success) { + throw createHttpError(result.error || '查询快手售后单失败', { + statusCode: 502, + errorCode: 'customer_refund_kuaishou_list_failed', + }) + } + for (const row of collectObjects(result.response || {})) { + const refundId = pickString(row, ['refundId', 'refund_id']) + if (refundId) rowsById.set(refundId, row) + } + windowEnd = windowBegin + } + const pendingRows = [...rowsById.values()] + .filter((row) => { + const oid = pickString(row, ['oid', 'orderId', 'order_id']) + const status = pickNumber(row, ['status', 'refundStatus', 'refund_status']) + return (!oid || oid === context.platformOrderId) && (!status || status === 10) + }) + .sort((left, right) => numericTime(right) - numericTime(left)) + const rows = pendingRows.filter((row) => { + const handlingWay = pickNumber(row, ['handlingWay', 'refundHandingWay']) + return !handlingWay || handlingWay === 10 + }) + const row = rows[0] + if (!row) { + if (pendingRows.length > 0) { + throw createHttpError('当前快手售后不是仅退款类型,第一版不能自动同意', { + statusCode: 409, + errorCode: 'customer_refund_kuaishou_return_goods_not_supported', + context: { + refundId: pickString(pendingRows[0] || {}, ['refundId', 'refund_id']), + refundHandingWay: pickNumber(pendingRows[0] || {}, ['handlingWay', 'refundHandingWay']), + }, + }) + } + return null + } + const refundId = pickString(row, ['refundId', 'refund_id']) + return { + provider: '91kaquan', + externalAfterSaleId: refundId, + platformUpdateTime: pickString(row, ['negotiateUpdateTime', 'updateTime', 'update_time']), + platformAmountFen: pickNumber(row, ['refundFee', 'refundAmount', 'refund_amount', 'amount']), + state: pickString(row, ['status', 'refundStatus', 'refund_status']) || 'pending', + sellerId, + refundHandingWay: 10, + raw: row, + } +} + +async function assertRefundClosureEligible(orderId: number) { + const accepted = await query<{ count: number }>( + ` + SELECT ( + (SELECT COUNT(*) FROM work_orders + WHERE order_id = $1 AND status = 'accepted') + + + (SELECT COUNT(*) FROM work_order_shares s + JOIN work_orders wo ON wo.id = s.work_order_id + WHERE wo.order_id = $1 AND s.status = 'accepted') + )::int AS count + `, + [orderId], + ) + if (Number(accepted.rows[0]?.count || 0) > 0) { + throw createHttpError('订单存在已验收或已结算内容,请走售后追缴,不能直接一键退款', { + statusCode: 409, + errorCode: 'customer_refund_settled_content_exists', + }) + } +} + +async function findResumableRefundCancelOperation( + context: RefundContext, +): Promise { + const result = await query<{ + id: number + status: RefundCancelOperation['status'] + current_step: string + provider: string + external_after_sale_id: string + amount_fen: number | string + updated_at: string + }>( + ` + SELECT + id, status, current_step, provider, external_after_sale_id, amount_fen, updated_at + FROM refund_cancel_operations + WHERE order_id = $1 AND order_item_id = $2 + ORDER BY id DESC + LIMIT 1 + `, + [context.orderId, context.orderItemId], + ) + const row = result.rows[0] + return row + ? { + id: Number(row.id), + status: row.status, + currentStep: row.current_step, + provider: row.provider, + externalAfterSaleId: row.external_after_sale_id, + amountFen: Number(row.amount_fen || 0), + updatedAt: row.updated_at, + } + : null +} + +async function buildCandidateFromRefundOperation( + context: RefundContext, + operation: RefundCancelOperation, +): Promise { + if (operation.provider === DOUYIN_PROVIDER) { + const afterSale = await findPlatformAfterSaleByExternalId({ + provider: DOUYIN_PROVIDER, + externalAfterSaleId: operation.externalAfterSaleId, + }) + return { + provider: DOUYIN_PROVIDER, + externalAfterSaleId: operation.externalAfterSaleId, + platformUpdateTime: afterSale?.platformUpdateTime || '', + platformAmountFen: afterSale?.amountFen || operation.amountFen, + state: afterSale?.state || operation.status, + raw: afterSale?.raw || {}, + } + } + return { + provider: '91kaquan', + externalAfterSaleId: operation.externalAfterSaleId, + platformUpdateTime: '', + platformAmountFen: operation.amountFen, + state: operation.status, + sellerId: findKuaishouSellerId(context), + refundHandingWay: 10, + raw: {}, + } +} + +async function isDouyinAfterSaleAlreadyApproved( + candidate: RefundAfterSaleCandidate, + operationStatus?: RefundCancelOperation['status'] | null, +) { + if (candidate.provider !== DOUYIN_PROVIDER) return false + if (operationStatus === 'platform_approved' || operationStatus === 'refund_recorded') { + return true + } + const afterSale = await findPlatformAfterSaleByExternalId({ + provider: DOUYIN_PROVIDER, + externalAfterSaleId: candidate.externalAfterSaleId, + }) + return afterSale?.state === 'agreed' || afterSale?.state === 'success' +} + +function findKuaishouSellerId(context: RefundContext) { + const matchedShop = listKuaishouIndustryShopConfigs().find( + (shop) => + shop.enabled !== false && + String(shop.shopId || '').trim() === String(context.shopId || '').trim(), + ) + return String(matchedShop?.sellerId || '').trim() +} + +async function beginRefundCancelOperation(input: { + context: RefundContext + candidate: RefundAfterSaleCandidate + actorUserId: number | null +}) { + return withTransaction(async (client) => { + type ClaimRow = { + id: number + status: RefundCancelOperation['status'] + current_step: string + updated_at: string + } + const now = nowIso() + const staleBefore = Date.now() - 2 * 60 * 1000 + const claimExisting = async (row: ClaimRow) => { + if (row.status === 'closed') { + return { + id: Number(row.id), + status: row.status, + currentStep: row.current_step, + previousStatus: row.status, + } + } + if (row.status === 'pending' && Date.parse(row.updated_at) >= staleBefore) { + throw createHttpError('该客户退款正在处理中,请稍后刷新', { + statusCode: 409, + errorCode: 'customer_refund_processing', + }) + } + const previousStatus = row.status + const claimed = await client.query( + `UPDATE refund_cancel_operations + SET status = 'pending', + current_step = 'claimed', + error_message = '', + completed_at = NULL, + updated_at = $2 + WHERE id = $1 + RETURNING id, status, current_step, updated_at`, + [row.id, now], + ) + const claimedRow = claimed.rows[0] + if (!claimedRow) { + throw createHttpError('客户退款操作记录锁定失败', { + statusCode: 500, + errorCode: 'customer_refund_operation_lock_failed', + }) + } + return { + id: Number(claimedRow.id), + status: claimedRow.status, + currentStep: claimedRow.current_step, + previousStatus, + } + } + + const existing = await client.query( + `SELECT id, status, current_step, updated_at + FROM refund_cancel_operations + WHERE provider = $1 AND external_after_sale_id = $2 AND order_item_id = $3 + FOR UPDATE`, + [input.candidate.provider, input.candidate.externalAfterSaleId, input.context.orderItemId], + ) + if (existing.rows[0]) { + return claimExisting(existing.rows[0]) + } + const operationNo = `RCO${Date.now()}${input.context.workOrderId}` + const inserted = await client.query( + ` + INSERT INTO refund_cancel_operations ( + operation_no, order_id, order_item_id, work_order_id, provider, + external_after_sale_id, amount_fen, status, current_step, + created_by_admin_user_id, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', 'claimed', $8, $9, $9) + ON CONFLICT (provider, external_after_sale_id, order_item_id) DO NOTHING + RETURNING id, status, current_step, updated_at + `, + [ + operationNo, + input.context.orderId, + input.context.orderItemId, + input.context.workOrderId, + input.candidate.provider, + input.candidate.externalAfterSaleId, + input.context.remainingFen, + input.actorUserId, + now, + ], + ) + if (inserted.rows[0]) { + return { + id: Number(inserted.rows[0].id), + status: inserted.rows[0].status, + currentStep: inserted.rows[0].current_step, + previousStatus: null, + } + } + const conflicted = await client.query( + `SELECT id, status, current_step, updated_at + FROM refund_cancel_operations + WHERE provider = $1 AND external_after_sale_id = $2 AND order_item_id = $3 + FOR UPDATE`, + [input.candidate.provider, input.candidate.externalAfterSaleId, input.context.orderItemId], + ) + if (!conflicted.rows[0]) { + throw createHttpError('创建客户退款操作记录失败', { + statusCode: 500, + errorCode: 'customer_refund_operation_create_failed', + }) + } + return claimExisting(conflicted.rows[0]) + }) +} + +async function updateRefundCancelOperation( + operationId: number, + input: { + status: 'platform_approved' | 'refund_recorded' | 'closed' | 'failed' + currentStep: string + errorMessage: string + result?: unknown + completedAt?: string + }, +) { + await query( + ` + UPDATE refund_cancel_operations + SET status = $2, + current_step = $3, + error_message = $4, + result_json = COALESCE($5::jsonb, result_json), + updated_at = $6, + completed_at = $7 + WHERE id = $1 + `, + [ + operationId, + input.status, + input.currentStep, + input.errorMessage, + input.result === undefined ? null : JSON.stringify(input.result), + nowIso(), + input.completedAt || null, + ], + ) +} + +async function findRecordedPlatformRefund(orderId: number, externalRef: string) { + const result = await query<{ id: number }>( + `SELECT id FROM order_refunds + WHERE order_id = $1 AND source = 'platform' AND external_ref = $2 + LIMIT 1`, + [orderId, externalRef], + ) + return result.rows[0] || null +} + +async function closeRefundedOrderOperationally(input: { + context: RefundContext + candidate: RefundAfterSaleCandidate + reason: string + actorName: string +}) { + const closed = await withTransaction((client) => closeRefundedOrderWithClient(client, input)) + await reversePlatformVoucherState(input.context, input.reason) + return closed +} + +async function closeRefundedOrderWithClient( + client: PoolClient, + input: { + context: RefundContext + candidate: RefundAfterSaleCandidate + reason: string + actorName: string + }, +) { + const now = nowIso() + const workOrders = await client.query( + `SELECT * FROM work_orders WHERE order_id = $1 ORDER BY id FOR UPDATE`, + [input.context.orderId], + ) + let cancelledWorkOrders = 0 + let cancelledShares = 0 + let releasedDepositFen = 0 + + for (const workOrder of workOrders.rows) { + if (workOrder.status === 'cancelled') continue + const shares = await client.query<{ + id: number + worker_id: number + status: string + share_deposit: number + }>( + `SELECT id, worker_id, status, share_deposit + FROM work_order_shares + WHERE work_order_id = $1 AND status <> 'cancelled' + ORDER BY id + FOR UPDATE`, + [workOrder.id], + ) + if (shares.rows.some((share) => share.status === 'accepted')) { + throw createHttpError('订单存在已验收拼单,不能自动关闭', { + statusCode: 409, + errorCode: 'customer_refund_accepted_share_exists', + }) + } + for (const share of shares.rows) { + const workerId = Number(share.worker_id || 0) + if (!workerId) continue + await ensureWorkerWalletWithClient(client, workerId, now) + const wallet = await getWorkerWalletWithClient(client, workerId) + const releaseAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (releaseAmount > 0) { + const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) + await client.query( + `UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4`, + [nextAvailable, nextFrozen, now, workerId], + ) + await client.query( + `INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '客户退款关闭订单退还押金', $6::jsonb, $7)`, + [ + workerId, + releaseAmount, + nextAvailable, + nextFrozen, + workOrder.id, + JSON.stringify({ shareId: share.id, reason: input.reason }), + now, + ], + ) + releasedDepositFen += releaseAmount + } + } + if (shares.rows.length > 0) { + await client.query( + `UPDATE work_order_shares + SET status = 'cancelled', + cancelled_reason = 'customer_refund', + acceptance_json = '{}'::jsonb, + draft_acceptance_json = '{}'::jsonb, + submitted_at = NULL, + accepted_at = NULL, + updated_at = $1 + WHERE id = ANY($2::bigint[])`, + [now, shares.rows.map((share) => Number(share.id))], + ) + cancelledShares += shares.rows.length + } + + const assignedWorkerId = Number(workOrder.assigned_worker_id || 0) + if (assignedWorkerId) { + const forfeit = isWorkOrderGiftDepositForfeitable(workOrder) + if (forfeit) { + await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, { + workOrderId: Number(workOrder.id), + note: '客户退款关闭订单,没收寄卖发布押金', + now, + }) + } + await ensureWorkerWalletWithClient(client, assignedWorkerId, now) + const wallet = await getWorkerWalletWithClient(client, assignedWorkerId) + const releaseAmount = Math.min( + await getOutstandingDepositAmountWithClient(client, assignedWorkerId, workOrder.id), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (releaseAmount > 0) { + const nextAvailable = Number(wallet?.available_amount || 0) + (forfeit ? 0 : releaseAmount) + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) + await client.query( + `UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4`, + [nextAvailable, nextFrozen, now, assignedWorkerId], + ) + await client.query( + `INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`, + [ + assignedWorkerId, + forfeit ? 'deposit_deduction' : 'deposit_release', + forfeit ? -releaseAmount : releaseAmount, + nextAvailable, + nextFrozen, + workOrder.id, + forfeit ? '客户退款关闭订单扣除押金' : '客户退款关闭订单退还押金', + JSON.stringify({ reason: input.reason }), + now, + ], + ) + if (!forfeit) releasedDepositFen += releaseAmount + } + } + + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, workOrder.id, { + now, + reason: '客户退款关闭订单', + }) + await client.query( + `UPDATE work_orders + SET status = 'cancelled', + assigned_worker_id = NULL, + assigned_at = NULL, + deadline_at = NULL, + published_at = NULL, + pinned_at = NULL, + ${CLEAR_DELIVERY_RESIDUE_SQL}, + ${RESET_GIFT_FLOW_SQL}, + updated_at = $1 + WHERE id = $2`, + [now, workOrder.id], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: Number(workOrder.id), + actorType: 'admin', + actorId: input.actorName, + eventType: 'customer_refund_cancelled', + fromStatus: workOrder.status, + toStatus: 'cancelled', + payloadJson: JSON.stringify({ + reason: input.reason, + afterSaleId: input.candidate.externalAfterSaleId, + }), + now, + }) + cancelledWorkOrders += 1 + } + + const tasks = await client.query<{ id: number }>( + `UPDATE fulfillment_tasks + SET task_status = 'closed', + delivery_status = 'closed', + user_action_status = 'closed', + result_code = 'customer_refund', + result_message = $2, + claim_expires_at = $1, + updated_at = $1 + WHERE order_id = $3 + AND task_status NOT IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) + RETURNING id`, + [now, '客户退款,履约任务关闭', input.context.orderId], + ) + const taskIds = tasks.rows.map((task) => Number(task.id)) + if (taskIds.length > 0) { + await client.query( + `UPDATE claim_tokens + SET status = 'closed', expired_at = $1, updated_at = $1 + WHERE task_id = ANY($2::bigint[]) + AND status NOT IN ('closed', 'expired')`, + [now, taskIds], + ) + await client.query( + `INSERT INTO task_events (task_id, event_type, payload_json, created_at) + SELECT task_id, 'customer_refund_closed', $1::jsonb, $2 + FROM unnest($3::bigint[]) AS task_id`, + [ + JSON.stringify({ + reason: input.reason, + afterSaleId: input.candidate.externalAfterSaleId, + }), + now, + taskIds, + ], + ) + } + await client.query( + `UPDATE voucher_consume_jobs + SET status = 'cancelled', + last_error = '客户退款,取消待核销任务', + finished_at = $1, + updated_at = $1 + WHERE order_id = $2 AND status IN ('pending', 'processing')`, + [now, input.context.orderId], + ) + await client.query( + `UPDATE on_account_bills + SET status = 'void', updated_at = $1 + WHERE order_id = $2 AND status = 'pending'`, + [now, input.context.orderId], + ) + await client.query( + `UPDATE orders + SET pay_status = 'refunded', + order_status = 'refunded', + updated_at = $1 + WHERE id = $2`, + [now, input.context.orderId], + ) + return { + workOrderId: input.context.workOrderId, + orderId: input.context.orderId, + cancelledWorkOrders, + cancelledShares, + releasedDepositFen, + closedTaskCount: tasks.rowCount || 0, + } +} + +async function reversePlatformVoucherState(context: RefundContext, reason: string) { + if (KUISHOU_PROVIDERS.has(context.provider)) { + const vouchers = await query<{ voucher_code: string; status: string; seller_id: string }>( + `SELECT voucher_code, status, seller_id + FROM kuaishou_industry_vouchers + WHERE order_id = $1 AND status = 'CONSUMED'`, + [context.orderId], + ) + if (vouchers.rows.length > 0) { + await reverseAdminKuaishouIndustryVoucher({ + orderId: context.platformOrderId, + sellerId: vouchers.rows[0]?.seller_id || '', + etickets: vouchers.rows.map((voucher) => ({ + id: voucher.voucher_code, + code: voucher.voucher_code, + num: 1, + })), + reason, + }) + } + return + } + if (context.provider === DOUYIN_PROVIDER) { + const certificates = await query<{ id: number; cert_no: string; status: string }>( + `SELECT id, cert_no, status + FROM redeem_certificates + WHERE local_order_id = $1 AND status = 'verified'`, + [context.orderId], + ) + for (const certificate of certificates.rows) { + await cancelDouyinMashidaCouponVerify({ + certNo: certificate.cert_no, + batchNo: `customer_refund:${context.orderId}:${certificate.id}`, + }) + } + await query( + `UPDATE redeem_certificates + SET status = 'invalid', updated_at = $1 + WHERE local_order_id = $2 AND status <> 'invalid'`, + [nowIso(), context.orderId], + ) + } +} + +function collectObjects(value: unknown): JsonObject[] { + const output: JsonObject[] = [] + const visit = (current: unknown) => { + if (Array.isArray(current)) { + for (const item of current) visit(item) + return + } + if (!current || typeof current !== 'object') return + const row = current as JsonObject + if ( + 'refundId' in row || + 'refund_id' in row || + 'aftersale_id' in row || + 'orderId' in row || + 'oid' in row + ) { + output.push(row) + } + for (const child of Object.values(row)) visit(child) + } + visit(value) + return output +} + +function pickString(row: JsonObject, keys: string[]) { + for (const key of keys) { + const text = String(row[key] ?? '').trim() + if (text) return text + } + return '' +} + +function pickNumber(row: JsonObject, keys: string[]) { + for (const key of keys) { + const number = Number(row[key]) + if (Number.isFinite(number) && number > 0) return Math.round(number) + } + return 0 +} + +function numericTime(row: JsonObject) { + for (const key of ['updateTime', 'update_time', 'createTime', 'create_time', 'applyTime']) { + const value = row[key] + if (typeof value === 'number' && Number.isFinite(value)) return value + const parsed = Date.parse(String(value || '')) + if (Number.isFinite(parsed)) return parsed + } + return 0 +} + +function normalizeId(value: unknown, label: string) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw createHttpError(`${label}无效`, { statusCode: 400, errorCode: 'invalid_id' }) + } + return parsed +} + +function formatFen(value: number) { + return `¥${(value / 100).toFixed(2)}` +} diff --git a/apps/backend/src/services/finance/customer-refund.test.ts b/apps/backend/src/services/finance/customer-refund.test.ts new file mode 100644 index 00000000..a812b4b8 --- /dev/null +++ b/apps/backend/src/services/finance/customer-refund.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('客户退款第一版覆盖范围与原子关闭动作已声明', async () => { + const source = await readFile(new URL('./customer-refund-service.ts', import.meta.url), 'utf8') + for (const status of [ + 'pending_material', + 'unassigned', + 'open', + 'in_progress', + 'pending_acceptance', + 'problem', + ]) { + assert.ok(source.includes(`'${status}'`), `缺少工单状态 ${status}`) + } + assert.ok(source.includes("pay_status = 'refunded'")) + assert.ok(source.includes("order_status = 'refunded'")) + assert.ok(source.includes('work_order_shares')) + assert.ok(source.includes('voucher_consume_jobs')) + assert.ok(source.includes('redeem_certificates')) + assert.ok(source.includes('findResumableRefundCancelOperation')) + assert.ok(source.includes('customer_refund_processing')) + assert.ok(source.includes("status: 'platform_approved'")) + assert.ok(source.includes('refundFee')) + assert.ok(source.includes('queryType: 2')) + assert.ok( + source.includes('afterSale.operate') || source.includes('approveDouyinMashidaAfterSale'), + ) +}) + +test('抖音退款创建和关闭消息会进入自动发现白名单', async () => { + const [configSource, notifySource] = await Promise.all([ + readFile( + new URL('../platforms/douyin-mashida/source-config-service.ts', import.meta.url), + 'utf8', + ), + readFile(new URL('../platforms/douyin-mashida/notify-service.ts', import.meta.url), 'utf8'), + ]) + assert.ok(configSource.includes('refund_RefundCreated')) + assert.ok(configSource.includes('refund_RefundClosed')) + assert.ok(notifySource.includes('bindPlatformAfterSalesToLocalOrder')) +}) + +test('客户退款操作表和平台售后表具备幂等唯一键', async () => { + const migration = await readFile( + new URL('../../db/migrations/134_customer_refund_operations.sql', import.meta.url), + 'utf8', + ) + assert.ok(migration.includes('uq_platform_after_sales_external')) + assert.ok(migration.includes('uq_refund_cancel_operations_external')) + assert.ok(migration.includes('afterSale.operate') || migration.includes('平台售后')) + assert.ok(migration.includes('平台同意、本地退款、业务关闭')) +}) + +test('客户退款使用独立权限,客服默认可用且可由管理员收回', async () => { + const [routeSource, migration] = await Promise.all([ + readFile(new URL('../../routes/admin/worker-platform/orders.ts', import.meta.url), 'utf8'), + readFile( + new URL('../../db/migrations/134_customer_refund_operations.sql', import.meta.url), + 'utf8', + ), + ]) + assert.ok(routeSource.includes("requireAdminPermission('worker_order.customer_refund')")) + assert.ok(migration.includes("'worker_order.customer_refund'")) + assert.ok(migration.includes("('support')")) +}) diff --git a/apps/backend/src/services/finance/order-refund-service.ts b/apps/backend/src/services/finance/order-refund-service.ts index 13ed3ad4..d147de04 100644 --- a/apps/backend/src/services/finance/order-refund-service.ts +++ b/apps/backend/src/services/finance/order-refund-service.ts @@ -635,6 +635,8 @@ export async function approveAndRecordKuaishouRefund( refundedAt?: string | undefined sellerId?: string | undefined refundHandingWay?: unknown + status?: unknown + negotiateStatus?: unknown desc?: string | undefined }, session: AdminSession | null, @@ -770,6 +772,8 @@ export async function approveAndRecordKuaishouRefund( refundAmount: amountFen, desc: input.desc || input.note || '', refundHandingWay: input.refundHandingWay, + status: input.status, + negotiateStatus: input.negotiateStatus, ...(input.sellerId ? { sellerId: input.sellerId } : {}), }) await updatePlatformRefundIntent(intent.id, 'platform_approved') diff --git a/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts b/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts index 6e09e3e6..f2a5221d 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts @@ -124,6 +124,43 @@ export async function cancelDouyinMashidaCouponVerify( }) } +/** + * 同意用户售后(`afterSale.operate`,type=201)。 + * update_time 必须使用 refund_RefundCreated 消息中的原值,平台用它做版本校验。 + */ +export async function approveDouyinMashidaAfterSale( + input: { + afterSaleId: string + updateTime: string + reason?: string | undefined + remark?: string | undefined + } & DouyinMashidaCallOptions, +): Promise { + const afterSaleId = String(input.afterSaleId || '').trim() + const updateTime = String(input.updateTime || '').trim() + if (!afterSaleId || !updateTime) { + throw new Error('抖音售后审核缺少 aftersale_id 或 update_time') + } + const response = await douyinMashidaGatewayRequest({ + method: 'afterSale.operate', + params: { + type: 201, + items: JSON.stringify([ + { + repair_type: 1, + aftersale_id: afterSaleId, + reason: String(input.reason || '').trim(), + remark: String(input.remark || '').trim(), + update_time: updateTime, + }, + ]), + }, + config: input.config, + timeoutMs: input.timeoutMs, + }) + return isPlainObject(response.data) ? response.data : { data: response.data } +} + /** 订单消息(把「去使用链接」发给买家)。 */ export async function sendDouyinMashidaOrderMessage( input: { diff --git a/apps/backend/src/services/platforms/douyin-mashida/notify-service.test.ts b/apps/backend/src/services/platforms/douyin-mashida/notify-service.test.ts index 91a812d9..cb66b8fc 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/notify-service.test.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/notify-service.test.ts @@ -61,7 +61,7 @@ test('data 解析:坏 JSON 不抛错,返回空对象', () => { assert.deepEqual(parseMessageData(''), {}) }) -test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids 派生', () => { +test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids:aftersale_id 派生', () => { const eventId = buildDouyinMashidaMessageEventId({ topic: 'trade_TradePaid', pubTime: '1780000000', @@ -76,6 +76,23 @@ test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids 派生', () => { ) }) +test('幂等键:同一订单同一秒的两笔售后不会互相去重', () => { + const first = buildDouyinMashidaMessageEventId({ + topic: 'refund_RefundCreated', + pubTime: '1780000000', + data: '{}', + message: { p_id: '4837328765348567000', s_id: '4837328765348567001', aftersale_id: '1' }, + }) + const second = buildDouyinMashidaMessageEventId({ + topic: 'refund_RefundCreated', + pubTime: '1780000000', + data: '{}', + message: { p_id: '4837328765348567000', s_id: '4837328765348567001', aftersale_id: '2' }, + }) + + assert.notEqual(first, second) +}) + test('幂等键:拿不到订单号时退回 data 摘要(同一内容稳定、内容变则键变)', () => { const base = { topic: 'refund_RefundSuccess', pubTime: '1780000000' } const first = buildDouyinMashidaMessageEventId({ ...base, data: '{"a":1}', message: {} }) diff --git a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts index 10eb2258..96961d63 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts @@ -2,6 +2,7 @@ import { isPlainObject, type JsonObject } from '../../../types/json.js' import { insertWebhookEventOnce } from '../../../repositories/webhook-event-repo.js' import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js' +import { query } from '../../../db/client.js' import { upsertOrderFromSource } from '../../order/order-service.js' import { ensureCertificateForOrderItem, @@ -26,6 +27,11 @@ import { } from './source-config-service.js' import { verifyMessageSignature } from './sign.js' import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js' +import { + bindPlatformAfterSalesToLocalOrder, + markPlatformAfterSaleState, + upsertPlatformAfterSale, +} from '../platform-after-sales-service.js' /** * 抖音码速达消息推送入口(验签 → 幂等 → 分派)。 @@ -113,14 +119,96 @@ export async function handleDouyinMashidaMessage(input: { return handlePaidMessage({ message, topic, eventId, config }) } + if (topic === 'refund_RefundCreated') { + return handleAfterSaleCreated({ message, topic, eventId, pubTime }) + } + + if (topic === 'refund_RefundClosed') { + return handleAfterSaleClosed({ message, topic, eventId, pubTime }) + } + if (topic === 'trade_TradeCanceled' || topic === 'refund_RefundSuccess') { - return handleInvalidateMessage({ message, topic, eventId }) + return handleInvalidateMessage({ message, topic, eventId, pubTime }) } logIntegration('[douyin-mashida/notify]', 'topic 暂无处理逻辑', { topic, eventId }) return { ok: true, duplicate: false, topic, action: 'noop', eventId } } +async function handleAfterSaleCreated(input: { + message: JsonObject + topic: string + eventId: string + pubTime: string +}): Promise { + const afterSaleId = String(input.message.aftersale_id || '').trim() + const parentOrderId = resolveDouyinMashidaParentOrderId(input.message) + if (!afterSaleId || !parentOrderId) { + throw createHttpError('抖音售后消息缺少 aftersale_id 或 p_id', { + statusCode: 400, + errorCode: 'douyin_mashida_after_sale_id_missing', + context: { topic: input.topic, eventId: input.eventId }, + }) + } + const order = await findLatestOrderByPlatformOrderId({ + provider: DOUYIN_MASHIDA_PROVIDER, + platform: DOUYIN_MASHIDA_PLATFORM, + platformOrderId: parentOrderId, + }) + const orderItems = order + ? await queryOrderItemsForAfterSale(order.id, String(input.message.s_id || '').trim()) + : [] + const afterSale = await upsertPlatformAfterSale({ + provider: DOUYIN_MASHIDA_PROVIDER, + platformOrderId: parentOrderId, + subOrderId: String(input.message.s_id || ''), + externalAfterSaleId: afterSaleId, + localOrderId: order?.id || null, + localOrderItemId: orderItems.length === 1 ? orderItems[0]?.id || null : null, + amountFen: Number(input.message.refund_amount || 0), + afterSaleStatus: numberOrNull(input.message.aftersale_status), + afterSaleType: numberOrNull(input.message.aftersale_type), + refundStatus: numberOrNull(input.message.refund_status), + platformUpdateTime: String(input.message.update_time || ''), + state: 'open', + raw: input.message, + appliedAt: numberToIso(input.message.apply_time), + eventAt: numberToIso(input.pubTime), + }) + return { + ok: true, + duplicate: false, + topic: input.topic, + action: afterSale ? 'after-sale-opened' : 'after-sale-ignored', + eventId: input.eventId, + orderId: order?.id || null, + } +} + +async function handleAfterSaleClosed(input: { + message: JsonObject + topic: string + eventId: string + pubTime: string +}): Promise { + const afterSaleId = String(input.message.aftersale_id || '').trim() + if (afterSaleId) { + await markPlatformAfterSaleState({ + provider: DOUYIN_MASHIDA_PROVIDER, + externalAfterSaleId: afterSaleId, + state: 'closed', + eventAt: numberToIso(input.pubTime), + }) + } + return { + ok: true, + duplicate: false, + topic: input.topic, + action: afterSaleId ? 'after-sale-closed' : 'after-sale-close-id-missing', + eventId: input.eventId, + } +} + /** `trade_TradePaid`:拉订单详情 → 建单 → 生成券码并同步给平台。 */ async function handlePaidMessage(input: { message: JsonObject @@ -171,6 +259,14 @@ async function handlePaidMessage(input: { } } + const orderItems = result.orderItems || [] + await bindPlatformAfterSalesToLocalOrder({ + provider: DOUYIN_MASHIDA_PROVIDER, + platformOrderId: parentOrderId, + localOrderId: order.id, + localOrderItemId: orderItems.length === 1 ? orderItems[0]?.id || null : null, + }) + // 只有真正付款的单才发券(详情可能显示已取消/退款) if (String(order.pay_status || '') !== 'paid') { logIntegration('[douyin-mashida/notify]', '订单非已支付状态,跳过发券', { @@ -315,6 +411,7 @@ async function handleInvalidateMessage(input: { message: JsonObject topic: string eventId: string + pubTime: string }): Promise { const parentOrderId = resolveDouyinMashidaParentOrderId(input.message) if (!parentOrderId) { @@ -364,6 +461,15 @@ async function handleInvalidateMessage(input: { localOrderId: order.id, reason: input.topic, }) + const afterSaleId = String(input.message.refund_id || input.message.aftersale_id || '').trim() + if (afterSaleId) { + await markPlatformAfterSaleState({ + provider: DOUYIN_MASHIDA_PROVIDER, + externalAfterSaleId: afterSaleId, + state: input.topic === 'refund_RefundSuccess' ? 'success' : 'closed', + eventAt: numberToIso(input.message.success_time || input.pubTime), + }) + } return { ok: true, @@ -376,6 +482,28 @@ async function handleInvalidateMessage(input: { } } +async function queryOrderItemsForAfterSale(orderId: number, subOrderId: string) { + const result = await query<{ id: number; sku_code: string }>( + `SELECT id, sku_code FROM order_items WHERE order_id = $1 ORDER BY id`, + [orderId], + ) + if (subOrderId) { + const matched = result.rows.find((row) => String(row.sku_code || '').trim() === subOrderId) + if (matched) return [matched] + } + return result.rows.length === 1 ? result.rows : [] +} + +function numberOrNull(value: unknown) { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : null +} + +function numberToIso(value: unknown) { + const seconds = Number(value) + return Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000).toISOString() : null +} + /** * 从 POST body 取 `data` 原文(必须与平台签名时使用的字符串完全一致)。 * 兼容两种发法:body 直接是 JSON 字符串;或 `data=` 的 urlencoded 表单。 @@ -474,9 +602,12 @@ export function buildDouyinMashidaMessageEventId(input: { const subIds = Array.isArray(input.message.s_ids) ? input.message.s_ids.map((id) => String(id || '').trim()).filter(Boolean) : [] + const afterSaleId = String(input.message.aftersale_id || input.message.refund_id || '').trim() if (parentOrderId) { - return [input.topic, input.pubTime, parentOrderId, subIds.join(',')].join(':') + return [input.topic, input.pubTime, parentOrderId, subIds.join(','), afterSaleId] + .filter(Boolean) + .join(':') } const digest = createHash('sha256') diff --git a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts index e2cc541a..8f3349f6 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts @@ -30,7 +30,7 @@ const DOUYIN_MASHIDA_CONFIG_KEY = APP_CONFIG_KEYS.douyinMashida export const DOUYIN_MASHIDA_DEFAULT_BASE_URL = 'https://dy-openplatform-api.wanmahui.com' -/** 最小接入只处理这几条:付款进单、取消/退款作废券码。 */ +/** 付款进单、售后自动发现、取消/退款作废券码。 */ /** 兑换成功后 order.message 的默认正文(可在后台覆盖)。 */ export const DEFAULT_AFTER_VERIFY_MESSAGE = '您的订单已兑换成功' @@ -40,6 +40,8 @@ export const DEFAULT_CLAIM_MESSAGE_TEMPLATE = '您的订单已发货,请点击 export const DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS = [ 'trade_TradePaid', 'trade_TradeCanceled', + 'refund_RefundCreated', + 'refund_RefundClosed', 'refund_RefundSuccess', ] as const @@ -165,7 +167,13 @@ function normalizeHandledTopics(value: unknown): string[] { } const topics = [...new Set(value.map((item) => String(item || '').trim()).filter(Boolean))] - return topics.length > 0 ? topics : [...DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS] + return [ + ...new Set([ + ...(topics.length > 0 ? topics : DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS), + 'refund_RefundCreated', + 'refund_RefundClosed', + ]), + ] } function normalizePositiveInteger(value: unknown, fallback: number) { diff --git a/apps/backend/src/services/platforms/platform-after-sales-service.ts b/apps/backend/src/services/platforms/platform-after-sales-service.ts new file mode 100644 index 00000000..13d0aef3 --- /dev/null +++ b/apps/backend/src/services/platforms/platform-after-sales-service.ts @@ -0,0 +1,283 @@ +import { query } from '../../db/client.js' +import { nowIso } from '../../utils/time.js' +import type { JsonObject } from '../../types/json.js' + +export type PlatformAfterSaleState = 'open' | 'agreed' | 'refused' | 'closed' | 'success' | 'failed' + +export type PlatformAfterSaleInput = { + provider: string + platformOrderId: string + subOrderId?: string + externalAfterSaleId: string + localOrderId?: number | null + localOrderItemId?: number | null + amountFen?: number + afterSaleStatus?: number | null + afterSaleType?: number | null + refundStatus?: number | null + platformUpdateTime?: string + state?: PlatformAfterSaleState + raw?: JsonObject + appliedAt?: string | null + eventAt?: string | null +} + +export type PlatformAfterSale = { + id: number + provider: string + platformOrderId: string + subOrderId: string + externalAfterSaleId: string + localOrderId: number | null + localOrderItemId: number | null + amountFen: number + afterSaleStatus: number | null + afterSaleType: number | null + refundStatus: number | null + platformUpdateTime: string + state: PlatformAfterSaleState + raw: JsonObject + appliedAt: string | null + lastEventAt: string | null + createdAt: string + updatedAt: string +} + +type PlatformAfterSaleRow = { + id: number + provider: string + platform_order_id: string + sub_order_id: string + external_after_sale_id: string + local_order_id: number | null + local_order_item_id: number | null + amount_fen: number | string + after_sale_status: number | null + after_sale_type: number | null + refund_status: number | null + platform_update_time: string + state: PlatformAfterSaleState + raw_json: unknown + applied_at: string | null + last_event_at: string | null + created_at: string + updated_at: string +} + +export async function upsertPlatformAfterSale( + input: PlatformAfterSaleInput, +): Promise { + const provider = normalizeText(input.provider, 80) + const platformOrderId = normalizeText(input.platformOrderId, 120) + const externalAfterSaleId = normalizeText(input.externalAfterSaleId, 120) + if (!provider || !platformOrderId || !externalAfterSaleId) return null + const now = nowIso() + const result = await query( + ` + INSERT INTO platform_after_sales ( + provider, platform_order_id, sub_order_id, external_after_sale_id, + local_order_id, local_order_item_id, amount_fen, + after_sale_status, after_sale_type, refund_status, platform_update_time, + state, raw_json, applied_at, last_event_at, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, + $8, $9, $10, $11, + $12, $13::jsonb, $14, $15, $16, $16 + ) + ON CONFLICT (provider, external_after_sale_id) + DO UPDATE SET + platform_order_id = EXCLUDED.platform_order_id, + sub_order_id = EXCLUDED.sub_order_id, + local_order_id = COALESCE(EXCLUDED.local_order_id, platform_after_sales.local_order_id), + local_order_item_id = COALESCE( + EXCLUDED.local_order_item_id, + platform_after_sales.local_order_item_id + ), + amount_fen = CASE + WHEN EXCLUDED.amount_fen > 0 THEN EXCLUDED.amount_fen + ELSE platform_after_sales.amount_fen + END, + after_sale_status = COALESCE( + EXCLUDED.after_sale_status, + platform_after_sales.after_sale_status + ), + after_sale_type = COALESCE(EXCLUDED.after_sale_type, platform_after_sales.after_sale_type), + refund_status = COALESCE(EXCLUDED.refund_status, platform_after_sales.refund_status), + platform_update_time = CASE + WHEN EXCLUDED.platform_update_time <> '' THEN EXCLUDED.platform_update_time + ELSE platform_after_sales.platform_update_time + END, + state = CASE + WHEN platform_after_sales.state IN ('agreed', 'success', 'closed', 'refused') + AND EXCLUDED.state = 'open' + THEN platform_after_sales.state + ELSE EXCLUDED.state + END, + raw_json = EXCLUDED.raw_json, + applied_at = COALESCE(EXCLUDED.applied_at, platform_after_sales.applied_at), + last_event_at = COALESCE(EXCLUDED.last_event_at, platform_after_sales.last_event_at), + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + provider, + platformOrderId, + normalizeText(input.subOrderId, 120), + externalAfterSaleId, + normalizeOptionalId(input.localOrderId), + normalizeOptionalId(input.localOrderItemId), + normalizeAmountFen(input.amountFen), + normalizeOptionalInteger(input.afterSaleStatus), + normalizeOptionalInteger(input.afterSaleType), + normalizeOptionalInteger(input.refundStatus), + normalizeText(input.platformUpdateTime, 80), + input.state || 'open', + JSON.stringify(input.raw || {}), + input.appliedAt || null, + input.eventAt || now, + now, + ], + ) + return result.rows[0] ? mapPlatformAfterSale(result.rows[0]) : null +} + +export async function findOpenPlatformAfterSale(input: { + provider: string + localOrderId: number + localOrderItemId?: number | null +}): Promise { + const params: unknown[] = [input.provider, input.localOrderId] + let itemFilter = '' + if (input.localOrderItemId) { + params.push(input.localOrderItemId) + itemFilter = `AND local_order_item_id = $${params.length}` + } + const result = await query( + ` + SELECT * + FROM platform_after_sales + WHERE provider = $1 + AND local_order_id = $2 + AND state = 'open' + ${itemFilter} + ORDER BY last_event_at DESC NULLS LAST, id DESC + LIMIT 1 + `, + params, + ) + return result.rows[0] ? mapPlatformAfterSale(result.rows[0]) : null +} + +export async function findPlatformAfterSaleByExternalId(input: { + provider: string + externalAfterSaleId: string +}): Promise { + const result = await query( + ` + SELECT * + FROM platform_after_sales + WHERE provider = $1 AND external_after_sale_id = $2 + LIMIT 1 + `, + [input.provider, input.externalAfterSaleId], + ) + return result.rows[0] ? mapPlatformAfterSale(result.rows[0]) : null +} + +export async function bindPlatformAfterSalesToLocalOrder(input: { + provider: string + platformOrderId: string + localOrderId: number + localOrderItemId?: number | null +}): Promise { + const result = await query( + ` + UPDATE platform_after_sales + SET local_order_id = $3, + local_order_item_id = COALESCE(local_order_item_id, $4), + updated_at = $5 + WHERE provider = $1 + AND platform_order_id = $2 + AND local_order_id IS NULL + `, + [ + input.provider, + input.platformOrderId, + input.localOrderId, + normalizeOptionalId(input.localOrderItemId), + nowIso(), + ], + ) + return result.rowCount || 0 +} + +export async function markPlatformAfterSaleState(input: { + provider: string + externalAfterSaleId: string + state: PlatformAfterSaleState + eventAt?: string | null +}): Promise { + await query( + ` + UPDATE platform_after_sales + SET state = CASE + WHEN state = 'success' THEN state + WHEN state IN ('agreed', 'closed', 'refused') AND $3 = 'open' THEN state + ELSE $3 + END, + last_event_at = $4, + updated_at = $4 + WHERE provider = $1 AND external_after_sale_id = $2 + `, + [input.provider, input.externalAfterSaleId, input.state, input.eventAt || nowIso()], + ) +} + +function mapPlatformAfterSale(row: PlatformAfterSaleRow): PlatformAfterSale { + return { + id: Number(row.id), + provider: row.provider, + platformOrderId: row.platform_order_id, + subOrderId: row.sub_order_id || '', + externalAfterSaleId: row.external_after_sale_id, + localOrderId: row.local_order_id ? Number(row.local_order_id) : null, + localOrderItemId: row.local_order_item_id ? Number(row.local_order_item_id) : null, + amountFen: Number(row.amount_fen || 0), + afterSaleStatus: row.after_sale_status, + afterSaleType: row.after_sale_type, + refundStatus: row.refund_status, + platformUpdateTime: row.platform_update_time || '', + state: row.state, + raw: + row.raw_json && typeof row.raw_json === 'object' && !Array.isArray(row.raw_json) + ? (row.raw_json as JsonObject) + : {}, + appliedAt: row.applied_at, + lastEventAt: row.last_event_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function normalizeText(value: unknown, maxLength: number) { + return String(value ?? '') + .trim() + .slice(0, maxLength) +} + +function normalizeOptionalId(value: unknown): number | null { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +function normalizeAmountFen(value: unknown): number { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0 +} + +function normalizeOptionalInteger(value: unknown): number | null { + if (value === null || value === undefined || value === '') return null + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : null +} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index bc6695ca..a0d0b6ea 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -71,9 +71,11 @@ import { fetchAdminWorkerProductNameSuffix, fetchAdminWorkerUsers, fetchAdminWorkOrders, + executeAdminCustomerRefund, markAdminWorkOrderProblem, markAdminWorkOrderShareProblem, pinAdminWorkOrder, + previewAdminCustomerRefund, publishAdminWorkOrder, reduceAdminWorkOrderShareQuantity, reopenAdminCancelledWorkOrder, @@ -86,6 +88,7 @@ import { updateAdminWorkOrderNote, updateAdminWorkOrderSharing, } from '@/services/admin' +import type { CustomerRefundPreview } from '@/services/admin/worker-platform' import { checkWorkOrderConsignmentMatches, fulfillWorkOrderWithConsignment, @@ -203,6 +206,11 @@ type CancelSharingPartFormValues = { reason?: string } +type CustomerRefundFormValues = { + reason: string + note?: string +} + /** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */ function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null { const consignment = asRecord((row.material as Record | undefined)?.consignment) @@ -304,6 +312,9 @@ export default function WorkOrdersPanel() { const canCancelOrder = sessionPermissions ? sessionPermissions.includes('worker_order.cancel') : hasAdminPermission('worker_order.cancel') + const canCustomerRefund = sessionPermissions + ? sessionPermissions.includes('worker_order.customer_refund') + : hasAdminPermission('worker_order.customer_refund') const canCancelShare = sessionPermissions ? sessionPermissions.includes('worker_order.cancel_share') : hasAdminPermission('worker_order.cancel_share') @@ -379,6 +390,12 @@ export default function WorkOrdersPanel() { const [noteOrder, setNoteOrder] = useState(null) const [deductOrder, setDeductOrder] = useState(null) const [cancelOrder, setCancelOrder] = useState(null) + const [customerRefundOrder, setCustomerRefundOrder] = useState(null) + const [customerRefundPreview, setCustomerRefundPreview] = useState( + null, + ) + const [customerRefundLoading, setCustomerRefundLoading] = useState(false) + const [customerRefundSubmitting, setCustomerRefundSubmitting] = useState(false) const [problemForm] = Form.useForm() const [resolutionForm] = Form.useForm() const [materialForm] = Form.useForm() @@ -393,6 +410,7 @@ export default function WorkOrdersPanel() { const [cancelSharingForm] = Form.useForm() const [cancelSharingPartForm] = Form.useForm() const [deductForm] = Form.useForm<{ amount?: number; note?: string }>() + const [customerRefundForm] = Form.useForm() const [editForm] = Form.useForm<{ productName?: string platformOrderId?: string @@ -692,6 +710,46 @@ export default function WorkOrdersPanel() { cancelForm.setFieldsValue({ action: 'return_to_hall', reason: '' }) } + async function openCustomerRefundModal(row: WorkOrder) { + setCustomerRefundOrder(row) + setCustomerRefundPreview(null) + customerRefundForm.setFieldsValue({ reason: '客户退款', note: '' }) + setCustomerRefundLoading(true) + try { + const response = await previewAdminCustomerRefund(row.workOrderId) + setCustomerRefundPreview(response.data) + } catch (error) { + setCustomerRefundOrder(null) + message.error(error instanceof Error ? error.message : '客户退款预检失败') + } finally { + setCustomerRefundLoading(false) + } + } + + async function submitCustomerRefund(values: CustomerRefundFormValues) { + if (!customerRefundOrder || !customerRefundPreview?.canExecute) return + setCustomerRefundSubmitting(true) + try { + const response = await executeAdminCustomerRefund(customerRefundOrder.workOrderId, values) + message.success( + response.data.idempotent + ? '该客户退款已处理,无需重复操作' + : `客户退款已完成,退款 ${formatMoney(customerRefundPreview.remainingFen)}`, + ) + setCustomerRefundOrder(null) + setCustomerRefundPreview(null) + customerRefundForm.resetFields() + await refreshAll() + void queryClient.invalidateQueries({ queryKey: ['admin-redeem-certificates'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-kuaishou-industry-vouchers'] }) + void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) + } catch (error) { + message.error(error instanceof Error ? error.message : '客户退款失败') + } finally { + setCustomerRefundSubmitting(false) + } + } + function applyMaterialPaste(text: string) { if (!materialOrder) return const recognized = parseWorkerMaterialText(text) @@ -1990,6 +2048,27 @@ export default function WorkOrdersPanel() { 手动匹配寄售 ) : null} + {canCustomerRefund && + [ + 'pending_material', + 'unassigned', + 'open', + 'in_progress', + 'pending_acceptance', + 'problem', + ].includes(String(row.rawStatus || row.status || '')) ? ( + + ) : null} {row.capabilities?.canCancel && (canReturnToHall || canCancelOrder) ? (