From cc7491ecd659a911b8e556cc112bed39338b215a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 21 Aug 2026 18:53:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E6=89=93=E6=89=8B=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=98=A0=E5=B0=84=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/worker-platform/mappers.ts | 1102 +---------------- .../work-order-display-mappers.ts | 311 +++++ .../work-product-match-mappers.ts | 430 +++++++ .../worker-platform/worker-finance-mappers.ts | 320 +++++ 4 files changed, 1104 insertions(+), 1059 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/work-order-display-mappers.ts create mode 100644 apps/backend/src/services/worker-platform/work-product-match-mappers.ts create mode 100644 apps/backend/src/services/worker-platform/worker-finance-mappers.ts diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 446f5653..17651a93 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -1,52 +1,3 @@ -import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' -import { - addWorkerWalletCredit, - getWorkerUserById, - type WorkCategoryRow, - type WorkerFinanceRequestRow, - type WorkOrderRow, - type WorkOrderCapabilities, - type WorkProductRuleMappingRow, - type WorkProductRuleRow, - type WorkerLevelRow, - type WorkOrderShareRow, - type WorkerWalletLedgerRow, - type WorkerUserRow, -} from '../../repositories/worker-platform/index.js' -import type { JsonObject } from '../../types/json.js' -import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' -import { safeParseJson } from '../admin/admin-query-utils.js' -import { - refreshStoredFileUrl, - refreshUploadedFileUrls, -} from '../file-storage/file-storage-service.js' -import { createWorkerSessionToken } from './worker-auth-utils.js' -import { - normalizeBoolean, - normalizeInteger, - normalizePositiveInteger, - normalizeSessionVersion, -} from './worker-normalizers.js' -import { - canWorkerAutoAcceptWholeOrder, - resolveFreezeDepositAmount, - resolveVisibleDelaySeconds, - resolveWorkerPermissions, -} from './worker-permission-utils.js' -import { - normalizeRequirementFields, - resolveRequirementFields, -} from './work-order-requirement-fields.js' -import { - normalizeStringArray, - normalizeSubmittedFields, - normalizeStatuses, - normalizeUploadedFiles, -} from './worker-input-normalizers.js' -import { - mapAcceptanceForResponse, - resolveCollectSubmittedFieldValues, -} from './work-order-material-mappers.js' export { assertWithdrawChannelAllowedForAmount, isWorkerPasswordStrong, @@ -110,1018 +61,51 @@ export { resolveWorkerPermissions, } from './worker-permission-utils.js' -export const DEFAULT_LEVEL_KEY = 'vip1' -export const DEFAULT_LEVEL_NAME = 'VIP1' -export const DEFAULT_CATEGORY_KEY = 'default' -export const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000 -export const INVITE_CODE_LENGTH = 8 -export const INVITE_CODE_RETRY_LIMIT = 5 -export const WORKER_CANCEL_LIMIT_PER_WINDOW = 5 -export const WORKER_CANCEL_LIMIT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000 -export function createWorkerSession(worker: WorkerUserRow) { - const session = createWorkerSessionToken(worker) - return { - ...session, - worker: mapWorkerUser(worker), - } -} +export { + createWorkerSession, + DEFAULT_CATEGORY_KEY, + DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, + DEFAULT_LEVEL_KEY, + DEFAULT_LEVEL_NAME, + INVITE_CODE_LENGTH, + INVITE_CODE_RETRY_LIMIT, + mapFinanceRequest, + mapWallet, + mapWalletLedger, + mapWorkerAfterSalesCase, + mapWorkerCancelRequest, + mapWorkerLevel, + mapWorkerOrderFeedback, + mapWorkerUser, + WORKER_CANCEL_LIMIT_PER_WINDOW, + WORKER_CANCEL_LIMIT_WINDOW_MS, + normalizeWorkerType, +} from './worker-finance-mappers.js' -export function mapWorkerLevel(level: WorkerLevelRow | null | undefined) { - if (!level) return null - const permission = safeParseJson(level.permission_json) - return { - levelId: Number(level.id), - levelKey: level.level_key, - name: level.name, - sortOrder: Number(level.sort_order || 100), - status: level.status, - depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0), - maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1), - upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0), - autoAcceptWithoutEvidence: normalizeBoolean(permission.autoAcceptWithoutEvidence, false), - visibleDelaySeconds: resolveVisibleDelaySeconds( - level.level_key, - permission.visibleDelaySeconds, - ), - createdAt: level.created_at, - updatedAt: level.updated_at, - } -} +export { + mapCollectLookupOrder, + mapWorkCategory, + mapWorkOrderAdmin, + mapWorkOrderForWorker, + mapWorkOrderMaterial, + mapWorkOrderPublic, + mapWorkOrderShare, + mapWorkOrderSharingWorkers, + resolveWorkOrderOperationalStatus, + resolveWorkerShareProductName, + resolveWorkerShareStatus, +} from './work-order-display-mappers.js' -export function mapWorkCategory(category: WorkCategoryRow | null | undefined) { - if (!category) return null - return { - categoryId: Number(category.id), - categoryKey: category.category_key, - name: category.name, - sortOrder: Number(category.sort_order || 100), - status: category.status, - createdAt: category.created_at, - updatedAt: category.updated_at, - } -} - -export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) { - if (!rule) return null - const requirement = safeParseJson(rule.requirement_json) - const match = safeParseJson(rule.match_json) - return { - ruleId: Number(rule.id), - ruleKey: rule.rule_key, - provider: rule.provider || '', - platform: rule.platform || '', - shopId: rule.shop_id || '', - skuCode: rule.sku_code || '', - productName: rule.product_name || '', - matchType: rule.match_type || 'contains', - match: { - sellerId: String(match.sellerId || '').trim(), - itemId: String(match.itemId || '').trim(), - relItemId: String(match.relItemId || '').trim(), - skuId: String(match.skuId || '').trim(), - relSkuId: String(match.relSkuId || '').trim(), - itemTitle: String(match.itemTitle || '').trim(), - skuNick: String(match.skuNick || '').trim(), - itemTitleMatchType: match.itemTitleMatchType === 'exact' ? 'exact' : 'contains', - skuNickMatchType: match.skuNickMatchType === 'exact' ? 'exact' : 'contains', - }, - categoryId: rule.category_id ? Number(rule.category_id) : null, - categoryName: rule.category_name || '', - enabled: Boolean(rule.enabled), - autoCreate: Boolean(rule.auto_create), - rewardAmount: Number(rule.reward_amount || 0), - unitPriceFen: Number(rule.unit_price_fen || 0), - sharing: { - enabled: rule.sharing_enabled === true, - totalQuantity: Number(rule.sharing_total_quantity || 1), - unitReward: Number(rule.sharing_unit_reward || 0), - autoFromOrder: rule.sharing_auto_from_order === true, - }, - timeoutMinutes: Number(rule.timeout_minutes || 0), - timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(), - requirement: { - fields: normalizeRequirementFields(requirement.fields), - }, - sortOrder: Number(rule.sort_order || 100), - createdAt: rule.created_at, - updatedAt: rule.updated_at, - } -} - -export function normalizeWorkerType(value: unknown): 'internal' | 'external' { - return String(value || '').trim() === 'internal' ? 'internal' : 'external' -} - -export function mapWorkerUser(worker: WorkerUserRow) { - return { - workerId: Number(worker.id), - username: worker.username, - displayName: worker.display_name || worker.username, - phone: worker.phone, - status: worker.status, - workerType: normalizeWorkerType(worker.worker_type), - reviewNote: worker.review_note || '', - freezeReason: worker.frozen_reason || '', - frozenAt: worker.frozen_at, - inviteCode: worker.invite_code || '', - inviter: worker.inviter_id - ? { - workerId: Number(worker.inviter_id), - username: worker.inviter_username || '', - displayName: worker.inviter_display_name || worker.inviter_username || '', - } - : null, - level: worker.level_id - ? { - levelId: Number(worker.level_id), - levelKey: worker.level_key || '', - name: worker.level_name || '', - permissions: resolveWorkerPermissions(worker), - } - : null, - wallet: { - availableAmount: Number(worker.available_amount || 0), - frozenDepositAmount: Number(worker.frozen_deposit_amount || 0), - pendingUnfreezeAmount: Number(worker.pending_unfreeze_amount || 0), - totalCreditedAmount: Number(worker.total_credited_amount || 0), - totalSettledAmount: Number(worker.total_settled_amount || 0), - }, - createdAt: worker.created_at, - updatedAt: worker.updated_at, - reviewedAt: worker.reviewed_at, - } -} - -export function mapWorkerCancelRequest(request: { - id: number - work_order_id: number - work_order_share_id: number | null - worker_id: number - reason: string - status: string - review_note: string - reviewed_by: string - created_at: string - reviewed_at: string | null - platform_order_id?: string - shop_name?: string - worker_username?: string - worker_display_name?: string - share_quantity?: number | null -}) { - return { - requestId: Number(request.id), - workOrderId: Number(request.work_order_id), - shareId: request.work_order_share_id ? Number(request.work_order_share_id) : null, - workerId: Number(request.worker_id), - platformOrderId: String(request.platform_order_id || ''), - shopName: String(request.shop_name || ''), - workerName: String(request.worker_display_name || request.worker_username || ''), - shareQuantity: request.share_quantity ? Number(request.share_quantity) : null, - reason: String(request.reason || ''), - status: String(request.status || ''), - reviewNote: String(request.review_note || ''), - reviewedBy: String(request.reviewed_by || ''), - createdAt: request.created_at, - reviewedAt: request.reviewed_at, - } -} - -export function mapWorkerOrderFeedback(feedback: { - id: number - work_order_id: number - work_order_share_id: number | null - worker_id: number - content: string - status: string - reply: string - handled_by: string - created_at: string - handled_at: string | null - work_order_no?: string - product_name?: string - shop_name?: string - worker_username?: string - worker_display_name?: string - share_quantity?: number | null -}) { - return { - feedbackId: Number(feedback.id), - workOrderId: Number(feedback.work_order_id), - shareId: feedback.work_order_share_id ? Number(feedback.work_order_share_id) : null, - workerId: Number(feedback.worker_id), - workOrderNo: String(feedback.work_order_no || ''), - productName: String(feedback.product_name || ''), - shopName: String(feedback.shop_name || ''), - workerName: String(feedback.worker_display_name || feedback.worker_username || ''), - shareQuantity: feedback.share_quantity ? Number(feedback.share_quantity) : null, - content: String(feedback.content || ''), - status: String(feedback.status || ''), - reply: String(feedback.reply || ''), - handledBy: String(feedback.handled_by || ''), - createdAt: feedback.created_at, - handledAt: feedback.handled_at, - } -} - -export function mapWorkerAfterSalesCase(afterSalesCase: { - id: number - case_no: string - work_order_id: number - work_order_share_id: number | null - worker_id: number - problem_type: string - complaint_note: string - complaint_files_json: unknown - acceptance_snapshot_json: unknown - status: string - worker_response: string - worker_response_files_json: unknown - responded_at: string | null - resolution_action: string - resolution_note: string - recovery_amount: number - pending_deposit_deduction_amount: number - available_deduction_amount: number - debt_amount: number - created_by: string - resolved_by: string - resolved_at: string | null - created_at: string - updated_at: string - work_order_no?: string - platform_order_id?: string - product_name?: string - shop_name?: string - worker_username?: string - worker_display_name?: string - share_quantity?: number | null -}) { - const snapshot = safeParseJson(afterSalesCase.acceptance_snapshot_json) - return { - caseId: Number(afterSalesCase.id), - caseNo: String(afterSalesCase.case_no || ''), - workOrderId: Number(afterSalesCase.work_order_id), - shareId: afterSalesCase.work_order_share_id ? Number(afterSalesCase.work_order_share_id) : null, - workerId: Number(afterSalesCase.worker_id), - workOrderNo: String(afterSalesCase.work_order_no || ''), - platformOrderId: String(afterSalesCase.platform_order_id || ''), - productName: String(afterSalesCase.product_name || ''), - shopName: String(afterSalesCase.shop_name || ''), - workerName: String(afterSalesCase.worker_display_name || afterSalesCase.worker_username || ''), - shareQuantity: afterSalesCase.share_quantity ? Number(afterSalesCase.share_quantity) : null, - problemType: String(afterSalesCase.problem_type || ''), - complaintNote: String(afterSalesCase.complaint_note || ''), - complaintFiles: normalizeUploadedFiles(safeParseJson(afterSalesCase.complaint_files_json)).map( - refreshUploadedFileUrls, - ), - acceptanceSnapshot: { - ...snapshot, - acceptance: mapAcceptanceForResponse(snapshot.acceptance), - }, - status: String(afterSalesCase.status || ''), - workerResponse: String(afterSalesCase.worker_response || ''), - workerResponseFiles: normalizeUploadedFiles( - safeParseJson(afterSalesCase.worker_response_files_json), - ).map(refreshUploadedFileUrls), - respondedAt: afterSalesCase.responded_at, - resolutionAction: String(afterSalesCase.resolution_action || ''), - resolutionNote: String(afterSalesCase.resolution_note || ''), - recoveryAmount: Number(afterSalesCase.recovery_amount || 0), - pendingDepositDeductionAmount: Number(afterSalesCase.pending_deposit_deduction_amount || 0), - availableDeductionAmount: Number(afterSalesCase.available_deduction_amount || 0), - debtAmount: Number(afterSalesCase.debt_amount || 0), - createdBy: String(afterSalesCase.created_by || ''), - resolvedBy: String(afterSalesCase.resolved_by || ''), - resolvedAt: afterSalesCase.resolved_at, - createdAt: afterSalesCase.created_at, - updatedAt: afterSalesCase.updated_at, - } -} - -export function mapWalletLedger(ledger: WorkerWalletLedgerRow | null | undefined) { - if (!ledger) return null - return { - ledgerId: Number(ledger.id), - ledgerType: ledger.ledger_type, - amount: Number(ledger.amount || 0), - balanceAfter: Number(ledger.balance_after || 0), - frozenAfter: Number(ledger.frozen_after || 0), - relatedWorkOrderId: ledger.related_work_order_id ? Number(ledger.related_work_order_id) : null, - relatedPlatformOrderId: String(ledger.related_platform_order_id || '').trim() || null, - auditStatus: ledger.audit_status || '', - note: ledger.note || '', - payload: safeParseJson(ledger.payload_json), - createdAt: ledger.created_at, - } -} - -export function mapFinanceRequest(request: WorkerFinanceRequestRow | null | undefined) { - if (!request) return null - const payload = safeParseJson(request.payload_json) - const alipayQrCodeImage = refreshUploadedFileUrls(payload.alipayQrCodeImage) - if (String(alipayQrCodeImage.url || '').trim()) { - payload.alipayQrCodeImage = alipayQrCodeImage - } - const wechatQrCodeImage = refreshUploadedFileUrls(payload.wechatQrCodeImage) - if (String(wechatQrCodeImage.url || '').trim()) { - payload.wechatQrCodeImage = wechatQrCodeImage - } - return { - requestId: Number(request.id), - requestType: request.request_type || '', - status: request.status || '', - amount: Number(request.amount || 0), - accountChannel: request.account_channel || '', - accountName: request.account_name || '', - accountNo: request.account_no || '', - note: request.note || '', - reviewedNote: request.reviewed_note || '', - payload, - createdAt: request.created_at, - updatedAt: request.updated_at, - reviewedAt: request.reviewed_at, - worker: request.worker_id - ? { - workerId: Number(request.worker_id), - username: request.worker_username || '', - displayName: request.worker_display_name || request.worker_username || '', - phone: request.worker_phone || '', - workerType: normalizeWorkerType(request.worker_type), - levelKey: request.worker_level_key || '', - levelName: request.worker_level_name || '', - withdrawRequestCount: Number(request.worker_withdraw_request_count || 0), - totalWithdrawAmount: Number(request.worker_total_withdraw_amount || 0), - } - : null, - } -} - -/** 读取工单资料:刷新买家主页截图等文件的访问链接(签名 15 分钟过期,读取时必须重签) */ -export function mapWorkOrderMaterial(value: unknown) { - const material = safeParseJson(value) - const collect = - material.collect && typeof material.collect === 'object' && !Array.isArray(material.collect) - ? (material.collect as JsonObject) - : null - if (!collect) { - return material - } - return { - ...material, - collect: { - ...collect, - screenshots: normalizeUploadedFiles(collect.screenshots).map(refreshUploadedFileUrls), - }, - } -} - -/** 统一计算后台使用的工单业务状态,避免拼单订单继续直接暴露母工单 open。 */ -export function resolveWorkOrderOperationalStatus(workOrder: WorkOrderRow): string { - const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0)) - const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0)) - const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) - const hasActiveShares = activeShareQuantity > 0 - const sharingFilled = - workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity - - if (workOrder.status === WORK_ORDER_STATUS.OPEN && workOrder.sharing_enabled && hasActiveShares) { - return sharingFilled && pendingShareCount === 0 - ? WORK_ORDER_STATUS.PENDING_ACCEPTANCE - : WORK_ORDER_STATUS.IN_PROGRESS - } - return workOrder.status -} - -/** 汇总拼单参与者,已撤销份额不应继续展示为当前打手。 */ -export function mapWorkOrderSharingWorkers(shares: WorkOrderShareRow[]) { - const workers = new Map< - number, - { workerId: number; username: string; displayName: string; quantity: number } - >() - for (const share of shares) { - if (share.status === 'cancelled') continue - const workerId = Number(share.worker_id || 0) - if (workerId <= 0) continue - const existing = workers.get(workerId) - if (existing) { - existing.quantity += Number(share.quantity || 0) - continue - } - const username = String(share.worker_username || '').trim() - workers.set(workerId, { - workerId, - username, - displayName: String(share.worker_display_name || username || `打手#${workerId}`).trim(), - quantity: Number(share.quantity || 0), - }) - } - return [...workers.values()] -} - -export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderShareRow[]) { - const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0)) - const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0)) - const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) - const hasActiveShares = activeShareQuantity > 0 - const sharingFilled = - workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity - const status = resolveWorkOrderOperationalStatus(workOrder) - const hallAvailable = - workOrder.status === WORK_ORDER_STATUS.OPEN && - (!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity) - const editableBeforeClaim = - !workOrder.assigned_worker_id && - (workOrder.status === WORK_ORDER_STATUS.PENDING_MATERIAL || - workOrder.status === WORK_ORDER_STATUS.UNASSIGNED || - (workOrder.status === WORK_ORDER_STATUS.OPEN && !hasActiveShares)) - const capabilities: WorkOrderCapabilities = { - canEdit: editableBeforeClaim, - canConfigureSharing: editableBeforeClaim, - canAssign: - !workOrder.assigned_worker_id && - (workOrder.status === WORK_ORDER_STATUS.UNASSIGNED || - (workOrder.status === WORK_ORDER_STATUS.OPEN && !hasActiveShares)), - canPin: hallAvailable || Boolean(workOrder.pinned_at), - canUnpublish: - workOrder.status === WORK_ORDER_STATUS.OPEN && - !hasActiveShares && - !workOrder.assigned_worker_id, - canUpdateMaterial: - workOrder.status === WORK_ORDER_STATUS.PENDING_MATERIAL || - workOrder.status === WORK_ORDER_STATUS.UNASSIGNED || - workOrder.status === WORK_ORDER_STATUS.OPEN || - workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS, - canAccept: status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE, - canMarkProblem: - status === WORK_ORDER_STATUS.IN_PROGRESS || status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE, - canDelete: - !workOrder.assigned_worker_id && - !hasActiveShares && - [ - WORK_ORDER_STATUS.PENDING_MATERIAL, - WORK_ORDER_STATUS.UNASSIGNED, - WORK_ORDER_STATUS.CANCELLED, - ].includes(workOrder.status as never), - canReopen: workOrder.status === WORK_ORDER_STATUS.CANCELLED && !hasActiveShares, - canPublish: - workOrder.status === WORK_ORDER_STATUS.UNASSIGNED && Number(workOrder.reward_amount || 0) > 0, - canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id), - canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM, - canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED, - } - return { - workOrderId: Number(workOrder.id), - orderId: workOrder.order_id ? Number(workOrder.order_id) : null, - orderItemId: workOrder.order_item_id ? Number(workOrder.order_item_id) : null, - workOrderNo: workOrder.work_order_no, - platformOrderId: workOrder.platform_order_id, - productName: workOrder.product_name, - categoryId: workOrder.category_id ? Number(workOrder.category_id) : null, - categoryName: workOrder.category_name || '', - status, - rawStatus: workOrder.status, - hallAvailable, - sharingProgress: { - joinedQuantity: activeShareQuantity, - pendingSubmissionCount: pendingShareCount, - remainingQuantity: Math.max(0, sharingTotalQuantity - activeShareQuantity), - filled: sharingFilled, - }, - capabilities, - rewardAmount: Number(workOrder.reward_amount || 0), - requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), - sharing: { - enabled: workOrder.sharing_enabled === true, - totalQuantity: Number(workOrder.sharing_total_quantity || 1), - unitReward: Number(workOrder.sharing_unit_reward || 0), - }, - timeoutMinutes: Number(workOrder.timeout_minutes || 0), - timeoutPolicy: String(workOrder.timeout_policy || 'reopen').trim(), - deadlineAt: workOrder.deadline_at, - depositThresholdAmount: Number( - workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, - ), - material: mapWorkOrderMaterial(workOrder.material_json), - requirement: { - fields: resolveRequirementFields(workOrder), - }, - acceptance: mapAcceptanceForResponse(workOrder.acceptance_json), - draftAcceptance: mapAcceptanceForResponse(workOrder.draft_acceptance_json), - problemNote: workOrder.problem_note || '', - worker: workOrder.assigned_worker_id - ? { - workerId: Number(workOrder.assigned_worker_id), - username: workOrder.worker_username || '', - displayName: workOrder.worker_display_name || '', - } - : null, - ...(shares ? { sharingWorkers: mapWorkOrderSharingWorkers(shares) } : {}), - publishedAt: workOrder.published_at, - pinnedAt: workOrder.pinned_at, - assignedAt: workOrder.assigned_at, - submittedAt: workOrder.submitted_at, - acceptedAt: workOrder.accepted_at, - createdAt: workOrder.created_at, - updatedAt: workOrder.updated_at, - } -} - -export function mapWorkOrderForWorker( - workOrder: WorkOrderRow, - permissions: { depositFreeAmount: number }, - myShare?: WorkOrderShareRow | null, - workerNote = '', - viewedAt?: string | null, -) { - const mapped = mapWorkOrderAdmin(workOrder) - const { - requiredDepositAmount: _requiredDepositAmount, - depositThresholdAmount: _depositThresholdAmount, - worker: _worker, - orderId: _orderId, - orderItemId: _orderItemId, - rawStatus: _rawStatus, - hallAvailable: _hallAvailable, - capabilities: _capabilities, - ...visibleOrder - } = mapped - const personalStatus = resolveWorkerShareStatus(workOrder.status, myShare?.status) - return { - ...visibleOrder, - productName: myShare - ? resolveWorkerShareProductName(workOrder.product_name, Number(myShare.quantity || 0)) - : visibleOrder.productName, - status: personalStatus, - rewardAmount: myShare ? Number(myShare.share_reward || 0) : visibleOrder.rewardAmount, - freezeDepositAmount: myShare - ? Number(myShare.share_deposit || 0) - : resolveFreezeDepositAmount(workOrder, permissions), - assignedAt: myShare?.created_at || visibleOrder.assignedAt, - submittedAt: myShare?.submitted_at || visibleOrder.submittedAt, - acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt, - workerNote, - myShare: myShare ? mapWorkOrderShare(myShare) : null, - ...(viewedAt === undefined ? {} : { isUnread: !viewedAt }), - } -} - -/** 拼单参与者看到个人份额规格,而不是母工单的原始总规格。 */ -export function resolveWorkerShareProductName(productName: unknown, quantity: number): string { - const text = String(productName || '').trim() - const normalizedQuantity = Math.max(1, Number(quantity) || 1) - return text.replace( - /(\d+|[零一二三四五六七八九十百千两]+)(\s*)(个|份|张|枚)/, - (_matched, _oldQuantity: string, spacing: string, unit: string) => - `${normalizedQuantity}${spacing}${unit}`, - ) -} - -/** 把份额状态映射成打手端订单状态,避免母工单 open 被误显示为已超时。 */ -export function resolveWorkerShareStatus(workOrderStatus: string, shareStatus: unknown): string { - if (!shareStatus) return workOrderStatus - if (['accepted', 'cancelled', 'problem'].includes(workOrderStatus)) return workOrderStatus - if (shareStatus === 'joined') return WORK_ORDER_STATUS.IN_PROGRESS - if (shareStatus === 'submitted') return WORK_ORDER_STATUS.PENDING_ACCEPTANCE - if (shareStatus === 'accepted') return WORK_ORDER_STATUS.ACCEPTED - if (shareStatus === 'cancelled') return WORK_ORDER_STATUS.CANCELLED - return workOrderStatus -} - -export function mapWorkOrderShare(share: WorkOrderShareRow) { - return { - shareId: Number(share.id), - workOrderId: Number(share.work_order_id), - workerId: Number(share.worker_id), - quantity: Number(share.quantity || 0), - unitReward: Number(share.unit_reward || 0), - shareReward: Number(share.share_reward || 0), - shareDeposit: Number(share.share_deposit || 0), - status: share.status, - acceptance: mapAcceptanceForResponse(share.acceptance_json), - draftAcceptance: mapAcceptanceForResponse(share.draft_acceptance_json), - submittedAt: share.submitted_at, - acceptedAt: share.accepted_at, - createdAt: share.created_at, - updatedAt: share.updated_at, - worker: share.worker_id - ? { - workerId: Number(share.worker_id), - username: share.worker_username || '', - displayName: share.worker_display_name || share.worker_username || '', - } - : null, - } -} - -export function mapWorkOrderPublic(workOrder: WorkOrderRow) { - return { - workOrderId: Number(workOrder.id), - workOrderNo: workOrder.work_order_no, - platformOrderId: workOrder.platform_order_id, - productName: workOrder.product_name, - status: workOrder.status, - } -} - -export function mapCollectLookupOrder(workOrder: WorkOrderRow) { - const material = mapWorkOrderMaterial(workOrder.material_json) - return { - ...mapWorkOrderPublic(workOrder), - fields: resolveRequirementFields(workOrder), - values: resolveCollectSubmittedFieldValues(workOrder), - screenshots: - material.collect && Array.isArray(material.collect.screenshots) - ? material.collect.screenshots - : [], - } -} - -export function mapWallet(wallet: Awaited>) { - return { - workerId: Number(wallet?.worker_id || 0), - availableAmount: Number(wallet?.available_amount || 0), - frozenDepositAmount: Number(wallet?.frozen_deposit_amount || 0), - pendingUnfreezeAmount: Number(wallet?.pending_unfreeze_amount || 0), - totalCreditedAmount: Number(wallet?.total_credited_amount || 0), - totalSettledAmount: Number(wallet?.total_settled_amount || 0), - } -} - -export function resolveMatchingProductRule( - order: OrderRow, - item: OrderItemRow, - rules: WorkProductRuleRow[], - mappings: WorkProductRuleMappingRow[] = [], - options: { legacyFallbackEnabled?: boolean } = {}, -) { - return resolveMatchingProductRuleDecision(order, item, rules, mappings, options).rule -} - -export type WorkProductRuleMatchDecision = { - rule: WorkProductRuleRow | null - mappingId: number | null - reason: 'matched' | 'unmatched' | 'ambiguous' - score: number - candidates: Array<{ ruleKey: string; score: number; mappingId: number | null }> -} - -export type KuaishouWorkProductMatchContext = { - sellerId: string - itemId: string - relItemId: string - skuId: string - relSkuId: string - itemTitle: string - skuNick: string -} - -export function resolveMatchingProductRuleDecision( - order: OrderRow, - item: OrderItemRow, - rules: WorkProductRuleRow[], - mappings: WorkProductRuleMappingRow[] = [], - options: { legacyFallbackEnabled?: boolean } = {}, -): WorkProductRuleMatchDecision { - const context = resolveKuaishouWorkProductMatchContext(item) - const rulesById = new Map(rules.map((rule) => [Number(rule.id), rule])) - // 快手发码订单默认只允许确定性商品映射;非快手订单保留原有规则,避免影响其他来源。 - const useLegacyFallback = !context.sellerId || options.legacyFallbackEnabled === true - const candidates = [ - ...mappings.map((mapping) => scoreWorkProductRuleMapping(context, mapping, rulesById)), - ...(useLegacyFallback ? rules.map((rule) => scoreWorkProductRule(order, item, rule)) : []), - ] - .filter( - (candidate): candidate is { rule: WorkProductRuleRow; score: number; mappingId?: number } => - Boolean(candidate), - ) - .sort((left, right) => right.score - left.score || Number(right.rule.id) - Number(left.rule.id)) - - const top = candidates[0] - if (!top) { - return { rule: null, mappingId: null, reason: 'unmatched', score: 0, candidates: [] } - } - - const tied = candidates.filter((candidate) => candidate.score === top.score) - if (tied.length > 1) { - return { - rule: null, - mappingId: null, - reason: 'ambiguous', - score: top.score, - candidates: tied.map((candidate) => ({ - ruleKey: candidate.rule.rule_key, - score: candidate.score, - mappingId: candidate.mappingId || null, - })), - } - } - - return { - rule: top.rule, - mappingId: top.mappingId || null, - reason: 'matched', - score: top.score, - candidates: candidates.slice(0, 5).map((candidate) => ({ - ruleKey: candidate.rule.rule_key, - score: candidate.score, - mappingId: candidate.mappingId || null, - })), - } -} - -export function resolveKuaishouWorkProductMatchContext( - item: Pick, -): KuaishouWorkProductMatchContext { - const snapshot = safeParseJson(item.item_snapshot_json) - const source = safeParseJson(snapshot.kuaishouSendCode) - return { - sellerId: String(source.sellerId || '').trim(), - itemId: String(source.itemId || '').trim(), - relItemId: normalizeExternalMatchId(source.relItemId), - skuId: String(source.skuId || '').trim(), - relSkuId: normalizeExternalMatchId(source.relSkuId), - itemTitle: String(source.itemTitle || '').trim(), - skuNick: String(source.skuNick || source.skuName || snapshot.externalSkuName || '').trim(), - } -} - -function scoreWorkProductRuleMapping( - context: KuaishouWorkProductMatchContext, - mapping: WorkProductRuleMappingRow, - rulesById: Map, -) { - if (!mapping.enabled) return null - const rule = rulesById.get(Number(mapping.rule_id)) - if (!rule || !rule.enabled) return null - if ( - !mappingSellerIds(mapping.seller_ids_json).some((sellerId) => - sameMatchId(context.sellerId, sellerId), - ) - ) { - return null - } - - const mappingType = - mapping.mapping_type === 'sku_series' - ? 'sku_series' - : mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override' - ? 'sku_exact' - : 'product_default' - const mappedItemIds = normalizeMatchIdList(mapping.rel_item_id) - const mappedTitle = normalizeMatchText(mapping.item_title) - const hasContextItemId = Boolean(normalizeMatchId(context.relItemId)) - const itemIdMatched = mappedItemIds.some((mappedItemId) => - sameMatchId(context.relItemId, mappedItemId), - ) - const titleMatched = Boolean( - mappedTitle && matchNameCondition(normalizeMatchText(context.itemTitle), mappedTitle, true), - ) - const hasProductScope = Boolean(mappedItemIds.length > 0 || mappedTitle) - if (mappingType === 'product_default' || hasProductScope) { - // 配置了商品 ID 时必须要求订单也提供商品 ID,避免缺少 ID 时误落入同店其他商品。 - if ( - (mappedItemIds.length > 0 && (!hasContextItemId || !itemIdMatched)) || - (mappedItemIds.length === 0 && !titleMatched) - ) { - return null - } - } - - let score = mappingType === 'sku_exact' ? 10_000 : mappingType === 'sku_series' ? 8_000 : 5_000 - if (itemIdMatched) score += 400 - if (titleMatched) score += 200 - if (mappingType === 'sku_exact') { - const mappedSkuId = normalizeMatchId(mapping.rel_sku_id) - const mappedSkuNick = normalizeMatchText(mapping.sku_nick) - if (!mappedSkuId && !mappedSkuNick) return null - const skuIdMatched = Boolean(mappedSkuId && sameMatchId(context.relSkuId, mappedSkuId)) - const skuNickMatched = Boolean( - mappedSkuNick && matchNameCondition(normalizeMatchText(context.skuNick), mappedSkuNick, true), - ) - if (!skuIdMatched && !skuNickMatched) { - return null - } - if (skuIdMatched) score += 400 - if (skuNickMatched) score += 200 - } - if (mappingType === 'sku_series') { - const skuMatchMode = mapping.sku_match_mode === 'exact' ? 'exact' : 'fuzzy' - if (!matchSkuSeriesName(context.skuNick, mapping.sku_nick, skuMatchMode)) return null - score += 200 - } - return { rule, score, mappingId: Number(mapping.id) } -} - -function scoreWorkProductRule(order: OrderRow, item: OrderItemRow, rule: WorkProductRuleRow) { - if (!matchesOptionalText(rule.provider, order.provider)) return null - if (!matchesOptionalText(rule.platform, order.platform)) return null - - const snapshot = safeParseJson(item.item_snapshot_json) - const source = safeParseJson(snapshot.kuaishouSendCode) - const context = { - sellerId: normalizeMatchId(source.sellerId), - itemId: normalizeMatchId(source.itemId), - relItemId: normalizeMatchId(source.relItemId), - skuId: normalizeMatchId(source.skuId), - relSkuId: normalizeMatchId(source.relSkuId), - itemTitle: normalizeMatchText(source.itemTitle), - skuNick: normalizeMatchText(source.skuNick || source.skuName || snapshot.externalSkuName), - } - const ruleShopId = normalizeMatchId(rule.shop_id) - if ( - ruleShopId && - ruleShopId !== normalizeMatchId(order.shop_id) && - ruleShopId !== context.sellerId - ) { - return null - } - const match = safeParseJson(rule.match_json) - const hasStructuredMatch = Object.entries(match).some( - ([key, value]) => - !['itemTitleMatchType', 'skuNickMatchType'].includes(key) && String(value || '').trim(), - ) - - if (hasStructuredMatch) { - let score = 100 - if (!matchCondition(context.sellerId, match.sellerId)) return null - if (match.sellerId) score += 1000 - if (!matchCondition(context.itemId, match.itemId)) return null - if (match.itemId) score += 800 - if (!matchCondition(context.relItemId, match.relItemId)) return null - if (match.relItemId) score += 700 - if (!matchCondition(context.skuId, match.skuId)) return null - if (match.skuId) score += 1000 - if (!matchCondition(context.relSkuId, match.relSkuId)) return null - if (match.relSkuId) score += 900 - - const itemTitle = normalizeMatchText(match.itemTitle) - if (itemTitle) { - const exact = match.itemTitleMatchType === 'exact' - if (!matchNameCondition(context.itemTitle, itemTitle, exact)) return null - score += exact ? 600 : 250 - } - const skuNick = normalizeMatchText(match.skuNick) - if (skuNick) { - const exact = match.skuNickMatchType === 'exact' - if (!matchNameCondition(context.skuNick, skuNick, exact)) return null - score += exact ? 800 : 350 - } - return { rule, score } - } - - const skuCodes = [item.sku_code, snapshot.externalSkuCode, snapshot.externalItemId] - .map(normalizeMatchId) - .filter(Boolean) - const skuNames = [ - item.sku_name, - snapshot.externalSkuName, - snapshot.externalSkuNameNormalized, - source.skuNick, - source.itemTitle, - ] - .map(normalizeMatchText) - .filter(Boolean) - const ruleSkuCode = normalizeMatchId(rule.sku_code) - const ruleProductName = normalizeMatchText(rule.product_name) - - if (ruleSkuCode && !skuCodes.includes(ruleSkuCode)) return null - if (!ruleProductName) { - return ruleSkuCode ? { rule, score: 120 } : null - } - if (rule.match_type === 'exact') { - return skuNames.includes(ruleProductName) || skuCodes.includes(ruleProductName) - ? { rule, score: ruleSkuCode ? 300 : 150 } - : null - } - return skuNames.some((value) => value.includes(ruleProductName)) || - skuCodes.some((value) => value.includes(ruleProductName)) - ? { rule, score: ruleSkuCode ? 220 : 100 } - : null -} - -function matchCondition(actual: string, expected: unknown) { - const value = normalizeMatchId(expected) - return !value || actual === value -} - -function matchNameCondition(actual: string, expected: string, exact: boolean) { - return Boolean(actual) && (exact ? actual === expected : actual.includes(expected)) -} - -function normalizeMatchId(value: unknown) { - const normalized = String(value || '').trim() - return normalized === '0' ? '' : normalized.toLowerCase() -} - -function normalizeMatchIdList(value: unknown) { - const rawValues = Array.isArray(value) ? value : [value] - return Array.from( - new Set( - rawValues - .flatMap((entry) => String(entry || '').split(/[,,;;\s\n\r]+/)) - .map(normalizeMatchId) - .filter(Boolean), - ), - ) -} - -function normalizeExternalMatchId(value: unknown) { - const normalized = String(value || '').trim() - return normalized === '0' ? '' : normalized -} - -function sameMatchId(left: unknown, right: unknown) { - const normalizedLeft = normalizeMatchId(left) - const normalizedRight = normalizeMatchId(right) - return Boolean(normalizedLeft) && normalizedLeft === normalizedRight -} - -function normalizeMatchText(value: unknown) { - return String(value || '') - .normalize('NFKC') - .toLowerCase() - .replace(/[\s\u3000]+/g, '') - .trim() -} - -function mappingSellerIds(value: unknown) { - const parsed = Array.isArray(value) ? value : safeParseJson(value) - const values = Array.isArray(parsed) ? parsed : [] - return Array.from(new Set(values.map(normalizeMatchId).filter(Boolean))) -} - -/** - * 去除 SKU 中的数量规格后得到系列名。 - * 例如“指挥官密钥10个”和“1个盛夏礼卡”分别归一为“指挥官密钥”“盛夏礼卡”。 - */ -function normalizeSkuSeriesName(value: unknown) { - return normalizeMatchText(value).replace( - /(?:\d+|[零一二三四五六七八九十百千两]+)(?:个|份|张|枚)/g, - '', - ) -} - -function matchSkuSeriesName(actual: unknown, expected: unknown, matchMode: 'fuzzy' | 'exact') { - const normalizedActual = normalizeSkuSeriesName(actual) - const normalizedExpected = normalizeSkuSeriesName(expected) - if (!normalizedActual || !normalizedExpected) return false - if (matchMode === 'exact') return normalizedActual === normalizedExpected - - const fuzzyActual = normalizeFuzzySkuSeriesName(normalizedActual) - const fuzzyExpected = normalizeFuzzySkuSeriesName(normalizedExpected) - return fuzzyActual.includes(fuzzyExpected) || fuzzyExpected.includes(fuzzyActual) -} - -function normalizeFuzzySkuSeriesName(value: string) { - return value.replace(/秘钥|密匙|秘匙/g, '密钥') -} - -export function normalizeWorkProductRuleMatch(payload: JsonObject): JsonObject { - const match = - payload.match && typeof payload.match === 'object' && !Array.isArray(payload.match) - ? (payload.match as JsonObject) - : {} - const source = { - sellerId: payload.sellerId ?? payload.seller_id ?? match.sellerId, - itemId: payload.itemId ?? payload.item_id ?? match.itemId, - relItemId: payload.relItemId ?? payload.rel_item_id ?? match.relItemId, - skuId: payload.skuId ?? payload.sku_id ?? match.skuId, - relSkuId: payload.relSkuId ?? payload.rel_sku_id ?? match.relSkuId, - itemTitle: payload.itemTitle ?? payload.item_title ?? match.itemTitle, - skuNick: payload.skuNick ?? payload.sku_nick ?? match.skuNick, - } - const result: JsonObject = {} - for (const [key, value] of Object.entries(source)) { - const normalized = key.endsWith('Id') ? normalizeMatchId(value) : normalizeMatchText(value) - if (normalized) result[key] = normalized - } - result.itemTitleMatchType = - (payload.itemTitleMatchType ?? payload.item_title_match_type ?? match.itemTitleMatchType) === - 'exact' - ? 'exact' - : 'contains' - result.skuNickMatchType = - (payload.skuNickMatchType ?? payload.sku_nick_match_type ?? match.skuNickMatchType) === 'exact' - ? 'exact' - : 'contains' - return result -} - -export function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) { - const ruleText = String(ruleValue || '') - .trim() - .toLowerCase() - if (!ruleText) { - return true - } - return ( - ruleText === - String(sourceValue || '') - .trim() - .toLowerCase() - ) -} +export { + type KuaishouWorkProductMatchContext, + mapWorkProductRule, + matchesOptionalText, + normalizeWorkProductRuleMatch, + type WorkProductRuleMatchDecision, + resolveKuaishouWorkProductMatchContext, + resolveMatchingProductRule, + resolveMatchingProductRuleDecision, +} from './work-product-match-mappers.js' export { mapAcceptanceForResponse, diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts new file mode 100644 index 00000000..5f3b8dcb --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -0,0 +1,311 @@ +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + type WorkCategoryRow, + type WorkOrderCapabilities, + type WorkOrderRow, + type WorkOrderShareRow, +} from '../../repositories/worker-platform/index.js' +import type { JsonObject } from '../../types/json.js' +import { safeParseJson } from '../admin/admin-query-utils.js' +import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' +import { DEFAULT_DEPOSIT_THRESHOLD_AMOUNT } from './worker-finance-mappers.js' +import { normalizeUploadedFiles } from './worker-input-normalizers.js' +import { resolveFreezeDepositAmount } from './worker-permission-utils.js' +import { + mapAcceptanceForResponse, + resolveCollectSubmittedFieldValues, +} from './work-order-material-mappers.js' +import { resolveRequirementFields } from './work-order-requirement-fields.js' + +export function mapWorkCategory(category: WorkCategoryRow | null | undefined) { + if (!category) return null + return { + categoryId: Number(category.id), + categoryKey: category.category_key, + name: category.name, + sortOrder: Number(category.sort_order || 100), + status: category.status, + createdAt: category.created_at, + updatedAt: category.updated_at, + } +} + +/** 读取工单资料:刷新买家主页截图等文件的访问链接(签名 15 分钟过期,读取时必须重签) */ +export function mapWorkOrderMaterial(value: unknown) { + const material = safeParseJson(value) + const collect = + material.collect && typeof material.collect === 'object' && !Array.isArray(material.collect) + ? (material.collect as JsonObject) + : null + if (!collect) { + return material + } + return { + ...material, + collect: { + ...collect, + screenshots: normalizeUploadedFiles(collect.screenshots).map(refreshUploadedFileUrls), + }, + } +} + +/** 统一计算后台使用的工单业务状态,避免拼单订单继续直接暴露母工单 open。 */ +export function resolveWorkOrderOperationalStatus(workOrder: WorkOrderRow): string { + const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0)) + const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0)) + const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) + const hasActiveShares = activeShareQuantity > 0 + const sharingFilled = + workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity + + if (workOrder.status === WORK_ORDER_STATUS.OPEN && workOrder.sharing_enabled && hasActiveShares) { + return sharingFilled && pendingShareCount === 0 + ? WORK_ORDER_STATUS.PENDING_ACCEPTANCE + : WORK_ORDER_STATUS.IN_PROGRESS + } + return workOrder.status +} + +/** 汇总拼单参与者,已撤销份额不应继续展示为当前打手。 */ +export function mapWorkOrderSharingWorkers(shares: WorkOrderShareRow[]) { + const workers = new Map< + number, + { workerId: number; username: string; displayName: string; quantity: number } + >() + for (const share of shares) { + if (share.status === 'cancelled') continue + const workerId = Number(share.worker_id || 0) + if (workerId <= 0) continue + const existing = workers.get(workerId) + if (existing) { + existing.quantity += Number(share.quantity || 0) + continue + } + const username = String(share.worker_username || '').trim() + workers.set(workerId, { + workerId, + username, + displayName: String(share.worker_display_name || username || `打手#${workerId}`).trim(), + quantity: Number(share.quantity || 0), + }) + } + return [...workers.values()] +} + +export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderShareRow[]) { + const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0)) + const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0)) + const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) + const hasActiveShares = activeShareQuantity > 0 + const sharingFilled = + workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity + const status = resolveWorkOrderOperationalStatus(workOrder) + const hallAvailable = + workOrder.status === WORK_ORDER_STATUS.OPEN && + (!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity) + const editableBeforeClaim = + !workOrder.assigned_worker_id && + (workOrder.status === WORK_ORDER_STATUS.PENDING_MATERIAL || + workOrder.status === WORK_ORDER_STATUS.UNASSIGNED || + (workOrder.status === WORK_ORDER_STATUS.OPEN && !hasActiveShares)) + const capabilities: WorkOrderCapabilities = { + canEdit: editableBeforeClaim, + canConfigureSharing: editableBeforeClaim, + canAssign: + !workOrder.assigned_worker_id && + (workOrder.status === WORK_ORDER_STATUS.UNASSIGNED || + (workOrder.status === WORK_ORDER_STATUS.OPEN && !hasActiveShares)), + canPin: hallAvailable || Boolean(workOrder.pinned_at), + canUnpublish: + workOrder.status === WORK_ORDER_STATUS.OPEN && + !hasActiveShares && + !workOrder.assigned_worker_id, + canUpdateMaterial: + workOrder.status === WORK_ORDER_STATUS.PENDING_MATERIAL || + workOrder.status === WORK_ORDER_STATUS.UNASSIGNED || + workOrder.status === WORK_ORDER_STATUS.OPEN || + workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS, + canAccept: status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + canMarkProblem: + status === WORK_ORDER_STATUS.IN_PROGRESS || status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + canDelete: + !workOrder.assigned_worker_id && + !hasActiveShares && + [ + WORK_ORDER_STATUS.PENDING_MATERIAL, + WORK_ORDER_STATUS.UNASSIGNED, + WORK_ORDER_STATUS.CANCELLED, + ].includes(workOrder.status as never), + canReopen: workOrder.status === WORK_ORDER_STATUS.CANCELLED && !hasActiveShares, + canPublish: + workOrder.status === WORK_ORDER_STATUS.UNASSIGNED && Number(workOrder.reward_amount || 0) > 0, + canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id), + canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM, + canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED, + } + return { + workOrderId: Number(workOrder.id), + orderId: workOrder.order_id ? Number(workOrder.order_id) : null, + orderItemId: workOrder.order_item_id ? Number(workOrder.order_item_id) : null, + workOrderNo: workOrder.work_order_no, + platformOrderId: workOrder.platform_order_id, + productName: workOrder.product_name, + categoryId: workOrder.category_id ? Number(workOrder.category_id) : null, + categoryName: workOrder.category_name || '', + status, + rawStatus: workOrder.status, + hallAvailable, + sharingProgress: { + joinedQuantity: activeShareQuantity, + pendingSubmissionCount: pendingShareCount, + remainingQuantity: Math.max(0, sharingTotalQuantity - activeShareQuantity), + filled: sharingFilled, + }, + capabilities, + rewardAmount: Number(workOrder.reward_amount || 0), + requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), + sharing: { + enabled: workOrder.sharing_enabled === true, + totalQuantity: Number(workOrder.sharing_total_quantity || 1), + unitReward: Number(workOrder.sharing_unit_reward || 0), + }, + timeoutMinutes: Number(workOrder.timeout_minutes || 0), + timeoutPolicy: String(workOrder.timeout_policy || 'reopen').trim(), + deadlineAt: workOrder.deadline_at, + depositThresholdAmount: Number( + workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, + ), + material: mapWorkOrderMaterial(workOrder.material_json), + requirement: { + fields: resolveRequirementFields(workOrder), + }, + acceptance: mapAcceptanceForResponse(workOrder.acceptance_json), + draftAcceptance: mapAcceptanceForResponse(workOrder.draft_acceptance_json), + problemNote: workOrder.problem_note || '', + worker: workOrder.assigned_worker_id + ? { + workerId: Number(workOrder.assigned_worker_id), + username: workOrder.worker_username || '', + displayName: workOrder.worker_display_name || '', + } + : null, + ...(shares ? { sharingWorkers: mapWorkOrderSharingWorkers(shares) } : {}), + publishedAt: workOrder.published_at, + pinnedAt: workOrder.pinned_at, + assignedAt: workOrder.assigned_at, + submittedAt: workOrder.submitted_at, + acceptedAt: workOrder.accepted_at, + createdAt: workOrder.created_at, + updatedAt: workOrder.updated_at, + } +} + +export function mapWorkOrderForWorker( + workOrder: WorkOrderRow, + permissions: { depositFreeAmount: number }, + myShare?: WorkOrderShareRow | null, + workerNote = '', + viewedAt?: string | null, +) { + const mapped = mapWorkOrderAdmin(workOrder) + const { + requiredDepositAmount: _requiredDepositAmount, + depositThresholdAmount: _depositThresholdAmount, + worker: _worker, + orderId: _orderId, + orderItemId: _orderItemId, + rawStatus: _rawStatus, + hallAvailable: _hallAvailable, + capabilities: _capabilities, + ...visibleOrder + } = mapped + const personalStatus = resolveWorkerShareStatus(workOrder.status, myShare?.status) + return { + ...visibleOrder, + productName: myShare + ? resolveWorkerShareProductName(workOrder.product_name, Number(myShare.quantity || 0)) + : visibleOrder.productName, + status: personalStatus, + rewardAmount: myShare ? Number(myShare.share_reward || 0) : visibleOrder.rewardAmount, + freezeDepositAmount: myShare + ? Number(myShare.share_deposit || 0) + : resolveFreezeDepositAmount(workOrder, permissions), + assignedAt: myShare?.created_at || visibleOrder.assignedAt, + submittedAt: myShare?.submitted_at || visibleOrder.submittedAt, + acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt, + workerNote, + myShare: myShare ? mapWorkOrderShare(myShare) : null, + ...(viewedAt === undefined ? {} : { isUnread: !viewedAt }), + } +} + +/** 拼单参与者看到个人份额规格,而不是母工单的原始总规格。 */ +export function resolveWorkerShareProductName(productName: unknown, quantity: number): string { + const text = String(productName || '').trim() + const normalizedQuantity = Math.max(1, Number(quantity) || 1) + return text.replace( + /(\d+|[零一二三四五六七八九十百千两]+)(\s*)(个|份|张|枚)/, + (_matched, _oldQuantity: string, spacing: string, unit: string) => + `${normalizedQuantity}${spacing}${unit}`, + ) +} + +/** 把份额状态映射成打手端订单状态,避免母工单 open 被误显示为已超时。 */ +export function resolveWorkerShareStatus(workOrderStatus: string, shareStatus: unknown): string { + if (!shareStatus) return workOrderStatus + if (['accepted', 'cancelled', 'problem'].includes(workOrderStatus)) return workOrderStatus + if (shareStatus === 'joined') return WORK_ORDER_STATUS.IN_PROGRESS + if (shareStatus === 'submitted') return WORK_ORDER_STATUS.PENDING_ACCEPTANCE + if (shareStatus === 'accepted') return WORK_ORDER_STATUS.ACCEPTED + if (shareStatus === 'cancelled') return WORK_ORDER_STATUS.CANCELLED + return workOrderStatus +} + +export function mapWorkOrderShare(share: WorkOrderShareRow) { + return { + shareId: Number(share.id), + workOrderId: Number(share.work_order_id), + workerId: Number(share.worker_id), + quantity: Number(share.quantity || 0), + unitReward: Number(share.unit_reward || 0), + shareReward: Number(share.share_reward || 0), + shareDeposit: Number(share.share_deposit || 0), + status: share.status, + acceptance: mapAcceptanceForResponse(share.acceptance_json), + draftAcceptance: mapAcceptanceForResponse(share.draft_acceptance_json), + submittedAt: share.submitted_at, + acceptedAt: share.accepted_at, + createdAt: share.created_at, + updatedAt: share.updated_at, + worker: share.worker_id + ? { + workerId: Number(share.worker_id), + username: share.worker_username || '', + displayName: share.worker_display_name || share.worker_username || '', + } + : null, + } +} + +export function mapWorkOrderPublic(workOrder: WorkOrderRow) { + return { + workOrderId: Number(workOrder.id), + workOrderNo: workOrder.work_order_no, + platformOrderId: workOrder.platform_order_id, + productName: workOrder.product_name, + status: workOrder.status, + } +} + +export function mapCollectLookupOrder(workOrder: WorkOrderRow) { + const material = mapWorkOrderMaterial(workOrder.material_json) + return { + ...mapWorkOrderPublic(workOrder), + fields: resolveRequirementFields(workOrder), + values: resolveCollectSubmittedFieldValues(workOrder), + screenshots: + material.collect && Array.isArray(material.collect.screenshots) + ? material.collect.screenshots + : [], + } +} diff --git a/apps/backend/src/services/worker-platform/work-product-match-mappers.ts b/apps/backend/src/services/worker-platform/work-product-match-mappers.ts new file mode 100644 index 00000000..59859916 --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-product-match-mappers.ts @@ -0,0 +1,430 @@ +import { + type WorkProductRuleMappingRow, + type WorkProductRuleRow, +} from '../../repositories/worker-platform/index.js' +import type { JsonObject } from '../../types/json.js' +import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' +import { safeParseJson } from '../admin/admin-query-utils.js' +import { normalizeRequirementFields } from './work-order-requirement-fields.js' + +export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) { + if (!rule) return null + const requirement = safeParseJson(rule.requirement_json) + const match = safeParseJson(rule.match_json) + return { + ruleId: Number(rule.id), + ruleKey: rule.rule_key, + provider: rule.provider || '', + platform: rule.platform || '', + shopId: rule.shop_id || '', + skuCode: rule.sku_code || '', + productName: rule.product_name || '', + matchType: rule.match_type || 'contains', + match: { + sellerId: String(match.sellerId || '').trim(), + itemId: String(match.itemId || '').trim(), + relItemId: String(match.relItemId || '').trim(), + skuId: String(match.skuId || '').trim(), + relSkuId: String(match.relSkuId || '').trim(), + itemTitle: String(match.itemTitle || '').trim(), + skuNick: String(match.skuNick || '').trim(), + itemTitleMatchType: match.itemTitleMatchType === 'exact' ? 'exact' : 'contains', + skuNickMatchType: match.skuNickMatchType === 'exact' ? 'exact' : 'contains', + }, + categoryId: rule.category_id ? Number(rule.category_id) : null, + categoryName: rule.category_name || '', + enabled: Boolean(rule.enabled), + autoCreate: Boolean(rule.auto_create), + rewardAmount: Number(rule.reward_amount || 0), + unitPriceFen: Number(rule.unit_price_fen || 0), + sharing: { + enabled: rule.sharing_enabled === true, + totalQuantity: Number(rule.sharing_total_quantity || 1), + unitReward: Number(rule.sharing_unit_reward || 0), + autoFromOrder: rule.sharing_auto_from_order === true, + }, + timeoutMinutes: Number(rule.timeout_minutes || 0), + timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(), + requirement: { + fields: normalizeRequirementFields(requirement.fields), + }, + sortOrder: Number(rule.sort_order || 100), + createdAt: rule.created_at, + updatedAt: rule.updated_at, + } +} + +export function resolveMatchingProductRule( + order: OrderRow, + item: OrderItemRow, + rules: WorkProductRuleRow[], + mappings: WorkProductRuleMappingRow[] = [], + options: { legacyFallbackEnabled?: boolean } = {}, +) { + return resolveMatchingProductRuleDecision(order, item, rules, mappings, options).rule +} + +export type WorkProductRuleMatchDecision = { + rule: WorkProductRuleRow | null + mappingId: number | null + reason: 'matched' | 'unmatched' | 'ambiguous' + score: number + candidates: Array<{ ruleKey: string; score: number; mappingId: number | null }> +} + +export type KuaishouWorkProductMatchContext = { + sellerId: string + itemId: string + relItemId: string + skuId: string + relSkuId: string + itemTitle: string + skuNick: string +} + +export function resolveMatchingProductRuleDecision( + order: OrderRow, + item: OrderItemRow, + rules: WorkProductRuleRow[], + mappings: WorkProductRuleMappingRow[] = [], + options: { legacyFallbackEnabled?: boolean } = {}, +): WorkProductRuleMatchDecision { + const context = resolveKuaishouWorkProductMatchContext(item) + const rulesById = new Map(rules.map((rule) => [Number(rule.id), rule])) + // 快手发码订单默认只允许确定性商品映射;非快手订单保留原有规则,避免影响其他来源。 + const useLegacyFallback = !context.sellerId || options.legacyFallbackEnabled === true + const candidates = [ + ...mappings.map((mapping) => scoreWorkProductRuleMapping(context, mapping, rulesById)), + ...(useLegacyFallback ? rules.map((rule) => scoreWorkProductRule(order, item, rule)) : []), + ] + .filter( + (candidate): candidate is { rule: WorkProductRuleRow; score: number; mappingId?: number } => + Boolean(candidate), + ) + .sort((left, right) => right.score - left.score || Number(right.rule.id) - Number(left.rule.id)) + + const top = candidates[0] + if (!top) { + return { rule: null, mappingId: null, reason: 'unmatched', score: 0, candidates: [] } + } + + const tied = candidates.filter((candidate) => candidate.score === top.score) + if (tied.length > 1) { + return { + rule: null, + mappingId: null, + reason: 'ambiguous', + score: top.score, + candidates: tied.map((candidate) => ({ + ruleKey: candidate.rule.rule_key, + score: candidate.score, + mappingId: candidate.mappingId || null, + })), + } + } + + return { + rule: top.rule, + mappingId: top.mappingId || null, + reason: 'matched', + score: top.score, + candidates: candidates.slice(0, 5).map((candidate) => ({ + ruleKey: candidate.rule.rule_key, + score: candidate.score, + mappingId: candidate.mappingId || null, + })), + } +} + +export function resolveKuaishouWorkProductMatchContext( + item: Pick, +): KuaishouWorkProductMatchContext { + const snapshot = safeParseJson(item.item_snapshot_json) + const source = safeParseJson(snapshot.kuaishouSendCode) + return { + sellerId: String(source.sellerId || '').trim(), + itemId: String(source.itemId || '').trim(), + relItemId: normalizeExternalMatchId(source.relItemId), + skuId: String(source.skuId || '').trim(), + relSkuId: normalizeExternalMatchId(source.relSkuId), + itemTitle: String(source.itemTitle || '').trim(), + skuNick: String(source.skuNick || source.skuName || snapshot.externalSkuName || '').trim(), + } +} + +function scoreWorkProductRuleMapping( + context: KuaishouWorkProductMatchContext, + mapping: WorkProductRuleMappingRow, + rulesById: Map, +) { + if (!mapping.enabled) return null + const rule = rulesById.get(Number(mapping.rule_id)) + if (!rule || !rule.enabled) return null + if ( + !mappingSellerIds(mapping.seller_ids_json).some((sellerId) => + sameMatchId(context.sellerId, sellerId), + ) + ) { + return null + } + + const mappingType = + mapping.mapping_type === 'sku_series' + ? 'sku_series' + : mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override' + ? 'sku_exact' + : 'product_default' + const mappedItemIds = normalizeMatchIdList(mapping.rel_item_id) + const mappedTitle = normalizeMatchText(mapping.item_title) + const hasContextItemId = Boolean(normalizeMatchId(context.relItemId)) + const itemIdMatched = mappedItemIds.some((mappedItemId) => + sameMatchId(context.relItemId, mappedItemId), + ) + const titleMatched = Boolean( + mappedTitle && matchNameCondition(normalizeMatchText(context.itemTitle), mappedTitle, true), + ) + const hasProductScope = Boolean(mappedItemIds.length > 0 || mappedTitle) + if (mappingType === 'product_default' || hasProductScope) { + // 配置了商品 ID 时必须要求订单也提供商品 ID,避免缺少 ID 时误落入同店其他商品。 + if ( + (mappedItemIds.length > 0 && (!hasContextItemId || !itemIdMatched)) || + (mappedItemIds.length === 0 && !titleMatched) + ) { + return null + } + } + + let score = mappingType === 'sku_exact' ? 10_000 : mappingType === 'sku_series' ? 8_000 : 5_000 + if (itemIdMatched) score += 400 + if (titleMatched) score += 200 + if (mappingType === 'sku_exact') { + const mappedSkuId = normalizeMatchId(mapping.rel_sku_id) + const mappedSkuNick = normalizeMatchText(mapping.sku_nick) + if (!mappedSkuId && !mappedSkuNick) return null + const skuIdMatched = Boolean(mappedSkuId && sameMatchId(context.relSkuId, mappedSkuId)) + const skuNickMatched = Boolean( + mappedSkuNick && matchNameCondition(normalizeMatchText(context.skuNick), mappedSkuNick, true), + ) + if (!skuIdMatched && !skuNickMatched) { + return null + } + if (skuIdMatched) score += 400 + if (skuNickMatched) score += 200 + } + if (mappingType === 'sku_series') { + const skuMatchMode = mapping.sku_match_mode === 'exact' ? 'exact' : 'fuzzy' + if (!matchSkuSeriesName(context.skuNick, mapping.sku_nick, skuMatchMode)) return null + score += 200 + } + return { rule, score, mappingId: Number(mapping.id) } +} + +function scoreWorkProductRule(order: OrderRow, item: OrderItemRow, rule: WorkProductRuleRow) { + if (!matchesOptionalText(rule.provider, order.provider)) return null + if (!matchesOptionalText(rule.platform, order.platform)) return null + + const snapshot = safeParseJson(item.item_snapshot_json) + const source = safeParseJson(snapshot.kuaishouSendCode) + const context = { + sellerId: normalizeMatchId(source.sellerId), + itemId: normalizeMatchId(source.itemId), + relItemId: normalizeMatchId(source.relItemId), + skuId: normalizeMatchId(source.skuId), + relSkuId: normalizeMatchId(source.relSkuId), + itemTitle: normalizeMatchText(source.itemTitle), + skuNick: normalizeMatchText(source.skuNick || source.skuName || snapshot.externalSkuName), + } + const ruleShopId = normalizeMatchId(rule.shop_id) + if ( + ruleShopId && + ruleShopId !== normalizeMatchId(order.shop_id) && + ruleShopId !== context.sellerId + ) { + return null + } + const match = safeParseJson(rule.match_json) + const hasStructuredMatch = Object.entries(match).some( + ([key, value]) => + !['itemTitleMatchType', 'skuNickMatchType'].includes(key) && String(value || '').trim(), + ) + + if (hasStructuredMatch) { + let score = 100 + if (!matchCondition(context.sellerId, match.sellerId)) return null + if (match.sellerId) score += 1000 + if (!matchCondition(context.itemId, match.itemId)) return null + if (match.itemId) score += 800 + if (!matchCondition(context.relItemId, match.relItemId)) return null + if (match.relItemId) score += 700 + if (!matchCondition(context.skuId, match.skuId)) return null + if (match.skuId) score += 1000 + if (!matchCondition(context.relSkuId, match.relSkuId)) return null + if (match.relSkuId) score += 900 + + const itemTitle = normalizeMatchText(match.itemTitle) + if (itemTitle) { + const exact = match.itemTitleMatchType === 'exact' + if (!matchNameCondition(context.itemTitle, itemTitle, exact)) return null + score += exact ? 600 : 250 + } + const skuNick = normalizeMatchText(match.skuNick) + if (skuNick) { + const exact = match.skuNickMatchType === 'exact' + if (!matchNameCondition(context.skuNick, skuNick, exact)) return null + score += exact ? 800 : 350 + } + return { rule, score } + } + + const skuCodes = [item.sku_code, snapshot.externalSkuCode, snapshot.externalItemId] + .map(normalizeMatchId) + .filter(Boolean) + const skuNames = [ + item.sku_name, + snapshot.externalSkuName, + snapshot.externalSkuNameNormalized, + source.skuNick, + source.itemTitle, + ] + .map(normalizeMatchText) + .filter(Boolean) + const ruleSkuCode = normalizeMatchId(rule.sku_code) + const ruleProductName = normalizeMatchText(rule.product_name) + + if (ruleSkuCode && !skuCodes.includes(ruleSkuCode)) return null + if (!ruleProductName) { + return ruleSkuCode ? { rule, score: 120 } : null + } + if (rule.match_type === 'exact') { + return skuNames.includes(ruleProductName) || skuCodes.includes(ruleProductName) + ? { rule, score: ruleSkuCode ? 300 : 150 } + : null + } + return skuNames.some((value) => value.includes(ruleProductName)) || + skuCodes.some((value) => value.includes(ruleProductName)) + ? { rule, score: ruleSkuCode ? 220 : 100 } + : null +} + +function matchCondition(actual: string, expected: unknown) { + const value = normalizeMatchId(expected) + return !value || actual === value +} + +function matchNameCondition(actual: string, expected: string, exact: boolean) { + return Boolean(actual) && (exact ? actual === expected : actual.includes(expected)) +} + +function normalizeMatchId(value: unknown) { + const normalized = String(value || '').trim() + return normalized === '0' ? '' : normalized.toLowerCase() +} + +function normalizeMatchIdList(value: unknown) { + const rawValues = Array.isArray(value) ? value : [value] + return Array.from( + new Set( + rawValues + .flatMap((entry) => String(entry || '').split(/[,,;;\s\n\r]+/)) + .map(normalizeMatchId) + .filter(Boolean), + ), + ) +} + +function normalizeExternalMatchId(value: unknown) { + const normalized = String(value || '').trim() + return normalized === '0' ? '' : normalized +} + +function sameMatchId(left: unknown, right: unknown) { + const normalizedLeft = normalizeMatchId(left) + const normalizedRight = normalizeMatchId(right) + return Boolean(normalizedLeft) && normalizedLeft === normalizedRight +} + +function normalizeMatchText(value: unknown) { + return String(value || '') + .normalize('NFKC') + .toLowerCase() + .replace(/[\s\u3000]+/g, '') + .trim() +} + +function mappingSellerIds(value: unknown) { + const parsed = Array.isArray(value) ? value : safeParseJson(value) + const values = Array.isArray(parsed) ? parsed : [] + return Array.from(new Set(values.map(normalizeMatchId).filter(Boolean))) +} + +/** + * 去除 SKU 中的数量规格后得到系列名。 + * 例如“指挥官密钥10个”和“1个盛夏礼卡”分别归一为“指挥官密钥”“盛夏礼卡”。 + */ +function normalizeSkuSeriesName(value: unknown) { + return normalizeMatchText(value).replace( + /(?:\d+|[零一二三四五六七八九十百千两]+)(?:个|份|张|枚)/g, + '', + ) +} + +function matchSkuSeriesName(actual: unknown, expected: unknown, matchMode: 'fuzzy' | 'exact') { + const normalizedActual = normalizeSkuSeriesName(actual) + const normalizedExpected = normalizeSkuSeriesName(expected) + if (!normalizedActual || !normalizedExpected) return false + if (matchMode === 'exact') return normalizedActual === normalizedExpected + + const fuzzyActual = normalizeFuzzySkuSeriesName(normalizedActual) + const fuzzyExpected = normalizeFuzzySkuSeriesName(normalizedExpected) + return fuzzyActual.includes(fuzzyExpected) || fuzzyExpected.includes(fuzzyActual) +} + +function normalizeFuzzySkuSeriesName(value: string) { + return value.replace(/秘钥|密匙|秘匙/g, '密钥') +} + +export function normalizeWorkProductRuleMatch(payload: JsonObject): JsonObject { + const match = + payload.match && typeof payload.match === 'object' && !Array.isArray(payload.match) + ? (payload.match as JsonObject) + : {} + const source = { + sellerId: payload.sellerId ?? payload.seller_id ?? match.sellerId, + itemId: payload.itemId ?? payload.item_id ?? match.itemId, + relItemId: payload.relItemId ?? payload.rel_item_id ?? match.relItemId, + skuId: payload.skuId ?? payload.sku_id ?? match.skuId, + relSkuId: payload.relSkuId ?? payload.rel_sku_id ?? match.relSkuId, + itemTitle: payload.itemTitle ?? payload.item_title ?? match.itemTitle, + skuNick: payload.skuNick ?? payload.sku_nick ?? match.skuNick, + } + const result: JsonObject = {} + for (const [key, value] of Object.entries(source)) { + const normalized = key.endsWith('Id') ? normalizeMatchId(value) : normalizeMatchText(value) + if (normalized) result[key] = normalized + } + result.itemTitleMatchType = + (payload.itemTitleMatchType ?? payload.item_title_match_type ?? match.itemTitleMatchType) === + 'exact' + ? 'exact' + : 'contains' + result.skuNickMatchType = + (payload.skuNickMatchType ?? payload.sku_nick_match_type ?? match.skuNickMatchType) === 'exact' + ? 'exact' + : 'contains' + return result +} + +export function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) { + const ruleText = String(ruleValue || '') + .trim() + .toLowerCase() + if (!ruleText) { + return true + } + return ( + ruleText === + String(sourceValue || '') + .trim() + .toLowerCase() + ) +} diff --git a/apps/backend/src/services/worker-platform/worker-finance-mappers.ts b/apps/backend/src/services/worker-platform/worker-finance-mappers.ts new file mode 100644 index 00000000..5c5e62e0 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-finance-mappers.ts @@ -0,0 +1,320 @@ +import { + addWorkerWalletCredit, + type WorkerFinanceRequestRow, + type WorkerLevelRow, + type WorkerUserRow, + type WorkerWalletLedgerRow, +} from '../../repositories/worker-platform/index.js' +import { safeParseJson } from '../admin/admin-query-utils.js' +import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' +import { createWorkerSessionToken } from './worker-auth-utils.js' +import { + normalizeBoolean, + normalizeInteger, + normalizePositiveInteger, +} from './worker-normalizers.js' +import { + resolveVisibleDelaySeconds, + resolveWorkerPermissions, +} from './worker-permission-utils.js' +import { normalizeUploadedFiles } from './worker-input-normalizers.js' +import { mapAcceptanceForResponse } from './work-order-material-mappers.js' + +export const DEFAULT_LEVEL_KEY = 'vip1' +export const DEFAULT_LEVEL_NAME = 'VIP1' +export const DEFAULT_CATEGORY_KEY = 'default' +export const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000 +export const INVITE_CODE_LENGTH = 8 +export const INVITE_CODE_RETRY_LIMIT = 5 +export const WORKER_CANCEL_LIMIT_PER_WINDOW = 5 +export const WORKER_CANCEL_LIMIT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000 + +export function createWorkerSession(worker: WorkerUserRow) { + const session = createWorkerSessionToken(worker) + return { + ...session, + worker: mapWorkerUser(worker), + } +} + +export function mapWorkerLevel(level: WorkerLevelRow | null | undefined) { + if (!level) return null + const permission = safeParseJson(level.permission_json) + return { + levelId: Number(level.id), + levelKey: level.level_key, + name: level.name, + sortOrder: Number(level.sort_order || 100), + status: level.status, + depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0), + maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1), + upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0), + autoAcceptWithoutEvidence: normalizeBoolean(permission.autoAcceptWithoutEvidence, false), + visibleDelaySeconds: resolveVisibleDelaySeconds( + level.level_key, + permission.visibleDelaySeconds, + ), + createdAt: level.created_at, + updatedAt: level.updated_at, + } +} + +export function normalizeWorkerType(value: unknown): 'internal' | 'external' { + return String(value || '').trim() === 'internal' ? 'internal' : 'external' +} + +export function mapWorkerUser(worker: WorkerUserRow) { + return { + workerId: Number(worker.id), + username: worker.username, + displayName: worker.display_name || worker.username, + phone: worker.phone, + status: worker.status, + workerType: normalizeWorkerType(worker.worker_type), + reviewNote: worker.review_note || '', + freezeReason: worker.frozen_reason || '', + frozenAt: worker.frozen_at, + inviteCode: worker.invite_code || '', + inviter: worker.inviter_id + ? { + workerId: Number(worker.inviter_id), + username: worker.inviter_username || '', + displayName: worker.inviter_display_name || worker.inviter_username || '', + } + : null, + level: worker.level_id + ? { + levelId: Number(worker.level_id), + levelKey: worker.level_key || '', + name: worker.level_name || '', + permissions: resolveWorkerPermissions(worker), + } + : null, + wallet: { + availableAmount: Number(worker.available_amount || 0), + frozenDepositAmount: Number(worker.frozen_deposit_amount || 0), + pendingUnfreezeAmount: Number(worker.pending_unfreeze_amount || 0), + totalCreditedAmount: Number(worker.total_credited_amount || 0), + totalSettledAmount: Number(worker.total_settled_amount || 0), + }, + createdAt: worker.created_at, + updatedAt: worker.updated_at, + reviewedAt: worker.reviewed_at, + } +} + +export function mapWorkerCancelRequest(request: { + id: number + work_order_id: number + work_order_share_id: number | null + worker_id: number + reason: string + status: string + review_note: string + reviewed_by: string + created_at: string + reviewed_at: string | null + platform_order_id?: string + shop_name?: string + worker_username?: string + worker_display_name?: string + share_quantity?: number | null +}) { + return { + requestId: Number(request.id), + workOrderId: Number(request.work_order_id), + shareId: request.work_order_share_id ? Number(request.work_order_share_id) : null, + workerId: Number(request.worker_id), + platformOrderId: String(request.platform_order_id || ''), + shopName: String(request.shop_name || ''), + workerName: String(request.worker_display_name || request.worker_username || ''), + shareQuantity: request.share_quantity ? Number(request.share_quantity) : null, + reason: String(request.reason || ''), + status: String(request.status || ''), + reviewNote: String(request.review_note || ''), + reviewedBy: String(request.reviewed_by || ''), + createdAt: request.created_at, + reviewedAt: request.reviewed_at, + } +} + +export function mapWorkerOrderFeedback(feedback: { + id: number + work_order_id: number + work_order_share_id: number | null + worker_id: number + content: string + status: string + reply: string + handled_by: string + created_at: string + handled_at: string | null + work_order_no?: string + product_name?: string + shop_name?: string + worker_username?: string + worker_display_name?: string + share_quantity?: number | null +}) { + return { + feedbackId: Number(feedback.id), + workOrderId: Number(feedback.work_order_id), + shareId: feedback.work_order_share_id ? Number(feedback.work_order_share_id) : null, + workerId: Number(feedback.worker_id), + workOrderNo: String(feedback.work_order_no || ''), + productName: String(feedback.product_name || ''), + shopName: String(feedback.shop_name || ''), + workerName: String(feedback.worker_display_name || feedback.worker_username || ''), + shareQuantity: feedback.share_quantity ? Number(feedback.share_quantity) : null, + content: String(feedback.content || ''), + status: String(feedback.status || ''), + reply: String(feedback.reply || ''), + handledBy: String(feedback.handled_by || ''), + createdAt: feedback.created_at, + handledAt: feedback.handled_at, + } +} + +export function mapWorkerAfterSalesCase(afterSalesCase: { + id: number + case_no: string + work_order_id: number + work_order_share_id: number | null + worker_id: number + problem_type: string + complaint_note: string + complaint_files_json: unknown + acceptance_snapshot_json: unknown + status: string + worker_response: string + worker_response_files_json: unknown + responded_at: string | null + resolution_action: string + resolution_note: string + recovery_amount: number + pending_deposit_deduction_amount: number + available_deduction_amount: number + debt_amount: number + created_by: string + resolved_by: string + resolved_at: string | null + created_at: string + updated_at: string + work_order_no?: string + platform_order_id?: string + product_name?: string + shop_name?: string + worker_username?: string + worker_display_name?: string + share_quantity?: number | null +}) { + const snapshot = safeParseJson(afterSalesCase.acceptance_snapshot_json) + return { + caseId: Number(afterSalesCase.id), + caseNo: String(afterSalesCase.case_no || ''), + workOrderId: Number(afterSalesCase.work_order_id), + shareId: afterSalesCase.work_order_share_id ? Number(afterSalesCase.work_order_share_id) : null, + workerId: Number(afterSalesCase.worker_id), + workOrderNo: String(afterSalesCase.work_order_no || ''), + platformOrderId: String(afterSalesCase.platform_order_id || ''), + productName: String(afterSalesCase.product_name || ''), + shopName: String(afterSalesCase.shop_name || ''), + workerName: String(afterSalesCase.worker_display_name || afterSalesCase.worker_username || ''), + shareQuantity: afterSalesCase.share_quantity ? Number(afterSalesCase.share_quantity) : null, + problemType: String(afterSalesCase.problem_type || ''), + complaintNote: String(afterSalesCase.complaint_note || ''), + complaintFiles: normalizeUploadedFiles(safeParseJson(afterSalesCase.complaint_files_json)).map( + refreshUploadedFileUrls, + ), + acceptanceSnapshot: { + ...snapshot, + acceptance: mapAcceptanceForResponse(snapshot.acceptance), + }, + status: String(afterSalesCase.status || ''), + workerResponse: String(afterSalesCase.worker_response || ''), + workerResponseFiles: normalizeUploadedFiles( + safeParseJson(afterSalesCase.worker_response_files_json), + ).map(refreshUploadedFileUrls), + respondedAt: afterSalesCase.responded_at, + resolutionAction: String(afterSalesCase.resolution_action || ''), + resolutionNote: String(afterSalesCase.resolution_note || ''), + recoveryAmount: Number(afterSalesCase.recovery_amount || 0), + pendingDepositDeductionAmount: Number(afterSalesCase.pending_deposit_deduction_amount || 0), + availableDeductionAmount: Number(afterSalesCase.available_deduction_amount || 0), + debtAmount: Number(afterSalesCase.debt_amount || 0), + createdBy: String(afterSalesCase.created_by || ''), + resolvedBy: String(afterSalesCase.resolved_by || ''), + resolvedAt: afterSalesCase.resolved_at, + createdAt: afterSalesCase.created_at, + updatedAt: afterSalesCase.updated_at, + } +} + +export function mapWalletLedger(ledger: WorkerWalletLedgerRow | null | undefined) { + if (!ledger) return null + return { + ledgerId: Number(ledger.id), + ledgerType: ledger.ledger_type, + amount: Number(ledger.amount || 0), + balanceAfter: Number(ledger.balance_after || 0), + frozenAfter: Number(ledger.frozen_after || 0), + relatedWorkOrderId: ledger.related_work_order_id ? Number(ledger.related_work_order_id) : null, + relatedPlatformOrderId: String(ledger.related_platform_order_id || '').trim() || null, + auditStatus: ledger.audit_status || '', + note: ledger.note || '', + payload: safeParseJson(ledger.payload_json), + createdAt: ledger.created_at, + } +} + +export function mapFinanceRequest(request: WorkerFinanceRequestRow | null | undefined) { + if (!request) return null + const payload = safeParseJson(request.payload_json) + const alipayQrCodeImage = refreshUploadedFileUrls(payload.alipayQrCodeImage) + if (String(alipayQrCodeImage.url || '').trim()) { + payload.alipayQrCodeImage = alipayQrCodeImage + } + const wechatQrCodeImage = refreshUploadedFileUrls(payload.wechatQrCodeImage) + if (String(wechatQrCodeImage.url || '').trim()) { + payload.wechatQrCodeImage = wechatQrCodeImage + } + return { + requestId: Number(request.id), + requestType: request.request_type || '', + status: request.status || '', + amount: Number(request.amount || 0), + accountChannel: request.account_channel || '', + accountName: request.account_name || '', + accountNo: request.account_no || '', + note: request.note || '', + reviewedNote: request.reviewed_note || '', + payload, + createdAt: request.created_at, + updatedAt: request.updated_at, + reviewedAt: request.reviewed_at, + worker: request.worker_id + ? { + workerId: Number(request.worker_id), + username: request.worker_username || '', + displayName: request.worker_display_name || request.worker_username || '', + phone: request.worker_phone || '', + workerType: normalizeWorkerType(request.worker_type), + levelKey: request.worker_level_key || '', + levelName: request.worker_level_name || '', + withdrawRequestCount: Number(request.worker_withdraw_request_count || 0), + totalWithdrawAmount: Number(request.worker_total_withdraw_amount || 0), + } + : null, + } +} + +export function mapWallet(wallet: Awaited>) { + return { + workerId: Number(wallet?.worker_id || 0), + availableAmount: Number(wallet?.available_amount || 0), + frozenDepositAmount: Number(wallet?.frozen_deposit_amount || 0), + pendingUnfreezeAmount: Number(wallet?.pending_unfreeze_amount || 0), + totalCreditedAmount: Number(wallet?.total_credited_amount || 0), + totalSettledAmount: Number(wallet?.total_settled_amount || 0), + } +}