feat(order-service): 重构分类资料收集与订单状态链路
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user