diff --git a/apps/backend/src/db/migrations/033_worker_product_match_context.sql b/apps/backend/src/db/migrations/033_worker_product_match_context.sql new file mode 100644 index 00000000..b5d78041 --- /dev/null +++ b/apps/backend/src/db/migrations/033_worker_product_match_context.sql @@ -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 等字段'; diff --git a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts index b18aeb7c..19128d83 100644 --- a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts +++ b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts @@ -201,6 +201,31 @@ export async function listKuaishouIndustryVouchersByOid( return result.rows } +export type KuaishouIndustryVoucherWorkOrderSyncSource = { + oid: string + raw_payload_json: string | Record +} + +export async function listKuaishouIndustryVoucherWorkOrderSyncSources( + limit = 100, +): Promise { + const result = await query( + ` + 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, diff --git a/apps/backend/src/repositories/order-item-repo.ts b/apps/backend/src/repositories/order-item-repo.ts index b58a6a68..5d91fb10 100644 --- a/apps/backend/src/repositories/order-item-repo.ts +++ b/apps/backend/src/repositories/order-item-repo.ts @@ -209,3 +209,19 @@ export async function getOrderItemById(orderItemId: number | string): Promise { + 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 +} diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 6a062482..53549a82 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -153,6 +153,7 @@ export type WorkProductRuleRow = { timeout_minutes: number timeout_policy: string requirement_json: string | Record + match_json: string | Record sort_order: number created_at: string updated_at: string diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index 6876d07d..84d6b170 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -711,6 +711,7 @@ export async function upsertWorkProductRule(input: { timeoutMinutes?: number timeoutPolicy?: string requirementJson: string + matchJson?: string sortOrder: number now: string }): Promise { @@ -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, diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 3c9d70db..db6cbb40 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -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) : {}, + }), + }), +) + router.post( '/worker-platform/orders/:workOrderId/material', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 55e2bc41..2c21eb8e 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -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), diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 889b0311..6dcfd803 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -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 } /** diff --git a/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.test.ts b/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.test.ts index 3e563b0d..56757dc6 100644 --- a/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.test.ts +++ b/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.test.ts @@ -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, }) }) diff --git a/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts b/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts index 3df90339..ae939a21 100644 --- a/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts +++ b/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts @@ -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 @@ -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 { + 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 { - 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 +} diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 71b71409..0fe5a92a 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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 { + 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 { 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' })), diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index 827739e6..8de139b5 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -30,6 +30,7 @@ import { deleteAdminWorkProductRule, fetchAdminWorkCategories, fetchAdminWorkProductRules, + reprocessAdminKuaishouWorkOrderMatches, saveAdminWorkProductRule, } from '@/services/admin' import type { CollectField, WorkProductRule } from '@/types/worker-platform' @@ -92,12 +93,19 @@ export default function ProductRulesPanel() { skuCode: rule.skuCode, productName: rule.productName, matchType: rule.matchType, + sellerId: rule.match.sellerId, + itemId: rule.match.itemId, + relItemId: rule.match.relItemId, + skuId: rule.match.skuId, + relSkuId: rule.match.relSkuId, + itemTitle: rule.match.itemTitle, + skuNick: rule.match.skuNick, + itemTitleMatchType: rule.match.itemTitleMatchType, + skuNickMatchType: rule.match.skuNickMatchType, categoryId: rule.categoryId || undefined, pricingMode: rule.unitPriceFen > 0 ? 'unit' : 'fixed', rewardAmount: rule.rewardAmount / 100, unitPrice: rule.unitPriceFen / 100, - requiredDepositAmount: rule.requiredDepositAmount / 100, - depositThresholdAmount: rule.depositThresholdAmount / 100, sharingEnabled: rule.sharing?.enabled || false, sharingTotalQuantity: rule.sharing?.totalQuantity || 1, sharingUnitReward: (rule.sharing?.unitReward || 0) / 100, @@ -121,10 +129,17 @@ export default function ProductRulesPanel() { skuCode?: string productName?: string matchType?: string + sellerId?: string + itemId?: string + relItemId?: string + skuId?: string + relSkuId?: string + itemTitle?: string + skuNick?: string + itemTitleMatchType?: 'contains' | 'exact' + skuNickMatchType?: 'contains' | 'exact' categoryId?: number rewardAmount?: number - requiredDepositAmount?: number - depositThresholdAmount?: number pricingMode?: string unitPrice?: number sharingEnabled?: boolean @@ -172,11 +187,10 @@ export default function ProductRulesPanel() { skuCode: rule.skuCode, productName: rule.productName, matchType: rule.matchType, + ...rule.match, categoryId: rule.categoryId || undefined, rewardAmount: rule.rewardAmount / 100, unitPrice: rule.unitPriceFen / 100, - requiredDepositAmount: rule.requiredDepositAmount / 100, - depositThresholdAmount: rule.depositThresholdAmount / 100, sharingEnabled: rule.sharing?.enabled || false, sharingTotalQuantity: rule.sharing?.totalQuantity || 1, sharingUnitReward: (rule.sharing?.unitReward || 0) / 100, @@ -213,6 +227,23 @@ export default function ProductRulesPanel() { } } + async function reprocessKuaishouOrders() { + try { + const response = await reprocessAdminKuaishouWorkOrderMatches() + const result = response.data + message.success( + `已扫描 ${result.scannedCount} 笔快手订单,新增 ${result.createdCount} 个接单工单`, + ) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-rules'] }), + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }), + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }), + ]) + } catch (error) { + message.error(error instanceof Error ? error.message : '快手订单重匹配失败') + } + } + function syncSharingRuleForm(next: { sharingTotalQuantity?: number sharingUnitReward?: number @@ -239,11 +270,18 @@ export default function ProductRulesPanel() { { title: '规则 / 商品', key: 'rule', - width: '24%', + width: '29%', render: (_, row) => (
- - {row.productName || row.skuCode || row.ruleKey} + + {row.match.skuNick || + row.match.itemTitle || + row.productName || + row.skuCode || + row.ruleKey} @@ -255,6 +293,11 @@ export default function ProductRulesPanel() { ) : null} + {row.match.itemTitle || row.match.skuNick ? ( + + {[row.match.itemTitle, row.match.skuNick].filter(Boolean).join(' / ')} + + ) : null}
), }, @@ -318,20 +361,10 @@ export default function ProductRulesPanel() { ), }, - { - title: '押金', - key: 'deposit', - width: '9%', - render: (_, row) => ( - 0 ? undefined : 'secondary'}> - {formatMoney(row.requiredDepositAmount)} - - ), - }, { title: '匹配', dataIndex: 'matchType', - width: '7%', + width: '8%', render: (value) => ( {value === 'exact' ? '精确' : '包含'} @@ -434,9 +467,10 @@ export default function ProductRulesPanel() { initialValues={{ platform: 'kuaishou', matchType: 'contains', + itemTitleMatchType: 'exact', + skuNickMatchType: 'exact', pricingMode: 'fixed', rewardAmount: 25, - depositThresholdAmount: 200, enabled: true, autoCreate: false, sharingEnabled: false, @@ -483,7 +517,53 @@ export default function ProductRulesPanel() { - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -532,14 +612,6 @@ export default function ProductRulesPanel() { - - - - - - - - @@ -670,6 +742,7 @@ export default function ProductRulesPanel() { > 刷新 + diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index b171e54e..13ceaf8e 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -94,13 +94,31 @@ export function saveAdminWorkProductRule(payload: { skuCode?: string productName?: string matchType?: string + sellerId?: string + itemId?: string + relItemId?: string + skuId?: string + relSkuId?: string + itemTitle?: string + skuNick?: string + itemTitleMatchType?: 'contains' | 'exact' + skuNickMatchType?: 'contains' | 'exact' + match?: { + sellerId?: string + itemId?: string + relItemId?: string + skuId?: string + relSkuId?: string + itemTitle?: string + skuNick?: string + itemTitleMatchType?: 'contains' | 'exact' + skuNickMatchType?: 'contains' | 'exact' + } categoryId?: number enabled?: boolean autoCreate?: boolean rewardAmount?: number unitPrice?: number - requiredDepositAmount?: number - depositThresholdAmount?: number sharingEnabled?: boolean sharingTotalQuantity?: number sharingUnitReward?: number @@ -117,6 +135,13 @@ export function deleteAdminWorkProductRule(ruleId: number) { return apiDelete<{ deleted: boolean }>(`/api/v1/admin/worker-platform/product-rules/${ruleId}`) } +export function reprocessAdminKuaishouWorkOrderMatches(limit = 100) { + return apiPost<{ scannedCount: number; createdCount: number; skippedCount: number }>( + '/api/v1/admin/worker-platform/orders/reprocess-matches', + { limit }, + ) +} + export function reviewAdminWorkerUser( workerId: number, payload: { status: string; reviewNote?: string; levelId?: number; workerType?: string }, diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index fc545eb7..70a824ab 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -198,14 +198,23 @@ export type WorkProductRule = { skuCode: string productName: string matchType: 'contains' | 'exact' | string + match: { + sellerId: string + itemId: string + relItemId: string + skuId: string + relSkuId: string + itemTitle: string + skuNick: string + itemTitleMatchType: 'contains' | 'exact' + skuNickMatchType: 'contains' | 'exact' + } categoryId: number | null categoryName: string enabled: boolean autoCreate: boolean rewardAmount: number unitPriceFen: number - requiredDepositAmount: number - depositThresholdAmount: number sharing?: WorkOrderSharingConfig requirement: { fields: CollectField[]