feat(admin): 重构接单模板与商品绑定内联编辑 UI 布局
- 优化接单模板与模板列表 6:4 比例自适应布局 - 适用商品绑定支持左侧直接内联展开编辑,免弹窗模式 - 支持绑定规则多标签快速切换、实时新增与删除 - 基础表单与商品绑定统一采用 3 列网格布局,严格几何对齐 - 「启用拼单」开关及参数行内紧凑展开,移除多余背景框与纵向滚动条
This commit is contained in:
@@ -14,6 +14,7 @@ export async function listWorkProductRuleMappings(
|
||||
input: {
|
||||
enabled?: boolean | null
|
||||
sellerId?: string
|
||||
ruleId?: number | string | null
|
||||
} = {},
|
||||
): Promise<WorkProductRuleMappingRow[]> {
|
||||
const conditions: string[] = []
|
||||
@@ -29,6 +30,11 @@ export async function listWorkProductRuleMappings(
|
||||
params.push(sellerId)
|
||||
conditions.push(`wprm.seller_ids_json ? $${params.length}`)
|
||||
}
|
||||
const ruleId = Number(input.ruleId)
|
||||
if (Number.isInteger(ruleId) && ruleId > 0) {
|
||||
params.push(ruleId)
|
||||
conditions.push(`wprm.rule_id = $${params.length}`)
|
||||
}
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const result = await query<WorkProductRuleMappingRow>(
|
||||
`${MAPPING_SELECT}
|
||||
@@ -41,6 +47,61 @@ export async function listWorkProductRuleMappings(
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function listWorkProductRuleMappingsPage(
|
||||
input: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
enabled?: boolean | null
|
||||
keyword?: string
|
||||
ruleId?: number | string | null
|
||||
} = {},
|
||||
): Promise<{ items: WorkProductRuleMappingRow[]; total: number }> {
|
||||
const page = Math.max(1, Math.floor(Number(input.page) || 1))
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(input.pageSize) || 20)))
|
||||
const conditions: string[] = []
|
||||
const params: unknown[] = []
|
||||
if (input.enabled !== undefined && input.enabled !== null) {
|
||||
params.push(input.enabled)
|
||||
conditions.push(`wprm.enabled = $${params.length}`)
|
||||
}
|
||||
const ruleId = Number(input.ruleId)
|
||||
if (Number.isInteger(ruleId) && ruleId > 0) {
|
||||
params.push(ruleId)
|
||||
conditions.push(`wprm.rule_id = $${params.length}`)
|
||||
}
|
||||
const keyword = String(input.keyword || '').trim()
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
const parameter = `$${params.length}`
|
||||
conditions.push(`(
|
||||
wpr.rule_key ILIKE ${parameter}
|
||||
OR wpr.product_name ILIKE ${parameter}
|
||||
OR wprm.item_title ILIKE ${parameter}
|
||||
OR wprm.rel_item_id ILIKE ${parameter}
|
||||
OR wprm.rel_sku_id ILIKE ${parameter}
|
||||
OR wprm.sku_nick ILIKE ${parameter}
|
||||
)`)
|
||||
}
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const totalResult = await query<{ total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM work_product_rule_mappings wprm
|
||||
INNER JOIN work_product_rules wpr ON wpr.id = wprm.rule_id ${where}`,
|
||||
params,
|
||||
)
|
||||
const offset = (page - 1) * pageSize
|
||||
const listParams = [...params, pageSize, offset]
|
||||
const result = await query<WorkProductRuleMappingRow>(
|
||||
`${MAPPING_SELECT}
|
||||
${where}
|
||||
ORDER BY wprm.seller_ids_json::text ASC, wprm.rel_item_id ASC, wprm.item_title ASC,
|
||||
CASE WHEN wprm.mapping_type = 'product_default' THEN 0 ELSE 1 END ASC,
|
||||
wprm.id ASC
|
||||
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}`,
|
||||
listParams,
|
||||
)
|
||||
return { items: result.rows, total: Number(totalResult.rows[0]?.total || 0) }
|
||||
}
|
||||
|
||||
export async function upsertWorkProductRuleMapping(input: {
|
||||
mappingId?: number | null
|
||||
ruleId: number
|
||||
|
||||
@@ -195,7 +195,7 @@ router.delete(
|
||||
router.get(
|
||||
'/worker-platform/product-mappings',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(() => listAdminWorkProductRuleMappings(), {
|
||||
createJsonHandler((req) => listAdminWorkProductRuleMappings(req.query), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取商品映射失败',
|
||||
scope: '[admin/worker-platform/product-mappings]',
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
listWorkOrderEventsByOrderId,
|
||||
listWorkProductRules,
|
||||
listWorkProductRuleMappings,
|
||||
listWorkProductRuleMappingsPage,
|
||||
listWorkProductMatchLogs,
|
||||
listWorkProductRulesPage,
|
||||
listWorkerLevels,
|
||||
@@ -283,8 +284,25 @@ export async function reprocessAdminKuaishouSendCodeWorkOrders(payload: JsonObje
|
||||
return reprocessKuaishouSendCodeWorkOrders(limit)
|
||||
}
|
||||
|
||||
export async function listAdminWorkProductRuleMappings() {
|
||||
const items = await listWorkProductRuleMappings()
|
||||
export async function listAdminWorkProductRuleMappings(query: JsonObject = {}) {
|
||||
const ruleId = normalizeOptionalId(query.ruleId ?? query.rule_id) || 0
|
||||
const hasPagination = query.page !== undefined || query.pageSize !== undefined || query.keyword
|
||||
if (hasPagination) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const keyword = String(query.keyword || '').trim()
|
||||
const result = await listWorkProductRuleMappingsPage({
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
ruleId,
|
||||
})
|
||||
return {
|
||||
items: result.items.map(mapWorkProductRuleMapping),
|
||||
pagination: { page, pageSize, total: result.total },
|
||||
}
|
||||
}
|
||||
const items = await listWorkProductRuleMappings({ ruleId })
|
||||
return { items: items.map(mapWorkProductRuleMapping) }
|
||||
}
|
||||
|
||||
@@ -359,8 +377,20 @@ export async function saveAdminWorkProductRuleMapping(payload: JsonObject = {})
|
||||
errorCode: 'work_product_mapping_rule_not_found',
|
||||
})
|
||||
}
|
||||
const mappingId = normalizeOptionalId(payload.mappingId ?? payload.mapping_id)
|
||||
if (normalizeBoolean(payload.enabled, true)) {
|
||||
await assertNoConflictingWorkProductRuleMapping({
|
||||
mappingId,
|
||||
sellerIds,
|
||||
relItemId,
|
||||
itemTitle,
|
||||
relSkuId,
|
||||
skuNick,
|
||||
mappingType,
|
||||
})
|
||||
}
|
||||
const mapping = await upsertWorkProductRuleMapping({
|
||||
mappingId: normalizeOptionalId(payload.mappingId ?? payload.mapping_id),
|
||||
mappingId,
|
||||
ruleId,
|
||||
sellerIds,
|
||||
relItemId,
|
||||
@@ -547,6 +577,10 @@ function normalizeKuaishouMatchId(value: unknown) {
|
||||
return normalized === '0' ? '' : normalized
|
||||
}
|
||||
|
||||
function hasOwnPayload(payload: JsonObject, key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(payload, key)
|
||||
}
|
||||
|
||||
function normalizeKuaishouMatchIds(value: unknown) {
|
||||
const values = Array.isArray(value)
|
||||
? value
|
||||
@@ -580,6 +614,92 @@ function normalizeMatchTextForAdmin(value: unknown) {
|
||||
.trim()
|
||||
}
|
||||
|
||||
function assertNoConflictingWorkProductRuleMapping(input: {
|
||||
mappingId: number | null
|
||||
sellerIds: string[]
|
||||
relItemId: string
|
||||
itemTitle: string
|
||||
relSkuId: string
|
||||
skuNick: string
|
||||
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
|
||||
}) {
|
||||
return listWorkProductRuleMappings({ enabled: true }).then((mappings) => {
|
||||
const conflict = mappings.find((mapping) => {
|
||||
if (input.mappingId && Number(mapping.id) === input.mappingId) return false
|
||||
const existingType =
|
||||
mapping.mapping_type === 'sku_series'
|
||||
? 'sku_series'
|
||||
: mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override'
|
||||
? 'sku_exact'
|
||||
: 'product_default'
|
||||
if (existingType !== input.mappingType) return false
|
||||
const existingSellerIds = parseKuaishouMappingSellerIds(mapping.seller_ids_json)
|
||||
if (!existingSellerIds.some((sellerId) => input.sellerIds.includes(sellerId))) return false
|
||||
|
||||
const inputItemIds = normalizeKuaishouMatchIdList(input.relItemId)
|
||||
const existingItemIds = normalizeKuaishouMatchIdList(mapping.rel_item_id)
|
||||
if (
|
||||
!mappingProductScopesOverlap(
|
||||
inputItemIds,
|
||||
input.itemTitle,
|
||||
existingItemIds,
|
||||
mapping.item_title,
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (input.mappingType === 'product_default') return true
|
||||
if (input.mappingType === 'sku_series') {
|
||||
// 精确系列是模糊系列的子集,两者并存仍可能产生同分冲突,因此统一拦截。
|
||||
return (
|
||||
normalizeMatchTextForAdmin(input.skuNick) === normalizeMatchTextForAdmin(mapping.sku_nick)
|
||||
)
|
||||
}
|
||||
|
||||
const inputSkuId = normalizeKuaishouMatchId(input.relSkuId)
|
||||
const existingSkuId = normalizeKuaishouMatchId(mapping.rel_sku_id)
|
||||
const skuIdConflict = Boolean(inputSkuId && existingSkuId && inputSkuId === existingSkuId)
|
||||
const skuNameConflict =
|
||||
Boolean(input.skuNick && mapping.sku_nick) &&
|
||||
normalizeMatchTextForAdmin(input.skuNick) === normalizeMatchTextForAdmin(mapping.sku_nick)
|
||||
return skuIdConflict || skuNameConflict
|
||||
})
|
||||
|
||||
if (conflict) {
|
||||
throw createHttpError('商品映射与已有启用映射冲突,请停用或调整其中一条', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_product_mapping_conflict',
|
||||
context: {
|
||||
mappingId: Number(conflict.id),
|
||||
ruleKey: String(conflict.rule_key || ''),
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mappingProductScopesOverlap(
|
||||
leftItemIds: string[],
|
||||
leftTitle: string,
|
||||
rightItemIds: string[],
|
||||
rightTitle: string,
|
||||
) {
|
||||
if (leftItemIds.length > 0 && rightItemIds.length > 0) {
|
||||
return leftItemIds.some((itemId) => rightItemIds.includes(itemId))
|
||||
}
|
||||
// 只有一侧指定商品 ID 时,另一侧可能是全商品范围,按可能重叠处理并拦截。
|
||||
if (leftItemIds.length > 0 || rightItemIds.length > 0) return true
|
||||
const normalizedLeftTitle = normalizeMatchTextForAdmin(leftTitle)
|
||||
const normalizedRightTitle = normalizeMatchTextForAdmin(rightTitle)
|
||||
if (normalizedLeftTitle || normalizedRightTitle) {
|
||||
return Boolean(
|
||||
normalizedLeftTitle && normalizedRightTitle && normalizedLeftTitle === normalizedRightTitle,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function parseJsonArray(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value
|
||||
try {
|
||||
@@ -592,7 +712,38 @@ function parseJsonArray(value: unknown): unknown[] {
|
||||
|
||||
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
const defaults = await ensureWorkerPlatformDefaults()
|
||||
const match = normalizeWorkProductRuleMatch(payload)
|
||||
const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id)
|
||||
const existingRule = ruleId ? await getWorkProductRuleById(ruleId) : null
|
||||
if (ruleId && !existingRule) {
|
||||
throw createHttpError('接单模板不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_product_rule_not_found',
|
||||
})
|
||||
}
|
||||
const hasLegacyMatchPayload = [
|
||||
'match',
|
||||
'sellerId',
|
||||
'seller_id',
|
||||
'itemId',
|
||||
'item_id',
|
||||
'relItemId',
|
||||
'rel_item_id',
|
||||
'skuId',
|
||||
'sku_id',
|
||||
'relSkuId',
|
||||
'rel_sku_id',
|
||||
'itemTitle',
|
||||
'item_title',
|
||||
'skuNick',
|
||||
'sku_nick',
|
||||
'itemTitleMatchType',
|
||||
'item_title_match_type',
|
||||
'skuNickMatchType',
|
||||
'sku_nick_match_type',
|
||||
].some((key) => Object.prototype.hasOwnProperty.call(payload, key))
|
||||
const match = hasLegacyMatchPayload
|
||||
? normalizeWorkProductRuleMatch(payload)
|
||||
: safeParseJson(existingRule?.match_json)
|
||||
const productName = String(
|
||||
payload.productName || payload.product_name || payload.skuName || '',
|
||||
).trim()
|
||||
@@ -605,15 +756,6 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id)
|
||||
const existingRule = ruleId ? await getWorkProductRuleById(ruleId) : null
|
||||
if (ruleId && !existingRule) {
|
||||
throw createHttpError('接单模板不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_product_rule_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
|
||||
const unitPriceFen =
|
||||
payload.unitPrice === undefined && payload.unitPriceYuan === undefined
|
||||
@@ -678,12 +820,25 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
const ruleKey = existingRule?.rule_key || randomId('rule-')
|
||||
const rule = await upsertWorkProductRule({
|
||||
ruleKey,
|
||||
provider: String(payload.provider || '').trim(),
|
||||
platform: String(payload.platform || '').trim(),
|
||||
shopId: String(payload.shopId || payload.shop_id || '').trim(),
|
||||
skuCode,
|
||||
provider: hasOwnPayload(payload, 'provider')
|
||||
? String(payload.provider || '').trim()
|
||||
: String(existingRule?.provider || '').trim(),
|
||||
platform: hasOwnPayload(payload, 'platform')
|
||||
? String(payload.platform || '').trim()
|
||||
: String(existingRule?.platform || '').trim(),
|
||||
shopId:
|
||||
hasOwnPayload(payload, 'shopId') || hasOwnPayload(payload, 'shop_id')
|
||||
? String(payload.shopId || payload.shop_id || '').trim()
|
||||
: String(existingRule?.shop_id || '').trim(),
|
||||
skuCode:
|
||||
hasOwnPayload(payload, 'skuCode') || hasOwnPayload(payload, 'sku_code')
|
||||
? skuCode
|
||||
: String(existingRule?.sku_code || '').trim(),
|
||||
productName: productName || matchedProductName,
|
||||
matchType: normalizeMatchType(payload.matchType || payload.match_type),
|
||||
matchType:
|
||||
hasOwnPayload(payload, 'matchType') || hasOwnPayload(payload, 'match_type')
|
||||
? normalizeMatchType(payload.matchType || payload.match_type)
|
||||
: normalizeMatchType(existingRule?.match_type),
|
||||
categoryId:
|
||||
normalizeOptionalId(payload.categoryId || payload.category_id) ||
|
||||
defaults.category?.id ||
|
||||
|
||||
@@ -738,9 +738,9 @@ function scoreWorkProductRuleMapping(
|
||||
)
|
||||
const hasProductScope = Boolean(mappedItemIds.length > 0 || mappedTitle)
|
||||
if (mappingType === 'product_default' || hasProductScope) {
|
||||
// 有商品 ID 时只能按 ID 命中,避免同店同标题的不同商品串单;缺失 ID 才按标题回退。
|
||||
// 配置了商品 ID 时必须要求订单也提供商品 ID,避免缺少 ID 时误落入同店其他商品。
|
||||
if (
|
||||
(mappedItemIds.length > 0 && hasContextItemId && !itemIdMatched) ||
|
||||
(mappedItemIds.length > 0 && (!hasContextItemId || !itemIdMatched)) ||
|
||||
(mappedItemIds.length === 0 && !titleMatched)
|
||||
) {
|
||||
return null
|
||||
|
||||
@@ -351,6 +351,31 @@ test('商品默认映射支持用逗号填写多个关联商品 ID', () => {
|
||||
assert.equal(decision.rule?.id, rule.id)
|
||||
})
|
||||
|
||||
test('商品默认映射配置商品 ID 时,订单缺少商品 ID 不应误命中', () => {
|
||||
const item = buildOrderItemRow()
|
||||
item.item_snapshot_json = {
|
||||
kuaishouSendCode: {
|
||||
...item.item_snapshot_json.kuaishouSendCode,
|
||||
relItemId: '',
|
||||
},
|
||||
}
|
||||
const rule = buildProductRule({ product_name: '' })
|
||||
const decision = resolveMatchingProductRuleDecision(
|
||||
buildOrderRow(),
|
||||
item,
|
||||
[rule],
|
||||
[
|
||||
buildProductMapping({
|
||||
rel_item_id: '26765374805642',
|
||||
item_title: '',
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
assert.equal(decision.reason, 'unmatched')
|
||||
assert.equal(decision.rule, null)
|
||||
})
|
||||
|
||||
test('SKU 数量系列不会按公共词误匹配其他系列', () => {
|
||||
const item = buildOrderItemRow()
|
||||
item.sku_name = '指挥官隐藏款1个'
|
||||
|
||||
Reference in New Issue
Block a user