feat(worker-platform): 支持客户退款冲正并关闭订单

This commit is contained in:
yml2213
2026-11-05 17:15:41 +08:00
parent b72d5877da
commit fc928c6b18
15 changed files with 2129 additions and 6 deletions
@@ -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';
@@ -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<string, unknown>) : {},
}),
},
),
)
router.delete(
'/worker-platform/orders/:workOrderId',
requireAdminPermission('worker_order.delete'),
@@ -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',
File diff suppressed because it is too large Load Diff
@@ -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')"))
})
@@ -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')
@@ -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<JsonObject> {
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: {
@@ -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: {} })
@@ -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<DouyinMashidaMessageResult> {
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<DouyinMashidaMessageResult> {
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<DouyinMashidaMessageResult> {
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=<json>` 的 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')
@@ -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) {
@@ -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<PlatformAfterSale | null> {
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<PlatformAfterSaleRow>(
`
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<PlatformAfterSale | null> {
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<PlatformAfterSaleRow>(
`
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<PlatformAfterSale | null> {
const result = await query<PlatformAfterSaleRow>(
`
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<number> {
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<void> {
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
}
@@ -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<string, unknown> | 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<WorkOrder | null>(null)
const [deductOrder, setDeductOrder] = useState<WorkOrder | null>(null)
const [cancelOrder, setCancelOrder] = useState<WorkOrder | null>(null)
const [customerRefundOrder, setCustomerRefundOrder] = useState<WorkOrder | null>(null)
const [customerRefundPreview, setCustomerRefundPreview] = useState<CustomerRefundPreview | null>(
null,
)
const [customerRefundLoading, setCustomerRefundLoading] = useState(false)
const [customerRefundSubmitting, setCustomerRefundSubmitting] = useState(false)
const [problemForm] = Form.useForm()
const [resolutionForm] = Form.useForm()
const [materialForm] = Form.useForm<MaterialFormValues>()
@@ -393,6 +410,7 @@ export default function WorkOrdersPanel() {
const [cancelSharingForm] = Form.useForm<CancelSharingFormValues>()
const [cancelSharingPartForm] = Form.useForm<CancelSharingPartFormValues>()
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
const [customerRefundForm] = Form.useForm<CustomerRefundFormValues>()
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() {
手动匹配寄售
</Button>
) : null}
{canCustomerRefund &&
[
'pending_material',
'unassigned',
'open',
'in_progress',
'pending_acceptance',
'problem',
].includes(String(row.rawStatus || row.status || '')) ? (
<Button
danger
icon={<RollbackOutlined />}
loading={
customerRefundLoading && customerRefundOrder?.workOrderId === row.workOrderId
}
onClick={() => void openCustomerRefundModal(row)}
title="同意平台用户退款,并关闭工单、资料、领取链接和待核销任务"
>
客户退款
</Button>
) : null}
{row.capabilities?.canCancel && (canReturnToHall || canCancelOrder) ? (
<Button danger icon={<UndoOutlined />} onClick={() => openCancelModal(row)}>
撤单
@@ -3114,6 +3193,80 @@ export default function WorkOrdersPanel() {
</Form>
</Modal>
<Modal
title={`客户退款 · ${getDisplayOrderNo(customerRefundOrder)}`}
open={Boolean(customerRefundOrder)}
onCancel={() => {
setCustomerRefundOrder(null)
setCustomerRefundPreview(null)
customerRefundForm.resetFields()
}}
onOk={() => customerRefundForm.submit()}
okText="同意退款并关闭订单"
okButtonProps={{
danger: true,
disabled: customerRefundLoading || !customerRefundPreview?.canExecute,
}}
confirmLoading={customerRefundSubmitting}
destroyOnHidden
width={620}
>
{customerRefundLoading ? (
<Alert type="info" showIcon message="正在查询平台用户售后单..." />
) : customerRefundPreview ? (
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message="该操作会同意平台用户退款,并关闭订单下全部工单、资料提交、领取链接和待核销任务。"
description="订单、退款、成本与资金流水会保留用于审计;存在已验收或已结算内容时系统会拒绝执行。"
/>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="平台">
{customerRefundPreview.platform} / {customerRefundPreview.provider}
</Descriptions.Item>
<Descriptions.Item label="平台订单号">
{customerRefundPreview.platformOrderId}
</Descriptions.Item>
<Descriptions.Item label="平台售后单号">
{customerRefundPreview.externalAfterSaleId}
</Descriptions.Item>
<Descriptions.Item label="退款金额">
{formatMoney(customerRefundPreview.remainingFen)}
</Descriptions.Item>
<Descriptions.Item label="关闭范围">
{customerRefundPreview.workOrderCount} 个工单 / {customerRefundPreview.itemCount}{' '}
个订单项
</Descriptions.Item>
</Descriptions>
{customerRefundPreview.notes.length > 0 ? (
<Alert
type={customerRefundPreview.canExecute ? 'warning' : 'error'}
showIcon
message={customerRefundPreview.canExecute ? '处理提示' : '预检未通过'}
description={customerRefundPreview.notes.join(';')}
/>
) : null}
<Form
form={customerRefundForm}
layout="vertical"
onFinish={(values) => void submitCustomerRefund(values)}
>
<Form.Item
label="退款原因"
name="reason"
rules={[{ required: true, message: '请填写退款原因' }]}
>
<Input maxLength={200} />
</Form.Item>
<Form.Item label="处理备注" name="note">
<Input.TextArea rows={3} maxLength={500} showCount />
</Form.Item>
</Form>
</Space>
) : null}
</Modal>
<Modal
title={`撤单处理 · ${getDisplayOrderNo(cancelOrder)}`}
open={Boolean(cancelOrder)}
@@ -67,7 +67,13 @@ const MESSAGE_TYPE_OPTIONS = [
]
/** 默认本地处理白名单,作为 Select 的候选标签 */
const DEFAULT_HANDLED_TOPICS = ['trade_TradePaid', 'trade_TradeCanceled', 'refund_RefundSuccess']
const DEFAULT_HANDLED_TOPICS = [
'trade_TradePaid',
'trade_TradeCanceled',
'refund_RefundCreated',
'refund_RefundClosed',
'refund_RefundSuccess',
]
const DEFAULT_BASE_URL = 'https://dy-openplatform-api.wanmahui.com'
@@ -54,6 +54,24 @@ type WorkOrderVoucherConsumeResult = {
export type AdminLeaderboardPeriod = 'all' | 'daily' | 'weekly' | 'monthly'
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 type AdminWorkerLeaderboardEntry = {
rank: number
workerId: number
@@ -855,6 +873,29 @@ export function deleteAdminWorkOrder(workOrderId: number) {
return apiDelete<{ deleted: boolean }>(`/api/v1/admin/worker-platform/orders/${workOrderId}`)
}
export function previewAdminCustomerRefund(workOrderId: number) {
return apiPost<CustomerRefundPreview>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/customer-refund/preview`,
)
}
export function executeAdminCustomerRefund(
workOrderId: number,
payload: { reason: string; note?: string },
) {
return apiPost<{
idempotent: boolean
operationId: number
localRefundRecorded?: boolean
workOrderId?: number
orderId?: number
cancelledWorkOrders?: number
cancelledShares?: number
releasedDepositFen?: number
closedTaskCount?: number
}>(`/api/v1/admin/worker-platform/orders/${workOrderId}/customer-refund`, payload)
}
export function unpublishAdminWorkOrder(workOrderId: number) {
return apiPost<{ order: WorkOrder }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/unpublish`,
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
test('接单工单提供客户退款入口和确认弹窗', async () => {
const [panel, service] = await Promise.all([
readFile(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url), 'utf8'),
readFile(new URL('../src/services/admin/worker-platform.ts', import.meta.url), 'utf8'),
])
assert.ok(panel.includes('客户退款'))
assert.ok(panel.includes('同意退款并关闭订单'))
assert.ok(panel.includes('worker_order.customer_refund'))
assert.ok(panel.includes('previewAdminCustomerRefund'))
assert.ok(panel.includes('executeAdminCustomerRefund'))
assert.ok(service.includes('/customer-refund/preview'))
assert.ok(service.includes('/customer-refund'))
})