优化接单模板匹配

This commit is contained in:
yml2213
2026-08-18 13:37:32 +08:00
parent 9ebc027b96
commit 79990066f0
11 changed files with 135 additions and 31 deletions
@@ -0,0 +1,8 @@
-- 035_worker_product_match_sku_mode.sql —— SKU 系列匹配方式。
ALTER TABLE work_product_rule_mappings
ADD COLUMN IF NOT EXISTS sku_match_mode TEXT NOT NULL DEFAULT 'fuzzy'
CHECK (sku_match_mode IN ('fuzzy', 'exact'));
COMMENT ON COLUMN work_product_rule_mappings.sku_match_mode
IS 'SKU 数量系列匹配方式:fuzzy 兼容密钥/秘钥与附加文案,exact 仅完整系列名匹配';
@@ -49,6 +49,7 @@ export async function upsertWorkProductRuleMapping(input: {
itemTitle: string itemTitle: string
relSkuId: string relSkuId: string
skuNick: string skuNick: string
skuMatchMode: 'fuzzy' | 'exact'
mappingType: 'product_default' | 'sku_exact' | 'sku_series' mappingType: 'product_default' | 'sku_exact' | 'sku_series'
enabled: boolean enabled: boolean
now: string now: string
@@ -64,9 +65,10 @@ export async function upsertWorkProductRuleMapping(input: {
item_title = $5, item_title = $5,
rel_sku_id = $6, rel_sku_id = $6,
sku_nick = $7, sku_nick = $7,
mapping_type = $8, sku_match_mode = $8,
enabled = $9, mapping_type = $9,
updated_at = $10 enabled = $10,
updated_at = $11
WHERE id = $1 WHERE id = $1
RETURNING * RETURNING *
`, `,
@@ -78,6 +80,7 @@ export async function upsertWorkProductRuleMapping(input: {
input.itemTitle, input.itemTitle,
input.relSkuId, input.relSkuId,
input.skuNick, input.skuNick,
input.skuMatchMode,
input.mappingType, input.mappingType,
input.enabled, input.enabled,
input.now, input.now,
@@ -90,8 +93,8 @@ export async function upsertWorkProductRuleMapping(input: {
` `
INSERT INTO work_product_rule_mappings ( INSERT INTO work_product_rule_mappings (
rule_id, seller_ids_json, rel_item_id, item_title, rel_sku_id, sku_nick, rule_id, seller_ids_json, rel_item_id, item_title, rel_sku_id, sku_nick,
mapping_type, enabled, created_at, updated_at sku_match_mode, mapping_type, enabled, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)
RETURNING * RETURNING *
`, `,
[ [
@@ -101,6 +104,7 @@ export async function upsertWorkProductRuleMapping(input: {
input.itemTitle, input.itemTitle,
input.relSkuId, input.relSkuId,
input.skuNick, input.skuNick,
input.skuMatchMode,
input.mappingType, input.mappingType,
input.enabled, input.enabled,
input.now, input.now,
@@ -168,6 +168,7 @@ export type WorkProductRuleMappingRow = {
item_title: string item_title: string
rel_sku_id: string rel_sku_id: string
sku_nick: string sku_nick: string
sku_match_mode: 'fuzzy' | 'exact' | string
mapping_type: 'product_default' | 'sku_exact' | 'sku_series' | string mapping_type: 'product_default' | 'sku_exact' | 'sku_series' | string
enabled: boolean enabled: boolean
created_at: string created_at: string
@@ -680,6 +680,16 @@ export async function getWorkProductRuleByKey(ruleKey: string): Promise<WorkProd
return result.rows[0] || null return result.rows[0] || null
} }
export async function getWorkProductRuleById(
ruleId: number | string,
): Promise<WorkProductRuleRow | null> {
const result = await query<WorkProductRuleRow>(
`${WORK_PRODUCT_RULE_SELECT} WHERE wpr.id = $1 LIMIT 1`,
[Number(ruleId)],
)
return result.rows[0] || null
}
export async function deleteWorkProductRule( export async function deleteWorkProductRule(
ruleId: number | string, ruleId: number | string,
): Promise<{ deleted: boolean }> { ): Promise<{ deleted: boolean }> {
@@ -29,7 +29,7 @@ import {
getWorkerUserById, getWorkerUserById,
getWorkOrderStatistics, getWorkOrderStatistics,
getWorkOrderByOrderItemId, getWorkOrderByOrderItemId,
getWorkProductRuleByKey, getWorkProductRuleById,
listAllWorkCategories, listAllWorkCategories,
listWorkerFinanceRequests, listWorkerFinanceRequests,
listWorkOrders, listWorkOrders,
@@ -306,6 +306,11 @@ export async function saveAdminWorkProductRuleMapping(payload: JsonObject = {})
mappingType === 'product_default' mappingType === 'product_default'
? '' ? ''
: String(payload.skuNick ?? payload.sku_nick ?? '').trim() : String(payload.skuNick ?? payload.sku_nick ?? '').trim()
const skuMatchMode =
mappingType === 'sku_series' &&
String(payload.skuMatchMode ?? payload.sku_match_mode ?? '').trim() === 'exact'
? 'exact'
: 'fuzzy'
const requiresProductScope = mappingType === 'product_default' const requiresProductScope = mappingType === 'product_default'
if ( if (
!ruleId || !ruleId ||
@@ -357,6 +362,7 @@ export async function saveAdminWorkProductRuleMapping(payload: JsonObject = {})
itemTitle, itemTitle,
relSkuId, relSkuId,
skuNick, skuNick,
skuMatchMode,
mappingType, mappingType,
enabled: normalizeBoolean(payload.enabled, true), enabled: normalizeBoolean(payload.enabled, true),
now: nowIso(), now: nowIso(),
@@ -488,6 +494,7 @@ function mapWorkProductRuleMapping(mapping: WorkProductRuleMappingRow) {
itemTitle: String(mapping.item_title || ''), itemTitle: String(mapping.item_title || ''),
relSkuId: String(mapping.rel_sku_id || ''), relSkuId: String(mapping.rel_sku_id || ''),
skuNick: String(mapping.sku_nick || ''), skuNick: String(mapping.sku_nick || ''),
skuMatchMode: mapping.sku_match_mode === 'exact' ? 'exact' : 'fuzzy',
mappingType: mappingType:
mapping.mapping_type === 'sku_series' mapping.mapping_type === 'sku_series'
? 'sku_series' ? 'sku_series'
@@ -622,18 +629,16 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
} }
const finalRewardAmount = sharingEnabled ? resolvedSharingTotalAmount : rewardAmount const finalRewardAmount = sharingEnabled ? resolvedSharingTotalAmount : rewardAmount
const fields = normalizeRequirementFieldsFromPayload(payload)
const ruleKey = normalizeSlugKey(
payload.ruleKey || payload.rule_key || skuCode || productName || matchedProductName,
`rule-${Date.now()}`,
)
const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id) const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id)
if (!ruleId && (await getWorkProductRuleByKey(ruleKey))) { const existingRule = ruleId ? await getWorkProductRuleById(ruleId) : null
throw createHttpError('规则标识已存在,请更换后重试', { if (ruleId && !existingRule) {
statusCode: 409, throw createHttpError('接单模板不存在', {
errorCode: 'work_product_rule_key_taken', statusCode: 404,
errorCode: 'work_product_rule_not_found',
}) })
} }
const fields = normalizeRequirementFieldsFromPayload(payload)
const ruleKey = existingRule?.rule_key || randomId('rule-')
const rule = await upsertWorkProductRule({ const rule = await upsertWorkProductRule({
ruleKey, ruleKey,
provider: String(payload.provider || '').trim(), provider: String(payload.provider || '').trim(),
@@ -760,9 +760,8 @@ function scoreWorkProductRuleMapping(
if (skuNickMatched) score += 200 if (skuNickMatched) score += 200
} }
if (mappingType === 'sku_series') { if (mappingType === 'sku_series') {
const mappedSeries = normalizeSkuSeriesName(mapping.sku_nick) const skuMatchMode = mapping.sku_match_mode === 'exact' ? 'exact' : 'fuzzy'
const actualSeries = normalizeSkuSeriesName(context.skuNick) if (!matchSkuSeriesName(context.skuNick, mapping.sku_nick, skuMatchMode)) return null
if (!mappedSeries || !actualSeries || actualSeries !== mappedSeries) return null
score += 200 score += 200
} }
return { rule, score, mappingId: Number(mapping.id) } return { rule, score, mappingId: Number(mapping.id) }
@@ -917,6 +916,21 @@ function normalizeSkuSeriesName(value: unknown) {
) )
} }
function matchSkuSeriesName(actual: unknown, expected: unknown, matchMode: 'fuzzy' | 'exact') {
const normalizedActual = normalizeSkuSeriesName(actual)
const normalizedExpected = normalizeSkuSeriesName(expected)
if (!normalizedActual || !normalizedExpected) return false
if (matchMode === 'exact') return normalizedActual === normalizedExpected
const fuzzyActual = normalizeFuzzySkuSeriesName(normalizedActual)
const fuzzyExpected = normalizeFuzzySkuSeriesName(normalizedExpected)
return fuzzyActual.includes(fuzzyExpected) || fuzzyExpected.includes(fuzzyActual)
}
function normalizeFuzzySkuSeriesName(value: string) {
return value.replace(/秘钥|密匙|秘匙/g, '密钥')
}
export function normalizeWorkProductRuleMatch(payload: JsonObject): JsonObject { export function normalizeWorkProductRuleMatch(payload: JsonObject): JsonObject {
const match = const match =
payload.match && typeof payload.match === 'object' && !Array.isArray(payload.match) payload.match && typeof payload.match === 'object' && !Array.isArray(payload.match)
@@ -109,6 +109,7 @@ function buildProductMapping(
item_title: '和平精英密钥指挥官特种兵侦察兵密钥', item_title: '和平精英密钥指挥官特种兵侦察兵密钥',
rel_sku_id: '', rel_sku_id: '',
sku_nick: '', sku_nick: '',
sku_match_mode: 'fuzzy',
mapping_type: 'product_default', mapping_type: 'product_default',
enabled: true, enabled: true,
created_at: '2026-08-18T00:00:00.000Z', created_at: '2026-08-18T00:00:00.000Z',
@@ -253,6 +254,55 @@ test('SKU 数量系列可覆盖多店铺的前后数量规格', () => {
assert.equal(decision.rule?.rule_key, 'commander-series') assert.equal(decision.rule?.rule_key, 'commander-series')
}) })
test('SKU 数量系列默认模糊匹配密钥和秘钥', () => {
const item = buildOrderItemRow()
item.item_snapshot_json = {
kuaishouSendCode: {
...item.item_snapshot_json.kuaishouSendCode,
skuNick: '指挥官秘钥8个',
},
}
const rule = buildProductRule({ product_name: '' })
const decision = resolveMatchingProductRuleDecision(
buildOrderRow(),
item,
[rule],
[
buildProductMapping({
mapping_type: 'sku_series',
sku_nick: '指挥官密钥',
}),
],
)
assert.equal(decision.reason, 'matched')
})
test('SKU 数量系列精确匹配不兼容密钥和秘钥', () => {
const item = buildOrderItemRow()
item.item_snapshot_json = {
kuaishouSendCode: {
...item.item_snapshot_json.kuaishouSendCode,
skuNick: '指挥官秘钥8个',
},
}
const rule = buildProductRule({ product_name: '' })
const decision = resolveMatchingProductRuleDecision(
buildOrderRow(),
item,
[rule],
[
buildProductMapping({
mapping_type: 'sku_series',
sku_nick: '指挥官密钥',
sku_match_mode: 'exact',
}),
],
)
assert.equal(decision.reason, 'unmatched')
})
test('商品默认映射支持用逗号填写多个关联商品 ID', () => { test('商品默认映射支持用逗号填写多个关联商品 ID', () => {
const item = buildOrderItemRow() const item = buildOrderItemRow()
item.item_snapshot_json = { item.item_snapshot_json = {
@@ -49,6 +49,7 @@ type MappingFormValues = {
itemTitle?: string itemTitle?: string
relSkuId?: string relSkuId?: string
skuNick?: string skuNick?: string
skuMatchMode: 'fuzzy' | 'exact'
mappingType: 'product_default' | 'sku_exact' | 'sku_series' mappingType: 'product_default' | 'sku_exact' | 'sku_series'
enabled?: boolean enabled?: boolean
} }
@@ -109,6 +110,7 @@ export default function ProductMatchPanel() {
mappingForm.setFieldsValue({ mappingForm.setFieldsValue({
mappingType: 'product_default', mappingType: 'product_default',
productScope: 'selected_products', productScope: 'selected_products',
skuMatchMode: 'fuzzy',
enabled: true, enabled: true,
}) })
} }
@@ -130,6 +132,7 @@ export default function ProductMatchPanel() {
: mappingType === 'sku_series' : mappingType === 'sku_series'
? normalizeSkuSeriesName(source.skuNick) ? normalizeSkuSeriesName(source.skuNick)
: source.skuNick, : source.skuNick,
skuMatchMode: 'fuzzy',
mappingType, mappingType,
enabled: true, enabled: true,
}) })
@@ -145,6 +148,7 @@ export default function ProductMatchPanel() {
itemTitle: mapping.itemTitle, itemTitle: mapping.itemTitle,
relSkuId: mapping.relSkuId, relSkuId: mapping.relSkuId,
skuNick: mapping.skuNick, skuNick: mapping.skuNick,
skuMatchMode: mapping.skuMatchMode,
mappingType: mapping.mappingType, mappingType: mapping.mappingType,
enabled: mapping.enabled, enabled: mapping.enabled,
}) })
@@ -224,6 +228,9 @@ export default function ProductMatchPanel() {
{row.mappingType !== 'product_default' ? ( {row.mappingType !== 'product_default' ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.mappingType === 'sku_series' ? 'SKU 系列' : 'SKU'} {row.skuNick || row.relSkuId} {row.mappingType === 'sku_series' ? 'SKU 系列' : 'SKU'} {row.skuNick || row.relSkuId}
{row.mappingType === 'sku_series'
? ` · ${row.skuMatchMode === 'exact' ? '精确' : '模糊'}`
: ''}
</Typography.Text> </Typography.Text>
) : null} ) : null}
</div> </div>
@@ -405,6 +412,7 @@ export default function ProductMatchPanel() {
initialValues={{ initialValues={{
mappingType: 'product_default', mappingType: 'product_default',
productScope: 'selected_products', productScope: 'selected_products',
skuMatchMode: 'fuzzy',
enabled: true, enabled: true,
}} }}
onValuesChange={handleMappingValuesChange} onValuesChange={handleMappingValuesChange}
@@ -492,9 +500,19 @@ export default function ProductMatchPanel() {
</Form.Item> </Form.Item>
</> </>
) : mappingType === 'sku_series' ? ( ) : mappingType === 'sku_series' ? (
<>
<Form.Item label="SKU 匹配方式" name="skuMatchMode">
<Select
options={[
{ value: 'fuzzy', label: '模糊匹配(默认)' },
{ value: 'exact', label: '精确匹配' },
]}
/>
</Form.Item>
<Form.Item label="SKU 系列名称" name="skuNick"> <Form.Item label="SKU 系列名称" name="skuNick">
<Input placeholder="例如:指挥官密钥;自动覆盖 1个、10个等数量规格" /> <Input placeholder="例如:指挥官密钥;自动覆盖 1个、10个等数量规格" />
</Form.Item> </Form.Item>
</>
) : null} ) : null}
</> </>
) )
@@ -86,7 +86,6 @@ export default function ProductRulesPanel() {
function editRule(rule: WorkProductRule) { function editRule(rule: WorkProductRule) {
setEditingRule(rule) setEditingRule(rule)
form.setFieldsValue({ form.setFieldsValue({
ruleKey: rule.ruleKey,
provider: rule.provider, provider: rule.provider,
platform: rule.platform, platform: rule.platform,
shopId: rule.shopId, shopId: rule.shopId,
@@ -122,7 +121,7 @@ export default function ProductRulesPanel() {
async function saveRule(values: { async function saveRule(values: {
ruleId?: number ruleId?: number
ruleKey: string ruleKey?: string
provider?: string provider?: string
platform?: string platform?: string
shopId?: string shopId?: string
@@ -159,6 +158,7 @@ export default function ProductRulesPanel() {
await saveAdminWorkProductRule({ await saveAdminWorkProductRule({
...payload, ...payload,
ruleId: editingRule?.ruleId, ruleId: editingRule?.ruleId,
ruleKey: editingRule?.ruleKey || '',
match: editingRule?.match, match: editingRule?.match,
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0, unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
}) })
@@ -472,14 +472,6 @@ export default function ProductRulesPanel() {
onFinish={saveRule} onFinish={saveRule}
> >
<div className="rule-form-grid-2col"> <div className="rule-form-grid-2col">
<Form.Item
label="规则标识"
name="ruleKey"
rules={[{ required: true, message: '请输入规则标识' }]}
>
<Input placeholder="skin-training" disabled={Boolean(editingRule)} />
</Form.Item>
<Form.Item label="来源" name="provider"> <Form.Item label="来源" name="provider">
<Input placeholder="如 91kaquan" /> <Input placeholder="如 91kaquan" />
</Form.Item> </Form.Item>
@@ -152,6 +152,7 @@ export function saveAdminWorkProductRuleMapping(payload: {
itemTitle?: string itemTitle?: string
relSkuId?: string relSkuId?: string
skuNick?: string skuNick?: string
skuMatchMode?: 'fuzzy' | 'exact'
mappingType: 'product_default' | 'sku_exact' | 'sku_series' mappingType: 'product_default' | 'sku_exact' | 'sku_series'
enabled?: boolean enabled?: boolean
}) { }) {
@@ -237,6 +237,7 @@ export type WorkProductRuleMapping = {
itemTitle: string itemTitle: string
relSkuId: string relSkuId: string
skuNick: string skuNick: string
skuMatchMode: 'fuzzy' | 'exact'
mappingType: 'product_default' | 'sku_exact' | 'sku_series' mappingType: 'product_default' | 'sku_exact' | 'sku_series'
enabled: boolean enabled: boolean
createdAt: string createdAt: string