feat(order-service): 重构分类资料收集与订单状态链路

This commit is contained in:
yml2213
2026-11-03 19:46:49 +08:00
parent 6e4758a3c7
commit 9f14e8de7a
59 changed files with 4866 additions and 1734 deletions
@@ -59,7 +59,12 @@ const ALLOWED_CONTENT_TYPES = new Set([
'video/webm',
])
const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
export const ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
])
const IMAGE_CONTENT_TYPES = ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES
export type UploadedFileDto = {
objectKey: string
@@ -78,6 +83,7 @@ type UploadFileAssetInput = {
scene: unknown
uploaderType: string
uploaderId: string
allowedContentTypes?: ReadonlySet<string>
/** 前端读取到的视频时长(秒),仅在容器里解析不到时作为兜底 */
durationSeconds?: unknown
}
@@ -92,11 +98,17 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
}
const contentType = normalizeContentType(file.mimetype, file.buffer)
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP、PDF 或 MP4/WebM 视频', {
statusCode: 400,
errorCode: 'upload_file_type_invalid',
})
const allowedContentTypes = input.allowedContentTypes || ALLOWED_CONTENT_TYPES
if (!allowedContentTypes.has(contentType)) {
throw createHttpError(
input.allowedContentTypes
? '文件不符合规则,仅支持 JPG、PNG、WebP 图片'
: '文件不符合规则,仅支持 JPG、PNG、WebP、PDF 或 MP4/WebM 视频',
{
statusCode: 400,
errorCode: 'upload_file_type_invalid',
},
)
}
const isVideo = VIDEO_CONTENT_TYPES.has(contentType)
@@ -0,0 +1,43 @@
import { query } from '../../db/client.js'
import { nowIso } from '../../utils/time.js'
export async function enqueueCustomerOrderNotification(input: {
orderId?: number | string | null
orderItemId?: number | string | null
workOrderId?: number | string | null
eventType: string
templateKey?: string
payload?: Record<string, unknown>
}) {
const dedupeKey = [
'order',
input.orderId || 0,
input.workOrderId || input.orderItemId || 0,
input.eventType,
].join(':')
await query(
`
INSERT INTO customer_order_notifications (
order_id, order_item_id, work_order_id, event_type,
channel, status, phone, template_key, payload_json,
dedupe_key, next_run_at
) VALUES (
$1, $2, $3, $4,
'sms', 'skipped', '', $5, $6::jsonb,
$7, NOW()
)
ON CONFLICT (dedupe_key) DO NOTHING
`,
[
input.orderId ? Number(input.orderId) : null,
input.orderItemId ? Number(input.orderItemId) : null,
input.workOrderId ? Number(input.workOrderId) : null,
input.eventType,
input.templateKey || input.eventType,
JSON.stringify(input.payload || {}),
dedupeKey,
],
)
return { dedupeKey, at: nowIso() }
}
@@ -18,6 +18,7 @@ const PRODUCT_NORMALIZE_JOB_ID = 'product-normalize'
const COST_SNAPSHOT_JOB_ID = 'cost-snapshot'
const SALES_FACTS_JOB_ID = 'sales-facts'
const LEDGER_POST_JOB_ID = 'ledger-post'
const ORDER_SERVICE_AUTO_PUBLISH_JOB_ID = 'order-service-auto-publish'
export function getScheduledJobsFilePath() {
return SCHEDULED_JOBS_FILE_PATH
@@ -89,6 +90,9 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
const hasCostSnapshot = jobs.some((item) => item.id === COST_SNAPSHOT_JOB_ID)
const hasSalesFacts = jobs.some((item) => item.id === SALES_FACTS_JOB_ID)
const hasLedgerPost = jobs.some((item) => item.id === LEDGER_POST_JOB_ID)
const hasOrderServiceAutoPublish = jobs.some(
(item) => item.id === ORDER_SERVICE_AUTO_PUBLISH_JOB_ID,
)
if (!hasWorkOrderTimeout) {
jobs.push(createDefaultWorkOrderTimeoutJob())
@@ -111,6 +115,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
if (!hasCostSnapshot) jobs.push(createDefaultCostSnapshotJob())
if (!hasSalesFacts) jobs.push(createDefaultSalesFactsJob())
if (!hasLedgerPost) jobs.push(createDefaultLedgerPostJob())
if (!hasOrderServiceAutoPublish) jobs.push(createDefaultOrderServiceAutoPublishJob())
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
@@ -158,6 +163,9 @@ function normalizeScheduledJob(rawValue: unknown) {
if (type === 'ledger_post') {
return normalizeLedgerPostJob(rawValue)
}
if (type === 'order_service_auto_publish') {
return normalizeOrderServiceAutoPublishJob(rawValue)
}
return null
}
@@ -228,6 +236,7 @@ function createDefaultScheduledJobsConfig() {
createDefaultCostSnapshotJob(),
createDefaultSalesFactsJob(),
createDefaultLedgerPostJob(),
createDefaultOrderServiceAutoPublishJob(),
],
}
}
@@ -300,6 +309,19 @@ function normalizeSalesFactsJob(rawValue: JsonObject) {
}
}
function normalizeOrderServiceAutoPublishJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: ORDER_SERVICE_AUTO_PUBLISH_JOB_ID,
type: 'order_service_auto_publish',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600),
config: {
limit: normalizeRangeInteger(config.limit, 50, 1, 200),
},
}
}
function normalizeLedgerPostJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
@@ -353,6 +375,16 @@ function createDefaultCostSnapshotJob() {
}
}
function createDefaultOrderServiceAutoPublishJob() {
return {
id: ORDER_SERVICE_AUTO_PUBLISH_JOB_ID,
type: 'order_service_auto_publish',
enabled: true,
intervalSeconds: 60,
config: { limit: 50 },
}
}
function createDefaultSalesFactsJob() {
return {
id: SALES_FACTS_JOB_ID,
@@ -3,6 +3,7 @@ import { query } from '../../db/client.js'
import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js'
import { runAffiliateFallback } from '../fulfillment/affiliate-fallback-service.js'
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
import { processDueOrderServiceAutoPublishJobs } from '../worker-platform/order-service-auto-publish-service.js'
import {
resolveExceptionsForItemsByReason,
resolveOrphanCostExceptions,
@@ -497,6 +498,16 @@ export async function runSalesFactsJob(job: JsonObject = {}) {
}
}
export async function runOrderServiceAutoPublishJobs(job: JsonObject = {}) {
const config = jobConfig(job)
const result = await processDueOrderServiceAutoPublishJobs(clampInt(config.limit, 50, 1, 200))
return {
status: 'ok',
message: `订单服务自动发布:处理 ${result.scanned},成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}`,
...result,
}
}
export async function runLedgerPostJob(job: JsonObject = {}) {
const config = jobConfig(job)
const days = clampInt(config.days, 3, 1, 60)
@@ -11,6 +11,7 @@ import {
runChannelAssignJob,
runCostSnapshotJob,
runLedgerPostJob,
runOrderServiceAutoPublishJobs,
runProductNormalizeJob,
runSalesFactsJob,
} from './finance-pipeline-jobs.js'
@@ -230,6 +231,9 @@ function dispatchJob(job: JsonObject) {
if (job.type === 'ledger_post') {
return runLedgerPostJob(job)
}
if (job.type === 'order_service_auto_publish') {
return runOrderServiceAutoPublishJobs(job)
}
throw createHttpError(`不支持的定时任务类型:${job.type}`, {
statusCode: 400,
@@ -16,8 +16,10 @@ import {
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { logWarn } from '../../utils/logger.js'
import { nowIso } from '../../utils/time.js'
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
import { enqueueCustomerOrderNotification } from '../notification/customer-order-notification-service.js'
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
import {
consumeIndustryVouchersBeforeWorkOrderPublish,
@@ -342,7 +344,11 @@ export async function reopenAdminCancelledWorkOrder(workOrderId: number | string
return { order: mapWorkOrderAdmin(updated) }
}
export async function publishAdminWorkOrder(workOrderId: number | string, actorName = '') {
export async function publishAdminWorkOrder(
workOrderId: number | string,
actorName = '',
options: { requireVoucherConsume?: boolean } = {},
) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) {
throw createHttpError('只有未分配订单可以发布到大厅', {
@@ -358,21 +364,9 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN
}
const now = nowIso()
const published = await publishUnassignedWorkOrder({
workOrderId: workOrder.id,
now,
actorName,
})
if (!published.order) {
throw createHttpError('订单状态已变化,无法发布,请刷新后重试', {
statusCode: 409,
errorCode: 'work_order_publish_conflict',
})
}
let voucherConsume: WorkOrderVoucherConsumeResult
try {
voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(published.order)
voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(workOrder)
} catch (error) {
voucherConsume = {
ok: false,
@@ -390,15 +384,46 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN
actorType: 'system',
actorId: 'kuaishou_send_code',
eventType: 'publish_voucher_consume_failed',
fromStatus: WORK_ORDER_STATUS.OPEN,
toStatus: WORK_ORDER_STATUS.OPEN,
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify(voucherConsume),
now: nowIso(),
})
if (options.requireVoucherConsume === true) {
throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未发布', {
statusCode: 409,
errorCode: 'work_order_publish_voucher_consume_failed',
context: { voucherConsume },
})
}
}
const published = await publishUnassignedWorkOrder({
workOrderId: workOrder.id,
now,
actorName,
})
if (!published.order) {
throw createHttpError('订单状态已变化,无法发布,请刷新后重试', {
statusCode: 409,
errorCode: 'work_order_publish_conflict',
})
}
const currentOrder = await getRequiredWorkOrder(workOrder.id)
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true })
await enqueueCustomerOrderNotification({
orderId: currentOrder.order_id,
orderItemId: currentOrder.order_item_id,
workOrderId: currentOrder.id,
eventType: 'work_order_published',
payload: { source: 'work_order_publish' },
}).catch((error) => {
logWarn('[work-order/publish]', '客户发布通知写入失败', {
workOrderId: currentOrder.id,
error: error instanceof Error ? error.message : String(error),
})
})
const hallConfig = getWorkerHallConfig()
const hallEntry = await listWorkOrders({
page: 1,
@@ -531,7 +531,14 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
if (existingRule && enabled) {
await assertWorkOrderTemplateCanEnable(existingRule.id)
}
const fields = normalizeRequirementFieldsFromPayload(payload)
const hasRequirementPayload = ['fields', 'requirement', 'fieldsText', 'requiredFieldsText'].some(
(key) => hasOwnPayload(payload, key),
)
const fields = hasRequirementPayload
? normalizeRequirementFieldsFromPayload(payload)
: normalizeRequirementFieldsFromPayload({
fields: safeParseJson(existingRule?.requirement_json).fields,
})
const ruleKey = existingRule?.rule_key || randomId('rule-')
const hasCategoryPayload =
hasOwnPayload(payload, 'categoryId') || hasOwnPayload(payload, 'category_id')
@@ -13,7 +13,6 @@ export * from './worker-profile-service.js'
export * from './worker-hall-service.js'
export * from './worker-order-timeout-service.js'
export * from './worker-order-realtime-service.js'
export * from './worker-collect-service.js'
export * from './worker-acceptance-service.js'
export * from './worker-gift-material-service.js'
export * from './worker-my-order-service.js'
@@ -67,3 +66,6 @@ export * from './sync-worker-orders-service.js'
export * from './consignment-contact-service.js'
export * from './consignment-pending-match-service.js'
export * from './work-order-material-link-service.js'
export * from './order-service-profile-service.js'
export * from './order-service-public-service.js'
export * from './order-service-auto-publish-service.js'
@@ -84,7 +84,6 @@ export {
export {
isWorkerOrderVipEvidencePending,
mapCollectLookupOrder,
mapWorkCategory,
mapWorkOrderAdmin,
mapWorkOrderForHall,
@@ -0,0 +1,328 @@
import process from 'node:process'
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { publishAdminWorkOrder } from './admin-work-order-management-service.js'
type Queryable = Pick<PoolClient, 'query'>
type AutoPublishJobRow = {
id: number
work_order_id: number
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'skipped'
attempt_count: number
max_attempts: number
next_run_at: string
last_error: string
result_json: Record<string, unknown>
locked_until: string | null
locked_by: string
finished_at: string | null
}
const ACTIVE_JOB_STATUSES = ['pending', 'processing'] as const
const JOB_STATUSES = new Set(['pending', 'processing', 'succeeded', 'failed', 'skipped'])
const JOB_LEASE_SECONDS = 120
export async function enqueueOrderServiceAutoPublish(
input: {
workOrderId: number | string
maxAttempts?: number
},
client?: Queryable,
) {
const workOrderId = Number(input.workOrderId)
if (!Number.isInteger(workOrderId) || workOrderId <= 0) return null
const maxAttempts = Math.max(1, Math.min(11, Math.trunc(Number(input.maxAttempts ?? 4) || 4)))
const db = client || { query }
const inserted = await db.query<{ id: number }>(
`
INSERT INTO order_service_auto_publish_jobs (work_order_id, max_attempts)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
RETURNING id
`,
[workOrderId, maxAttempts],
)
if (inserted.rows[0]) return Number(inserted.rows[0].id)
const active = await db.query<{ id: number }>(
`
SELECT id
FROM order_service_auto_publish_jobs
WHERE work_order_id = $1
AND status = ANY($2::text[])
LIMIT 1
`,
[workOrderId, [...ACTIVE_JOB_STATUSES]],
)
return active.rows[0] ? Number(active.rows[0].id) : null
}
export async function processDueOrderServiceAutoPublishJobs(limitInput: unknown = 10) {
const limit = Math.min(200, Math.max(1, Math.trunc(Number(limitInput) || 10)))
const workerId = `order-service:${process.pid}`
const pending = await query<AutoPublishJobRow>(
`
WITH due AS (
SELECT id
FROM order_service_auto_publish_jobs
WHERE (
(status = 'pending' AND next_run_at <= NOW())
OR (status = 'processing' AND locked_until < NOW())
)
ORDER BY next_run_at, id
FOR UPDATE SKIP LOCKED
LIMIT $1
)
UPDATE order_service_auto_publish_jobs job
SET status = 'processing',
attempt_count = job.attempt_count + 1,
locked_until = NOW() + ($2::text || ' seconds')::interval,
locked_by = $3,
updated_at = NOW()
FROM due
WHERE job.id = due.id
RETURNING job.*
`,
[limit, String(JOB_LEASE_SECONDS), workerId],
)
const succeeded: number[] = []
const failed: number[] = []
const skipped: number[] = []
for (const job of pending.rows) {
const outcome = await processAutoPublishJob(job).catch(async (error) => {
const message = error instanceof Error ? error.message : String(error)
return retryOrFailJob(job, message)
})
if (outcome === 'succeeded') succeeded.push(Number(job.id))
if (outcome === 'failed') failed.push(Number(job.id))
if (outcome === 'skipped') skipped.push(Number(job.id))
}
return {
scanned: pending.rows.length,
succeeded: succeeded.length,
failed: failed.length,
skipped: skipped.length,
}
}
async function processAutoPublishJob(
job: AutoPublishJobRow,
): Promise<'succeeded' | 'failed' | 'skipped'> {
const workOrderId = Number(job.work_order_id)
const orderResult = await query<{
status: string
reward_amount: number
service_profile_snapshot_json: unknown
}>(
`
SELECT status, reward_amount, service_profile_snapshot_json
FROM work_orders
WHERE id = $1
LIMIT 1
`,
[workOrderId],
)
const order = orderResult.rows[0]
if (!order || order.status !== WORK_ORDER_STATUS.UNASSIGNED) {
await finishJob(job, 'skipped', '订单已不在待分配状态', {})
return 'skipped'
}
if (Number(order.reward_amount || 0) <= 0) {
await finishJob(job, 'failed', '接单金额为 0,不能自动发布', {})
return 'failed'
}
const snapshot = safeParseJson(order.service_profile_snapshot_json)
const autoPublish =
snapshot.autoPublish && typeof snapshot.autoPublish === 'object'
? (snapshot.autoPublish as Record<string, unknown>)
: {}
const requireVoucherConsume = autoPublish.requireVoucherConsume !== false
try {
const result = await publishAdminWorkOrder(workOrderId, '系统:订单服务自动发布', {
requireVoucherConsume,
})
await finishJob(job, 'succeeded', '', result)
return 'succeeded'
} catch (error) {
return retryOrFailJob(job, error instanceof Error ? error.message : String(error))
}
}
async function retryOrFailJob(job: AutoPublishJobRow, errorMessage: string): Promise<'failed'> {
if (Number(job.attempt_count || 0) >= Number(job.max_attempts || 4)) {
await finishJob(job, 'failed', errorMessage, {})
return 'failed'
}
const delaySeconds = Math.min(300, 15 * 2 ** Math.max(0, Number(job.attempt_count || 1) - 1))
await query(
`
UPDATE order_service_auto_publish_jobs
SET status = 'pending',
last_error = $2,
next_run_at = NOW() + ($3::text || ' seconds')::interval,
locked_until = NULL,
locked_by = '',
updated_at = NOW()
WHERE id = $1
`,
[Number(job.id), errorMessage, String(delaySeconds)],
)
return 'failed'
}
async function finishJob(
job: AutoPublishJobRow,
status: 'succeeded' | 'failed' | 'skipped',
errorMessage: string,
result: Record<string, unknown>,
) {
const now = nowIso()
await query(
`
UPDATE order_service_auto_publish_jobs
SET status = $2,
last_error = $3,
result_json = $4::jsonb,
locked_until = NULL,
locked_by = '',
finished_at = $5,
updated_at = $5
WHERE id = $1
AND status = 'processing'
`,
[Number(job.id), status, errorMessage, JSON.stringify(result), now],
)
await query(
`
INSERT INTO work_order_events (
work_order_id, actor_type, actor_id, event_type,
from_status, to_status, payload_json, created_at
)
SELECT
$2, 'system', 'order_service_auto_publish', $3,
status, status, $4::jsonb, $5
FROM work_orders WHERE id = $1
`,
[
Number(job.work_order_id),
Number(job.work_order_id),
`auto_publish_${status}`,
JSON.stringify({ jobId: Number(job.id), status, error: errorMessage, result }),
now,
],
)
}
export async function listAdminOrderServiceAutoPublishJobs(statusInput: unknown = '') {
const rawStatus = String(statusInput || '').trim()
const status = JOB_STATUSES.has(rawStatus) ? rawStatus : ''
const result = await query(
`
SELECT job.*, wo.product_name, wo.platform_order_id, wo.status AS work_order_status
FROM order_service_auto_publish_jobs job
JOIN work_orders wo ON wo.id = job.work_order_id
${status ? 'WHERE job.status = $1' : ''}
ORDER BY job.updated_at DESC, job.id DESC
LIMIT 100
`,
status ? [status] : [],
)
return {
items: result.rows.map((row: Record<string, unknown>) => ({
id: Number(row.id),
workOrderId: Number(row.work_order_id),
status: String(row.status || ''),
attemptCount: Number(row.attempt_count || 0),
maxAttempts: Number(row.max_attempts || 0),
nextRunAt: row.next_run_at || null,
lastError: String(row.last_error || ''),
result: safeParseJson(row.result_json),
productName: String(row.product_name || ''),
platformOrderId: String(row.platform_order_id || ''),
workOrderStatus: String(row.work_order_status || ''),
createdAt: row.created_at || null,
updatedAt: row.updated_at || null,
})),
}
}
export async function retryAdminOrderServiceAutoPublishJob(jobIdInput: number | string) {
const jobId = Number(jobIdInput)
if (!Number.isInteger(jobId) || jobId <= 0) {
throw createHttpError('任务不存在', {
statusCode: 400,
errorCode: 'order_service_auto_publish_job_invalid',
})
}
return withTransaction(async (client) => {
const locked = await client.query<AutoPublishJobRow>(
`SELECT * FROM order_service_auto_publish_jobs WHERE id = $1 FOR UPDATE`,
[jobId],
)
const job = locked.rows[0]
if (!job) {
throw createHttpError('任务不存在', {
statusCode: 404,
errorCode: 'order_service_auto_publish_job_not_found',
})
}
if (job.status === 'processing') {
throw createHttpError('任务正在处理,请稍后刷新', {
statusCode: 409,
errorCode: 'order_service_auto_publish_job_processing',
})
}
if (job.status !== 'failed' && job.status !== 'skipped') {
throw createHttpError('只有失败或跳过的任务可以重试', {
statusCode: 409,
errorCode: 'order_service_auto_publish_job_status_invalid',
})
}
const active = await client.query<{ id: number }>(
`
SELECT id
FROM order_service_auto_publish_jobs
WHERE work_order_id = $1
AND status IN ('pending', 'processing')
AND id <> $2
LIMIT 1
`,
[job.work_order_id, jobId],
)
if (active.rows[0]) {
throw createHttpError('该工单已有待处理的自动发布任务', {
statusCode: 409,
errorCode: 'order_service_auto_publish_job_already_active',
})
}
const result = await client.query<{ id: number }>(
`
UPDATE order_service_auto_publish_jobs
SET status = 'pending',
attempt_count = 0,
next_run_at = NOW(),
last_error = '',
locked_until = NULL,
locked_by = '',
finished_at = NULL,
updated_at = NOW()
WHERE id = $1
RETURNING id
`,
[jobId],
)
return { jobId: Number(result.rows[0]?.id || 0) }
})
}
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
isOrderServiceMaterialRequired,
normalizeProfileConfig,
} from './order-service-profile-service.js'
function config(patch: Record<string, unknown> = {}) {
return normalizeProfileConfig({
name: '测试配置',
fields: [
{
key: 'gameId',
label: '游戏编号',
type: 'text',
required: false,
},
],
screenshots: {
enabled: true,
required: false,
minCount: 0,
maxCount: 5,
},
autoPublish: {
enabled: false,
requireVoucherConsume: true,
retryMax: 3,
},
...patch,
})
}
test('订单服务资料判断:所有字段选填且截图不强制时不阻塞建单', () => {
assert.equal(isOrderServiceMaterialRequired(config()), false)
})
test('订单服务资料判断:必填字段或必填截图都会让工单进入待补资料', () => {
assert.equal(
isOrderServiceMaterialRequired(
config({
fields: [
{
key: 'gameId',
label: '游戏编号',
type: 'text',
required: true,
},
],
}),
),
true,
)
assert.equal(
isOrderServiceMaterialRequired(
config({
screenshots: {
enabled: true,
required: true,
minCount: 1,
maxCount: 5,
},
}),
),
true,
)
})
test('订单服务配置规范化:未知字段类型降级为文本,空下拉字段丢弃', () => {
const normalized = normalizeProfileConfig({
fields: [
{ key: 'a', label: 'A', type: 'unknown' },
{ key: 'b', label: 'B', type: 'select', options: [] },
],
})
assert.equal(normalized.fields.length, 1)
assert.equal(normalized.fields[0]?.type, 'text')
})
@@ -0,0 +1,374 @@
import type { PoolClient } from 'pg'
import { withTransaction } from '../../db/client.js'
import { query } from '../../db/client.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import {
archiveOrderServiceProfile,
createOrderServiceProfileVersion,
getOrderServiceProfileVersion,
getPublishedOrderServiceProfileByCategory,
listOrderServiceProfiles,
setOrderServiceProfileCurrentVersion,
upsertOrderServiceProfile,
type OrderServiceProfileRow,
} from '../../repositories/worker-platform/order-service-profile-repo.js'
import { listAllWorkCategories } from '../../repositories/worker-platform/work-category-repo.js'
import { normalizeUploadedFiles } from './worker-input-normalizers.js'
export type OrderServiceFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
export type OrderServiceField = {
key: string
label: string
type: OrderServiceFieldType
required: boolean
placeholder: string
helpText: string
options: string[]
sortOrder: number
}
export type OrderServiceScreenshots = {
enabled: boolean
required: boolean
minCount: number
maxCount: number
label: string
helpText: string
}
export type OrderServiceAutoPublish = {
enabled: boolean
requireVoucherConsume: boolean
retryMax: number
}
export type OrderServiceProfileConfig = {
name: string
title: string
description: string
guideImage: Record<string, unknown>
fields: OrderServiceField[]
screenshots: OrderServiceScreenshots
autoPublish: OrderServiceAutoPublish
submitText: string
successText: string
}
export type OrderServiceProfileSnapshot = {
profileId: number
categoryId: number
versionId: number
versionNo: number
config: OrderServiceProfileConfig
}
const FIELD_TYPES = new Set<OrderServiceFieldType>([
'text',
'textarea',
'select',
'radio',
'number',
'date',
])
export async function listAdminOrderServiceProfiles() {
const [categories, profiles] = await Promise.all([
listAllWorkCategories(),
listOrderServiceProfiles(),
])
return {
categories: categories.map((category) => ({
categoryId: Number(category.id),
categoryKey: category.category_key,
name: category.name,
parentId: category.parent_id ? Number(category.parent_id) : null,
status: category.status,
})),
profiles: profiles.map(mapAdminProfile),
}
}
export async function saveAdminOrderServiceProfile(payload: JsonObject = {}) {
const categoryId = Number(payload.categoryId ?? payload.category_id ?? 0)
if (!Number.isInteger(categoryId) || categoryId <= 0) {
throw createHttpError('请选择分类', {
statusCode: 400,
errorCode: 'order_service_category_required',
})
}
const categoryResult = await query<{ id: number }>(
'SELECT id FROM work_categories WHERE id = $1 LIMIT 1',
[categoryId],
)
if (!categoryResult.rows[0]) {
throw createHttpError('分类不存在', {
statusCode: 404,
errorCode: 'work_category_not_found',
})
}
const fields = normalizeFields(payload.fields ?? payload.fields_json)
const screenshots = normalizeScreenshots(payload.screenshots ?? payload.screenshots_json)
const autoPublish = normalizeAutoPublish(payload.autoPublish ?? payload.auto_publish_json)
const guideImage = normalizeGuideImage(payload.guideImage ?? payload.guide_image_json)
const adminUserId = Number(payload.adminUserId ?? payload.admin_user_id ?? 0) || null
const saved = await upsertOrderServiceProfile({
categoryId,
name: text(payload.name, 80) || '订单服务配置',
title: text(payload.title, 80),
description: text(payload.description, 300),
guideImage,
fields,
screenshots,
autoPublish,
submitText: text(payload.submitText, 30) || '提交资料',
successText: text(payload.successText, 80) || '资料已提交,请等待处理',
adminUserId,
})
return { profile: mapAdminProfile(saved) }
}
export async function publishAdminOrderServiceProfile(
profileIdInput: number | string,
adminUserIdInput: unknown = null,
) {
const profileId = Number(profileIdInput)
if (!Number.isInteger(profileId) || profileId <= 0) {
throw createHttpError('配置不存在', {
statusCode: 400,
errorCode: 'order_service_profile_invalid',
})
}
const adminUserId = Number(adminUserIdInput ?? 0) || null
return withTransaction(async (client) => {
const locked = await client.query<OrderServiceProfileRow>(
`SELECT * FROM order_service_profiles WHERE id = $1 FOR UPDATE`,
[profileId],
)
const profile = locked.rows[0]
if (!profile) {
throw createHttpError('订单服务配置不存在', {
statusCode: 404,
errorCode: 'order_service_profile_not_found',
})
}
const config = buildProfileConfig(profile)
if (config.fields.length === 0) {
throw createHttpError('至少需要配置一个资料字段', {
statusCode: 400,
errorCode: 'order_service_fields_required',
})
}
const { versionId, versionNo } = await createOrderServiceProfileVersion(client, {
profileId,
config,
adminUserId,
})
await setOrderServiceProfileCurrentVersion(client, { profileId, versionId })
return { versionNo, profileId, versionId }
})
}
export async function archiveAdminOrderServiceProfile(profileIdInput: number | string) {
const profileId = Number(profileIdInput)
const profile = await archiveOrderServiceProfile(profileId)
if (!profile) {
throw createHttpError('订单服务配置不存在', {
statusCode: 404,
errorCode: 'order_service_profile_not_found',
})
}
return { profile: mapAdminProfile(profile) }
}
function mapAdminProfile(row: OrderServiceProfileRow) {
return {
id: Number(row.id),
categoryId: Number(row.category_id),
name: row.name,
title: row.title,
description: row.description,
guideImage: row.guide_image_json,
fields: row.fields_json,
screenshots: row.screenshots_json,
autoPublish: row.auto_publish_json,
submitText: row.submit_text,
successText: row.success_text,
status: row.status,
currentVersionId: row.current_version_id,
currentVersionNo: row.current_version_no,
updatedAt: row.updated_at,
config: buildProfileConfig(row),
}
}
export function buildProfileConfig(row: {
name: string
title: string
description: string
guide_image_json: unknown
fields_json: unknown
screenshots_json: unknown
auto_publish_json: unknown
submit_text: string
success_text: string
}): OrderServiceProfileConfig & { guideImage: Record<string, unknown> } {
return {
name: row.name,
title: row.title,
description: row.description,
guideImage: normalizeGuideImage(row.guide_image_json),
fields: normalizeFields(row.fields_json),
screenshots: normalizeScreenshots(row.screenshots_json),
autoPublish: normalizeAutoPublish(row.auto_publish_json),
submitText: row.submit_text || '提交资料',
successText: row.success_text || '资料已提交,请等待处理',
}
}
export async function getPublishedOrderServiceProfileSnapshotByCategory(
categoryId: number | string,
client?: Pick<PoolClient, 'query'>,
): Promise<OrderServiceProfileSnapshot | null> {
const row = await getPublishedOrderServiceProfileByCategory(categoryId, client)
if (!row) return null
return {
profileId: Number(row.profile_id),
categoryId: Number(row.category_id),
versionId: Number(row.version_id),
versionNo: Number(row.version_no),
config: normalizeProfileConfig(row.config_json),
}
}
export async function getOrderServiceProfileSnapshotByVersion(input: {
profileId: number | string
categoryId: number | string
versionId: number | string
client?: Pick<PoolClient, 'query'>
}): Promise<OrderServiceProfileSnapshot | null> {
const version = await getOrderServiceProfileVersion(input.versionId, input.client)
if (!version) return null
return {
profileId: Number(input.profileId),
categoryId: Number(input.categoryId),
versionId: Number(version.id),
versionNo: Number(version.version_no),
config: normalizeProfileConfig(version.config_json),
}
}
export function isOrderServiceMaterialRequired(config: OrderServiceProfileConfig): boolean {
if (config.fields.some((field) => field.required)) return true
return config.screenshots.enabled && config.screenshots.minCount > 0
}
export function normalizeProfileConfig(value: JsonObject): OrderServiceProfileConfig {
return {
name: text(value.name, 80) || '订单服务配置',
title: text(value.title, 80),
description: text(value.description, 300),
guideImage: normalizeGuideImage(value.guideImage ?? value.guide_image),
fields: normalizeFields(value.fields),
screenshots: normalizeScreenshots(value.screenshots),
autoPublish: normalizeAutoPublish(value.autoPublish),
submitText: text(value.submitText, 30) || '提交资料',
successText: text(value.successText, 80) || '资料已提交,请等待处理',
}
}
function normalizeFields(value: unknown): OrderServiceField[] {
const rows = Array.isArray(value) ? value : []
const seen = new Set<string>()
return rows
.map((row, index): OrderServiceField | null => {
const source = row && typeof row === 'object' ? (row as JsonObject) : {}
const key = text(source.key ?? source.fieldKey, 60).replace(/[^a-zA-Z0-9_]/g, '')
if (!key || seen.has(key)) return null
seen.add(key)
const rawType = text(source.type, 20)
const type = (
FIELD_TYPES.has(rawType as OrderServiceFieldType) ? rawType : 'text'
) as OrderServiceFieldType
const options = Array.isArray(source.options)
? source.options.map((option) => text(option, 60)).filter(Boolean)
: []
if (type !== 'select' && type !== 'radio')
return {
key,
label: text(source.label, 60) || key,
type,
required: source.required !== false,
placeholder: text(source.placeholder, 100),
helpText: text(source.helpText ?? source.help_text, 200),
options: [],
sortOrder: Number(source.sortOrder ?? source.sort_order ?? (index + 1) * 10),
}
if (options.length === 0) return null
return {
key,
label: text(source.label, 60) || key,
type,
required: source.required !== false,
placeholder: '',
helpText: text(source.helpText ?? source.help_text, 200),
options,
sortOrder: Number(source.sortOrder ?? source.sort_order ?? (index + 1) * 10),
}
})
.filter((row): row is OrderServiceField => Boolean(row))
.sort((left, right) => left.sortOrder - right.sortOrder)
}
function normalizeScreenshots(value: unknown): OrderServiceScreenshots {
const source = value && typeof value === 'object' ? (value as JsonObject) : {}
const enabled = source.enabled !== false
const required = enabled && source.required !== false
const maxCount = clampInteger(source.maxCount ?? source.max_count, 5, 1, 10)
const requestedMinCount = clampInteger(
source.minCount ?? source.min_count,
required ? 1 : 0,
0,
maxCount,
)
return {
enabled,
required,
minCount: required ? Math.max(1, requestedMinCount) : requestedMinCount,
maxCount,
label: text(source.label, 40) || '买家主页截图',
helpText: text(source.helpText ?? source.help_text, 200),
}
}
function normalizeAutoPublish(value: unknown): OrderServiceAutoPublish {
const source = value && typeof value === 'object' ? (value as JsonObject) : {}
return {
enabled: source.enabled === true,
requireVoucherConsume: source.requireVoucherConsume !== false,
retryMax: clampInteger(source.retryMax ?? source.retry_max, 3, 0, 10),
}
}
function normalizeGuideImage(value: unknown): Record<string, unknown> {
const normalized = normalizeUploadedFiles([value])[0]
return normalized ? { ...normalized } : {}
}
function clampInteger(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return fallback
return Math.min(max, Math.max(min, Math.trunc(parsed)))
}
function text(value: unknown, maxLength: number) {
return String(value ?? '')
.trim()
.slice(0, maxLength)
}
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
resolveOrderServiceStatusLabel,
resolveOrderServiceStatusMessage,
} from './order-service-public-service.js'
test('订单服务状态文案:待抢单提示高峰期等待', () => {
const message = resolveOrderServiceStatusMessage({
status: WORK_ORDER_STATUS.OPEN,
canSubmit: false,
})
assert.match(message, /高峰期/)
assert.match(message, /耐心等待/)
})
test('订单服务状态文案:接单与完成提示查看邮箱', () => {
assert.match(
resolveOrderServiceStatusMessage({
status: WORK_ORDER_STATUS.IN_PROGRESS,
canSubmit: false,
}),
/30-60 分钟.*邮箱/,
)
assert.match(
resolveOrderServiceStatusMessage({
status: WORK_ORDER_STATUS.ACCEPTED,
canSubmit: false,
}),
/检查邮件.*客服/,
)
})
test('订单服务状态文案:待补资料不显示只读提示', () => {
assert.equal(
resolveOrderServiceStatusMessage({
status: WORK_ORDER_STATUS.PENDING_MATERIAL,
canSubmit: true,
}),
'',
)
})
test('订单服务状态标签使用客户端语义', () => {
assert.equal(resolveOrderServiceStatusLabel(WORK_ORDER_STATUS.OPEN), '等待接单')
assert.equal(resolveOrderServiceStatusLabel(WORK_ORDER_STATUS.IN_PROGRESS), '处理中')
assert.equal(resolveOrderServiceStatusLabel(WORK_ORDER_STATUS.ACCEPTED), '已完成')
assert.equal(resolveOrderServiceStatusLabel('unknown', '处理中'), '处理中')
})
@@ -0,0 +1,492 @@
import type { PoolClient } from 'pg'
import { withTransaction } from '../../db/client.js'
import { query } from '../../db/client.js'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
createWorkOrderEventWithClient,
getWorkOrderForUpdateWithClient,
updateWorkOrderMaterialWithClient,
} from '../../repositories/worker-platform/index.js'
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
import { getFileAssetByObjectKey } from '../../repositories/file-asset-repo.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { logWarn } from '../../utils/logger.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
import { normalizeSubmittedFields, normalizeUploadedFiles } from './worker-input-normalizers.js'
import {
ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES,
buildFileUrl,
refreshUploadedFileUrls,
type UploadedFileDto,
} from '../file-storage/file-storage-service.js'
import { enqueueCustomerOrderNotification } from '../notification/customer-order-notification-service.js'
import {
pickSubmittedMaterialFields,
resolvePublicMaterialOrderState,
throwPublicMaterialBlocked,
} from './work-order-material-link-service.js'
import {
getOrderServiceProfileSnapshotByVersion,
normalizeProfileConfig,
type OrderServiceField,
type OrderServiceProfileConfig,
} from './order-service-profile-service.js'
import {
enqueueOrderServiceAutoPublish,
processDueOrderServiceAutoPublishJobs,
} from './order-service-auto-publish-service.js'
const PUBLIC_STATUS_MESSAGES: Record<string, string> = {
[WORK_ORDER_STATUS.PENDING_MATERIAL]: '请补充订单资料。',
[WORK_ORDER_STATUS.UNASSIGNED]: '资料已提交,客服正在安排处理,请耐心等待。',
[WORK_ORDER_STATUS.OPEN]: '您的订单已经发布,当前处于高峰期,请耐心等待。',
[WORK_ORDER_STATUS.IN_PROGRESS]:
'您的订单已经有人接单,请您 30-60 分钟内注意查看邮箱,确认是否收到相关邮件。',
[WORK_ORDER_STATUS.PENDING_ACCEPTANCE]: '交付内容已提交,正在确认。',
[WORK_ORDER_STATUS.ACCEPTED]: '订单已完成,请检查邮件是否收到。如有问题,请及时联系客服处理。',
[WORK_ORDER_STATUS.PROBLEM]: '订单需要客服介入处理,请联系客服。',
[WORK_ORDER_STATUS.CANCELLED]: '订单已取消。',
}
const PUBLIC_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.ACCEPTED]: '已完成',
[WORK_ORDER_STATUS.PROBLEM]: '客服处理中',
[WORK_ORDER_STATUS.CANCELLED]: '已取消',
}
export type OrderServicePublicItem = {
workOrderId: number
productName: string
categoryName: string
title: string
description: string
submitText: string
successText: string
state: string
stateLabel: string
message: string
canSubmit: boolean
blockedReason: string
fields?: OrderServiceField[]
values?: Record<string, string>
screenshots?: Array<Record<string, unknown>>
screenshotsRule?: OrderServiceProfileConfig['screenshots']
guideImage?: Record<string, unknown>
}
export function resolveOrderServiceStatusLabel(status: string, fallback = '处理中') {
return PUBLIC_STATUS_LABELS[String(status || '').trim()] || fallback
}
export function resolveOrderServiceStatusMessage(input: {
status: string
canSubmit: boolean
fallbackMessage?: string
}) {
if (input.canSubmit) return ''
return (
PUBLIC_STATUS_MESSAGES[String(input.status || '').trim()] ||
String(input.fallbackMessage || '').trim() ||
'订单状态已更新,如有疑问请联系客服。'
)
}
export async function getOrderServiceByPlatformOrderId(platformOrderIdInput: unknown) {
const platformOrderId = normalizeOrderNo(platformOrderIdInput)
const orders = await listWorkOrdersByPlatformOrderId(platformOrderId)
if (orders.length === 0) {
throw notFound()
}
const items = await Promise.all(orders.map((order) => mapPublicOrderItem(order)))
return {
platformOrderId,
items,
pendingCount: items.filter((item) => item.canSubmit).length,
}
}
export async function submitOrderServiceMaterial(
platformOrderIdInput: unknown,
payload: JsonObject = {},
) {
const platformOrderId = normalizeOrderNo(platformOrderIdInput)
const target = await resolveTarget(platformOrderId, payload.workOrderId)
const targetState = resolvePublicMaterialOrderState(target)
if (!targetState.canSubmit) throwPublicMaterialBlocked(targetState)
const result = await withTransaction(async (client) => {
const order = await getWorkOrderForUpdateWithClient(client, target.id)
if (!order) throw notFound()
if (String(order.platform_order_id || '').trim() !== platformOrderId) throw notFound()
const state = resolvePublicMaterialOrderState(order)
if (!state.canSubmit) throwPublicMaterialBlocked(state)
const profileConfig = await resolveProfileConfig(order, client)
const fields = profileConfig?.fields || []
if (!profileConfig || fields.length === 0) {
throw createHttpError('该订单尚未配置资料收集字段,请联系客服', {
statusCode: 409,
errorCode: 'order_service_fields_missing',
})
}
const submittedFields = pickSubmittedMaterialFields(
fields,
normalizeSubmittedFields(payload.fields || {}),
)
const missing = fields.filter(
(field) => field.required && !String(submittedFields[field.key] || '').trim(),
)
if (missing.length > 0) {
throw createHttpError(
`请填写完整必填资料:${missing.map((field) => field.label).join('、')}`,
{
statusCode: 400,
errorCode: 'material_link_fields_incomplete',
context: { missingFields: missing.map((field) => field.key) },
},
)
}
const screenshotRule = profileConfig.screenshots
const requestedScreenshots = screenshotRule.enabled
? normalizeUploadedFiles(payload.screenshots || [])
: []
const screenshots = await resolveOwnedOrderServiceScreenshots(
requestedScreenshots,
Number(order.id),
client,
)
if (screenshotRule.enabled && screenshots.length < screenshotRule.minCount) {
throw createHttpError(`${screenshotRule.label}至少需要 ${screenshotRule.minCount} 张`, {
statusCode: 400,
errorCode: 'order_service_screenshots_required',
})
}
if (screenshots.length > screenshotRule.maxCount) {
throw createHttpError(`${screenshotRule.label}最多 ${screenshotRule.maxCount} 张`, {
statusCode: 400,
errorCode: 'material_link_screenshots_too_many',
})
}
const now = nowIso()
const material = {
...safeParseJson(order.material_json),
collect: {
fields: submittedFields,
screenshots,
submittedAt: now,
complete: true,
source: 'order_service',
},
}
const updated = await updateWorkOrderMaterialWithClient(client, {
workOrderId: order.id,
status: WORK_ORDER_STATUS.UNASSIGNED,
materialJson: JSON.stringify(material),
now,
})
if (!updated) {
throw createHttpError('更新订单资料失败,请稍后重试', {
statusCode: 500,
errorCode: 'material_link_update_failed',
})
}
// 把分类配置同步到工单 requirement,保证打手端/后台兼容旧双读链路。
await client.query(
`UPDATE work_orders SET requirement_json = $2::jsonb, updated_at = $3 WHERE id = $1`,
[order.id, JSON.stringify({ fields }), now],
)
await createWorkOrderEventWithClient(client, {
workOrderId: order.id,
actorType: 'collect',
actorId: '客户按订单号提交资料',
eventType: 'material_completed',
fromStatus: order.status,
toStatus: WORK_ORDER_STATUS.UNASSIGNED,
payloadJson: JSON.stringify({
fields: submittedFields,
complete: true,
source: 'order_service',
}),
now,
})
if (profileConfig.autoPublish.enabled) {
await enqueueOrderServiceAutoPublish(
{
workOrderId: Number(order.id),
maxAttempts: profileConfig.autoPublish.retryMax + 1,
},
client,
)
}
return {
order: updated,
autoPublishEnabled: profileConfig.autoPublish.enabled,
successText: profileConfig.successText,
}
})
await enqueueCustomerOrderNotification({
orderId: target.order_id,
orderItemId: target.order_item_id,
workOrderId: result.order.id,
eventType: 'material_submitted',
payload: { source: 'order_service' },
}).catch(() => undefined)
try {
await resolveAdminNotificationEntity(
'work_order',
Number(result.order.id),
'material_completed',
)
} catch (error) {
logWarn('[order-service]', '补资料完成通知处理失败', {
workOrderId: result.order.id,
error: error instanceof Error ? error.message : String(error),
})
}
publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) })
if (result.autoPublishEnabled) {
void processDueOrderServiceAutoPublishJobs(1).catch(() => undefined)
}
return {
complete: true,
workOrderId: Number(result.order.id),
order: mapSafeOrder(result.order),
successText: result.successText,
}
}
export async function getOrderServiceUploadContext(
platformOrderIdInput: unknown,
workOrderIdInput: unknown,
) {
const platformOrderId = normalizeOrderNo(platformOrderIdInput)
const order = await resolveTarget(platformOrderId, workOrderIdInput)
const state = resolvePublicMaterialOrderState(order)
if (!state.canSubmit) throwPublicMaterialBlocked(state)
return { order }
}
async function listWorkOrdersByPlatformOrderId(platformOrderId: string) {
const result = await query<WorkOrderRow>(
`
SELECT wo.*, wc.name AS category_name, parent_wc.name AS parent_category_name,
COALESCE(share.active_share_quantity, 0)::int AS active_share_quantity
FROM work_orders wo
LEFT JOIN work_categories wc ON wc.id = wo.category_id
LEFT JOIN work_categories parent_wc ON parent_wc.id = wc.parent_id
LEFT JOIN LATERAL (
SELECT SUM(quantity) AS active_share_quantity
FROM work_order_shares share
WHERE share.work_order_id = wo.id AND share.status <> 'cancelled'
) share ON TRUE
WHERE wo.platform_order_id = $1
ORDER BY wo.id ASC
`,
[platformOrderId],
)
return result.rows
}
async function resolveTarget(platformOrderId: string, workOrderIdInput: unknown) {
const workOrderId = Number(workOrderIdInput ?? 0)
if (Number.isInteger(workOrderId) && workOrderId > 0) {
const result = await query<WorkOrderRow>(
`SELECT wo.*, wc.name AS category_name, parent_wc.name AS parent_category_name,
COALESCE(share.active_share_quantity, 0)::int AS active_share_quantity
FROM work_orders wo
LEFT JOIN work_categories wc ON wc.id = wo.category_id
LEFT JOIN work_categories parent_wc ON parent_wc.id = wc.parent_id
LEFT JOIN LATERAL (
SELECT SUM(quantity) AS active_share_quantity
FROM work_order_shares share
WHERE share.work_order_id = wo.id AND share.status <> 'cancelled'
) share ON TRUE
WHERE wo.id = $1 LIMIT 1`,
[workOrderId],
)
const order = result.rows[0]
if (!order || String(order.platform_order_id || '').trim() !== platformOrderId) throw notFound()
return order
}
const orders = await listWorkOrdersByPlatformOrderId(platformOrderId)
if (orders.length === 0) throw notFound()
if (orders.length > 1) {
throw createHttpError('该订单号下有多个商品,请先选择商品', {
statusCode: 409,
errorCode: 'material_link_work_order_required',
})
}
return orders[0]!
}
async function mapPublicOrderItem(order: WorkOrderRow): Promise<OrderServicePublicItem> {
const state = resolvePublicMaterialOrderState(order)
const profile = await resolveProfileConfig(order)
const material = safeParseJson(order.material_json)
const collect = material.collect && typeof material.collect === 'object' ? material.collect : {}
const values = normalizeSubmittedFields(collect.fields || {})
const screenshots = normalizeUploadedFiles(collect.screenshots || [])
.map(refreshUploadedFileUrls)
.filter(Boolean)
const base: OrderServicePublicItem = {
workOrderId: Number(order.id),
productName: String(order.product_name || ''),
categoryName: String(order.parent_category_name || order.category_name || ''),
title: profile?.title || profile?.name || '订单服务',
description: profile?.description || '',
submitText: profile?.submitText || '提交资料',
successText: profile?.successText || '资料已提交,请等待处理',
state: order.status,
stateLabel: resolveOrderServiceStatusLabel(order.status, state.statusLabel),
message: resolveOrderServiceStatusMessage({
status: order.status,
canSubmit: state.canSubmit,
fallbackMessage: state.message,
}),
canSubmit: state.canSubmit,
blockedReason: state.blockedReason,
}
if (!state.canSubmit) return base
const fields = profile?.fields || []
return {
...base,
fields,
values,
screenshots,
screenshotsRule: profile?.screenshots || {
enabled: true,
required: true,
minCount: 0,
maxCount: 5,
label: '买家主页截图',
helpText: '',
},
guideImage: profile?.guideImage || {},
}
}
async function resolveProfileConfig(
order: WorkOrderRow,
client?: Pick<PoolClient, 'query'>,
): Promise<OrderServiceProfileConfig | null> {
const snapshot = safeParseJson(order.service_profile_snapshot_json)
const config = snapshot.config
if (config && typeof config === 'object' && Array.isArray((config as JsonObject).fields)) {
return normalizeProfileConfig(config as JsonObject)
}
if (order.category_id && order.service_profile_id && order.service_profile_version_id) {
const snapshotFromVersion = await getOrderServiceProfileSnapshotByVersion({
profileId: order.service_profile_id,
categoryId: order.category_id,
versionId: order.service_profile_version_id,
...(client ? { client } : {}),
})
if (snapshotFromVersion) return snapshotFromVersion.config
}
const legacy = safeParseJson(order.requirement_json)
if (Array.isArray(legacy.fields)) {
return {
name: '通用资料',
title: '',
description: '',
guideImage: {},
fields: legacy.fields as OrderServiceField[],
screenshots: {
enabled: true,
required: true,
minCount: 0,
maxCount: 5,
label: '买家主页截图',
helpText: '',
},
autoPublish: { enabled: false, requireVoucherConsume: true, retryMax: 3 },
submitText: '提交资料',
successText: '资料已提交,请等待处理',
}
}
return null
}
async function resolveOwnedOrderServiceScreenshots(
files: ReturnType<typeof normalizeUploadedFiles>,
workOrderId: number,
client: Pick<PoolClient, 'query'>,
): Promise<UploadedFileDto[]> {
const expectedUploaderId = `work-order-${workOrderId}`
const result: UploadedFileDto[] = []
for (const file of files) {
const objectKey = String(file.objectKey || '').trim()
const asset = objectKey ? await getFileAssetByObjectKey(objectKey, client) : null
if (
!asset ||
asset.status !== 'active' ||
asset.uploader_type !== 'collect' ||
asset.uploader_id !== expectedUploaderId ||
asset.scene !== 'collect-material' ||
!ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES.has(String(asset.content_type || '').trim())
) {
throw createHttpError('截图文件无效,请重新上传', {
statusCode: 400,
errorCode: 'order_service_screenshot_invalid',
})
}
result.push({
objectKey: asset.object_key,
url: buildFileUrl(asset.object_key),
thumbnailUrl: buildFileUrl(asset.thumbnail_object_key || asset.object_key),
mediumUrl: buildFileUrl(asset.medium_object_key || asset.object_key),
filename: asset.original_filename,
contentType: asset.content_type,
size: Number(asset.size_bytes || 0),
})
}
return result
}
function mapSafeOrder(order: WorkOrderRow) {
return {
workOrderNo: order.work_order_no,
platformOrderId: order.platform_order_id,
productName: order.product_name,
status: order.status,
}
}
function normalizeOrderNo(value: unknown) {
const normalized = String(value ?? '').trim()
if (!normalized) throw notFound()
return normalized
}
function notFound() {
return createHttpError('订单号无效,请确认订单号是否正确', {
statusCode: 404,
errorCode: 'order_service_not_found',
})
}
@@ -56,6 +56,10 @@ import {
} from '../fulfillment/internal-sku-resolver.js'
import { loadActiveCarrierEvidence } from '../fulfillment/carrier-evidence-service.js'
import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js'
import {
getPublishedOrderServiceProfileSnapshotByCategory,
isOrderServiceMaterialRequired,
} from './order-service-profile-service.js'
export function shouldBootstrapWorkOrderChannel(
plan: InternalSkuChannelPlan | undefined,
@@ -343,7 +347,12 @@ export async function syncWorkerOrdersForSourceOrder(
continue
}
const fields = normalizeRequirementFields(safeParseJson(rule.requirement_json).fields)
const serviceProfile = rule.category_id
? await getPublishedOrderServiceProfileSnapshotByCategory(rule.category_id)
: null
const fields = serviceProfile
? normalizeRequirementFields(serviceProfile.config.fields)
: normalizeRequirementFields(safeParseJson(rule.requirement_json).fields)
const skuQuantity = resolveSkuNameQuantity(item.sku_name)
const { sharingTotalQuantity, sharingUnitReward, rewardAmount } = resolveWorkOrderRulePricing({
skuQuantity,
@@ -359,7 +368,9 @@ export async function syncWorkerOrdersForSourceOrder(
? false
: resolveWorkOrderSharingEnabled(rule.sharing_enabled === true, sharingTotalQuantity)
const now = nowIso()
const materialComplete = fields.every((field) => !field.required)
const materialComplete = serviceProfile
? !isOrderServiceMaterialRequired(serviceProfile.config)
: fields.every((field) => !field.required)
// 工单素材 = source 摘要 +(抖音)该单已有的券快照:打手要能看到「交付什么券」。
// 券通常在发券后回填(certificate-service),这里覆盖「运营补模板后重新匹配」的建单路径。
let material: JsonObject = {
@@ -408,6 +419,17 @@ export async function syncWorkerOrdersForSourceOrder(
giftCooldownMinutes: normalizeWorkOrderGiftCooldownMinutes(rule.gift_cooldown_minutes),
materialJson: JSON.stringify(material),
requirementJson: JSON.stringify({ fields }),
serviceProfileId: serviceProfile?.profileId || null,
serviceProfileVersionId: serviceProfile?.versionId || null,
serviceProfileSnapshotJson: serviceProfile
? JSON.stringify({
profileId: serviceProfile.profileId,
categoryId: serviceProfile.categoryId,
versionId: serviceProfile.versionId,
versionNo: serviceProfile.versionNo,
config: serviceProfile.config,
})
: null,
now,
})
if (!workOrder) {
@@ -446,6 +468,8 @@ export async function syncWorkerOrdersForSourceOrder(
toStatus: workOrder.status,
payloadJson: JSON.stringify({
ruleKey: rule.rule_key,
serviceProfileId: serviceProfile?.profileId || null,
serviceProfileVersionId: serviceProfile?.versionId || null,
orderId: order.id,
orderItemId: item.id,
}),
@@ -26,10 +26,7 @@ import {
import { DEFAULT_DEPOSIT_THRESHOLD_AMOUNT } from './worker-finance-mappers.js'
import { normalizeUploadedFiles } from './worker-input-normalizers.js'
import { resolveFreezeDepositAmount } from './worker-permission-utils.js'
import {
mapAcceptanceForResponse,
resolveCollectSubmittedFieldValues,
} from './work-order-material-mappers.js'
import { mapAcceptanceForResponse } from './work-order-material-mappers.js'
import { resolveRequirementFields } from './work-order-requirement-fields.js'
export function mapWorkCategory(
@@ -972,16 +969,3 @@ export function mapWorkOrderPublic(workOrder: WorkOrderRow) {
status: workOrder.status,
}
}
export function mapCollectLookupOrder(workOrder: WorkOrderRow) {
const material = mapWorkOrderMaterial(workOrder.material_json)
return {
...mapWorkOrderPublic(workOrder),
fields: resolveRequirementFields(workOrder),
values: resolveCollectSubmittedFieldValues(workOrder),
screenshots:
material.collect && Array.isArray(material.collect.screenshots)
? material.collect.screenshots
: [],
}
}
@@ -3,7 +3,6 @@ import test from 'node:test'
import {
formatPublicMaterialStatus,
pickPendingMaterialOrders,
pickSubmittedMaterialFields,
resolveMaterialResetBlockReason,
resolvePublicMaterialOrderState,
@@ -130,22 +129,6 @@ test('客服重置补资料:只允许待分配、未指派、无份额、未
)
})
test('公开补资料:只查询待完善工单,其他状态一律不返回', () => {
const orders = [
{ status: 'pending_material' },
{ status: 'unassigned' },
{ status: 'open' },
{ status: 'accepted' },
{ status: 'cancelled' },
] as WorkOrderRow[]
assert.deepEqual(
pickPendingMaterialOrders(orders).map((order) => order.status),
['pending_material'],
)
assert.deepEqual(pickPendingMaterialOrders([]), [])
})
test('公开补资料:提交字段只保留 requirements 白名单,额外字段被丢弃', () => {
const result = pickSubmittedMaterialFields([{ key: 'gameId' }, { key: 'gameNickname' }], {
gameId: '123',
@@ -1,29 +1,6 @@
import { withTransaction } from '../../db/client.js'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
createWorkOrderEventWithClient,
getWorkOrderForUpdateWithClient,
listWorkOrdersByPlatformOrderId,
updateWorkOrderMaterialWithClient,
} from '../../repositories/worker-platform/index.js'
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { logWarn } from '../../utils/logger.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
import {
mapCollectLookupOrder,
normalizeOptionalId,
normalizeSubmittedFields,
normalizeUploadedFiles,
resolveRequirementFields,
} from './mappers.js'
import { getRequiredWorkOrder } from './worker-session-context-service.js'
const MAX_MATERIAL_SCREENSHOTS = 5
const PUBLIC_MATERIAL_STATUS_LABELS: Record<string, string> = {
[WORK_ORDER_STATUS.PENDING_MATERIAL]: '待完善',
@@ -53,18 +30,11 @@ export type PublicMaterialOrderState = {
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 resolvePublicMaterialOrderState(
order: Pick<WorkOrderRow, 'status' | 'assigned_worker_id' | 'active_share_quantity'>,
): PublicMaterialOrderState {
@@ -114,12 +84,6 @@ export function resolvePublicMaterialOrderState(
}
}
/**
* 客服重置补资料状态的前置条件。
*
* 只允许「待分配 + 未指派 + 无份额 + 未发布」的工单回到待完善;
* 其他状态一律拒绝,避免把已进入接单流程的工单改回可编辑。
*/
export function resolveMaterialResetBlockReason(
order: Pick<
WorkOrderRow,
@@ -140,12 +104,6 @@ export function resolveMaterialResetBlockMessage(reason: MaterialResetBlockReaso
return '只有待分配(未指派、无份额、未发布)的工单可以重置补资料状态'
}
/**
* 只保留工单 requirement 里定义过的字段。
*
* 公开提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的
* 额外 key 混进 material.collect.fields;这里按白名单裁剪一次。
*/
export function pickSubmittedMaterialFields(
fields: Array<{ key: string }>,
submitted: Record<string, string>,
@@ -154,7 +112,7 @@ export function pickSubmittedMaterialFields(
return Object.fromEntries(Object.entries(submitted).filter(([key]) => allowedKeys.has(key)))
}
function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never {
export function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never {
if (state.blockedReason === 'already_submitted') {
throw createHttpError(state.message, {
statusCode: 409,
@@ -166,272 +124,3 @@ function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never {
errorCode: 'material_link_order_status_invalid',
})
}
function mapPublicOrder(order: WorkOrderRow) {
return {
workOrderNo: order.work_order_no,
platformOrderId: order.platform_order_id,
productName: order.product_name,
status: order.status,
}
}
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]!
}
/**
* 公开页只允许查询待完善工单。
*
* 待分配、待抢单、代练中、已验收、已取消等状态一律不返回,
* 避免客户用订单号看到已完成订单的商品与状态。
*/
export function pickPendingMaterialOrders(orders: WorkOrderRow[]): WorkOrderRow[] {
return orders.filter((order) => order.status === WORK_ORDER_STATUS.PENDING_MATERIAL)
}
/**
* 公开页按平台订单号读取补资料内容。
*
* 不再要求客服先生成链接;订单号存在且仍有待完善工单时才返回内容。
* 只返回待完善工单;已提交(待分配)及其他状态统一提示无需补充资料。
*/
export async function getPublicWorkOrderMaterialLink(platformOrderId: string) {
const code = String(platformOrderId || '').trim()
if (!code) {
throw createHttpError('订单号无效,请确认订单号是否正确', {
statusCode: 404,
errorCode: 'material_link_not_found',
})
}
const orders = await listWorkOrdersByPlatformOrderId(code)
if (orders.length === 0) {
throw createHttpError('订单号无效,请确认订单号是否正确', {
statusCode: 404,
errorCode: 'material_link_not_found',
})
}
const pendingOrders = pickPendingMaterialOrders(orders)
if (pendingOrders.length === 0) {
throw createHttpError('该订单当前无需补充资料', {
statusCode: 404,
errorCode: 'material_link_not_pending',
})
}
const publicOrders = pendingOrders.map(mapPublicMaterialOrder)
return {
platformOrderId: code,
orders: publicOrders,
pendingCount: publicOrders.filter((order) => order.canSubmit).length,
}
}
/**
* 公开页提交资料。
*
* 一次性语义完全由工单状态保证:只有 `pending_material` 能成功提交,
* 提交后改成 `unassigned`;并发的第二个提交会锁到 `unassigned` 并返回 409。
*/
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, target.id)
if (!order) {
throw createHttpError('接单订单不存在', {
statusCode: 404,
errorCode: 'work_order_not_found',
})
}
if (String(order.platform_order_id || '').trim() !== code) {
throw createHttpError('订单号无效,请确认订单号是否正确', {
statusCode: 404,
errorCode: 'material_link_work_order_invalid',
})
}
const orderState = resolvePublicMaterialOrderState(order)
if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState)
const fields = resolveRequirementFields(order)
const submittedFields = pickSubmittedMaterialFields(
fields,
normalizeSubmittedFields(payload.fields || {}),
)
const missing = fields.filter(
(field) => field.required && !String(submittedFields[field.key] || '').trim(),
)
if (missing.length > 0) {
throw createHttpError(
`请填写完整必填资料:${missing.map((field) => field.label).join('、')}`,
{
statusCode: 400,
errorCode: 'material_link_fields_incomplete',
context: { missingFields: missing.map((field) => field.key) },
},
)
}
const screenshots = normalizeUploadedFiles(payload.screenshots || [])
if (screenshots.length > MAX_MATERIAL_SCREENSHOTS) {
throw createHttpError(`买家主页截图最多 ${MAX_MATERIAL_SCREENSHOTS} 张`, {
statusCode: 400,
errorCode: 'material_link_screenshots_too_many',
})
}
const now = nowIso()
const material = {
...safeParseJson(order.material_json),
collect: {
fields: submittedFields,
screenshots,
submittedAt: now,
complete: true,
source: 'customer_order_no',
},
}
const updated = await updateWorkOrderMaterialWithClient(client, {
workOrderId: order.id,
status: WORK_ORDER_STATUS.UNASSIGNED,
materialJson: JSON.stringify(material),
now,
})
if (!updated) {
throw createHttpError('更新订单资料失败,请稍后重试', {
statusCode: 500,
errorCode: 'material_link_update_failed',
})
}
await createWorkOrderEventWithClient(client, {
workOrderId: order.id,
actorType: 'collect',
actorId: '客户按订单号补资料',
eventType: 'material_completed',
fromStatus: order.status,
toStatus: WORK_ORDER_STATUS.UNASSIGNED,
payloadJson: JSON.stringify({
fields: submittedFields,
complete: true,
source: 'customer_order_no',
}),
now,
})
return { order: updated }
})
// 事务已提交,后续通知/实时广播失败不能让客户误以为提交失败。
try {
await resolveAdminNotificationEntity(
'work_order',
Number(result.order.id),
'material_completed',
)
} catch (error) {
logWarn('[work-order-material-link]', '补资料完成通知处理失败', {
workOrderId: result.order.id,
error: error instanceof Error ? error.message : String(error),
})
}
publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) })
return {
complete: true,
workOrderId: Number(result.order.id),
order: mapPublicOrder(result.order),
}
}
/**
* 上传前校验平台订单号 / workOrderId 与工单状态;返回工单供文件归属使用。
* 注意:这里不消耗提交机会,只有最终提交才把状态改成待分配。
*/
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 order = await resolvePublicMaterialTarget(code, workOrderId)
const orderState = resolvePublicMaterialOrderState(order)
if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState)
return { order, now: nowIso() }
}
@@ -1,136 +0,0 @@
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
createWorkOrderEvent,
listPendingMaterialWorkOrdersByPlatformOrderId,
updateWorkOrder,
type WorkOrderRow,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
import {
mapCollectLookupOrder,
mapWorkOrderPublic,
normalizeOptionalId,
normalizeSubmittedFields,
normalizeUploadedFiles,
resolveRequirementFields,
} from './mappers.js'
export async function collectLookupWorkOrder(payload: JsonObject = {}) {
const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim()
if (!orderNo) {
throw createHttpError('请填写订单号', {
statusCode: 400,
errorCode: 'collect_order_no_required',
})
}
const workOrders = await listPendingMaterialWorkOrdersByPlatformOrderId(orderNo)
if (workOrders.length === 0) {
throw createHttpError('未找到待完善订单,请确认订单号是否正确', {
statusCode: 404,
errorCode: 'collect_order_not_found',
})
}
const [primaryOrder] = workOrders
if (!primaryOrder) {
throw createHttpError('未找到待完善订单,请确认订单号是否正确', {
statusCode: 404,
errorCode: 'collect_order_not_found',
})
}
return {
order: mapWorkOrderPublic(primaryOrder),
fields: resolveRequirementFields(primaryOrder),
orders: workOrders.map(mapCollectLookupOrder),
pendingCount: workOrders.length,
}
}
export async function collectSubmitWorkOrder(payload: JsonObject = {}) {
const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim()
if (!orderNo) {
throw createHttpError('请填写订单号', {
statusCode: 400,
errorCode: 'collect_order_no_required',
})
}
const workOrders = await listPendingMaterialWorkOrdersByPlatformOrderId(orderNo)
if (workOrders.length === 0) {
throw createHttpError('未找到待完善订单,请确认订单号是否正确', {
statusCode: 404,
errorCode: 'collect_order_not_found',
})
}
const workOrder = resolveCollectSubmitTargetWorkOrder(
orderNo,
workOrders,
payload.workOrderId || payload.work_order_id,
)
const fields = resolveRequirementFields(workOrder)
const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload)
const complete = fields.every(
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
)
const now = nowIso()
const screenshots = normalizeUploadedFiles(payload.screenshots || payload.files)
const material = {
...safeParseJson(workOrder.material_json),
collect: {
fields: submittedFields,
screenshots,
submittedAt: now,
complete,
},
}
const nextStatus = complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL
const updated = await updateWorkOrder(workOrder.id, {
status: nextStatus,
material_json: material,
updated_at: now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'collect',
actorId: orderNo,
eventType: 'material_submitted',
fromStatus: workOrder.status,
toStatus: nextStatus,
payloadJson: JSON.stringify({ fields: submittedFields, complete }),
now,
})
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id) })
return {
order: mapWorkOrderPublic(updated || workOrder),
complete,
}
}
export function resolveCollectSubmitTargetWorkOrder(
orderNo: string,
workOrders: WorkOrderRow[],
requestedWorkOrderId: unknown,
): WorkOrderRow {
const workOrderId = normalizeOptionalId(requestedWorkOrderId)
if (workOrderId) {
const matched = workOrders.find((item) => Number(item.id) === workOrderId)
if (matched) return matched
throw createHttpError('待完善订单不存在或已更新,请刷新后重试', {
statusCode: 404,
errorCode: 'collect_work_order_not_found',
context: { orderNo, workOrderId },
})
}
if (workOrders.length === 1) {
const [singleWorkOrder] = workOrders
if (singleWorkOrder) return singleWorkOrder
}
throw createHttpError('该订单存在多个待完善商品,请逐个提交资料', {
statusCode: 409,
errorCode: 'collect_work_order_required',
context: { orderNo, pendingCount: workOrders.length },
})
}
@@ -44,7 +44,6 @@ import {
mapFinanceRequest,
mapWalletLedger,
maskWorkerLeaderboardDisplayName,
resolveCollectSubmitTargetWorkOrder,
resolveAdminMaterialRewardAmount,
resolveFreezeDepositAmount,
resolveAdminMockWorkOrderInput,
@@ -2297,55 +2296,6 @@ test('后台更新资料禁止修改已有参与者的拼单价格', () => {
)
})
test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => {
const workOrder = buildWorkOrderRow()
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '')
assert.equal(selected.id, workOrder.id)
})
test('resolveCollectSubmitTargetWorkOrder requires explicit work order id when multiple orders are pending', () => {
const workOrders = [
buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' }),
buildWorkOrderRow({ id: 2, work_order_no: 'WO0002', order_item_id: 202 }),
]
assert.throws(
() => resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, ''),
(error: unknown) =>
Boolean(
error &&
typeof error === 'object' &&
(error as { statusCode?: number }).statusCode === 409 &&
(error as { errorCode?: string }).errorCode === 'collect_work_order_required',
),
)
})
test('resolveCollectSubmitTargetWorkOrder returns the matching work order when workOrderId is provided', () => {
const workOrders = [
buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' }),
buildWorkOrderRow({ id: 2, work_order_no: 'WO0002', order_item_id: 202 }),
]
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, '2')
assert.equal(selected.id, 2)
})
test('resolveCollectSubmitTargetWorkOrder rejects unknown workOrderId', () => {
const workOrders = [buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' })]
assert.throws(
() => resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, 999),
(error: unknown) =>
Boolean(
error &&
typeof error === 'object' &&
(error as { statusCode?: number }).statusCode === 404 &&
(error as { errorCode?: string }).errorCode === 'collect_work_order_not_found',
),
)
})
test('资金流水映射展示关联的平台订单号', () => {
const ledger = {
id: 1,
@@ -77,9 +77,3 @@ export {
submitWorkerOrderAcceptance,
supplementWorkerAcceptedOrderEvidence,
} from './worker-acceptance-service.js'
export {
collectLookupWorkOrder,
collectSubmitWorkOrder,
resolveCollectSubmitTargetWorkOrder,
} from './worker-collect-service.js'