优化快手接单匹配
This commit is contained in:
@@ -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' })),
|
||||
|
||||
@@ -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) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong title={row.productName || row.skuCode}>
|
||||
{row.productName || row.skuCode || row.ruleKey}
|
||||
<Typography.Text
|
||||
strong
|
||||
title={row.match.skuNick || row.match.itemTitle || row.productName || row.skuCode}
|
||||
>
|
||||
{row.match.skuNick ||
|
||||
row.match.itemTitle ||
|
||||
row.productName ||
|
||||
row.skuCode ||
|
||||
row.ruleKey}
|
||||
</Typography.Text>
|
||||
<Space size={4} wrap style={{ marginTop: 2 }}>
|
||||
<Tag color="blue" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
|
||||
@@ -255,6 +293,11 @@ export default function ProductRulesPanel() {
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{row.match.itemTitle || row.match.skuNick ? (
|
||||
<Typography.Text type="secondary" ellipsis={{ tooltip: true }} style={{ fontSize: 11 }}>
|
||||
{[row.match.itemTitle, row.match.skuNick].filter(Boolean).join(' / ')}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -318,20 +361,10 @@ export default function ProductRulesPanel() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '押金',
|
||||
key: 'deposit',
|
||||
width: '9%',
|
||||
render: (_, row) => (
|
||||
<Typography.Text type={row.requiredDepositAmount > 0 ? undefined : 'secondary'}>
|
||||
{formatMoney(row.requiredDepositAmount)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '匹配',
|
||||
dataIndex: 'matchType',
|
||||
width: '7%',
|
||||
width: '8%',
|
||||
render: (value) => (
|
||||
<Tag color={value === 'exact' ? 'blue' : 'orange'} style={{ margin: 0 }}>
|
||||
{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() {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="商品名称" name="productName" className="span-2">
|
||||
<Input placeholder="用于包含/精确匹配商品" />
|
||||
<Input placeholder="旧规则兼容字段;快手订单请配置下方大标题和 SKU" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="快手大标题" name="itemTitle" className="span-2">
|
||||
<Input placeholder="itemTitle,例如:和平精英密钥……" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="大标题匹配" name="itemTitleMatchType">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'exact', label: '精确' },
|
||||
{ value: 'contains', label: '包含' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="具体 SKU 名称" name="skuNick">
|
||||
<Input placeholder="ext.skuNick,例如:1个精英尊尚专属礼盒" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="SKU 名称匹配" name="skuNickMatchType">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'exact', label: '精确' },
|
||||
{ value: 'contains', label: '包含' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="快手卖家 ID" name="sellerId">
|
||||
<Input placeholder="sellerId,留空通配" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="快手商品 ID" name="itemId">
|
||||
<Input placeholder="itemId,精确匹配" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="快手 SKU ID" name="skuId">
|
||||
<Input placeholder="skuId,精确匹配" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="关联商品 ID" name="relItemId">
|
||||
<Input placeholder="ext.relItemId,精确匹配" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="关联 SKU ID" name="relSkuId">
|
||||
<Input placeholder="ext.relSkuId,精确匹配" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="所属分类" name="categoryId">
|
||||
@@ -532,14 +612,6 @@ export default function ProductRulesPanel() {
|
||||
<InputNumber min={0} max={9999} className="full-width" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="押金阈值" name="depositThresholdAmount">
|
||||
<InputNumber min={0} step={10} addonAfter="元" className="full-width" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="固定押金" name="requiredDepositAmount">
|
||||
<InputNumber min={0} step={10} addonAfter="元" className="full-width" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="任务时限" name="timeoutMinutes">
|
||||
<InputNumber min={0} step={5} addonAfter="分钟" className="full-width" />
|
||||
</Form.Item>
|
||||
@@ -670,6 +742,7 @@ export default function ProductRulesPanel() {
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={reprocessKuaishouOrders}>重匹配快手订单</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={resetRuleForm}>
|
||||
新建规则
|
||||
</Button>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user