From 47c25664e4a7af7eb8d2ee7e502d4bd30c1ad193 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 21 Aug 2026 16:58:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E6=89=93=E6=89=8B=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/worker-platform/index.ts | 1 + .../worker-acceptance-service.ts | 744 +++++++++++++++++ .../worker-platform/worker-service.ts | 757 +----------------- 3 files changed, 753 insertions(+), 749 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/worker-acceptance-service.ts diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 24a380c3..51f20c73 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -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' diff --git a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts new file mode 100644 index 00000000..98ae527c --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts @@ -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, 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, + } +} diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 8ca1b84d..00f27f10 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -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,