优化快手接单匹配

This commit is contained in:
yml2213
2026-08-18 10:06:01 +08:00
parent fe569fe75a
commit c6e9a9b962
14 changed files with 665 additions and 142 deletions
@@ -0,0 +1,6 @@
-- 033_worker_product_match_context.sql —— 接单规则支持快手大标题与 SKU 组合匹配。
ALTER TABLE work_product_rules
ADD COLUMN IF NOT EXISTS match_json JSONB NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN work_product_rules.match_json IS '接单规则匹配条件:快手 sellerId/itemId/skuId/itemTitle/skuNick 等字段';
@@ -201,6 +201,31 @@ export async function listKuaishouIndustryVouchersByOid(
return result.rows
}
export type KuaishouIndustryVoucherWorkOrderSyncSource = {
oid: string
raw_payload_json: string | Record<string, unknown>
}
export async function listKuaishouIndustryVoucherWorkOrderSyncSources(
limit = 100,
): Promise<KuaishouIndustryVoucherWorkOrderSyncSource[]> {
const result = await query<KuaishouIndustryVoucherWorkOrderSyncSource>(
`
SELECT oid, raw_payload_json
FROM (
SELECT DISTINCT ON (oid) oid, raw_payload_json, updated_at, id
FROM kuaishou_industry_vouchers
WHERE raw_payload_json ? 'body'
ORDER BY oid, updated_at DESC, id DESC
) AS latest_source
ORDER BY updated_at DESC, id DESC
LIMIT $1
`,
[normalizePositiveLimit(limit, 100)],
)
return result.rows
}
export async function listKuaishouIndustryVouchersForSendCallbackRetry(
limit = 100,
maxAttempts = 8,
@@ -209,3 +209,19 @@ export async function getOrderItemById(orderItemId: number | string): Promise<Or
])
return result.rows[0] || null
}
export async function updateOrderItemSourceSnapshot(
orderItemId: number | string,
input: { skuName: string; itemSnapshotJson: string; updatedAt: string },
): Promise<OrderItemRow | null> {
const result = await query<{ id: number }>(
`
UPDATE order_items
SET sku_name = $1, item_snapshot_json = $2::jsonb, updated_at = $3
WHERE id = $4
RETURNING id
`,
[input.skuName, input.itemSnapshotJson, input.updatedAt, Number(orderItemId)],
)
return result.rows[0] ? getOrderItemById(orderItemId) : null
}
@@ -153,6 +153,7 @@ export type WorkProductRuleRow = {
timeout_minutes: number
timeout_policy: string
requirement_json: string | Record<string, unknown>
match_json: string | Record<string, unknown>
sort_order: number
created_at: string
updated_at: string
@@ -711,6 +711,7 @@ export async function upsertWorkProductRule(input: {
timeoutMinutes?: number
timeoutPolicy?: string
requirementJson: string
matchJson?: string
sortOrder: number
now: string
}): Promise<WorkProductRuleRow | null> {
@@ -723,7 +724,7 @@ export async function upsertWorkProductRule(input: {
required_deposit_amount, deposit_threshold_amount,
sharing_enabled, sharing_total_quantity, sharing_unit_reward,
timeout_minutes, timeout_policy,
requirement_json,
requirement_json, match_json,
sort_order, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
@@ -732,8 +733,8 @@ export async function upsertWorkProductRule(input: {
$13, $14,
$15, $16, $17,
$18, $19,
$20::jsonb,
$21, $22, $23
$20::jsonb, $21::jsonb,
$22, $23, $24
)
ON CONFLICT (rule_key) DO UPDATE
SET
@@ -756,6 +757,7 @@ export async function upsertWorkProductRule(input: {
timeout_minutes = EXCLUDED.timeout_minutes,
timeout_policy = EXCLUDED.timeout_policy,
requirement_json = EXCLUDED.requirement_json,
match_json = EXCLUDED.match_json,
sort_order = EXCLUDED.sort_order,
updated_at = EXCLUDED.updated_at
RETURNING *
@@ -781,6 +783,7 @@ export async function upsertWorkProductRule(input: {
toPositiveInteger(input.timeoutMinutes, 0),
String(input.timeoutPolicy || 'reopen').trim(),
input.requirementJson,
input.matchJson || '{}',
input.sortOrder,
input.now,
input.now,
@@ -28,6 +28,7 @@ import {
markAdminWorkOrderProblem,
pinAdminWorkOrder,
publishAdminWorkOrder,
reprocessAdminKuaishouSendCodeWorkOrders,
reviewAdminWorkerFinanceRequest,
resolveAdminProblemWorkOrder,
reviewAdminWorkerUser,
@@ -372,6 +373,22 @@ router.post(
}),
)
router.post(
'/worker-platform/orders/reprocess-matches',
requireAdminRoles(['admin', 'operator']),
createJsonHandler((req) => reprocessAdminKuaishouSendCodeWorkOrders(req.body || {}), {
successMessage: '快手订单重匹配完成',
errorMessage: '快手订单重匹配失败',
scope: '[admin/worker-platform/orders/reprocess-matches]',
audit: (_req, data) => ({
action: 'work_order_kuaishou_reprocessed',
targetType: 'work_order',
targetId: '',
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
}),
)
router.post(
'/worker-platform/orders/:workOrderId/material',
requireAdminRoles(['admin', 'operator', 'support']),
@@ -100,6 +100,7 @@ import {
normalizeFinanceRequestType,
normalizeInteger,
normalizeMatchType,
normalizeWorkProductRuleMatch,
normalizeOptionalId,
normalizePositiveInteger,
normalizeProofFiles,
@@ -116,7 +117,7 @@ import {
normalizeWithdrawChannel,
resolveFreezeDepositAmount,
mapWorkOrderEvents,
resolveMatchingProductRule,
resolveMatchingProductRuleDecision,
resolveRequirementFields,
resolveSkuNameQuantity,
resolveWorkerPermissions,
@@ -127,6 +128,7 @@ import {
getRequiredWorker,
normalizeWorkOrderTimeoutPolicy,
} from './worker-service.js'
import { reprocessKuaishouSendCodeWorkOrders } from './sync-work-orders-from-send-code.js'
export async function listAdminWorkerLevels() {
await ensureWorkerPlatformDefaults()
@@ -262,13 +264,20 @@ export async function listAdminWorkProductRules(query: JsonObject = {}) {
}
}
export async function reprocessAdminKuaishouSendCodeWorkOrders(payload: JsonObject = {}) {
const limit = Math.min(500, normalizePositiveInteger(payload.limit, 100))
return reprocessKuaishouSendCodeWorkOrders(limit)
}
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
const defaults = await ensureWorkerPlatformDefaults()
const match = normalizeWorkProductRuleMatch(payload)
const productName = String(
payload.productName || payload.product_name || payload.skuName || '',
).trim()
const skuCode = String(payload.skuCode || payload.sku_code || '').trim()
if (!productName && !skuCode) {
const matchedProductName = String(match.itemTitle || match.skuNick || '').trim()
if (!productName && !skuCode && !matchedProductName) {
throw createHttpError('请填写商品名或 SKU', {
statusCode: 400,
errorCode: 'work_product_rule_target_required',
@@ -306,19 +315,9 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
}
const finalRewardAmount = sharingEnabled ? resolvedSharingTotalAmount : rewardAmount
const depositThresholdAmount = normalizeAmountFen(
payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan,
DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
)
const requiredDepositAmount =
payload.requiredDepositAmount === undefined && payload.requiredDepositAmountYuan === undefined
? finalRewardAmount > depositThresholdAmount
? finalRewardAmount
: 0
: normalizeAmountFen(payload.requiredDepositAmount ?? payload.requiredDepositAmountYuan, 0)
const fields = normalizeRequirementFieldsFromPayload(payload)
const ruleKey = normalizeSlugKey(
payload.ruleKey || payload.rule_key || skuCode || productName,
payload.ruleKey || payload.rule_key || skuCode || productName || matchedProductName,
`rule-${Date.now()}`,
)
const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id)
@@ -334,7 +333,7 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
platform: String(payload.platform || '').trim(),
shopId: String(payload.shopId || payload.shop_id || '').trim(),
skuCode,
productName,
productName: productName || matchedProductName,
matchType: normalizeMatchType(payload.matchType || payload.match_type),
categoryId:
normalizeOptionalId(payload.categoryId || payload.category_id) ||
@@ -344,14 +343,16 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, false),
rewardAmount: finalRewardAmount,
unitPriceFen,
requiredDepositAmount,
depositThresholdAmount,
// 商品规则不配置押金;工单创建后由运营按需手工填写。
requiredDepositAmount: 0,
depositThresholdAmount: 0,
sharingEnabled,
sharingTotalQuantity,
sharingUnitReward,
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
requirementJson: JSON.stringify({ fields }),
matchJson: JSON.stringify(match),
sortOrder: normalizeInteger(payload.sortOrder, 100),
now: nowIso(),
})
@@ -1690,11 +1691,15 @@ export async function syncWorkerOrdersForSourceOrder(
const skipped: Array<{ orderItemId: number; reason: string }> = []
for (const item of orderItems) {
const rule = resolveMatchingProductRule(order, item, rules)
if (!rule) {
skipped.push({ orderItemId: Number(item.id), reason: 'rule_not_matched' })
const match = resolveMatchingProductRuleDecision(order, item, rules)
if (!match.rule) {
skipped.push({
orderItemId: Number(item.id),
reason: match.reason === 'ambiguous' ? 'rule_match_ambiguous' : 'rule_not_matched',
})
continue
}
const rule = match.rule
if (options.autoOnly && !rule.auto_create) {
skipped.push({ orderItemId: Number(item.id), reason: 'auto_create_disabled' })
continue
@@ -1710,12 +1715,6 @@ export async function syncWorkerOrdersForSourceOrder(
// 按数量计价:单价 × SKU 名称文字中的规格数量(如 指挥官秘钥15个);未启用则用固定接单金额
const rewardAmount =
unitPriceFen > 0 ? unitPriceFen * skuQuantity : Number(rule.reward_amount || 0)
const depositThresholdAmount = Number(
rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
)
const requiredDepositAmount =
Number(rule.required_deposit_amount || 0) ||
(rewardAmount > depositThresholdAmount ? rewardAmount : 0)
const now = nowIso()
const materialComplete = fields.every((field) => !field.required)
const workOrder = await createWorkOrder({
@@ -1728,8 +1727,8 @@ export async function syncWorkerOrdersForSourceOrder(
categoryId: rule.category_id,
status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL,
rewardAmount,
requiredDepositAmount,
depositThresholdAmount,
requiredDepositAmount: 0,
depositThresholdAmount: 0,
sharingEnabled: rule.sharing_enabled === true,
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
@@ -168,6 +168,7 @@ export function mapWorkCategory(category: WorkCategoryRow | null | undefined) {
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,
@@ -177,16 +178,23 @@ export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined)
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),
requiredDepositAmount: Number(rule.required_deposit_amount || 0),
depositThresholdAmount: Number(
rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
),
sharing: {
enabled: rule.sharing_enabled === true,
totalQuantity: Number(rule.sharing_total_quantity || 1),
@@ -602,49 +610,195 @@ export function resolveMatchingProductRule(
item: OrderItemRow,
rules: WorkProductRuleRow[],
) {
return (
rules.find((rule) => {
if (!matchesOptionalText(rule.provider, order.provider)) return false
if (!matchesOptionalText(rule.platform, order.platform)) return false
if (!matchesOptionalText(rule.shop_id, order.shop_id)) return false
return resolveMatchingProductRuleDecision(order, item, rules).rule
}
const snapshot = safeParseJson(item.item_snapshot_json)
const skuCodes = [item.sku_code, snapshot.externalSkuCode, snapshot.externalItemId]
.map((value) =>
String(value || '')
.trim()
.toLowerCase(),
)
.filter(Boolean)
const skuNames = [item.sku_name, snapshot.externalSkuName, snapshot.externalSkuNameNormalized]
.map((value) =>
String(value || '')
.trim()
.toLowerCase(),
)
.filter(Boolean)
const ruleSkuCode = String(rule.sku_code || '')
.trim()
.toLowerCase()
const ruleProductName = String(rule.product_name || '')
.trim()
.toLowerCase()
export type WorkProductRuleMatchDecision = {
rule: WorkProductRuleRow | null
reason: 'matched' | 'unmatched' | 'ambiguous'
score: number
candidates: Array<{ ruleKey: string; score: number }>
}
if (ruleSkuCode && !skuCodes.includes(ruleSkuCode)) {
return false
}
if (!ruleProductName) {
return Boolean(ruleSkuCode)
}
if (rule.match_type === 'exact') {
return skuNames.includes(ruleProductName) || skuCodes.includes(ruleProductName)
}
return (
skuNames.some((value) => value.includes(ruleProductName)) ||
skuCodes.some((value) => value.includes(ruleProductName))
)
}) || null
export function resolveMatchingProductRuleDecision(
order: OrderRow,
item: OrderItemRow,
rules: WorkProductRuleRow[],
): WorkProductRuleMatchDecision {
const candidates = rules
.map((rule) => scoreWorkProductRule(order, item, rule))
.filter((candidate): candidate is { rule: WorkProductRuleRow; score: 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: [] }
}
const tied = candidates.filter((candidate) => candidate.score === top.score)
if (tied.length > 1) {
return {
rule: null,
reason: 'ambiguous',
score: top.score,
candidates: tied.map((candidate) => ({
ruleKey: candidate.rule.rule_key,
score: candidate.score,
})),
}
}
return {
rule: top.rule,
reason: 'matched',
score: top.score,
candidates: candidates.slice(0, 5).map((candidate) => ({
ruleKey: candidate.rule.rule_key,
score: candidate.score,
})),
}
}
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 normalizeMatchText(value: unknown) {
return String(value || '')
.normalize('NFKC')
.toLowerCase()
.replace(/[\s\u3000]+/g, '')
.trim()
}
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
}
/**
@@ -13,6 +13,7 @@ test('快手发码工单优先使用 ext.skuNick 与 payment', () => {
itemId: '25873410102642',
itemTitle: '和平精英指挥官密钥秘钥隐藏款密钥暮色之下灰烬之翎珍宝礼盒礼卡',
skuId: '185469355612642',
num: 1,
paymentFen: 1400,
rawParams: {
ext: '{"skuNick":"指挥官密钥1个","payment":1400}',
@@ -32,8 +33,12 @@ test('快手发码工单优先使用 ext.skuNick 与 payment', () => {
sellerId: '141242642',
shopName: '快手密钥店',
itemId: '25873410102642',
itemTitle: '和平精英指挥官密钥秘钥隐藏款密钥暮色之下灰烬之翎珍宝礼盒礼卡',
skuId: '185469355612642',
skuName: '指挥官密钥1个',
relItemId: '',
relSkuId: '',
skuNick: '指挥官密钥1个',
quantity: 1,
paymentFen: 1400,
})
})
@@ -19,7 +19,15 @@ import {
findLatestOrderByPlatformOrderId,
updateOrder,
} from '../../repositories/order-repo.js'
import { listOrderItemsByOrderId, replaceOrderItems } from '../../repositories/order-item-repo.js'
import {
listKuaishouIndustryVoucherWorkOrderSyncSources,
listKuaishouIndustryVouchersByOid,
} from '../../repositories/kuaishou-industry-voucher-repo.js'
import {
listOrderItemsByOrderId,
replaceOrderItems,
updateOrderItemSourceSnapshot,
} from '../../repositories/order-item-repo.js'
import {
listWorkOrdersByPlatformOrderId,
updateWorkOrder,
@@ -33,6 +41,7 @@ import {
} from '../platforms/kuaishou-industry/source-config-service.js'
import { asJsonObject, type JsonObject } from '../../types/json.js'
import type { OrderUpdateInput } from '../../types/repository/inputs.js'
import { nowIso } from '../../utils/time.js'
import type {
KuaishouIndustryVoucherRow,
OrderItemRow,
@@ -68,8 +77,12 @@ export type KuaishouSendCodeWorkOrderData = {
sellerId: string
shopName: string
itemId: string
itemTitle: string
skuId: string
skuName: string
relItemId: string
relSkuId: string
skuNick: string
quantity: number
paymentFen: number
}
@@ -116,9 +129,10 @@ export async function syncWorkOrdersFromKuaishouSendCode(
let createdWorkOrderCount = 0
if (order) {
const orderItems = applyKuaishouSendCodeItemData(
const orderItems = await applyKuaishouSendCodeItemData(
await listOrderItemsByOrderId(order.id),
sourceData,
input.now,
)
const syncResult = await syncWorkerOrdersForSourceOrder(order, orderItems, {
@@ -155,6 +169,39 @@ export async function syncWorkOrdersFromKuaishouSendCode(
}
}
export async function reprocessKuaishouSendCodeWorkOrders(limit = 100) {
const sources = await listKuaishouIndustryVoucherWorkOrderSyncSources(limit)
let createdCount = 0
let skippedCount = 0
for (const source of sources) {
const raw = safeParseJson(source.raw_payload_json)
const body = safeParseJson(raw.body)
const oid = String(body.oid || source.oid || '').trim()
if (!oid || !body.sellerId) {
skippedCount += 1
continue
}
const result = await syncWorkOrdersFromKuaishouSendCode({
oid,
sellerId: String(body.sellerId || '').trim(),
itemId: String(body.itemId || '').trim(),
itemTitle: String(body.itemTitle || '').trim(),
skuId: String(body.skuId || '').trim(),
num: Math.max(1, Number(body.num) || 1),
paymentFen: normalizePaymentFen(safeParseJson(body.ext).payment),
rawParams: body,
vouchers: await listKuaishouIndustryVouchersByOid(oid),
now: nowIso(),
})
createdCount += result.createdWorkOrderCount
skippedCount += result.skipped.length
}
return { scannedCount: sources.length, createdCount, skippedCount }
}
async function backfillOrderShop(
order: OrderRow,
input: SyncWorkOrdersFromSendCodeInput,
@@ -219,10 +266,10 @@ async function createFallbackOrder(
reason?: string
}> {
const now = input.now
const { sellerId, itemId, skuName, paymentFen } = sourceData
const { sellerId, itemId, skuNick, paymentFen } = sourceData
const num = Math.max(1, Number(input.num) || 1)
if (!itemId && !skuName) {
if (!itemId && !skuNick) {
return { order: null, reason: 'order_item_missing' }
}
@@ -256,18 +303,23 @@ async function createFallbackOrder(
await replaceOrderItems(order.id, [
{
skuCode: itemId,
skuName,
skuName: skuNick || sourceData.itemTitle,
quantity: num,
specJson: JSON.stringify({
sellerId,
skuId: sourceData.skuId,
skuNick: skuName,
itemTitle: sourceData.itemTitle,
relItemId: sourceData.relItemId,
relSkuId: sourceData.relSkuId,
skuNick,
quantity: sourceData.quantity,
paymentFen,
source: KUAISHOU_SEND_CODE_SOURCE,
}),
itemSnapshotJson: JSON.stringify({
source: KUAISHOU_SEND_CODE_SOURCE,
params: input.rawParams,
kuaishouSendCode: sourceData,
}),
createdAt: now,
updatedAt: now,
@@ -280,7 +332,7 @@ async function createFallbackOrder(
export function resolveKuaishouSendCodeWorkOrderData(
input: Pick<
SyncWorkOrdersFromSendCodeInput,
'sellerId' | 'itemId' | 'itemTitle' | 'skuId' | 'paymentFen' | 'rawParams'
'sellerId' | 'itemId' | 'itemTitle' | 'skuId' | 'num' | 'paymentFen' | 'rawParams'
>,
shops: Array<
Pick<KuaishouIndustryShopConfig, 'sellerId' | 'shopId' | 'customShopName' | 'shopName'>
@@ -288,7 +340,7 @@ export function resolveKuaishouSendCodeWorkOrderData(
): KuaishouSendCodeWorkOrderData {
const sellerId = String(input.sellerId || '').trim()
const ext = safeParseJson(input.rawParams?.ext)
const skuName = String(ext.skuNick || input.itemTitle || '').trim()
const skuNick = String(ext.skuNick || '').trim()
const paymentFen =
normalizePaymentFen(input.paymentFen) ||
normalizePaymentFen(ext.payment ?? ext.totalGoodsValue ?? ext.goodsValue)
@@ -300,8 +352,12 @@ export function resolveKuaishouSendCodeWorkOrderData(
sellerId,
shopName: resolveKuaishouSendCodeShopName(sellerId, matchedShop),
itemId: String(input.itemId || '').trim(),
itemTitle: String(input.itemTitle || '').trim(),
skuId: String(input.skuId || '').trim(),
skuName,
relItemId: normalizeExternalId(ext.relItemId),
relSkuId: normalizeExternalId(ext.relSkuId),
skuNick,
quantity: Math.max(1, Number(input.num) || 1),
paymentFen,
}
}
@@ -322,11 +378,12 @@ export function resolveKuaishouSendCodeShopName(
)
}
function applyKuaishouSendCodeItemData(
async function applyKuaishouSendCodeItemData(
orderItems: OrderItemRow[],
sourceData: KuaishouSendCodeWorkOrderData,
): OrderItemRow[] {
if (!sourceData.skuName) {
now: string,
): Promise<OrderItemRow[]> {
if (!sourceData.skuNick && !sourceData.itemTitle) {
return orderItems
}
@@ -335,32 +392,44 @@ function applyKuaishouSendCodeItemData(
return orderItems
}
return orderItems.map((item) => {
if (!matchedIds.has(Number(item.id))) {
return item
}
const updatedItems = await Promise.all(
orderItems.map(async (item) => {
if (!matchedIds.has(Number(item.id))) {
return item
}
const snapshot = safeParseJson(item.item_snapshot_json)
return {
...item,
sku_name: sourceData.skuName,
item_snapshot_json: {
...snapshot,
externalSkuCode: sourceData.skuId || snapshot.externalSkuCode || '',
externalItemId: sourceData.itemId || snapshot.externalItemId || '',
externalSkuName: sourceData.skuName,
externalSkuNameNormalized: sourceData.skuName,
kuaishouSendCode: sourceData,
},
}
})
const snapshot = safeParseJson(item.item_snapshot_json)
const snapshotJson = {
...item,
item_snapshot_json: {
...snapshot,
externalSkuCode: sourceData.skuId || snapshot.externalSkuCode || '',
externalItemId: sourceData.itemId || snapshot.externalItemId || '',
externalSkuName: sourceData.skuNick || snapshot.externalSkuName || '',
externalSkuNameNormalized: sourceData.skuNick || snapshot.externalSkuNameNormalized || '',
kuaishouSendCode: sourceData,
},
}
const updated = await updateOrderItemSourceSnapshot(item.id, {
skuName: sourceData.skuNick || item.sku_name,
itemSnapshotJson: JSON.stringify(snapshotJson.item_snapshot_json),
updatedAt: now,
})
return updated || item
}),
)
return updatedItems
}
function findKuaishouSendCodeItemIds(
orderItems: OrderItemRow[],
sourceData: KuaishouSendCodeWorkOrderData,
): Set<number> {
const identifiers = new Set([sourceData.itemId, sourceData.skuId].filter(Boolean))
const identifiers = new Set(
[sourceData.itemId, sourceData.skuId, sourceData.relItemId, sourceData.relSkuId].filter(
Boolean,
),
)
const matched = orderItems.filter((item) => {
const snapshot = safeParseJson(item.item_snapshot_json)
return [
@@ -465,3 +534,8 @@ function safeParseJson(value: unknown): JsonObject {
return {}
}
}
function normalizeExternalId(value: unknown) {
const normalized = String(value || '').trim()
return normalized === '0' ? '' : normalized
}
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { WorkOrderRow, WorkerUserRow } from '../../repositories/worker-platform/index.js'
import type {
WorkOrderRow,
WorkProductRuleRow,
WorkerUserRow,
} from '../../repositories/worker-platform/index.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import {
assertWithdrawChannelAllowedForAmount,
assertWorkerLoginAllowed,
@@ -10,11 +15,87 @@ import {
normalizeRequirementFields,
mapWorkOrderForWorker,
resolveCollectSubmitTargetWorkOrder,
resolveMatchingProductRuleDecision,
resolveWorkerPermissions,
resolveSkuNameQuantity,
validateWorkerPassword,
} from './index.js'
function buildOrderItemRow(): OrderItemRow {
return {
id: 1,
order_id: 1,
sku_code: '25677565592936',
sku_name: '1个精英尊尚专属礼盒',
quantity: 1,
spec_json: {},
item_snapshot_json: {
kuaishouSendCode: {
sellerId: '3676797936',
itemId: '25677565592936',
itemTitle: '和平精英密钥指挥官特种兵侦察兵密钥',
skuId: '189872452606936',
relItemId: '25677565592936',
relSkuId: '',
skuNick: '1个精英尊尚专属礼盒',
},
},
}
}
function buildOrderRow(): OrderRow {
return {
id: 1,
provider: '91kaquan',
platform: 'kuaishou',
shop_id: '3676797936',
shop_name: '测试店铺',
platform_order_id: '2623001922675146',
order_status: 'paid',
pay_status: 'paid',
buyer_id: '',
buyer_name: '',
receiver_contact: '',
total_amount: 12600,
currency: 'CNY',
raw_payload_json: {},
paid_at: null,
created_at: '2026-08-18T00:00:00.000Z',
updated_at: '2026-08-18T00:00:00.000Z',
}
}
function buildProductRule(overrides: Partial<WorkProductRuleRow> = {}): WorkProductRuleRow {
return {
id: 1,
rule_key: 'rule-1',
provider: '91kaquan',
platform: 'kuaishou',
shop_id: '',
sku_code: '',
product_name: '和平精英',
match_type: 'contains',
category_id: null,
enabled: true,
auto_create: true,
reward_amount: 2500,
unit_price_fen: 0,
required_deposit_amount: 0,
deposit_threshold_amount: 20000,
sharing_enabled: false,
sharing_total_quantity: 1,
sharing_unit_reward: 0,
timeout_minutes: 0,
timeout_policy: 'reopen',
requirement_json: {},
match_json: {},
sort_order: 100,
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,
@@ -49,6 +130,41 @@ test('接单密码必须包含大小写字母、数字和标点符号', () => {
assert.throws(() => validateWorkerPassword('Aa123456'), { errorCode: 'worker_password_invalid' })
})
test('快手具体 SKU 精确规则优先于大标题包含规则', () => {
const decision = resolveMatchingProductRuleDecision(buildOrderRow(), buildOrderItemRow(), [
buildProductRule(),
buildProductRule({
id: 2,
rule_key: 'gift-box',
product_name: '精英尊尚专属礼盒',
match_json: {
sellerId: '3676797936',
itemTitle: '和平精英密钥指挥官特种兵侦察兵密钥',
skuNick: '1个精英尊尚专属礼盒',
itemTitleMatchType: 'exact',
skuNickMatchType: 'exact',
},
}),
])
assert.equal(decision.reason, 'matched')
assert.equal(decision.rule?.rule_key, 'gift-box')
})
test('快手同优先级精确规则进入冲突状态', () => {
const match = {
sellerId: '3676797936',
skuId: '189872452606936',
}
const decision = resolveMatchingProductRuleDecision(buildOrderRow(), buildOrderItemRow(), [
buildProductRule({ rule_key: 'gift-box-a', match_json: match }),
buildProductRule({ id: 2, rule_key: 'gift-box-b', match_json: match }),
])
assert.equal(decision.reason, 'ambiguous')
assert.equal(decision.rule, null)
})
test('assertWorkerLoginAllowed blocks rejected (frozen) workers with worker_rejected', () => {
assert.throws(
() => assertWorkerLoginAllowed(buildWorkerUserRow({ status: 'rejected' })),