拆分打手验收服务

This commit is contained in:
yml2213
2026-08-21 16:58:49 +08:00
parent 19bdb0cdad
commit 47c25664e4
3 changed files with 753 additions and 749 deletions
@@ -13,6 +13,7 @@ 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-platform-defaults.js'
export * from './work-order-timeout-policy.js'
export * from './after-sales-service.js'
@@ -0,0 +1,744 @@
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
acceptWorkOrderAndSettle,
acceptWorkOrderShareAndSettle,
countWorkOrderPendingSharingSubmissions,
createWorkOrderEvent,
getWorkOrderShare,
settleOverdueWorkOrder,
submitWorkOrderShareAcceptance,
updateWorkOrder,
updateWorkOrderAcceptanceRecord,
updateWorkOrderDraftAcceptance,
updateWorkOrderShareAcceptanceRecord,
updateWorkOrderShareDraftAcceptance,
type WorkOrderRow,
type WorkOrderShareRow,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { addHours, nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import {
createWorkerAcceptanceAdminNotification,
getWorkerAcceptanceReminder,
remindWorkerAcceptanceAdminNotification,
} from '../admin/admin-notification-service.js'
import {
publishWorkerWalletRealtimeChange,
publishWorkOrderRealtimeChange,
} from '../realtime/realtime-event-service.js'
import {
canWorkerAutoAcceptWholeOrder,
mapWorkOrderForWorker,
mapWorkOrderShare,
normalizeStringArray,
normalizeUploadedFiles,
resolveWorkerPermissions,
} from './mappers.js'
import { getWorkerFinanceConfig } from './worker-finance-config-service.js'
import { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js'
import {
getRequiredWorkOrder,
getRequiredWorker,
requireActiveWorkerSession,
type WorkerSession,
} from './worker-session-context-service.js'
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
const VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS = 24
export async function submitWorkerOrderAcceptance(
workOrderId: number | string,
payload: JsonObject = {},
session: WorkerSession,
) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const workerId = Number(session.workerId)
const worker = await getRequiredWorker(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
if (sharingShare) {
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
}
const canAutoAcceptWithoutEvidence = canWorkerAutoAcceptWholeOrder({
worker,
hasPersonalSharingShare: false,
})
const now = nowIso()
if (
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
workOrder.deadline_at &&
new Date(workOrder.deadline_at).getTime() <= Date.now()
) {
await settleOverdueWorkOrder({
workOrderId: workOrder.id,
policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy),
now,
})
throw createHttpError('任务已超时,系统已按超时策略处置', {
statusCode: 409,
errorCode: 'work_order_timeout_expired',
})
}
if (Number(workOrder.assigned_worker_id || 0) !== workerId) {
throw createHttpError('只能提交自己的订单', {
statusCode: 403,
errorCode: 'work_order_owner_required',
})
}
if (
![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never)
) {
throw createHttpError('当前状态不能提交验收', {
statusCode: 409,
errorCode: 'work_order_submit_status_invalid',
})
}
const { files, imageUrls, note } = resolveAcceptanceImages(
payload,
workOrder.draft_acceptance_json,
)
if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) {
throw createHttpError('请上传验收图片', {
statusCode: 400,
errorCode: 'work_order_acceptance_image_required',
})
}
const acceptance = {
note,
files,
imageUrls: [...new Set(imageUrls)],
submittedAt: now,
...(canAutoAcceptWithoutEvidence
? {
reviewMode: 'vip_auto',
evidenceStatus: imageUrls.length > 0 ? 'complete' : 'pending',
evidenceDueAt:
imageUrls.length > 0 ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS),
}
: {}),
}
const updated = await updateWorkOrder(workOrder.id, {
status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
acceptance_json: acceptance,
draft_acceptance_json: {},
submitted_at: now,
updated_at: now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'acceptance_submitted',
fromStatus: workOrder.status,
toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
payloadJson: JSON.stringify({
imageCount: imageUrls.length,
autoAccepted: canAutoAcceptWithoutEvidence,
evidenceDueAt: acceptance.evidenceDueAt || null,
}),
now,
})
if (worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence) {
const { order: acceptedOrder, failureReason } = await acceptWorkOrderAndSettle({
workOrderId: workOrder.id,
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
now,
actorType: 'worker',
actorName: worker.display_name || worker.username || '',
})
if (failureReason === 'sharing_pending_submissions') {
throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', {
statusCode: 409,
errorCode: 'work_order_sharing_pending_submissions',
})
}
if (!acceptedOrder) {
throw createHttpError('免审核验收失败,请稍后重试', {
statusCode: 409,
errorCode: 'work_order_internal_auto_accept_failed',
})
}
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: worker.worker_type === 'internal' ? 'accepted_by_internal' : 'accepted_by_vip',
fromStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
toStatus: WORK_ORDER_STATUS.ACCEPTED,
payloadJson: JSON.stringify({
autoAccepted: true,
evidenceStatus: acceptance.evidenceStatus || 'complete',
evidenceDueAt: acceptance.evidenceDueAt || null,
}),
now,
})
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id),
workerIds: [Number(worker.id)],
})
publishWorkerWalletRealtimeChange(Number(worker.id))
return {
order: mapWorkOrderForWorker(acceptedOrder, resolveWorkerPermissions(worker)),
}
}
await createWorkerAcceptanceAdminNotification({
workOrderId: Number(workOrder.id),
workOrderNo: workOrder.work_order_no,
productName: workOrder.product_name,
workerName: worker.display_name || worker.username,
imageCount: imageUrls.length,
})
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id),
workerIds: [Number(worker.id)],
})
return {
order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(worker)),
}
}
/** 打手在提交验收后催促后台处理;频率和次数均由服务端控制。 */
export async function remindWorkerOrderAcceptance(
workOrderId: number | string,
session: WorkerSession,
) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
if (
workOrder.sharing_enabled ||
Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)
) {
throw createHttpError('只能催促自己提交的整单验收', {
statusCode: 403,
errorCode: 'work_order_acceptance_reminder_owner_required',
})
}
if (workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE) {
throw createHttpError('当前订单无需催验收', {
statusCode: 409,
errorCode: 'work_order_acceptance_reminder_status_invalid',
})
}
const submittedAt = Date.parse(String(workOrder.submitted_at || ''))
const now = Date.now()
const firstReminderAt = submittedAt + 10 * 60 * 1000
if (!Number.isFinite(submittedAt) || now < firstReminderAt) {
throw createHttpError('提交验收满 10 分钟后才可催验收', {
statusCode: 409,
errorCode: 'work_order_acceptance_reminder_too_early',
})
}
let notification = await getWorkerAcceptanceReminder(Number(workOrder.id))
if (!notification) {
const worker = await getRequiredWorker(session.workerId)
await createWorkerAcceptanceAdminNotification({
workOrderId: Number(workOrder.id),
workOrderNo: workOrder.work_order_no,
productName: workOrder.product_name,
workerName: worker.display_name || worker.username,
imageCount: normalizeStringArray(safeParseJson(workOrder.acceptance_json).imageUrls).length,
})
notification = await getWorkerAcceptanceReminder(Number(workOrder.id))
}
if (!notification) {
throw createHttpError('催验收待办创建失败,请稍后重试', {
statusCode: 409,
errorCode: 'work_order_acceptance_reminder_notification_missing',
})
}
if (Number(notification.reminder_count || 0) >= 3) {
throw createHttpError('该订单最多催验收 3 次', {
statusCode: 409,
errorCode: 'work_order_acceptance_reminder_limit_reached',
})
}
const lastRemindedAt = Date.parse(String(notification.last_reminded_at || ''))
const nextRemindAt = lastRemindedAt + 30 * 60 * 1000
if (Number.isFinite(lastRemindedAt) && now < nextRemindAt) {
throw createHttpError('每 30 分钟可催验收一次,请稍后再试', {
statusCode: 409,
errorCode: 'work_order_acceptance_reminder_cooldown',
})
}
const reminded = await remindWorkerAcceptanceAdminNotification(Number(workOrder.id))
if (!reminded) {
throw createHttpError('订单状态已变化,请刷新后重试', {
statusCode: 409,
errorCode: 'work_order_acceptance_reminder_conflict',
})
}
const remindedAt = new Date(reminded.last_reminded_at || new Date().toISOString()).getTime()
await createWorkOrderEvent({
workOrderId: Number(workOrder.id),
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'acceptance_reminded',
fromStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
payloadJson: JSON.stringify({ reminderCount: reminded.reminder_count }),
now: new Date(reminded.last_reminded_at || new Date().toISOString()).toISOString(),
})
return {
reminderCount: Number(reminded.reminder_count || 0),
nextRemindAt: new Date(remindedAt + 30 * 60 * 1000).toISOString(),
}
}
/** 已验收订单可追加验收图片,不改变订单状态和结算结果。 */
export async function supplementWorkerAcceptedOrderEvidence(
workOrderId: number | string,
payload: JsonObject = {},
session: WorkerSession,
) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const worker = await getRequiredWorker(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, session.workerId)
if (sharingShare) {
return supplementWorkerAcceptedSharingEvidence(workOrder, sharingShare, payload, session)
}
if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) {
throw createHttpError('只能补充自己的订单验收图片', {
statusCode: 403,
errorCode: 'work_order_owner_required',
})
}
if (workOrder.status !== WORK_ORDER_STATUS.ACCEPTED) {
throw createHttpError('只有已验收订单可以补充验收图片', {
statusCode: 409,
errorCode: 'work_order_evidence_supplement_status_invalid',
})
}
const now = nowIso()
const previous = safeParseJson(workOrder.acceptance_json)
const mergedEvidence = mergeAcceptanceEvidence(previous, payload.files)
if (!mergedEvidence.changed) {
throw createHttpError('请上传新的验收图片', {
statusCode: 400,
errorCode: 'work_order_evidence_supplement_image_required',
})
}
const acceptance = {
...previous,
files: mergedEvidence.files,
imageUrls: mergedEvidence.imageUrls,
evidenceStatus: 'complete',
evidenceSupplementedAt: now,
updatedAt: now,
}
const updated = await updateWorkOrderAcceptanceRecord({
workOrderId: workOrder.id,
acceptanceJson: JSON.stringify(acceptance),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'acceptance_evidence_supplemented',
fromStatus: WORK_ORDER_STATUS.ACCEPTED,
toStatus: WORK_ORDER_STATUS.ACCEPTED,
payloadJson: JSON.stringify({
addedImageCount: mergedEvidence.addedFileCount,
totalImageCount: mergedEvidence.imageUrls.length,
}),
now,
})
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id) })
return {
order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(worker)),
}
}
async function submitWorkerSharingAcceptance(
workOrder: WorkOrderRow,
sharingShare: WorkOrderShareRow,
payload: JsonObject = {},
session: WorkerSession,
) {
if (workOrder.status === WORK_ORDER_STATUS.ACCEPTED) {
throw createHttpError('该拼单已验收完成', {
statusCode: 409,
errorCode: 'work_order_sharing_accepted',
})
}
const now = nowIso()
const worker = await getRequiredWorker(session.workerId)
const canAutoAcceptWithoutEvidence = resolveWorkerPermissions(worker).autoAcceptWithoutEvidence
const { files, imageUrls, note } = resolveAcceptanceImages(
payload,
sharingShare.draft_acceptance_json,
)
if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) {
throw createHttpError('请上传验收图片', {
statusCode: 400,
errorCode: 'work_order_acceptance_image_required',
})
}
const acceptance = {
note,
files,
imageUrls: [...new Set(imageUrls)],
submittedAt: now,
...(canAutoAcceptWithoutEvidence
? {
reviewMode: 'vip_auto',
evidenceStatus: imageUrls.length > 0 ? 'complete' : 'pending',
evidenceDueAt:
imageUrls.length > 0 ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS),
}
: {}),
}
const result = await submitWorkOrderShareAcceptance({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
acceptanceJson: JSON.stringify(acceptance),
now,
})
if (result.failureReason === 'share_status_invalid') {
throw createHttpError('当前拼单份额已提交,请勿重复提交', {
statusCode: 409,
errorCode: 'work_order_sharing_share_submitted',
})
}
if (!result.share) {
throw createHttpError('未找到拼单份额', {
statusCode: 404,
errorCode: 'work_order_sharing_share_not_found',
})
}
if (canAutoAcceptWithoutEvidence) {
const accepted = await acceptWorkOrderShareAndSettle({
workOrderId: Number(workOrder.id),
shareId: Number(result.share.id),
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
now,
actorType: 'worker',
actorName: worker.display_name || worker.username || '',
})
if (!accepted.share || !accepted.order) {
throw createHttpError('VIP 免审核验收失败,请稍后重试', {
statusCode: 409,
errorCode: 'work_order_sharing_vip_auto_accept_failed',
})
}
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
walletChangedWorkerIds: [Number(session.workerId)],
})
return {
share: mapWorkOrderShare(accepted.share),
sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)),
}
}
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return {
share: mapWorkOrderShare(result.share),
sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)),
}
}
/** 已验收拼单份额可追加验收图片,不改变订单状态和结算结果。 */
async function supplementWorkerAcceptedSharingEvidence(
workOrder: WorkOrderRow,
sharingShare: WorkOrderShareRow,
payload: JsonObject,
session: WorkerSession,
) {
if (sharingShare.status !== 'accepted') {
throw createHttpError('只有已验收的拼单份额可以补充验收图片', {
statusCode: 409,
errorCode: 'work_order_sharing_evidence_supplement_status_invalid',
})
}
const now = nowIso()
const previous = safeParseJson(sharingShare.acceptance_json)
const mergedEvidence = mergeAcceptanceEvidence(previous, payload.files)
if (!mergedEvidence.changed) {
throw createHttpError('请上传新的验收图片', {
statusCode: 400,
errorCode: 'work_order_sharing_evidence_supplement_image_required',
})
}
const acceptance = {
...previous,
files: mergedEvidence.files,
imageUrls: mergedEvidence.imageUrls,
evidenceStatus: 'complete',
evidenceSupplementedAt: now,
updatedAt: now,
}
const updated = await updateWorkOrderShareAcceptanceRecord({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
acceptanceJson: JSON.stringify(acceptance),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'sharing_acceptance_evidence_supplemented',
fromStatus: WORK_ORDER_STATUS.ACCEPTED,
toStatus: WORK_ORDER_STATUS.ACCEPTED,
payloadJson: JSON.stringify({
workerId: Number(session.workerId),
addedImageCount: mergedEvidence.addedFileCount,
totalImageCount: mergedEvidence.imageUrls.length,
}),
now,
})
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return { share: mapWorkOrderShare(updated || sharingShare) }
}
/** 从提交或草稿载荷中解析验收图片与说明。 */
function resolveAcceptanceImages(payload: JsonObject, draftValue: unknown) {
const files = normalizeUploadedFiles(payload.files)
const imageUrls = [
...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean),
...normalizeStringArray(payload.imageUrls),
]
if (imageUrls.length > 0) {
return {
files,
imageUrls: [...new Set(imageUrls)],
note: String(payload.note ?? '').trim(),
}
}
const draft = safeParseJson(draftValue)
return {
files: normalizeUploadedFiles(draft.files),
imageUrls: [...new Set(normalizeStringArray(draft.imageUrls))],
note: String(payload.note ?? draft.note ?? '').trim(),
}
}
/** 暂存验收图片或说明;已提交验收时更新已提交资料。 */
export async function saveWorkerOrderAcceptanceDraft(
workOrderId: number | string,
payload: JsonObject = {},
session: WorkerSession,
) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const workerId = Number(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
if (sharingShare) {
return saveWorkerSharingAcceptanceDraft(workOrder, sharingShare, payload, session)
}
if (Number(workOrder.assigned_worker_id || 0) !== workerId) {
throw createHttpError('只能操作自己的订单', {
statusCode: 403,
errorCode: 'work_order_owner_required',
})
}
const now = nowIso()
const { files, imageUrls } = resolveAcceptanceImages(payload, {})
const worker = await getRequiredWorker(session.workerId)
if (workOrder.status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE) {
if (imageUrls.length === 0) {
throw createHttpError('请至少保留一张验收图片', {
statusCode: 400,
errorCode: 'work_order_acceptance_image_required',
})
}
const previous = safeParseJson(workOrder.acceptance_json)
const acceptance = {
note: String(payload.note ?? previous.note ?? '').trim(),
files,
imageUrls: [...new Set(imageUrls)],
submittedAt: previous.submittedAt || workOrder.submitted_at || now,
updatedAt: now,
}
const updated = await updateWorkOrderAcceptanceRecord({
workOrderId: workOrder.id,
acceptanceJson: JSON.stringify(acceptance),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'acceptance_updated',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({ imageCount: imageUrls.length }),
now,
})
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return {
order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(worker)),
}
}
if (
![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never)
) {
throw createHttpError('当前状态不能保存验收图片', {
statusCode: 409,
errorCode: 'work_order_draft_status_invalid',
})
}
const draft = {
note: String(payload.note || '').trim(),
files,
imageUrls: [...new Set(imageUrls)],
updatedAt: now,
}
const updated = await updateWorkOrderDraftAcceptance({
workOrderId: workOrder.id,
draftJson: JSON.stringify(draft),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'acceptance_draft_saved',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({ imageCount: imageUrls.length }),
now,
})
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return {
order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(worker)),
}
}
async function saveWorkerSharingAcceptanceDraft(
workOrder: WorkOrderRow,
sharingShare: WorkOrderShareRow,
payload: JsonObject = {},
session: WorkerSession,
) {
if (workOrder.status === WORK_ORDER_STATUS.ACCEPTED) {
throw createHttpError('该拼单已验收完成,不能再修改验收图片', {
statusCode: 409,
errorCode: 'work_order_sharing_accepted',
})
}
const now = nowIso()
const { files, imageUrls } = resolveAcceptanceImages(payload, {})
if (sharingShare.status === 'submitted') {
if (imageUrls.length === 0) {
throw createHttpError('请至少保留一张验收图片', {
statusCode: 400,
errorCode: 'work_order_acceptance_image_required',
})
}
const previous = safeParseJson(sharingShare.acceptance_json)
const acceptance = {
note: String(payload.note ?? previous.note ?? '').trim(),
files,
imageUrls: [...new Set(imageUrls)],
submittedAt: previous.submittedAt || sharingShare.submitted_at || now,
updatedAt: now,
}
const share = await updateWorkOrderShareAcceptanceRecord({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
acceptanceJson: JSON.stringify(acceptance),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'sharing_acceptance_updated',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({
workerId: Number(session.workerId),
imageCount: imageUrls.length,
}),
now,
})
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return { share: share ? mapWorkOrderShare(share) : mapWorkOrderShare(sharingShare) }
}
if (sharingShare.status !== 'joined') {
throw createHttpError('当前拼单状态不能保存验收图片', {
statusCode: 409,
errorCode: 'work_order_sharing_draft_status_invalid',
})
}
const draft = {
note: String(payload.note || '').trim(),
files,
imageUrls: [...new Set(imageUrls)],
updatedAt: now,
}
const share = await updateWorkOrderShareDraftAcceptance({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
draftJson: JSON.stringify(draft),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'sharing_acceptance_draft_saved',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({
workerId: Number(session.workerId),
imageCount: imageUrls.length,
}),
now,
})
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return { share: share ? mapWorkOrderShare(share) : mapWorkOrderShare(sharingShare) }
}
function mergeAcceptanceEvidence(previous: Record<string, unknown>, uploadedValue: unknown) {
const existingFiles = normalizeUploadedFiles(previous.files)
const uploadedFiles = normalizeUploadedFiles(uploadedValue)
const files = [...existingFiles]
const fileIdentities = new Set(
existingFiles.map((file) => file.objectKey || file.url || file.mediumUrl || file.thumbnailUrl),
)
for (const file of uploadedFiles) {
const identity = file.objectKey || file.url || file.mediumUrl || file.thumbnailUrl
if (!identity || fileIdentities.has(identity)) continue
fileIdentities.add(identity)
files.push(file)
}
const previousImageUrls = normalizeStringArray(previous.imageUrls)
const imageUrls = [
...new Set([
...previousImageUrls,
...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean),
]),
]
return {
files,
imageUrls,
addedFileCount: files.length - existingFiles.length,
changed: files.length !== existingFiles.length || imageUrls.length !== previousImageUrls.length,
}
}
@@ -1,17 +1,13 @@
import { runtimeConfig } from '../../config/runtime.js'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
acceptWorkOrderAndSettle,
acceptWorkOrderShareAndSettle,
createWorkerCancelRequest,
createWorkerOrderFeedback,
countWorkerActiveOrders,
countWorkOrderPendingSharingSubmissions,
consumeWorkerSmsCode,
createWorkOrderEvent,
getLatestWorkerSmsCode,
recordWorkerSmsCodeFailure,
settleOverdueWorkOrder,
getWorkerUserById,
getWorkerUserByPhone,
getWorkerUserByUsername,
@@ -34,24 +30,16 @@ import {
listWorkOrders,
listWorkOrderEventsByOrderId,
getWorkerCancelRequestRisk,
submitWorkOrderShareAcceptance,
updateWorkOrder,
updateWorkOrderAcceptanceRecord,
updateWorkOrderDraftAcceptance,
updateWorkOrderShareAcceptanceRecord,
updateWorkOrderShareDraftAcceptance,
upsertWorkerWorkOrderNote,
markWorkerWorkOrderViewed,
type WorkOrderRow,
type WorkOrderShareRow,
type WorkerSessionRow,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { addHours, nowIso } from '../../utils/time.js'
import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js'
import { nowIso } from '../../utils/time.js'
import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
import { getWorkerAnnouncementConfig } from './worker-announcement-config-service.js'
import { getWorkerFinanceConfig } from './worker-finance-config-service.js'
import { getWorkerHallConfig } from './worker-hall-config-service.js'
import {
getRequiredWorkOrder,
@@ -60,11 +48,8 @@ import {
type WorkerSession,
} from './worker-session-context-service.js'
import {
createWorkerAcceptanceAdminNotification,
createWorkerCancelRequestAdminNotification,
createWorkerOrderFeedbackAdminNotification,
getWorkerAcceptanceReminder,
remindWorkerAcceptanceAdminNotification,
} from '../admin/admin-notification-service.js'
import {
publishWorkerWalletRealtimeChange,
@@ -81,11 +66,8 @@ import {
normalizeOptionalId,
normalizePositiveInteger,
normalizeStatuses,
normalizeStringArray,
normalizeUploadedFiles,
resolveFreezeDepositAmount,
resolveWorkerPermissions,
canWorkerAutoAcceptWholeOrder,
throwGrabWorkOrderFailure,
} from './mappers.js'
import {
@@ -119,8 +101,6 @@ import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
import { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js'
import { settleOverdueWorkOrders } from './worker-order-timeout-service.js'
const VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS = 24
export {
getRequiredWorkOrder,
getRequiredWorker,
@@ -638,733 +618,12 @@ function resolveWorkOrderShareJoinError(
})
}
export async function submitWorkerOrderAcceptance(
workOrderId: number | string,
payload: JsonObject = {},
session: WorkerSession,
) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const workerId = Number(session.workerId)
const worker = await getRequiredWorker(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
if (sharingShare) {
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
}
const canAutoAcceptWithoutEvidence = canWorkerAutoAcceptWholeOrder({
worker,
hasPersonalSharingShare: false,
})
const now = nowIso()
if (
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
workOrder.deadline_at &&
new Date(workOrder.deadline_at).getTime() <= Date.now()
) {
await settleOverdueWorkOrder({
workOrderId: workOrder.id,
policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy),
now,
})
throw createHttpError('任务已超时,系统已按超时策略处置', {
statusCode: 409,
errorCode: 'work_order_timeout_expired',
})
}
if (Number(workOrder.assigned_worker_id || 0) !== workerId) {
throw createHttpError('只能提交自己的订单', {
statusCode: 403,
errorCode: 'work_order_owner_required',
})
}
if (
![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never)
) {
throw createHttpError('当前状态不能提交验收', {
statusCode: 409,
errorCode: 'work_order_submit_status_invalid',
})
}
const { files, imageUrls, note } = resolveAcceptanceImages(
payload,
workOrder.draft_acceptance_json,
)
if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) {
throw createHttpError('请上传验收图片', {
statusCode: 400,
errorCode: 'work_order_acceptance_image_required',
})
}
const acceptance = {
note,
files,
imageUrls: [...new Set(imageUrls)],
submittedAt: now,
...(canAutoAcceptWithoutEvidence
? {
reviewMode: 'vip_auto',
evidenceStatus: imageUrls.length > 0 ? 'complete' : 'pending',
evidenceDueAt:
imageUrls.length > 0 ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS),
}
: {}),
}
const updated = await updateWorkOrder(workOrder.id, {
status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
acceptance_json: acceptance,
draft_acceptance_json: {},
submitted_at: now,
updated_at: now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'acceptance_submitted',
fromStatus: workOrder.status,
toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
payloadJson: JSON.stringify({
imageCount: imageUrls.length,
autoAccepted: canAutoAcceptWithoutEvidence,
evidenceDueAt: acceptance.evidenceDueAt || null,
}),
now,
})
if (worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence) {
const { order: acceptedOrder, failureReason } = await acceptWorkOrderAndSettle({
workOrderId: workOrder.id,
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
now,
actorType: 'worker',
actorName: worker.display_name || worker.username || '',
})
if (failureReason === 'sharing_pending_submissions') {
throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', {
statusCode: 409,
errorCode: 'work_order_sharing_pending_submissions',
})
}
if (!acceptedOrder) {
throw createHttpError('免审核验收失败,请稍后重试', {
statusCode: 409,
errorCode: 'work_order_internal_auto_accept_failed',
})
}
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: worker.worker_type === 'internal' ? 'accepted_by_internal' : 'accepted_by_vip',
fromStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
toStatus: WORK_ORDER_STATUS.ACCEPTED,
payloadJson: JSON.stringify({
autoAccepted: true,
evidenceStatus: acceptance.evidenceStatus || 'complete',
evidenceDueAt: acceptance.evidenceDueAt || null,
}),
now,
})
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id),
workerIds: [Number(worker.id)],
})
publishWorkerWalletRealtimeChange(Number(worker.id))
return {
order: mapWorkOrderForWorker(acceptedOrder, resolveWorkerPermissions(worker)),
}
}
await createWorkerAcceptanceAdminNotification({
workOrderId: Number(workOrder.id),
workOrderNo: workOrder.work_order_no,
productName: workOrder.product_name,
workerName: worker.display_name || worker.username,
imageCount: imageUrls.length,
})
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id),
workerIds: [Number(worker.id)],
})
return {
order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(worker)),
}
}
/** 打手在提交验收后催促后台处理;频率和次数均由服务端控制。 */
export async function remindWorkerOrderAcceptance(
workOrderId: number | string,
session: WorkerSession,
) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
if (
workOrder.sharing_enabled ||
Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)
) {
throw createHttpError('只能催促自己提交的整单验收', {
statusCode: 403,
errorCode: 'work_order_acceptance_reminder_owner_required',
})
}
if (workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE) {
throw createHttpError('当前订单无需催验收', {
statusCode: 409,
errorCode: 'work_order_acceptance_reminder_status_invalid',
})
}
const submittedAt = Date.parse(String(workOrder.submitted_at || ''))
const now = Date.now()
const firstReminderAt = submittedAt + 10 * 60 * 1000
if (!Number.isFinite(submittedAt) || now < firstReminderAt) {
throw createHttpError('提交验收满 10 分钟后才可催验收', {
statusCode: 409,
errorCode: 'work_order_acceptance_reminder_too_early',
})
}
let notification = await getWorkerAcceptanceReminder(Number(workOrder.id))
if (!notification) {
const worker = await getRequiredWorker(session.workerId)
await createWorkerAcceptanceAdminNotification({
workOrderId: Number(workOrder.id),
workOrderNo: workOrder.work_order_no,
productName: workOrder.product_name,
workerName: worker.display_name || worker.username,
imageCount: normalizeStringArray(safeParseJson(workOrder.acceptance_json).imageUrls).length,
})
notification = await getWorkerAcceptanceReminder(Number(workOrder.id))
}
if (!notification) {
throw createHttpError('催验收待办创建失败,请稍后重试', {
statusCode: 409,
errorCode: 'work_order_acceptance_reminder_notification_missing',
})
}
if (Number(notification.reminder_count || 0) >= 3) {
throw createHttpError('该订单最多催验收 3 次', {
statusCode: 409,
errorCode: 'work_order_acceptance_reminder_limit_reached',
})
}
const lastRemindedAt = Date.parse(String(notification.last_reminded_at || ''))
const nextRemindAt = lastRemindedAt + 30 * 60 * 1000
if (Number.isFinite(lastRemindedAt) && now < nextRemindAt) {
throw createHttpError('每 30 分钟可催验收一次,请稍后再试', {
statusCode: 409,
errorCode: 'work_order_acceptance_reminder_cooldown',
})
}
const reminded = await remindWorkerAcceptanceAdminNotification(Number(workOrder.id))
if (!reminded) {
throw createHttpError('订单状态已变化,请刷新后重试', {
statusCode: 409,
errorCode: 'work_order_acceptance_reminder_conflict',
})
}
const remindedAt = new Date(reminded.last_reminded_at || new Date().toISOString()).getTime()
await createWorkOrderEvent({
workOrderId: Number(workOrder.id),
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'acceptance_reminded',
fromStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
payloadJson: JSON.stringify({ reminderCount: reminded.reminder_count }),
now: new Date(reminded.last_reminded_at || new Date().toISOString()).toISOString(),
})
return {
reminderCount: Number(reminded.reminder_count || 0),
nextRemindAt: new Date(remindedAt + 30 * 60 * 1000).toISOString(),
}
}
/** 已验收订单可追加验收图片,不改变订单状态和结算结果。 */
export async function supplementWorkerAcceptedOrderEvidence(
workOrderId: number | string,
payload: JsonObject = {},
session: WorkerSession,
) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const worker = await getRequiredWorker(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, session.workerId)
if (sharingShare) {
return supplementWorkerAcceptedSharingEvidence(workOrder, sharingShare, payload, session)
}
if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) {
throw createHttpError('只能补充自己的订单验收图片', {
statusCode: 403,
errorCode: 'work_order_owner_required',
})
}
if (workOrder.status !== WORK_ORDER_STATUS.ACCEPTED) {
throw createHttpError('只有已验收订单可以补充验收图片', {
statusCode: 409,
errorCode: 'work_order_evidence_supplement_status_invalid',
})
}
const now = nowIso()
const previous = safeParseJson(workOrder.acceptance_json)
const existingFiles = normalizeUploadedFiles(previous.files)
const uploadedFiles = normalizeUploadedFiles(payload.files)
const mergedFiles = [...existingFiles]
const fileIdentities = new Set(
existingFiles.map((file) => file.objectKey || file.url || file.mediumUrl || file.thumbnailUrl),
)
for (const file of uploadedFiles) {
const identity = file.objectKey || file.url || file.mediumUrl || file.thumbnailUrl
if (!identity || fileIdentities.has(identity)) continue
fileIdentities.add(identity)
mergedFiles.push(file)
}
const previousImageUrls = normalizeStringArray(previous.imageUrls)
const imageUrls = [
...new Set([
...previousImageUrls,
...mergedFiles.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean),
]),
]
if (
mergedFiles.length === existingFiles.length &&
imageUrls.length === previousImageUrls.length
) {
throw createHttpError('请上传新的验收图片', {
statusCode: 400,
errorCode: 'work_order_evidence_supplement_image_required',
})
}
const acceptance = {
...previous,
files: mergedFiles,
imageUrls,
evidenceStatus: 'complete',
evidenceSupplementedAt: now,
updatedAt: now,
}
const updated = await updateWorkOrderAcceptanceRecord({
workOrderId: workOrder.id,
acceptanceJson: JSON.stringify(acceptance),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'acceptance_evidence_supplemented',
fromStatus: WORK_ORDER_STATUS.ACCEPTED,
toStatus: WORK_ORDER_STATUS.ACCEPTED,
payloadJson: JSON.stringify({
addedImageCount: mergedFiles.length - existingFiles.length,
totalImageCount: imageUrls.length,
}),
now,
})
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id) })
return {
order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(worker)),
}
}
async function submitWorkerSharingAcceptance(
workOrder: WorkOrderRow,
sharingShare: WorkOrderShareRow,
payload: JsonObject = {},
session: WorkerSession,
) {
if (workOrder.status === WORK_ORDER_STATUS.ACCEPTED) {
throw createHttpError('该拼单已验收完成', {
statusCode: 409,
errorCode: 'work_order_sharing_accepted',
})
}
const now = nowIso()
const worker = await getRequiredWorker(session.workerId)
const canAutoAcceptWithoutEvidence = resolveWorkerPermissions(worker).autoAcceptWithoutEvidence
const { files, imageUrls, note } = resolveAcceptanceImages(
payload,
sharingShare.draft_acceptance_json,
)
if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) {
throw createHttpError('请上传验收图片', {
statusCode: 400,
errorCode: 'work_order_acceptance_image_required',
})
}
const acceptance = {
note,
files,
imageUrls: [...new Set(imageUrls)],
submittedAt: now,
...(canAutoAcceptWithoutEvidence
? {
reviewMode: 'vip_auto',
evidenceStatus: imageUrls.length > 0 ? 'complete' : 'pending',
evidenceDueAt:
imageUrls.length > 0 ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS),
}
: {}),
}
const result = await submitWorkOrderShareAcceptance({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
acceptanceJson: JSON.stringify(acceptance),
now,
})
if (result.failureReason === 'share_status_invalid') {
throw createHttpError('当前拼单份额已提交,请勿重复提交', {
statusCode: 409,
errorCode: 'work_order_sharing_share_submitted',
})
}
if (!result.share) {
throw createHttpError('未找到拼单份额', {
statusCode: 404,
errorCode: 'work_order_sharing_share_not_found',
})
}
if (canAutoAcceptWithoutEvidence) {
const accepted = await acceptWorkOrderShareAndSettle({
workOrderId: Number(workOrder.id),
shareId: Number(result.share.id),
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
now,
actorType: 'worker',
actorName: worker.display_name || worker.username || '',
})
if (!accepted.share || !accepted.order) {
throw createHttpError('VIP 免审核验收失败,请稍后重试', {
statusCode: 409,
errorCode: 'work_order_sharing_vip_auto_accept_failed',
})
}
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
walletChangedWorkerIds: [Number(session.workerId)],
})
return {
share: mapWorkOrderShare(accepted.share),
sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)),
}
}
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return {
share: mapWorkOrderShare(result.share),
sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)),
}
}
/** 已验收拼单份额可追加验收图片,不改变订单状态和结算结果。 */
async function supplementWorkerAcceptedSharingEvidence(
workOrder: WorkOrderRow,
sharingShare: WorkOrderShareRow,
payload: JsonObject,
session: WorkerSession,
) {
if (sharingShare.status !== 'accepted') {
throw createHttpError('只有已验收的拼单份额可以补充验收图片', {
statusCode: 409,
errorCode: 'work_order_sharing_evidence_supplement_status_invalid',
})
}
const now = nowIso()
const previous = safeParseJson(sharingShare.acceptance_json)
const existingFiles = normalizeUploadedFiles(previous.files)
const uploadedFiles = normalizeUploadedFiles(payload.files)
const mergedFiles = [...existingFiles]
const fileIdentities = new Set(
existingFiles.map((file) => file.objectKey || file.url || file.mediumUrl || file.thumbnailUrl),
)
for (const file of uploadedFiles) {
const identity = file.objectKey || file.url || file.mediumUrl || file.thumbnailUrl
if (!identity || fileIdentities.has(identity)) continue
fileIdentities.add(identity)
mergedFiles.push(file)
}
const previousImageUrls = normalizeStringArray(previous.imageUrls)
const imageUrls = [
...new Set([
...previousImageUrls,
...mergedFiles.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean),
]),
]
if (
mergedFiles.length === existingFiles.length &&
imageUrls.length === previousImageUrls.length
) {
throw createHttpError('请上传新的验收图片', {
statusCode: 400,
errorCode: 'work_order_sharing_evidence_supplement_image_required',
})
}
const acceptance = {
...previous,
files: mergedFiles,
imageUrls,
evidenceStatus: 'complete',
evidenceSupplementedAt: now,
updatedAt: now,
}
const updated = await updateWorkOrderShareAcceptanceRecord({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
acceptanceJson: JSON.stringify(acceptance),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'sharing_acceptance_evidence_supplemented',
fromStatus: WORK_ORDER_STATUS.ACCEPTED,
toStatus: WORK_ORDER_STATUS.ACCEPTED,
payloadJson: JSON.stringify({
workerId: Number(session.workerId),
addedImageCount: mergedFiles.length - existingFiles.length,
totalImageCount: imageUrls.length,
}),
now,
})
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return { share: mapWorkOrderShare(updated || sharingShare) }
}
/**
* 从提交/保存验收的载荷中解析图片与说明。
* 载荷未带图片时回退到暂存草稿(上传图片与提交验收分离:先暂存、后提交)。
*/
function resolveAcceptanceImages(payload: JsonObject, draftValue: unknown) {
const files = normalizeUploadedFiles(payload.files)
const imageUrls = [
...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean),
...normalizeStringArray(payload.imageUrls),
]
if (imageUrls.length > 0) {
return {
files,
imageUrls: [...new Set(imageUrls)],
note: String(payload.note ?? '').trim(),
}
}
const draft = safeParseJson(draftValue)
return {
files: normalizeUploadedFiles(draft.files),
imageUrls: [...new Set(normalizeStringArray(draft.imageUrls))],
note: String(payload.note ?? draft.note ?? '').trim(),
}
}
/**
* 暂存验收图片/说明(草稿)。
* - 未提交(in_progress / problem / 拼单 joined):写入草稿,不改变工单状态;
* - 已提交(pending_acceptance / 拼单 submitted):直接更新已提交的验收资料(保留提交时间)。
*/
export async function saveWorkerOrderAcceptanceDraft(
workOrderId: number | string,
payload: JsonObject = {},
session: WorkerSession,
) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const workerId = Number(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
if (sharingShare) {
return saveWorkerSharingAcceptanceDraft(workOrder, sharingShare, payload, session)
}
if (Number(workOrder.assigned_worker_id || 0) !== workerId) {
throw createHttpError('只能操作自己的订单', {
statusCode: 403,
errorCode: 'work_order_owner_required',
})
}
const now = nowIso()
const files = normalizeUploadedFiles(payload.files)
const imageUrls = [
...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean),
...normalizeStringArray(payload.imageUrls),
]
const worker = await getRequiredWorker(session.workerId)
if (workOrder.status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE) {
if (imageUrls.length === 0) {
throw createHttpError('请至少保留一张验收图片', {
statusCode: 400,
errorCode: 'work_order_acceptance_image_required',
})
}
const previous = safeParseJson(workOrder.acceptance_json)
const acceptance = {
note: String(payload.note ?? previous.note ?? '').trim(),
files,
imageUrls: [...new Set(imageUrls)],
submittedAt: previous.submittedAt || workOrder.submitted_at || now,
updatedAt: now,
}
const updated = await updateWorkOrderAcceptanceRecord({
workOrderId: workOrder.id,
acceptanceJson: JSON.stringify(acceptance),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'acceptance_updated',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({ imageCount: imageUrls.length }),
now,
})
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return {
order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(worker)),
}
}
if (
![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never)
) {
throw createHttpError('当前状态不能保存验收图片', {
statusCode: 409,
errorCode: 'work_order_draft_status_invalid',
})
}
const draft = {
note: String(payload.note || '').trim(),
files,
imageUrls: [...new Set(imageUrls)],
updatedAt: now,
}
const updated = await updateWorkOrderDraftAcceptance({
workOrderId: workOrder.id,
draftJson: JSON.stringify(draft),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'acceptance_draft_saved',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({ imageCount: imageUrls.length }),
now,
})
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return {
order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(worker)),
}
}
async function saveWorkerSharingAcceptanceDraft(
workOrder: WorkOrderRow,
sharingShare: WorkOrderShareRow,
payload: JsonObject = {},
session: WorkerSession,
) {
if (workOrder.status === WORK_ORDER_STATUS.ACCEPTED) {
throw createHttpError('该拼单已验收完成,不能再修改验收图片', {
statusCode: 409,
errorCode: 'work_order_sharing_accepted',
})
}
const now = nowIso()
const files = normalizeUploadedFiles(payload.files)
const imageUrls = [
...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean),
...normalizeStringArray(payload.imageUrls),
]
if (sharingShare.status === 'submitted') {
if (imageUrls.length === 0) {
throw createHttpError('请至少保留一张验收图片', {
statusCode: 400,
errorCode: 'work_order_acceptance_image_required',
})
}
const previous = safeParseJson(sharingShare.acceptance_json)
const acceptance = {
note: String(payload.note ?? previous.note ?? '').trim(),
files,
imageUrls: [...new Set(imageUrls)],
submittedAt: previous.submittedAt || sharingShare.submitted_at || now,
updatedAt: now,
}
const share = await updateWorkOrderShareAcceptanceRecord({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
acceptanceJson: JSON.stringify(acceptance),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'sharing_acceptance_updated',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({
workerId: Number(session.workerId),
imageCount: imageUrls.length,
}),
now,
})
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return { share: share ? mapWorkOrderShare(share) : mapWorkOrderShare(sharingShare) }
}
if (sharingShare.status !== 'joined') {
throw createHttpError('当前拼单状态不能保存验收图片', {
statusCode: 409,
errorCode: 'work_order_sharing_draft_status_invalid',
})
}
const draft = {
note: String(payload.note || '').trim(),
files,
imageUrls: [...new Set(imageUrls)],
updatedAt: now,
}
const share = await updateWorkOrderShareDraftAcceptance({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
draftJson: JSON.stringify(draft),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'sharing_acceptance_draft_saved',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({
workerId: Number(session.workerId),
imageCount: imageUrls.length,
}),
now,
})
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return { share: share ? mapWorkOrderShare(share) : mapWorkOrderShare(sharingShare) }
}
export {
remindWorkerOrderAcceptance,
saveWorkerOrderAcceptanceDraft,
submitWorkerOrderAcceptance,
supplementWorkerAcceptedOrderEvidence,
} from './worker-acceptance-service.js'
export {
collectLookupWorkOrder,