优化接单商品匹配
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import { listKuaishouIndustryVoucherWorkOrderSyncSources } from '../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import {
|
||||
acceptWorkOrderAndSettle,
|
||||
addWorkerWalletCredit,
|
||||
@@ -12,10 +13,12 @@ import {
|
||||
countWorkProductRulesByCategory,
|
||||
countWorkOrderPendingSharingSubmissions,
|
||||
countTimeoutEventsByWorkerIds,
|
||||
createWorkProductMatchLog,
|
||||
createWorkOrder,
|
||||
createWorkOrderEvent,
|
||||
deductPendingDepositUnfreeze,
|
||||
deleteWorkCategory,
|
||||
deleteWorkProductRuleMapping,
|
||||
deleteWorkProductRule,
|
||||
deleteWorkOrder,
|
||||
deleteWorkerLevel,
|
||||
@@ -32,6 +35,8 @@ import {
|
||||
listWorkOrders,
|
||||
listWorkOrderEventsByOrderId,
|
||||
listWorkProductRules,
|
||||
listWorkProductRuleMappings,
|
||||
listWorkProductMatchLogs,
|
||||
listWorkProductRulesPage,
|
||||
listWorkerLevels,
|
||||
listWorkerUsers,
|
||||
@@ -48,8 +53,11 @@ import {
|
||||
upsertWorkerWithdrawalAccount,
|
||||
upsertWorkCategory,
|
||||
upsertWorkProductRule,
|
||||
upsertWorkProductRuleMapping,
|
||||
upsertWorkerLevel,
|
||||
type WorkOrderRow,
|
||||
type WorkProductMatchLogRow,
|
||||
type WorkProductRuleMappingRow,
|
||||
type WorkOrderShareRow,
|
||||
type WorkerWithdrawalAccountRow,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
@@ -118,6 +126,7 @@ import {
|
||||
resolveFreezeDepositAmount,
|
||||
mapWorkOrderEvents,
|
||||
resolveMatchingProductRuleDecision,
|
||||
resolveKuaishouWorkProductMatchContext,
|
||||
resolveRequirementFields,
|
||||
resolveSkuNameQuantity,
|
||||
resolveWorkerPermissions,
|
||||
@@ -269,6 +278,304 @@ export async function reprocessAdminKuaishouSendCodeWorkOrders(payload: JsonObje
|
||||
return reprocessKuaishouSendCodeWorkOrders(limit)
|
||||
}
|
||||
|
||||
export async function listAdminWorkProductRuleMappings() {
|
||||
const items = await listWorkProductRuleMappings()
|
||||
return { items: items.map(mapWorkProductRuleMapping) }
|
||||
}
|
||||
|
||||
export async function saveAdminWorkProductRuleMapping(payload: JsonObject = {}) {
|
||||
const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id)
|
||||
const sellerIds = normalizeKuaishouMatchIds(
|
||||
payload.sellerIds ?? payload.seller_ids ?? payload.sellerId ?? payload.seller_id,
|
||||
)
|
||||
const relItemIds = normalizeKuaishouMatchIdList(payload.relItemId ?? payload.rel_item_id)
|
||||
const relItemId = relItemIds.join(',')
|
||||
const itemTitle = String(payload.itemTitle ?? payload.item_title ?? '').trim()
|
||||
const rawMappingType = String(payload.mappingType ?? payload.mapping_type ?? '').trim()
|
||||
const mappingType =
|
||||
rawMappingType === 'sku_series'
|
||||
? 'sku_series'
|
||||
: rawMappingType === 'sku_exact' || rawMappingType === 'sku_override'
|
||||
? 'sku_exact'
|
||||
: 'product_default'
|
||||
const relSkuId =
|
||||
mappingType === 'sku_exact'
|
||||
? normalizeKuaishouMatchId(payload.relSkuId ?? payload.rel_sku_id)
|
||||
: ''
|
||||
const skuNick =
|
||||
mappingType === 'product_default'
|
||||
? ''
|
||||
: String(payload.skuNick ?? payload.sku_nick ?? '').trim()
|
||||
const requiresProductScope = mappingType === 'product_default'
|
||||
if (
|
||||
!ruleId ||
|
||||
sellerIds.length === 0 ||
|
||||
(requiresProductScope && relItemIds.length === 0 && !itemTitle)
|
||||
) {
|
||||
throw createHttpError(
|
||||
'请选择接单模板,并填写至少一个店铺;商品默认规则还需填写大标题或关联商品 ID',
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'work_product_mapping_identity_required',
|
||||
},
|
||||
)
|
||||
}
|
||||
if (
|
||||
mappingType === 'product_default' &&
|
||||
sellerIds.length > 1 &&
|
||||
(!itemTitle || relItemIds.length > 0)
|
||||
) {
|
||||
throw createHttpError('多店共享规则请填写快手大标题,并留空关联商品 ID', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_product_mapping_multi_shop_scope_invalid',
|
||||
})
|
||||
}
|
||||
if (mappingType === 'sku_exact' && !relSkuId && !skuNick) {
|
||||
throw createHttpError('SKU 精确规则需要关联 SKU ID 或具体 SKU 名称', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_product_mapping_sku_required',
|
||||
})
|
||||
}
|
||||
if (mappingType === 'sku_series' && !skuNick) {
|
||||
throw createHttpError('SKU 数量系列需要填写系列名称', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_product_mapping_series_required',
|
||||
})
|
||||
}
|
||||
const rule = (await listWorkProductRules()).find((item) => Number(item.id) === ruleId)
|
||||
if (!rule) {
|
||||
throw createHttpError('接单模板不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_product_mapping_rule_not_found',
|
||||
})
|
||||
}
|
||||
const mapping = await upsertWorkProductRuleMapping({
|
||||
mappingId: normalizeOptionalId(payload.mappingId ?? payload.mapping_id),
|
||||
ruleId,
|
||||
sellerIds,
|
||||
relItemId,
|
||||
itemTitle,
|
||||
relSkuId,
|
||||
skuNick,
|
||||
mappingType,
|
||||
enabled: normalizeBoolean(payload.enabled, true),
|
||||
now: nowIso(),
|
||||
})
|
||||
if (!mapping) {
|
||||
throw createHttpError('商品映射保存失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'work_product_mapping_save_failed',
|
||||
})
|
||||
}
|
||||
return {
|
||||
mapping: mapWorkProductRuleMapping({
|
||||
...mapping,
|
||||
rule_key: rule.rule_key,
|
||||
product_name: rule.product_name,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAdminWorkProductRuleMapping(mappingId: number | string) {
|
||||
const normalizedMappingId = normalizeOptionalId(mappingId)
|
||||
if (!normalizedMappingId) {
|
||||
throw createHttpError('商品映射 ID 不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_product_mapping_id_invalid',
|
||||
})
|
||||
}
|
||||
return deleteWorkProductRuleMapping(normalizedMappingId)
|
||||
}
|
||||
|
||||
export async function listAdminKuaishouMatchSources(payload: JsonObject = {}) {
|
||||
const limit = Math.min(500, normalizePositiveInteger(payload.limit, 100))
|
||||
const sources = await listKuaishouIndustryVoucherWorkOrderSyncSources(limit)
|
||||
const grouped = new Map<string, JsonObject>()
|
||||
for (const source of sources) {
|
||||
const rawPayload = safeParseJson(source.raw_payload_json)
|
||||
const body = resolveKuaishouMatchPayload(rawPayload)
|
||||
const ext = safeParseJson(body.ext)
|
||||
const sellerId = normalizeKuaishouMatchId(body.sellerId)
|
||||
const relItemId = normalizeKuaishouMatchId(ext.relItemId)
|
||||
const itemTitle = String(body.itemTitle || '').trim()
|
||||
const relSkuId = normalizeKuaishouMatchId(ext.relSkuId)
|
||||
const skuNick = String(ext.skuNick || '').trim()
|
||||
if (!sellerId || (!relItemId && !itemTitle)) continue
|
||||
const key = [
|
||||
sellerId,
|
||||
relItemId,
|
||||
normalizeMatchTextForAdmin(itemTitle),
|
||||
relSkuId,
|
||||
normalizeMatchTextForAdmin(skuNick),
|
||||
].join('|')
|
||||
const current = grouped.get(key)
|
||||
grouped.set(key, {
|
||||
sellerId,
|
||||
relItemId,
|
||||
itemTitle,
|
||||
relSkuId,
|
||||
skuNick,
|
||||
sampleOid: String(body.oid || source.oid || '').trim(),
|
||||
lastSeenAt: source.updated_at,
|
||||
seenCount: Number(current?.seenCount || 0) + 1,
|
||||
rawPayload,
|
||||
})
|
||||
}
|
||||
return {
|
||||
items: Array.from(grouped.values()).sort((left, right) =>
|
||||
String(right.lastSeenAt).localeCompare(String(left.lastSeenAt)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export async function testAdminKuaishouProductMatch(payload: JsonObject = {}) {
|
||||
const body = resolveKuaishouMatchPayload(payload.rawPayload ?? payload.raw_payload ?? payload)
|
||||
const ext = safeParseJson(body.ext)
|
||||
const order = {
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shop_id: String(body.sellerId || '').trim(),
|
||||
} as OrderRow
|
||||
const item = {
|
||||
id: 0,
|
||||
order_id: 0,
|
||||
sku_code: String(body.itemId || '').trim(),
|
||||
sku_name: String(ext.skuNick || '').trim(),
|
||||
quantity: Math.max(1, Number(body.num) || 1),
|
||||
spec_json: {},
|
||||
item_snapshot_json: {
|
||||
kuaishouSendCode: {
|
||||
sellerId: String(body.sellerId || '').trim(),
|
||||
itemId: String(body.itemId || '').trim(),
|
||||
itemTitle: String(body.itemTitle || '').trim(),
|
||||
skuId: String(body.skuId || '').trim(),
|
||||
relItemId: normalizeKuaishouMatchId(ext.relItemId),
|
||||
relSkuId: normalizeKuaishouMatchId(ext.relSkuId),
|
||||
skuNick: String(ext.skuNick || '').trim(),
|
||||
},
|
||||
},
|
||||
} as OrderItemRow
|
||||
const [rules, mappings] = await Promise.all([
|
||||
listWorkProductRules({ enabled: true }),
|
||||
listWorkProductRuleMappings({ enabled: true }),
|
||||
])
|
||||
const decision = resolveMatchingProductRuleDecision(order, item, rules, mappings)
|
||||
const context = resolveKuaishouWorkProductMatchContext(item)
|
||||
return {
|
||||
context,
|
||||
status: decision.reason,
|
||||
mappingId: decision.mappingId,
|
||||
rule: decision.rule ? mapWorkProductRule(decision.rule) : null,
|
||||
candidates: decision.candidates,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAdminWorkProductMatchLogs(payload: JsonObject = {}) {
|
||||
const limit = Math.min(500, normalizePositiveInteger(payload.limit, 100))
|
||||
return { items: (await listWorkProductMatchLogs(limit)).map(mapWorkProductMatchLog) }
|
||||
}
|
||||
|
||||
function mapWorkProductRuleMapping(mapping: WorkProductRuleMappingRow) {
|
||||
const sellerIds = parseKuaishouMappingSellerIds(mapping.seller_ids_json)
|
||||
return {
|
||||
mappingId: Number(mapping.id),
|
||||
ruleId: Number(mapping.rule_id),
|
||||
ruleKey: String(mapping.rule_key || ''),
|
||||
productName: String(mapping.product_name || ''),
|
||||
sellerIds,
|
||||
sellerId: sellerIds[0] || '',
|
||||
relItemId: String(mapping.rel_item_id || ''),
|
||||
itemTitle: String(mapping.item_title || ''),
|
||||
relSkuId: String(mapping.rel_sku_id || ''),
|
||||
skuNick: String(mapping.sku_nick || ''),
|
||||
mappingType:
|
||||
mapping.mapping_type === 'sku_series'
|
||||
? 'sku_series'
|
||||
: mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override'
|
||||
? 'sku_exact'
|
||||
: 'product_default',
|
||||
enabled: mapping.enabled === true,
|
||||
createdAt: mapping.created_at,
|
||||
updatedAt: mapping.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkProductMatchLog(log: WorkProductMatchLogRow) {
|
||||
return {
|
||||
logId: Number(log.id),
|
||||
orderId: log.order_id ? Number(log.order_id) : null,
|
||||
orderItemId: log.order_item_id ? Number(log.order_item_id) : null,
|
||||
source: String(log.source || ''),
|
||||
sellerId: String(log.seller_id || ''),
|
||||
relItemId: String(log.rel_item_id || ''),
|
||||
itemTitle: String(log.item_title || ''),
|
||||
relSkuId: String(log.rel_sku_id || ''),
|
||||
skuNick: String(log.sku_nick || ''),
|
||||
status: String(log.match_status || ''),
|
||||
ruleId: log.rule_id ? Number(log.rule_id) : null,
|
||||
mappingId: log.mapping_id ? Number(log.mapping_id) : null,
|
||||
ruleKey: String(log.rule_key || ''),
|
||||
productName: String(log.product_name || ''),
|
||||
candidates: parseJsonArray(log.candidates_json),
|
||||
rawPayload: safeParseJson(log.raw_payload_json),
|
||||
createdAt: log.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveKuaishouMatchPayload(value: unknown): JsonObject {
|
||||
const raw = safeParseJson(value)
|
||||
const body = safeParseJson(raw.body)
|
||||
return Object.keys(body).length > 0 ? body : raw
|
||||
}
|
||||
|
||||
function normalizeKuaishouMatchId(value: unknown) {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized === '0' ? '' : normalized
|
||||
}
|
||||
|
||||
function normalizeKuaishouMatchIds(value: unknown) {
|
||||
const values = Array.isArray(value)
|
||||
? value
|
||||
: String(value ?? '')
|
||||
.split(/[,,\n]/)
|
||||
.map((item) => item.trim())
|
||||
return Array.from(new Set(values.map(normalizeKuaishouMatchId).filter(Boolean)))
|
||||
}
|
||||
|
||||
function normalizeKuaishouMatchIdList(value: unknown) {
|
||||
const values = Array.isArray(value) ? value : [value]
|
||||
return normalizeKuaishouMatchIds(
|
||||
values.flatMap((item) => String(item ?? '').split(/[,,;;\s\n\r]+/)),
|
||||
)
|
||||
}
|
||||
|
||||
function parseKuaishouMappingSellerIds(value: unknown) {
|
||||
if (Array.isArray(value)) return normalizeKuaishouMatchIds(value)
|
||||
try {
|
||||
return normalizeKuaishouMatchIds(JSON.parse(String(value || '[]')))
|
||||
} catch {
|
||||
return normalizeKuaishouMatchIds(value)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMatchTextForAdmin(value: unknown) {
|
||||
return String(value || '')
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[\s\u3000]+/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function parseJsonArray(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || '[]'))
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
const defaults = await ensureWorkerPlatformDefaults()
|
||||
const match = normalizeWorkProductRuleMatch(payload)
|
||||
@@ -1686,12 +1993,40 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
orderItems: OrderItemRow[],
|
||||
options: { source?: string; autoOnly?: boolean; sourceMetadata?: JsonObject } = {},
|
||||
) {
|
||||
const rules = await listWorkProductRules({ enabled: true })
|
||||
const [rules, mappings] = await Promise.all([
|
||||
listWorkProductRules({ enabled: true }),
|
||||
listWorkProductRuleMappings({ enabled: true }),
|
||||
])
|
||||
const created: WorkOrderRow[] = []
|
||||
const skipped: Array<{ orderItemId: number; reason: string }> = []
|
||||
|
||||
for (const item of orderItems) {
|
||||
const match = resolveMatchingProductRuleDecision(order, item, rules)
|
||||
const match = resolveMatchingProductRuleDecision(order, item, rules, mappings)
|
||||
const matchContext = resolveKuaishouWorkProductMatchContext(item)
|
||||
if (matchContext.sellerId) {
|
||||
const snapshot = safeParseJson(item.item_snapshot_json)
|
||||
await createWorkProductMatchLog({
|
||||
orderId: Number(order.id),
|
||||
orderItemId: Number(item.id),
|
||||
source: options.source || 'source_order',
|
||||
sellerId: matchContext.sellerId,
|
||||
relItemId: matchContext.relItemId,
|
||||
itemTitle: matchContext.itemTitle,
|
||||
relSkuId: matchContext.relSkuId,
|
||||
skuNick: matchContext.skuNick,
|
||||
matchStatus: match.reason,
|
||||
ruleId: match.rule?.id || null,
|
||||
mappingId: match.mappingId,
|
||||
candidatesJson: JSON.stringify(match.candidates),
|
||||
rawPayloadJson: JSON.stringify(
|
||||
options.sourceMetadata?.kuaishouSendCodeRawPayload ||
|
||||
snapshot.kuaishouSendCodeRawPayload ||
|
||||
snapshot.kuaishouSendCode ||
|
||||
{},
|
||||
),
|
||||
now: nowIso(),
|
||||
})
|
||||
}
|
||||
if (!match.rule) {
|
||||
skipped.push({
|
||||
orderItemId: Number(item.id),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type WorkCategoryRow,
|
||||
type WorkerFinanceRequestRow,
|
||||
type WorkOrderRow,
|
||||
type WorkProductRuleMappingRow,
|
||||
type WorkProductRuleRow,
|
||||
type WorkerLevelRow,
|
||||
type WorkOrderShareRow,
|
||||
@@ -609,58 +610,164 @@ export function resolveMatchingProductRule(
|
||||
order: OrderRow,
|
||||
item: OrderItemRow,
|
||||
rules: WorkProductRuleRow[],
|
||||
mappings: WorkProductRuleMappingRow[] = [],
|
||||
) {
|
||||
return resolveMatchingProductRuleDecision(order, item, rules).rule
|
||||
return resolveMatchingProductRuleDecision(order, item, rules, mappings).rule
|
||||
}
|
||||
|
||||
export type WorkProductRuleMatchDecision = {
|
||||
rule: WorkProductRuleRow | null
|
||||
mappingId: number | null
|
||||
reason: 'matched' | 'unmatched' | 'ambiguous'
|
||||
score: number
|
||||
candidates: Array<{ ruleKey: string; 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[] = [],
|
||||
): WorkProductRuleMatchDecision {
|
||||
const candidates = rules
|
||||
.map((rule) => scoreWorkProductRule(order, item, rule))
|
||||
.filter((candidate): candidate is { rule: WorkProductRuleRow; score: number } =>
|
||||
Boolean(candidate),
|
||||
const context = resolveKuaishouWorkProductMatchContext(item)
|
||||
const rulesById = new Map(rules.map((rule) => [Number(rule.id), rule]))
|
||||
const candidates = [
|
||||
...mappings.map((mapping) => scoreWorkProductRuleMapping(context, mapping, rulesById)),
|
||||
...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, reason: 'unmatched', score: 0, candidates: [] }
|
||||
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 mappedSeries = normalizeSkuSeriesName(mapping.sku_nick)
|
||||
const actualSeries = normalizeSkuSeriesName(context.skuNick)
|
||||
if (!mappedSeries || !actualSeries || actualSeries !== mappedSeries) 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
|
||||
@@ -762,6 +869,29 @@ function normalizeMatchId(value: unknown) {
|
||||
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')
|
||||
@@ -770,6 +900,23 @@ function normalizeMatchText(value: unknown) {
|
||||
.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,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeWorkProductRuleMatch(payload: JsonObject): JsonObject {
|
||||
const match =
|
||||
payload.match && typeof payload.match === 'object' && !Array.isArray(payload.match)
|
||||
|
||||
@@ -132,6 +132,7 @@ export async function syncWorkOrdersFromKuaishouSendCode(
|
||||
const orderItems = await applyKuaishouSendCodeItemData(
|
||||
await listOrderItemsByOrderId(order.id),
|
||||
sourceData,
|
||||
input.rawParams,
|
||||
input.now,
|
||||
)
|
||||
|
||||
@@ -140,6 +141,7 @@ export async function syncWorkOrdersFromKuaishouSendCode(
|
||||
autoOnly: true,
|
||||
sourceMetadata: {
|
||||
kuaishouSendCode: sourceData,
|
||||
kuaishouSendCodeRawPayload: input.rawParams,
|
||||
},
|
||||
})
|
||||
createdWorkOrderCount = syncResult.createdCount
|
||||
@@ -381,6 +383,7 @@ export function resolveKuaishouSendCodeShopName(
|
||||
async function applyKuaishouSendCodeItemData(
|
||||
orderItems: OrderItemRow[],
|
||||
sourceData: KuaishouSendCodeWorkOrderData,
|
||||
rawPayload: JsonObject,
|
||||
now: string,
|
||||
): Promise<OrderItemRow[]> {
|
||||
if (!sourceData.skuNick && !sourceData.itemTitle) {
|
||||
@@ -408,6 +411,7 @@ async function applyKuaishouSendCodeItemData(
|
||||
externalSkuName: sourceData.skuNick || snapshot.externalSkuName || '',
|
||||
externalSkuNameNormalized: sourceData.skuNick || snapshot.externalSkuNameNormalized || '',
|
||||
kuaishouSendCode: sourceData,
|
||||
kuaishouSendCodeRawPayload: rawPayload,
|
||||
},
|
||||
}
|
||||
const updated = await updateOrderItemSourceSnapshot(item.id, {
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test'
|
||||
|
||||
import type {
|
||||
WorkOrderRow,
|
||||
WorkProductRuleMappingRow,
|
||||
WorkProductRuleRow,
|
||||
WorkerUserRow,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
@@ -97,6 +98,25 @@ function buildProductRule(overrides: Partial<WorkProductRuleRow> = {}): WorkProd
|
||||
}
|
||||
}
|
||||
|
||||
function buildProductMapping(
|
||||
overrides: Partial<WorkProductRuleMappingRow> = {},
|
||||
): WorkProductRuleMappingRow {
|
||||
return {
|
||||
id: 1,
|
||||
rule_id: 1,
|
||||
seller_ids_json: ['3676797936'],
|
||||
rel_item_id: '25677565592936',
|
||||
item_title: '和平精英密钥指挥官特种兵侦察兵密钥',
|
||||
rel_sku_id: '',
|
||||
sku_nick: '',
|
||||
mapping_type: 'product_default',
|
||||
enabled: true,
|
||||
created_at: '2026-08-18T00:00:00.000Z',
|
||||
updated_at: '2026-08-18T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkerUserRow(overrides: Partial<WorkerUserRow> = {}): WorkerUserRow {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -172,6 +192,141 @@ test('快手同优先级精确规则进入冲突状态', () => {
|
||||
assert.equal(decision.rule, null)
|
||||
})
|
||||
|
||||
test('快手 SKU 覆盖映射优先于商品默认映射', () => {
|
||||
const defaultRule = buildProductRule({ rule_key: 'commander-default', product_name: '' })
|
||||
const skuRule = buildProductRule({ id: 2, rule_key: 'gift-box-sku', product_name: '' })
|
||||
const decision = resolveMatchingProductRuleDecision(
|
||||
buildOrderRow(),
|
||||
buildOrderItemRow(),
|
||||
[defaultRule, skuRule],
|
||||
[
|
||||
buildProductMapping({ rule_id: defaultRule.id }),
|
||||
buildProductMapping({
|
||||
id: 2,
|
||||
rule_id: skuRule.id,
|
||||
rel_sku_id: '',
|
||||
sku_nick: '1个精英尊尚专属礼盒',
|
||||
mapping_type: 'sku_exact',
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
assert.equal(decision.reason, 'matched')
|
||||
assert.equal(decision.rule?.rule_key, 'gift-box-sku')
|
||||
assert.equal(decision.mappingId, 2)
|
||||
})
|
||||
|
||||
test('SKU 数量系列可覆盖多店铺的前后数量规格', () => {
|
||||
const order = buildOrderRow()
|
||||
order.shop_id = '3676797937'
|
||||
const item = buildOrderItemRow()
|
||||
item.sku_name = '指挥官密钥10个'
|
||||
item.item_snapshot_json = {
|
||||
kuaishouSendCode: {
|
||||
sellerId: '3676797937',
|
||||
itemId: '26765374805642',
|
||||
itemTitle: '和平精英密钥指挥官特种兵侦察兵密钥',
|
||||
skuId: '189872452606936',
|
||||
relItemId: '26765374805642',
|
||||
relSkuId: '',
|
||||
skuNick: '指挥官密钥10个',
|
||||
},
|
||||
}
|
||||
const rule = buildProductRule({ rule_key: 'commander-series', product_name: '' })
|
||||
const decision = resolveMatchingProductRuleDecision(
|
||||
order,
|
||||
item,
|
||||
[rule],
|
||||
[
|
||||
buildProductMapping({
|
||||
rule_id: rule.id,
|
||||
seller_ids_json: ['3676797936', '3676797937'],
|
||||
rel_item_id: '',
|
||||
item_title: '',
|
||||
mapping_type: 'sku_series',
|
||||
sku_nick: '指挥官密钥',
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
assert.equal(decision.reason, 'matched')
|
||||
assert.equal(decision.rule?.rule_key, 'commander-series')
|
||||
})
|
||||
|
||||
test('商品默认映射支持用逗号填写多个关联商品 ID', () => {
|
||||
const item = buildOrderItemRow()
|
||||
item.item_snapshot_json = {
|
||||
kuaishouSendCode: {
|
||||
...item.item_snapshot_json.kuaishouSendCode,
|
||||
relItemId: '26765374805642',
|
||||
},
|
||||
}
|
||||
const rule = buildProductRule({ product_name: '' })
|
||||
const decision = resolveMatchingProductRuleDecision(
|
||||
buildOrderRow(),
|
||||
item,
|
||||
[rule],
|
||||
[
|
||||
buildProductMapping({
|
||||
rel_item_id: '25677565592936, 26765374805642',
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
assert.equal(decision.reason, 'matched')
|
||||
assert.equal(decision.rule?.id, rule.id)
|
||||
})
|
||||
|
||||
test('SKU 数量系列不会按公共词误匹配其他系列', () => {
|
||||
const item = buildOrderItemRow()
|
||||
item.sku_name = '指挥官隐藏款1个'
|
||||
item.item_snapshot_json = {
|
||||
kuaishouSendCode: {
|
||||
sellerId: '3676797936',
|
||||
itemId: '25677565592936',
|
||||
itemTitle: '和平精英密钥指挥官特种兵侦察兵密钥',
|
||||
skuId: '189872452606936',
|
||||
relItemId: '25677565592936',
|
||||
relSkuId: '',
|
||||
skuNick: '指挥官隐藏款1个',
|
||||
},
|
||||
}
|
||||
const rule = buildProductRule({ product_name: '' })
|
||||
const decision = resolveMatchingProductRuleDecision(
|
||||
buildOrderRow(),
|
||||
item,
|
||||
[rule],
|
||||
[
|
||||
buildProductMapping({
|
||||
rule_id: rule.id,
|
||||
mapping_type: 'sku_series',
|
||||
sku_nick: '指挥官密钥',
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
assert.equal(decision.reason, 'unmatched')
|
||||
})
|
||||
|
||||
test('商品映射不以公共词包含匹配,完整大标题不同则不命中', () => {
|
||||
const rule = buildProductRule({ product_name: '' })
|
||||
const decision = resolveMatchingProductRuleDecision(
|
||||
buildOrderRow(),
|
||||
buildOrderItemRow(),
|
||||
[rule],
|
||||
[
|
||||
buildProductMapping({
|
||||
rule_id: rule.id,
|
||||
rel_item_id: '',
|
||||
item_title: '和平精英指挥官限量礼包',
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
assert.equal(decision.reason, 'unmatched')
|
||||
assert.equal(decision.rule, null)
|
||||
})
|
||||
|
||||
test('assertWorkerLoginAllowed blocks rejected (frozen) workers with worker_rejected', () => {
|
||||
assert.throws(
|
||||
() => assertWorkerLoginAllowed(buildWorkerUserRow({ status: 'rejected' })),
|
||||
|
||||
Reference in New Issue
Block a user