优化接单商品匹配

This commit is contained in:
yml2213
2026-08-18 12:05:12 +08:00
parent 41b39bc5a0
commit f2c95bc3c8
15 changed files with 1756 additions and 96 deletions
@@ -0,0 +1,58 @@
-- 034_worker_product_match_catalog.sql —— 接单模板与快手商品/SKU 映射、匹配日志。
CREATE TABLE IF NOT EXISTS work_product_rule_mappings (
id BIGSERIAL PRIMARY KEY,
rule_id BIGINT NOT NULL REFERENCES work_product_rules(id) ON DELETE CASCADE,
seller_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb,
rel_item_id TEXT NOT NULL DEFAULT '',
item_title TEXT NOT NULL DEFAULT '',
rel_sku_id TEXT NOT NULL DEFAULT '',
sku_nick TEXT NOT NULL DEFAULT '',
mapping_type TEXT NOT NULL DEFAULT 'product_default'
CHECK (mapping_type IN ('product_default', 'sku_exact', 'sku_series')),
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CHECK (jsonb_typeof(seller_ids_json) = 'array' AND jsonb_array_length(seller_ids_json) > 0),
CHECK (mapping_type IN ('sku_exact', 'sku_series') OR rel_item_id <> '' OR item_title <> ''),
CHECK (
(mapping_type = 'product_default' AND rel_sku_id = '' AND sku_nick = '')
OR
(mapping_type = 'sku_exact' AND (rel_sku_id <> '' OR sku_nick <> ''))
OR
(mapping_type = 'sku_series' AND rel_sku_id = '' AND sku_nick <> '')
)
);
CREATE INDEX IF NOT EXISTS work_product_rule_mappings_lookup_idx
ON work_product_rule_mappings (rel_item_id, mapping_type, enabled);
CREATE INDEX IF NOT EXISTS work_product_rule_mappings_seller_ids_idx
ON work_product_rule_mappings USING GIN (seller_ids_json);
CREATE TABLE IF NOT EXISTS work_product_match_logs (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
source TEXT NOT NULL DEFAULT '',
seller_id TEXT NOT NULL DEFAULT '',
rel_item_id TEXT NOT NULL DEFAULT '',
item_title TEXT NOT NULL DEFAULT '',
rel_sku_id TEXT NOT NULL DEFAULT '',
sku_nick TEXT NOT NULL DEFAULT '',
match_status TEXT NOT NULL CHECK (match_status IN ('matched', 'unmatched', 'ambiguous')),
rule_id BIGINT REFERENCES work_product_rules(id) ON DELETE SET NULL,
mapping_id BIGINT REFERENCES work_product_rule_mappings(id) ON DELETE SET NULL,
candidates_json JSONB NOT NULL DEFAULT '[]'::jsonb,
raw_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS work_product_match_logs_created_idx
ON work_product_match_logs (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS work_product_match_logs_status_idx
ON work_product_match_logs (match_status, created_at DESC);
COMMENT ON TABLE work_product_rule_mappings IS '快手多店商品、SKU 精确与数量系列到接单模板的确定性映射';
COMMENT ON TABLE work_product_match_logs IS '快手接单商品匹配日志与调试载荷快照';
@@ -204,6 +204,7 @@ export async function listKuaishouIndustryVouchersByOid(
export type KuaishouIndustryVoucherWorkOrderSyncSource = {
oid: string
raw_payload_json: string | Record<string, unknown>
updated_at: string
}
export async function listKuaishouIndustryVoucherWorkOrderSyncSources(
@@ -211,7 +212,7 @@ export async function listKuaishouIndustryVoucherWorkOrderSyncSources(
): Promise<KuaishouIndustryVoucherWorkOrderSyncSource[]> {
const result = await query<KuaishouIndustryVoucherWorkOrderSyncSource>(
`
SELECT oid, raw_payload_json
SELECT oid, raw_payload_json, updated_at
FROM (
SELECT DISTINCT ON (oid) oid, raw_payload_json, updated_at, id
FROM kuaishou_industry_vouchers
@@ -2,4 +2,5 @@ export * from './types.js'
export * from './shared.js'
export * from './worker-repo.js'
export * from './work-order-repo.js'
export * from './product-match-repo.js'
export * from './sms-code-repo.js'
@@ -0,0 +1,181 @@
import { query } from '../../db/client.js'
import type { WorkProductMatchLogRow, WorkProductRuleMappingRow } from './types.js'
const MAPPING_SELECT = `
SELECT
wprm.*,
wpr.rule_key,
wpr.product_name
FROM work_product_rule_mappings wprm
INNER JOIN work_product_rules wpr ON wpr.id = wprm.rule_id
`
export async function listWorkProductRuleMappings(
input: {
enabled?: boolean | null
sellerId?: string
} = {},
): Promise<WorkProductRuleMappingRow[]> {
const conditions: string[] = []
const params: unknown[] = []
if (input.enabled !== undefined && input.enabled !== null) {
params.push(input.enabled)
conditions.push(`wprm.enabled = $${params.length}`)
}
const sellerId = String(input.sellerId || '')
.trim()
.toLowerCase()
if (sellerId) {
params.push(sellerId)
conditions.push(`wprm.seller_ids_json ? $${params.length}`)
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const result = await query<WorkProductRuleMappingRow>(
`${MAPPING_SELECT}
${where}
ORDER BY wprm.seller_ids_json::text ASC, wprm.rel_item_id ASC, wprm.item_title ASC,
CASE WHEN wprm.mapping_type = 'product_default' THEN 0 ELSE 1 END ASC,
wprm.id ASC`,
params,
)
return result.rows
}
export async function upsertWorkProductRuleMapping(input: {
mappingId?: number | null
ruleId: number
sellerIds: string[]
relItemId: string
itemTitle: string
relSkuId: string
skuNick: string
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
enabled: boolean
now: string
}): Promise<WorkProductRuleMappingRow | null> {
if (input.mappingId) {
const result = await query<WorkProductRuleMappingRow>(
`
UPDATE work_product_rule_mappings
SET
rule_id = $2,
seller_ids_json = $3::jsonb,
rel_item_id = $4,
item_title = $5,
rel_sku_id = $6,
sku_nick = $7,
mapping_type = $8,
enabled = $9,
updated_at = $10
WHERE id = $1
RETURNING *
`,
[
input.mappingId,
input.ruleId,
JSON.stringify(input.sellerIds),
input.relItemId,
input.itemTitle,
input.relSkuId,
input.skuNick,
input.mappingType,
input.enabled,
input.now,
],
)
return result.rows[0] || null
}
const result = await query<WorkProductRuleMappingRow>(
`
INSERT INTO work_product_rule_mappings (
rule_id, seller_ids_json, rel_item_id, item_title, rel_sku_id, sku_nick,
mapping_type, enabled, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
RETURNING *
`,
[
input.ruleId,
JSON.stringify(input.sellerIds),
input.relItemId,
input.itemTitle,
input.relSkuId,
input.skuNick,
input.mappingType,
input.enabled,
input.now,
],
)
return result.rows[0] || null
}
export async function deleteWorkProductRuleMapping(mappingId: number | string) {
const result = await query<{ id: number }>(
'DELETE FROM work_product_rule_mappings WHERE id = $1 RETURNING id',
[Number(mappingId)],
)
return { deleted: Boolean(result.rows[0]) }
}
export async function createWorkProductMatchLog(input: {
orderId?: number | null
orderItemId?: number | null
source: string
sellerId: string
relItemId: string
itemTitle: string
relSkuId: string
skuNick: string
matchStatus: 'matched' | 'unmatched' | 'ambiguous'
ruleId?: number | null
mappingId?: number | null
candidatesJson: string
rawPayloadJson: string
now: string
}): Promise<WorkProductMatchLogRow | null> {
const result = await query<WorkProductMatchLogRow>(
`
INSERT INTO work_product_match_logs (
order_id, order_item_id, source, seller_id, rel_item_id, item_title,
rel_sku_id, sku_nick, match_status, rule_id, mapping_id,
candidates_json, raw_payload_json, created_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12::jsonb, $13::jsonb, $14
)
RETURNING *
`,
[
input.orderId || null,
input.orderItemId || null,
input.source,
input.sellerId,
input.relItemId,
input.itemTitle,
input.relSkuId,
input.skuNick,
input.matchStatus,
input.ruleId || null,
input.mappingId || null,
input.candidatesJson,
input.rawPayloadJson,
input.now,
],
)
return result.rows[0] || null
}
export async function listWorkProductMatchLogs(limit = 100): Promise<WorkProductMatchLogRow[]> {
const result = await query<WorkProductMatchLogRow>(
`
SELECT wpml.*, wpr.rule_key, wpr.product_name
FROM work_product_match_logs wpml
LEFT JOIN work_product_rules wpr ON wpr.id = wpml.rule_id
ORDER BY wpml.created_at DESC, wpml.id DESC
LIMIT $1
`,
[Math.min(500, Math.max(1, Math.floor(Number(limit) || 100)))],
)
return result.rows
}
@@ -160,6 +160,42 @@ export type WorkProductRuleRow = {
category_name?: string
}
export type WorkProductRuleMappingRow = {
id: number
rule_id: number
seller_ids_json: string | string[]
rel_item_id: string
item_title: string
rel_sku_id: string
sku_nick: string
mapping_type: 'product_default' | 'sku_exact' | 'sku_series' | string
enabled: boolean
created_at: string
updated_at: string
rule_key?: string
product_name?: string
}
export type WorkProductMatchLogRow = {
id: number
order_id: number | null
order_item_id: number | null
source: string
seller_id: string
rel_item_id: string
item_title: string
rel_sku_id: string
sku_nick: string
match_status: 'matched' | 'unmatched' | 'ambiguous' | string
rule_id: number | null
mapping_id: number | null
candidates_json: string | Record<string, unknown> | unknown[]
raw_payload_json: string | Record<string, unknown>
created_at: string
rule_key?: string
product_name?: string
}
export type WorkOrderShareRow = {
id: number
work_order_id: number
@@ -21,6 +21,9 @@ import {
listAdminWorkerFinanceRequests,
listAdminWorkerWithdrawalAccounts,
listAdminWorkCategories,
listAdminKuaishouMatchSources,
listAdminWorkProductMatchLogs,
listAdminWorkProductRuleMappings,
listAdminWorkProductRules,
listAdminWorkerLevels,
listAdminWorkerUsers,
@@ -36,12 +39,15 @@ import {
saveAdminWorkerPlatformNotificationConfig,
saveAdminWorkerWithdrawalAccount,
saveAdminWorkCategory,
saveAdminWorkProductRuleMapping,
saveAdminWorkProductRule,
testAdminKuaishouProductMatch,
saveAdminWorkerLevel,
submitAdminWorkOrderMaterial,
unpublishAdminWorkOrder,
updateAdminWorkOrder,
updateAdminWorkOrderSharing,
deleteAdminWorkProductRuleMapping,
} from '../../services/worker-platform/index.js'
import { createJsonHandler, requireAdminRoles } from './session.js'
@@ -184,6 +190,75 @@ router.delete(
}),
)
router.get(
'/worker-platform/product-mappings',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler(() => listAdminWorkProductRuleMappings(), {
successMessage: 'ok',
errorMessage: '读取商品映射失败',
scope: '[admin/worker-platform/product-mappings]',
}),
)
router.post(
'/worker-platform/product-mappings',
requireAdminRoles(['admin', 'operator']),
createJsonHandler((req) => saveAdminWorkProductRuleMapping(req.body || {}), {
successMessage: '商品映射已保存',
errorMessage: '保存商品映射失败',
scope: '[admin/worker-platform/product-mappings]',
audit: (_req, data) => ({
action: 'work_product_mapping_saved',
targetType: 'work_product_mapping',
targetId: String((data as { mapping?: { mappingId?: number } })?.mapping?.mappingId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
}),
)
router.delete(
'/worker-platform/product-mappings/:mappingId',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) => deleteAdminWorkProductRuleMapping(String(req.params.mappingId || '')),
{
successMessage: '商品映射已删除',
errorMessage: '删除商品映射失败',
scope: '[admin/worker-platform/product-mappings/:mappingId]',
},
),
)
router.get(
'/worker-platform/product-match-sources',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler((req) => listAdminKuaishouMatchSources(req.query), {
successMessage: 'ok',
errorMessage: '读取快手匹配载荷失败',
scope: '[admin/worker-platform/product-match-sources]',
}),
)
router.post(
'/worker-platform/product-match-test',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler((req) => testAdminKuaishouProductMatch(req.body || {}), {
successMessage: '匹配测试完成',
errorMessage: '匹配测试失败',
scope: '[admin/worker-platform/product-match-test]',
}),
)
router.get(
'/worker-platform/product-match-logs',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler((req) => listAdminWorkProductMatchLogs(req.query), {
successMessage: 'ok',
errorMessage: '读取匹配日志失败',
scope: '[admin/worker-platform/product-match-logs]',
}),
)
router.get(
'/worker-platform/workers',
requireAdminRoles(['admin', 'operator', 'support']),
@@ -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' })),