拆分打手平台映射器

This commit is contained in:
yml2213
2026-08-21 18:53:59 +08:00
parent eeb3d2f297
commit cc7491ecd6
4 changed files with 1104 additions and 1059 deletions
File diff suppressed because it is too large Load Diff
@@ -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
: [],
}
}
@@ -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<OrderItemRow, 'item_snapshot_json'>,
): 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<number, WorkProductRuleRow>,
) {
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()
)
}
@@ -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<ReturnType<typeof addWorkerWalletCredit>>) {
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),
}
}