feat(collect): 补资料改为订单号直连并按状态一次性提交
- 公开补资料页 /supplement/:订单号 直接按平台订单号查询,不再依赖链接记录;旧 token 链接一律 404 - 仅「待完善 + 未指派 + 无拼单份额」可提交,提交后状态变为待分配,重复提交返回 409 - 非可编辑工单只返回商品、工单号、状态与客服提示,不返回已填资料和截图 - 管理端新增「重置补资料」:待分配且未指派、无份额、未发布时一键重置回待完善 - 新增 canResetMaterial 能力与 material_reset 流转事件,工单状态文案统一为「待分配」
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
createWorkOrderEventWithClient,
|
||||
getWorkOrderForUpdateWithClient,
|
||||
resetWorkOrderMaterialStatusWithClient,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import { mapWorkOrderAdmin } from './mappers.js'
|
||||
import {
|
||||
resolveMaterialResetBlockMessage,
|
||||
resolveMaterialResetBlockReason,
|
||||
} from './work-order-material-link-service.js'
|
||||
|
||||
/**
|
||||
* 客服重置补资料状态:把「待分配」工单改回「待完善」,让客户可以凭订单号重新提交。
|
||||
*
|
||||
* 只允许未指派、无拼单份额、未发布的工单;原资料保留,客户刷新补资料页后
|
||||
* 会看到之前填过的内容,可以在此基础上修改。
|
||||
*/
|
||||
export async function resetAdminWorkOrderMaterialStatus(
|
||||
workOrderId: number | string,
|
||||
actorName = '',
|
||||
) {
|
||||
const result = await withTransaction(async (client) => {
|
||||
const order = await getWorkOrderForUpdateWithClient(client, workOrderId)
|
||||
if (!order) {
|
||||
throw createHttpError('接单订单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const blockReason = resolveMaterialResetBlockReason(order)
|
||||
if (blockReason) {
|
||||
throw createHttpError(resolveMaterialResetBlockMessage(blockReason), {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_material_reset_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const updated = await resetWorkOrderMaterialStatusWithClient(client, {
|
||||
workOrderId: order.id,
|
||||
fromStatus: WORK_ORDER_STATUS.UNASSIGNED,
|
||||
toStatus: WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||
now,
|
||||
})
|
||||
if (!updated) {
|
||||
throw createHttpError('重置补资料状态失败,订单可能已变化', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_material_reset_failed',
|
||||
})
|
||||
}
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: order.id,
|
||||
actorType: 'admin',
|
||||
actorId: String(actorName || '').trim(),
|
||||
eventType: 'material_reset',
|
||||
fromStatus: WORK_ORDER_STATUS.UNASSIGNED,
|
||||
toStatus: WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||
payloadJson: JSON.stringify({ source: 'admin_reset' }),
|
||||
now,
|
||||
})
|
||||
|
||||
return { order: updated }
|
||||
})
|
||||
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) })
|
||||
return { order: mapWorkOrderAdmin(result.order) }
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export * from './admin-worker-cancel-service.js'
|
||||
export * from './admin-work-order-sharing-service.js'
|
||||
export * from './admin-work-order-realtime-service.js'
|
||||
export * from './admin-work-order-material-service.js'
|
||||
export * from './admin-work-order-material-reset-service.js'
|
||||
export * from './admin-work-order-gift-service.js'
|
||||
export * from './admin-mock-work-order-service.js'
|
||||
export * from './admin-mock-custom-shop-service.js'
|
||||
|
||||
@@ -290,6 +290,12 @@ export function mapWorkOrderAdmin(
|
||||
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED ||
|
||||
workOrder.status === WORK_ORDER_STATUS.OPEN ||
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS,
|
||||
canResetMaterial:
|
||||
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED &&
|
||||
!workOrder.assigned_worker_id &&
|
||||
!hasActiveShares &&
|
||||
!workOrder.published_at &&
|
||||
!workOrder.hall_queued_at,
|
||||
canAccept:
|
||||
status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE ||
|
||||
(isFriendGift &&
|
||||
|
||||
+149
-39
@@ -2,63 +2,173 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
resolveWorkOrderMaterialLinkUnavailableReason,
|
||||
formatPublicMaterialStatus,
|
||||
pickSubmittedMaterialFields,
|
||||
resolveMaterialResetBlockReason,
|
||||
resolvePublicMaterialOrderState,
|
||||
resolveWorkOrderMaterialLinkOrderBlockReason,
|
||||
} from './work-order-material-link-service.js'
|
||||
|
||||
const NOW = '2026-10-31T12:00:00.000Z'
|
||||
test('公开补资料:只有待完善且未指派/无份额可提交', () => {
|
||||
const editable = resolvePublicMaterialOrderState({
|
||||
status: 'pending_material',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 0,
|
||||
})
|
||||
assert.equal(editable.canSubmit, true)
|
||||
assert.equal(editable.blockedReason, '')
|
||||
assert.equal(editable.statusLabel, '待完善')
|
||||
|
||||
test('补资料链接:active 且未过期时可用', () => {
|
||||
const alreadySubmitted = resolvePublicMaterialOrderState({
|
||||
status: 'unassigned',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 0,
|
||||
})
|
||||
assert.equal(alreadySubmitted.canSubmit, false)
|
||||
assert.equal(alreadySubmitted.blockedReason, 'already_submitted')
|
||||
assert.equal(alreadySubmitted.statusLabel, '待分配')
|
||||
assert.match(alreadySubmitted.message, /联系客服/)
|
||||
|
||||
const assigned = resolvePublicMaterialOrderState({
|
||||
status: 'pending_material',
|
||||
assigned_worker_id: 9,
|
||||
active_share_quantity: 0,
|
||||
})
|
||||
assert.equal(assigned.canSubmit, false)
|
||||
assert.equal(assigned.blockedReason, 'assigned')
|
||||
|
||||
const shares = resolvePublicMaterialOrderState({
|
||||
status: 'pending_material',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 2,
|
||||
})
|
||||
assert.equal(shares.canSubmit, false)
|
||||
assert.equal(shares.blockedReason, 'shares')
|
||||
|
||||
const open = resolvePublicMaterialOrderState({
|
||||
status: 'open',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 0,
|
||||
})
|
||||
assert.equal(open.canSubmit, false)
|
||||
assert.equal(open.blockedReason, 'status')
|
||||
assert.equal(open.statusLabel, '待抢单')
|
||||
assert.match(open.message, /联系客服/)
|
||||
})
|
||||
|
||||
test('公开补资料:状态文案覆盖待分配与后续流程', () => {
|
||||
assert.equal(formatPublicMaterialStatus('pending_material'), '待完善')
|
||||
assert.equal(formatPublicMaterialStatus('unassigned'), '待分配')
|
||||
assert.equal(formatPublicMaterialStatus('in_progress'), '代练中')
|
||||
assert.equal(formatPublicMaterialStatus('pending_acceptance'), '待验收')
|
||||
assert.equal(formatPublicMaterialStatus('accepted'), '已验收')
|
||||
assert.equal(formatPublicMaterialStatus('cancelled'), '已取消')
|
||||
assert.equal(formatPublicMaterialStatus('unknown_status'), 'unknown_status')
|
||||
})
|
||||
|
||||
test('客服重置补资料:只允许待分配、未指派、无份额、未发布', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkUnavailableReason(
|
||||
{ status: 'active', expires_at: '2026-10-31T13:00:00.000Z' },
|
||||
NOW,
|
||||
),
|
||||
resolveMaterialResetBlockReason({
|
||||
status: 'unassigned',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 0,
|
||||
published_at: null,
|
||||
hall_queued_at: null,
|
||||
}),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
test('补资料链接:active 但已过期按过期处理', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkUnavailableReason({ status: 'active', expires_at: NOW }, NOW),
|
||||
'expired',
|
||||
resolveMaterialResetBlockReason({
|
||||
status: 'pending_material',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 0,
|
||||
published_at: null,
|
||||
hall_queued_at: null,
|
||||
}),
|
||||
'status',
|
||||
)
|
||||
assert.equal(
|
||||
resolveMaterialResetBlockReason({
|
||||
status: 'unassigned',
|
||||
assigned_worker_id: 9,
|
||||
active_share_quantity: 0,
|
||||
published_at: null,
|
||||
hall_queued_at: null,
|
||||
}),
|
||||
'assigned',
|
||||
)
|
||||
assert.equal(
|
||||
resolveMaterialResetBlockReason({
|
||||
status: 'unassigned',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 2,
|
||||
published_at: null,
|
||||
hall_queued_at: null,
|
||||
}),
|
||||
'shares',
|
||||
)
|
||||
assert.equal(
|
||||
resolveMaterialResetBlockReason({
|
||||
status: 'unassigned',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 0,
|
||||
published_at: '2026-11-01T00:00:00.000Z',
|
||||
hall_queued_at: null,
|
||||
}),
|
||||
'published',
|
||||
)
|
||||
assert.equal(
|
||||
resolveMaterialResetBlockReason({
|
||||
status: 'unassigned',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 0,
|
||||
published_at: null,
|
||||
hall_queued_at: '2026-11-01T00:00:00.000Z',
|
||||
}),
|
||||
'published',
|
||||
)
|
||||
})
|
||||
|
||||
test('补资料链接:used / expired / revoked 分别返回对应原因', () => {
|
||||
test('管理端链接准入:待完善/待分配可用,已指派/有份额/其他状态拒绝', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkUnavailableReason(
|
||||
{ status: 'used', expires_at: '2026-10-31T13:00:00.000Z' },
|
||||
NOW,
|
||||
),
|
||||
'used',
|
||||
resolveWorkOrderMaterialLinkOrderBlockReason({
|
||||
status: 'pending_material',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 0,
|
||||
}),
|
||||
'',
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkUnavailableReason(
|
||||
{ status: 'expired', expires_at: '2026-10-31T13:00:00.000Z' },
|
||||
NOW,
|
||||
),
|
||||
'expired',
|
||||
resolveWorkOrderMaterialLinkOrderBlockReason({
|
||||
status: 'unassigned',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 0,
|
||||
}),
|
||||
'',
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkUnavailableReason(
|
||||
{ status: 'revoked', expires_at: '2026-10-31T13:00:00.000Z' },
|
||||
NOW,
|
||||
),
|
||||
'revoked',
|
||||
resolveWorkOrderMaterialLinkOrderBlockReason({
|
||||
status: 'open',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 0,
|
||||
}),
|
||||
'status',
|
||||
)
|
||||
})
|
||||
|
||||
test('补资料链接:未知状态一律视为失效,避免误放行', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkUnavailableReason(
|
||||
{
|
||||
status: 'unknown' as never,
|
||||
expires_at: '2026-10-31T13:00:00.000Z',
|
||||
},
|
||||
NOW,
|
||||
),
|
||||
'revoked',
|
||||
resolveWorkOrderMaterialLinkOrderBlockReason({
|
||||
status: 'pending_material',
|
||||
assigned_worker_id: 9,
|
||||
active_share_quantity: 0,
|
||||
}),
|
||||
'assigned',
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkOrderBlockReason({
|
||||
status: 'pending_material',
|
||||
assigned_worker_id: null,
|
||||
active_share_quantity: 2,
|
||||
}),
|
||||
'shares',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -5,15 +5,11 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
createWorkOrderEventWithClient,
|
||||
createWorkOrderMaterialLinkWithClient,
|
||||
findWorkOrderMaterialLinkByToken,
|
||||
findActiveWorkOrderMaterialLinkWithClient,
|
||||
getWorkOrderForUpdateWithClient,
|
||||
getWorkOrderMaterialLinkForUpdateWithClient,
|
||||
markWorkOrderMaterialLinkExpired,
|
||||
markWorkOrderMaterialLinkUsedWithClient,
|
||||
listWorkOrdersByPlatformOrderId,
|
||||
revokeActiveWorkOrderMaterialLinksWithClient,
|
||||
updateWorkOrderMaterialWithClient,
|
||||
type WorkOrderMaterialLinkRow,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
@@ -26,6 +22,7 @@ import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-servi
|
||||
import {
|
||||
mapCollectLookupOrder,
|
||||
mapWorkOrderAdmin,
|
||||
normalizeOptionalId,
|
||||
normalizeSubmittedFields,
|
||||
normalizeUploadedFiles,
|
||||
resolveRequirementFields,
|
||||
@@ -40,29 +37,142 @@ const ALLOWED_MATERIAL_LINK_ORDER_STATUSES = new Set<string>([
|
||||
|
||||
const MAX_MATERIAL_SCREENSHOTS = 5
|
||||
|
||||
export type WorkOrderMaterialLinkUnavailableReason = 'used' | 'expired' | 'revoked' | ''
|
||||
const PUBLIC_MATERIAL_STATUS_LABELS: Record<string, string> = {
|
||||
[WORK_ORDER_STATUS.PENDING_MATERIAL]: '待完善',
|
||||
[WORK_ORDER_STATUS.UNASSIGNED]: '待分配',
|
||||
[WORK_ORDER_STATUS.OPEN]: '待抢单',
|
||||
[WORK_ORDER_STATUS.IN_PROGRESS]: '代练中',
|
||||
[WORK_ORDER_STATUS.PENDING_ACCEPTANCE]: '待验收',
|
||||
[WORK_ORDER_STATUS.PROBLEM]: '问题单',
|
||||
[WORK_ORDER_STATUS.ACCEPTED]: '已验收',
|
||||
[WORK_ORDER_STATUS.CANCELLED]: '已取消',
|
||||
vip_evidence_pending: '待补图',
|
||||
}
|
||||
|
||||
export type WorkOrderMaterialLinkOrderBlockReason = 'status' | 'assigned' | 'shares' | ''
|
||||
|
||||
export type PublicMaterialOrderBlockReason =
|
||||
| 'already_submitted'
|
||||
| 'status'
|
||||
| 'assigned'
|
||||
| 'shares'
|
||||
| ''
|
||||
|
||||
export type PublicMaterialOrderState = {
|
||||
canSubmit: boolean
|
||||
blockedReason: PublicMaterialOrderBlockReason
|
||||
statusLabel: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type MaterialResetBlockReason = 'status' | 'assigned' | 'shares' | 'published' | ''
|
||||
|
||||
/** 公开页状态文案;后台状态列/时间线也统一用「待分配」。 */
|
||||
export function formatPublicMaterialStatus(status: string): string {
|
||||
const normalized = String(status || '').trim()
|
||||
return PUBLIC_MATERIAL_STATUS_LABELS[normalized] || normalized || '未知状态'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断链接是否还能用。
|
||||
* 公开补资料页的准入判断。
|
||||
*
|
||||
* 只做纯函数判断,方便单测覆盖;数据库状态与过期时间都由调用方传入。
|
||||
* 只有「待完善 + 未指派 + 无拼单份额」可以提交;待分配视为已提交,
|
||||
* 其他状态只展示状态与客服提示,不再展示表单。
|
||||
*/
|
||||
export function resolveWorkOrderMaterialLinkUnavailableReason(
|
||||
link: Pick<WorkOrderMaterialLinkRow, 'status' | 'expires_at'>,
|
||||
now: string,
|
||||
): WorkOrderMaterialLinkUnavailableReason {
|
||||
if (link.status === 'used') return 'used'
|
||||
if (link.status === 'revoked') return 'revoked'
|
||||
if (link.status === 'expired') return 'expired'
|
||||
if (link.status !== 'active') return 'revoked'
|
||||
if (new Date(link.expires_at).getTime() <= new Date(now).getTime()) return 'expired'
|
||||
export function resolvePublicMaterialOrderState(
|
||||
order: Pick<WorkOrderRow, 'status' | 'assigned_worker_id' | 'active_share_quantity'>,
|
||||
): PublicMaterialOrderState {
|
||||
const statusLabel = formatPublicMaterialStatus(order.status)
|
||||
|
||||
if (order.status === WORK_ORDER_STATUS.UNASSIGNED) {
|
||||
return {
|
||||
canSubmit: false,
|
||||
blockedReason: 'already_submitted',
|
||||
statusLabel,
|
||||
message: '资料已提交,订单已进入待分配;如需修改请联系客服。',
|
||||
}
|
||||
}
|
||||
|
||||
if (order.status !== WORK_ORDER_STATUS.PENDING_MATERIAL) {
|
||||
return {
|
||||
canSubmit: false,
|
||||
blockedReason: 'status',
|
||||
statusLabel,
|
||||
message: `当前状态为「${statusLabel}」,不能修改资料;如需修改请联系客服。`,
|
||||
}
|
||||
}
|
||||
|
||||
if (order.assigned_worker_id) {
|
||||
return {
|
||||
canSubmit: false,
|
||||
blockedReason: 'assigned',
|
||||
statusLabel,
|
||||
message: '订单已指派打手,不能修改资料;如需修改请联系客服。',
|
||||
}
|
||||
}
|
||||
|
||||
if (Number(order.active_share_quantity || 0) > 0) {
|
||||
return {
|
||||
canSubmit: false,
|
||||
blockedReason: 'shares',
|
||||
statusLabel,
|
||||
message: '订单已有拼单份额,不能修改资料;如需修改请联系客服。',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canSubmit: true,
|
||||
blockedReason: '',
|
||||
statusLabel,
|
||||
message: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 客服重置补资料状态的前置条件。
|
||||
*
|
||||
* 只允许「待分配 + 未指派 + 无份额 + 未发布」的工单回到待完善;
|
||||
* 其他状态一律拒绝,避免把已进入接单流程的工单改回可编辑。
|
||||
*/
|
||||
export function resolveMaterialResetBlockReason(
|
||||
order: Pick<
|
||||
WorkOrderRow,
|
||||
'status' | 'assigned_worker_id' | 'active_share_quantity' | 'published_at' | 'hall_queued_at'
|
||||
>,
|
||||
): MaterialResetBlockReason {
|
||||
if (order.status !== WORK_ORDER_STATUS.UNASSIGNED) return 'status'
|
||||
if (order.assigned_worker_id) return 'assigned'
|
||||
if (Number(order.active_share_quantity || 0) > 0) return 'shares'
|
||||
if (order.published_at || order.hall_queued_at) return 'published'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveMaterialResetBlockMessage(reason: MaterialResetBlockReason): string {
|
||||
if (reason === 'assigned') return '订单已指派打手,不能重置补资料状态'
|
||||
if (reason === 'shares') return '订单已有拼单份额,不能重置补资料状态'
|
||||
if (reason === 'published') return '订单已发布到接单大厅,不能重置补资料状态'
|
||||
return '只有待分配(未指派、无份额、未发布)的工单可以重置补资料状态'
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端生成/复用一次性链接的准入判断。
|
||||
*
|
||||
* 用户要求保留现有“复制补资料链接”按钮,所以这里仍按原口径允许
|
||||
* 待完善 / 待分配,并拒绝已指派/有份额的工单。
|
||||
*/
|
||||
export function resolveWorkOrderMaterialLinkOrderBlockReason(
|
||||
order: Pick<WorkOrderRow, 'status' | 'assigned_worker_id' | 'active_share_quantity'>,
|
||||
): WorkOrderMaterialLinkOrderBlockReason {
|
||||
if (!ALLOWED_MATERIAL_LINK_ORDER_STATUSES.has(order.status)) return 'status'
|
||||
if (order.assigned_worker_id) return 'assigned'
|
||||
if (Number(order.active_share_quantity || 0) > 0) return 'shares'
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 只保留工单 requirement 里定义过的字段。
|
||||
*
|
||||
* 公开链接的提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的
|
||||
* 公开提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的
|
||||
* 额外 key 混进 material.collect.fields;这里按白名单裁剪一次。
|
||||
*/
|
||||
export function pickSubmittedMaterialFields(
|
||||
@@ -73,57 +183,42 @@ export function pickSubmittedMaterialFields(
|
||||
return Object.fromEntries(Object.entries(submitted).filter(([key]) => allowedKeys.has(key)))
|
||||
}
|
||||
|
||||
function throwMaterialLinkUnavailable(
|
||||
reason: Exclude<WorkOrderMaterialLinkUnavailableReason, ''>,
|
||||
): never {
|
||||
if (reason === 'used') {
|
||||
throw createHttpError('链接已使用,如需修改资料请联系客服重新获取', {
|
||||
statusCode: 410,
|
||||
errorCode: 'material_link_used',
|
||||
function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never {
|
||||
if (state.blockedReason === 'already_submitted') {
|
||||
throw createHttpError(state.message, {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_already_submitted',
|
||||
})
|
||||
}
|
||||
if (reason === 'expired') {
|
||||
throw createHttpError('链接已过期,请联系客服重新获取', {
|
||||
statusCode: 410,
|
||||
errorCode: 'material_link_expired',
|
||||
})
|
||||
}
|
||||
throw createHttpError('链接已失效,请联系客服重新获取', {
|
||||
statusCode: 410,
|
||||
errorCode: 'material_link_revoked',
|
||||
throw createHttpError(state.message, {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_order_status_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开链接只对「待完善 / 未分配」开放,且不能已经指派打手或有进行中的拼单份额。
|
||||
* 这三个条件同时满足,提交后才可以安全地把状态改成「未分配」。
|
||||
*/
|
||||
/** 管理端生成补资料链接时仍沿用旧的准入文案。 */
|
||||
function assertWorkOrderAllowedForMaterialLink(order: WorkOrderRow): void {
|
||||
if (!ALLOWED_MATERIAL_LINK_ORDER_STATUSES.has(order.status)) {
|
||||
throw createHttpError('订单当前状态不允许补资料,请联系客服重新获取链接', {
|
||||
const reason = resolveWorkOrderMaterialLinkOrderBlockReason(order)
|
||||
if (reason === 'status') {
|
||||
throw createHttpError('当前状态不能生成补资料链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_order_status_invalid',
|
||||
errorCode: 'material_link_status_invalid',
|
||||
})
|
||||
}
|
||||
if (order.assigned_worker_id) {
|
||||
throw createHttpError('订单已指派打手,不能通过公开链接修改资料', {
|
||||
if (reason === 'assigned') {
|
||||
throw createHttpError('订单已指派打手,不能生成补资料链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_order_status_invalid',
|
||||
errorCode: 'material_link_status_invalid',
|
||||
})
|
||||
}
|
||||
if (Number(order.active_share_quantity || 0) > 0) {
|
||||
throw createHttpError('订单已有拼单份额,不能通过公开链接修改资料', {
|
||||
if (reason === 'shares') {
|
||||
throw createHttpError('订单已有拼单份额,不能生成补资料链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_order_status_invalid',
|
||||
errorCode: 'material_link_status_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function assertMaterialLinkRecordUsable(link: WorkOrderMaterialLinkRow, now: string): void {
|
||||
const reason = resolveWorkOrderMaterialLinkUnavailableReason(link, now)
|
||||
if (reason) throwMaterialLinkUnavailable(reason)
|
||||
}
|
||||
|
||||
function mapPublicOrder(order: WorkOrderRow) {
|
||||
return {
|
||||
workOrderNo: order.work_order_no,
|
||||
@@ -139,11 +234,67 @@ function toIsoTimestamp(value: string): string {
|
||||
return Number.isNaN(date.getTime()) ? String(value || '') : date.toISOString()
|
||||
}
|
||||
|
||||
function mapPublicMaterialOrder(order: WorkOrderRow) {
|
||||
const state = resolvePublicMaterialOrderState(order)
|
||||
const base = {
|
||||
workOrderId: Number(order.id),
|
||||
workOrderNo: order.work_order_no,
|
||||
platformOrderId: order.platform_order_id,
|
||||
productName: order.product_name,
|
||||
status: order.status,
|
||||
statusLabel: state.statusLabel,
|
||||
canSubmit: state.canSubmit,
|
||||
blockedReason: state.blockedReason,
|
||||
message: state.message,
|
||||
}
|
||||
if (!state.canSubmit) return base
|
||||
|
||||
const mapped = mapCollectLookupOrder(order)
|
||||
return {
|
||||
...base,
|
||||
fields: mapped.fields,
|
||||
values: mapped.values,
|
||||
screenshots: mapped.screenshots,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePublicMaterialTarget(
|
||||
platformOrderId: string,
|
||||
workOrderIdInput: unknown,
|
||||
): Promise<WorkOrderRow> {
|
||||
const workOrderId = normalizeOptionalId(workOrderIdInput)
|
||||
if (workOrderId) {
|
||||
const order = await getRequiredWorkOrder(workOrderId)
|
||||
if (String(order.platform_order_id || '').trim() !== platformOrderId) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_work_order_invalid',
|
||||
})
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
const orders = await listWorkOrdersByPlatformOrderId(platformOrderId)
|
||||
if (orders.length === 0) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
if (orders.length > 1) {
|
||||
throw createHttpError('该订单号下有多个商品,请先选择商品', {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_work_order_required',
|
||||
})
|
||||
}
|
||||
return orders[0]!
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端生成/复用一次性链接。
|
||||
*
|
||||
* 幂等策略:同一工单已有未过期 active 链接时直接返回,避免客服连续点两次
|
||||
* 把刚发出去的链接顶掉;只有已使用/已过期/已撤销时才生成新 token。
|
||||
* 用户要求保留现有“复制补资料链接”按钮,因此链接记录仍会生成;
|
||||
* 但公开页已经不再依赖它,复制出的 `/supplement/<平台订单号>` 始终可用。
|
||||
*/
|
||||
export async function createAdminWorkOrderMaterialLink(
|
||||
workOrderId: number | string,
|
||||
@@ -166,6 +317,15 @@ export async function createAdminWorkOrderMaterialLink(
|
||||
errorCode: 'material_link_status_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const platformOrderId = String(order.platform_order_id || '').trim()
|
||||
if (!platformOrderId) {
|
||||
throw createHttpError('该工单没有平台订单号,不能生成补资料链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_platform_order_missing',
|
||||
})
|
||||
}
|
||||
|
||||
assertWorkOrderAllowedForMaterialLink(order)
|
||||
|
||||
// 锁到手后再取时间,避免等锁期间旧链接过期却仍按旧时间复用。
|
||||
@@ -174,6 +334,7 @@ export async function createAdminWorkOrderMaterialLink(
|
||||
if (existing) {
|
||||
return {
|
||||
token: existing.token,
|
||||
platformOrderId,
|
||||
expiresAt: toIsoTimestamp(existing.expires_at),
|
||||
reused: true,
|
||||
}
|
||||
@@ -192,6 +353,7 @@ export async function createAdminWorkOrderMaterialLink(
|
||||
|
||||
return {
|
||||
token: link.token,
|
||||
platformOrderId,
|
||||
expiresAt: toIsoTimestamp(link.expires_at),
|
||||
reused: false,
|
||||
}
|
||||
@@ -199,83 +361,76 @@ export async function createAdminWorkOrderMaterialLink(
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页读取链接内容。
|
||||
* 公开页按平台订单号读取补资料内容。
|
||||
*
|
||||
* 只返回客户补资料需要的最少信息:商品/单号、动态字段、已填值、截图、过期时间。
|
||||
* 不返回发单价格、押金、成本、打手、后台备注等任何后台字段。
|
||||
* 不再要求客服先生成链接;只要订单号存在,就返回该订单号下所有工单的
|
||||
* 状态与准入信息。只有可提交的待完善工单返回字段/已填值/截图,其他状态
|
||||
* 只返回商品、工单号、状态和客服提示。
|
||||
*/
|
||||
export async function getPublicWorkOrderMaterialLink(token: string) {
|
||||
const link = await findWorkOrderMaterialLinkByToken(token)
|
||||
if (!link) {
|
||||
throw createHttpError('链接无效,请确认链接是否完整', {
|
||||
export async function getPublicWorkOrderMaterialLink(platformOrderId: string) {
|
||||
const code = String(platformOrderId || '').trim()
|
||||
if (!code) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const reason = resolveWorkOrderMaterialLinkUnavailableReason(link, now)
|
||||
if (reason) {
|
||||
if (reason === 'expired') {
|
||||
await markWorkOrderMaterialLinkExpired(link.id, now)
|
||||
}
|
||||
throwMaterialLinkUnavailable(reason)
|
||||
const orders = await listWorkOrdersByPlatformOrderId(code)
|
||||
if (orders.length === 0) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const order = await getRequiredWorkOrder(link.work_order_id)
|
||||
assertWorkOrderAllowedForMaterialLink(order)
|
||||
|
||||
const mapped = mapCollectLookupOrder(order)
|
||||
const publicOrders = orders.map(mapPublicMaterialOrder)
|
||||
return {
|
||||
order: {
|
||||
workOrderNo: mapped.workOrderNo,
|
||||
platformOrderId: mapped.platformOrderId,
|
||||
productName: mapped.productName,
|
||||
status: mapped.status,
|
||||
},
|
||||
fields: mapped.fields,
|
||||
values: mapped.values,
|
||||
screenshots: mapped.screenshots,
|
||||
expiresAt: toIsoTimestamp(link.expires_at),
|
||||
platformOrderId: code,
|
||||
orders: publicOrders,
|
||||
pendingCount: publicOrders.filter((order) => order.canSubmit).length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页提交资料。
|
||||
*
|
||||
* 一次性语义:只有「完整提交」成功时才把 token 标为 used;
|
||||
* 少填必填项、订单状态变化、网络失败等都会在事务里回滚,链接继续可用。
|
||||
* 一次性语义完全由工单状态保证:只有 `pending_material` 能成功提交,
|
||||
* 提交后改成 `unassigned`;并发的第二个提交会锁到 `unassigned` 并返回 409。
|
||||
*/
|
||||
export async function submitPublicWorkOrderMaterialLink(token: string, payload: JsonObject = {}) {
|
||||
const preliminary = await findWorkOrderMaterialLinkByToken(token)
|
||||
if (!preliminary) {
|
||||
throw createHttpError('链接无效,请确认链接是否完整', {
|
||||
export async function submitPublicWorkOrderMaterialLink(
|
||||
platformOrderId: string,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const code = String(platformOrderId || '').trim()
|
||||
if (!code) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const target = await resolvePublicMaterialTarget(code, payload.workOrderId)
|
||||
const targetState = resolvePublicMaterialOrderState(target)
|
||||
if (!targetState.canSubmit) throwPublicMaterialBlocked(targetState)
|
||||
|
||||
const result = await withTransaction(async (client) => {
|
||||
// 先锁工单再锁链接,和后台生成链接保持同样的加锁顺序,避免死锁。
|
||||
const order = await getWorkOrderForUpdateWithClient(client, preliminary.work_order_id)
|
||||
// 锁工单:并发提交/客服重置都串行化,状态是唯一的一次性凭据。
|
||||
const order = await getWorkOrderForUpdateWithClient(client, target.id)
|
||||
if (!order) {
|
||||
throw createHttpError('接单订单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_not_found',
|
||||
})
|
||||
}
|
||||
const link = await getWorkOrderMaterialLinkForUpdateWithClient(client, preliminary.id)
|
||||
if (!link) {
|
||||
throw createHttpError('链接无效,请确认链接是否完整', {
|
||||
if (String(order.platform_order_id || '').trim() !== code) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
errorCode: 'material_link_work_order_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
// 锁到手后再取时间:等锁期间链接可能已经过期,不能沿用进入事务前的时间。
|
||||
const now = nowIso()
|
||||
assertMaterialLinkRecordUsable(link, now)
|
||||
assertWorkOrderAllowedForMaterialLink(order)
|
||||
const orderState = resolvePublicMaterialOrderState(order)
|
||||
if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState)
|
||||
|
||||
const fields = resolveRequirementFields(order)
|
||||
const submittedFields = pickSubmittedMaterialFields(
|
||||
@@ -304,6 +459,7 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload:
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const material = {
|
||||
...safeParseJson(order.material_json),
|
||||
collect: {
|
||||
@@ -311,7 +467,7 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload:
|
||||
screenshots,
|
||||
submittedAt: now,
|
||||
complete: true,
|
||||
source: 'customer_link',
|
||||
source: 'customer_order_no',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -328,33 +484,22 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload:
|
||||
})
|
||||
}
|
||||
|
||||
const used = await markWorkOrderMaterialLinkUsedWithClient(client, link.id, now)
|
||||
if (!used) {
|
||||
// 并发提交,或刚好在写入瞬间过期:按当前状态给出准确原因,事务整体回滚。
|
||||
const latest = await getWorkOrderMaterialLinkForUpdateWithClient(client, link.id)
|
||||
if (latest) {
|
||||
const reason = resolveWorkOrderMaterialLinkUnavailableReason(latest, now)
|
||||
if (reason) throwMaterialLinkUnavailable(reason)
|
||||
}
|
||||
throwMaterialLinkUnavailable('used')
|
||||
}
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: order.id,
|
||||
actorType: 'collect',
|
||||
actorId: '客户补资料链接',
|
||||
actorId: '客户按订单号补资料',
|
||||
eventType: 'material_completed',
|
||||
fromStatus: order.status,
|
||||
toStatus: WORK_ORDER_STATUS.UNASSIGNED,
|
||||
payloadJson: JSON.stringify({
|
||||
fields: submittedFields,
|
||||
complete: true,
|
||||
source: 'customer_link',
|
||||
source: 'customer_order_no',
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
return { order: updated, link: used }
|
||||
return { order: updated }
|
||||
})
|
||||
|
||||
// 事务已提交,后续通知/实时广播失败不能让客户误以为提交失败。
|
||||
@@ -374,28 +519,30 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload:
|
||||
|
||||
return {
|
||||
complete: true,
|
||||
workOrderId: Number(result.order.id),
|
||||
order: mapPublicOrder(result.order),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前校验 token 与工单状态;返回工单 id 供文件归属使用。
|
||||
* 注意:这里不消耗 token,只有最终提交才消耗。
|
||||
* 上传前校验平台订单号 / workOrderId 与工单状态;返回工单供文件归属使用。
|
||||
* 注意:这里不消耗提交机会,只有最终提交才把状态改成待分配。
|
||||
*/
|
||||
export async function getPublicWorkOrderMaterialLinkUploadContext(token: string) {
|
||||
const link = await findWorkOrderMaterialLinkByToken(token)
|
||||
if (!link) {
|
||||
throw createHttpError('链接无效,请确认链接是否完整', {
|
||||
export async function getPublicWorkOrderMaterialLinkUploadContext(
|
||||
platformOrderId: string,
|
||||
workOrderId?: unknown,
|
||||
) {
|
||||
const code = String(platformOrderId || '').trim()
|
||||
if (!code) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
assertMaterialLinkRecordUsable(link, now)
|
||||
const order = await resolvePublicMaterialTarget(code, workOrderId)
|
||||
const orderState = resolvePublicMaterialOrderState(order)
|
||||
if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState)
|
||||
|
||||
const order = await getRequiredWorkOrder(link.work_order_id)
|
||||
assertWorkOrderAllowedForMaterialLink(order)
|
||||
|
||||
return { link, order, now }
|
||||
return { order, now: nowIso() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user