feat(worker-platform): 商品名按来源追加可配置后缀
- 新增来源后缀配置(来源下拉、启停、备注,同一时间仅启用一个) - 管理端列表/详情与打手端列表/详情/大厅展示「商品名(后缀)」 - 打手端 DTO 透出 sourceProvider,仅展示,不改写商品名 - 新增来源选项接口与前后端单测
This commit is contained in:
@@ -7,6 +7,8 @@ export const APP_CONFIG_KEYS = {
|
||||
workerProductMatch: 'worker_product_match',
|
||||
workerAnnouncement: 'worker_announcement',
|
||||
workerHall: 'worker_hall',
|
||||
/** 商品名来源后缀:按 material.source.provider 在商品名后追加(文字),仅展示用 */
|
||||
workerProductNameSuffix: 'worker_product_name_suffix',
|
||||
workerRiskControl: 'worker_risk_control',
|
||||
workerMockShops: 'worker_mock_shops',
|
||||
kuaishouIndustrySource: 'kuaishou_industry_source',
|
||||
|
||||
@@ -1689,6 +1689,24 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台「商品名来源后缀」下拉选项:取工单资料里的来源标识。
|
||||
* 去重、去空、限制数量,避免脏数据把下拉撑爆。
|
||||
*/
|
||||
export async function listWorkOrderSourceProviders(limit = 200): Promise<string[]> {
|
||||
const result = await query<{ provider: string }>(
|
||||
`
|
||||
SELECT DISTINCT NULLIF(BTRIM(material_json->'source'->>'provider'), '') AS provider
|
||||
FROM work_orders
|
||||
WHERE NULLIF(BTRIM(material_json->'source'->>'provider'), '') IS NOT NULL
|
||||
ORDER BY provider ASC
|
||||
LIMIT $1
|
||||
`,
|
||||
[limit],
|
||||
)
|
||||
return result.rows.map((row) => row.provider).filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。
|
||||
* 候选集与列表接口同源(FIFO 窗口 + 全部置顶单),所以置顶单即使排在窗口外也能正常抢单/拼单。
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getAdminWorkerPlatformNotificationConfig,
|
||||
getAdminWorkerPlatformSummary,
|
||||
getAdminWorkerProductMatchConfig,
|
||||
getAdminWorkerProductNameSuffixConfig,
|
||||
listAdminKuaishouMatchSources,
|
||||
listAdminWorkCategories,
|
||||
listAdminWorkProductMatchLogs,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
listAdminWorkProductRuleMappings,
|
||||
listAdminWorkProductRules,
|
||||
listAdminWorkerLevels,
|
||||
listWorkerProductNameSuffixSourceOptions,
|
||||
saveAdminWorkCategory,
|
||||
saveAdminWorkProductRule,
|
||||
saveAdminWorkProductRuleMapping,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
saveAdminWorkerLevel,
|
||||
saveAdminWorkerPlatformNotificationConfig,
|
||||
saveAdminWorkerProductMatchConfig,
|
||||
saveAdminWorkerProductNameSuffixConfig,
|
||||
syncAdminWorkProductRulePrice,
|
||||
testAdminKuaishouProductMatch,
|
||||
testAdminProductMatchByOrderItem,
|
||||
@@ -309,6 +312,44 @@ router.post(
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/product-name-suffix',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(() => getAdminWorkerProductNameSuffixConfig(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取商品名来源后缀配置失败',
|
||||
scope: '[admin/worker-platform/product-name-suffix]',
|
||||
}),
|
||||
)
|
||||
|
||||
// 下拉选项来自工单资料里的来源标识,低频配置,给浏览器 5 分钟缓存。
|
||||
router.get(
|
||||
'/worker-platform/product-name-suffix/sources',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
setHttpCache(300),
|
||||
createJsonHandler(() => listWorkerProductNameSuffixSourceOptions(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取工单来源选项失败',
|
||||
scope: '[admin/worker-platform/product-name-suffix/sources]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/product-name-suffix',
|
||||
requireAdminRoles(['admin']),
|
||||
createJsonHandler((req) => saveAdminWorkerProductNameSuffixConfig(req.body || {}), {
|
||||
successMessage: '商品名来源后缀已保存',
|
||||
errorMessage: '保存商品名来源后缀失败',
|
||||
scope: '[admin/worker-platform/product-name-suffix]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'worker_product_name_suffix_saved',
|
||||
targetType: 'worker_product_name_suffix',
|
||||
targetId: 'worker_product_name_suffix',
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/product-match-config',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getWorkerMyOrderEvents,
|
||||
getWorkerMyOrderOverview,
|
||||
getWorkerOrderFeedbackSummary,
|
||||
getWorkerProductNameSuffixDisplayConfig,
|
||||
listWorkerMyOrders,
|
||||
markWorkerOrderRead,
|
||||
remindWorkerOrderAcceptance,
|
||||
@@ -90,6 +91,17 @@ router.get(
|
||||
}),
|
||||
)
|
||||
|
||||
// 商品名来源后缀映射:只下发已启用配置,打手端列表/详情/大厅统一按来源追加(文字),只读。
|
||||
router.get(
|
||||
'/product-name-suffix',
|
||||
requireActiveWorker,
|
||||
createRouteHandler(() => getWorkerProductNameSuffixDisplayConfig(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取商品名来源后缀配置失败',
|
||||
scope: '[worker/product-name-suffix]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/orders/feedbacks',
|
||||
requireActiveWorker,
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
getWorkerProductMatchConfig,
|
||||
saveWorkerProductMatchConfig,
|
||||
} from './worker-product-match-config-service.js'
|
||||
import {
|
||||
getWorkerProductNameSuffixConfig,
|
||||
saveWorkerProductNameSuffixConfig,
|
||||
} from './worker-product-name-suffix-service.js'
|
||||
|
||||
export async function getAdminWorkerPlatformNotificationConfig() {
|
||||
return getWorkerPlatformNotificationConfig()
|
||||
@@ -37,6 +41,14 @@ export async function saveAdminWorkerProductMatchConfig(payload: JsonObject = {}
|
||||
return saveWorkerProductMatchConfig(payload)
|
||||
}
|
||||
|
||||
export async function getAdminWorkerProductNameSuffixConfig() {
|
||||
return getWorkerProductNameSuffixConfig()
|
||||
}
|
||||
|
||||
export async function saveAdminWorkerProductNameSuffixConfig(payload: JsonObject = {}) {
|
||||
return saveWorkerProductNameSuffixConfig(payload)
|
||||
}
|
||||
|
||||
export async function pruneAdminWorkProductMatchLogs(payload: JsonObject = {}) {
|
||||
const config = getWorkerProductMatchConfig()
|
||||
const maxRows = payload.maxRows !== undefined ? Number(payload.maxRows) : config.maxMatchLogs
|
||||
|
||||
@@ -61,6 +61,7 @@ export {
|
||||
export * from './worker-product-match-config-service.js'
|
||||
export * from './worker-announcement-config-service.js'
|
||||
export * from './worker-hall-config-service.js'
|
||||
export * from './worker-product-name-suffix-service.js'
|
||||
export * from './worker-acceptance-config-service.js'
|
||||
export * from './sync-worker-orders-service.js'
|
||||
export * from './consignment-contact-service.js'
|
||||
|
||||
@@ -525,6 +525,14 @@ export function summarizeWorkerShares(
|
||||
}
|
||||
}
|
||||
|
||||
/** 来源平台标识(如 91kaquan):管理端直接读 material,打手端单独透出,避免大厅裁剪 material 后丢失。 */
|
||||
function resolveSourceProviderFromMaterial(material: unknown): string {
|
||||
if (!material || typeof material !== 'object' || Array.isArray(material)) return ''
|
||||
const source = (material as Record<string, unknown>).source
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return ''
|
||||
return String((source as Record<string, unknown>).provider || '').trim()
|
||||
}
|
||||
|
||||
export function mapWorkOrderForWorker(
|
||||
workOrder: WorkOrderRow,
|
||||
permissions: { depositFreeAmount: number },
|
||||
@@ -571,6 +579,8 @@ export function mapWorkOrderForWorker(
|
||||
productName: hasActiveShares
|
||||
? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity)
|
||||
: visibleOrder.productName,
|
||||
/** 来源平台标识;前端按后台配置决定是否在商品名后追加(文字) */
|
||||
sourceProvider: resolveSourceProviderFromMaterial(visibleOrder.material),
|
||||
status: personalStatus,
|
||||
/** 行上展示的问题备注:母单问题单用母单备注,否则用被标记份额的备注 */
|
||||
problemNote:
|
||||
@@ -664,6 +674,7 @@ export function mapWorkOrderForHall(
|
||||
workOrderNo: mapped.workOrderNo,
|
||||
platformOrderId: mapped.platformOrderId,
|
||||
productName: mapped.productName,
|
||||
sourceProvider: mapped.sourceProvider,
|
||||
categoryId: mapped.categoryId,
|
||||
categoryName: mapped.categoryName,
|
||||
parentCategoryId: workOrder.parent_category_id ? Number(workOrder.parent_category_id) : null,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildWorkerProductNameSuffixDisplayConfig,
|
||||
normalizeProductNameSuffixText,
|
||||
normalizeWorkerProductNameSuffixConfig,
|
||||
} from './worker-product-name-suffix-service.js'
|
||||
|
||||
function item(
|
||||
source: string,
|
||||
suffix: string,
|
||||
extra: Partial<{ enabled: boolean; note: string }> = {},
|
||||
) {
|
||||
return { source, suffix, enabled: false, note: '', ...extra }
|
||||
}
|
||||
|
||||
test('来源后缀配置:默认空配置', () => {
|
||||
assert.deepEqual(normalizeWorkerProductNameSuffixConfig(undefined), { items: [] })
|
||||
assert.deepEqual(normalizeWorkerProductNameSuffixConfig({}), { items: [] })
|
||||
assert.deepEqual(normalizeWorkerProductNameSuffixConfig({ items: 'bad' }), { items: [] })
|
||||
})
|
||||
|
||||
test('来源后缀配置:来源去空格转小写,后缀剥离外层括号', () => {
|
||||
assert.deepEqual(
|
||||
normalizeWorkerProductNameSuffixConfig({
|
||||
items: [
|
||||
{ source: ' 91KAQUAN ', suffix: '(91卡券)' },
|
||||
{ source: 'douyin', suffix: '(抖音)' },
|
||||
],
|
||||
}),
|
||||
{
|
||||
items: [item('91kaquan', '91卡券'), item('douyin', '抖音')],
|
||||
},
|
||||
)
|
||||
assert.equal(normalizeProductNameSuffixText('(91卡券)'), '91卡券')
|
||||
assert.equal(normalizeProductNameSuffixText('(91卡券)'), '91卡券')
|
||||
})
|
||||
|
||||
test('来源后缀配置:空来源或空后缀被丢弃', () => {
|
||||
assert.deepEqual(
|
||||
normalizeWorkerProductNameSuffixConfig({
|
||||
items: [
|
||||
{ source: '', suffix: '空来源' },
|
||||
{ source: 'empty-suffix', suffix: ' ' },
|
||||
{ source: 'ok', suffix: '保留' },
|
||||
],
|
||||
}),
|
||||
{ items: [item('ok', '保留')] },
|
||||
)
|
||||
})
|
||||
|
||||
test('来源后缀配置:同一来源不区分大小写去重,保留第一条', () => {
|
||||
assert.deepEqual(
|
||||
normalizeWorkerProductNameSuffixConfig({
|
||||
items: [
|
||||
{ source: '91kaquan', suffix: '第一条' },
|
||||
{ source: '91Kaquan', suffix: '第二条' },
|
||||
],
|
||||
}),
|
||||
{ items: [item('91kaquan', '第一条')] },
|
||||
)
|
||||
})
|
||||
|
||||
test('来源后缀配置:最多 50 条', () => {
|
||||
const items = Array.from({ length: 60 }, (_, index) => ({
|
||||
source: `source-${index}`,
|
||||
suffix: `后缀${index}`,
|
||||
}))
|
||||
assert.equal(normalizeWorkerProductNameSuffixConfig({ items }).items.length, 50)
|
||||
})
|
||||
|
||||
test('来源后缀配置:来源最长 64、后缀最长 32', () => {
|
||||
const config = normalizeWorkerProductNameSuffixConfig({
|
||||
items: [{ source: 'a'.repeat(80), suffix: 's'.repeat(40) }],
|
||||
})
|
||||
assert.equal(config.items[0]?.source.length, 64)
|
||||
assert.equal(config.items[0]?.suffix.length, 32)
|
||||
})
|
||||
|
||||
test('来源后缀配置:非对象条目被忽略', () => {
|
||||
assert.deepEqual(
|
||||
normalizeWorkerProductNameSuffixConfig({
|
||||
items: ['bad', null, 42, { source: 'ok', suffix: '保留' }],
|
||||
}),
|
||||
{ items: [item('ok', '保留')] },
|
||||
)
|
||||
})
|
||||
|
||||
test('来源后缀配置:启停与备注原样归一化,旧配置默认停用', () => {
|
||||
assert.deepEqual(
|
||||
normalizeWorkerProductNameSuffixConfig({
|
||||
items: [
|
||||
{ source: 'a', suffix: '甲', enabled: true, note: ' 第一条备注 ' },
|
||||
{ source: 'b', suffix: '乙', note: '只有备注' },
|
||||
],
|
||||
}),
|
||||
{
|
||||
items: [
|
||||
item('a', '甲', { enabled: true, note: '第一条备注' }),
|
||||
item('b', '乙', { note: '只有备注' }),
|
||||
],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('来源后缀配置:同一时间只保留第一条启用项', () => {
|
||||
const config = normalizeWorkerProductNameSuffixConfig({
|
||||
items: [
|
||||
{ source: 'a', suffix: '甲', enabled: true },
|
||||
{ source: 'b', suffix: '乙', enabled: true },
|
||||
{ source: 'c', suffix: '丙', enabled: true },
|
||||
],
|
||||
})
|
||||
assert.deepEqual(
|
||||
config.items.map((row) => [row.source, row.enabled]),
|
||||
[
|
||||
['a', true],
|
||||
['b', false],
|
||||
['c', false],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('来源后缀配置:备注最长 200', () => {
|
||||
const config = normalizeWorkerProductNameSuffixConfig({
|
||||
items: [{ source: 'ok', suffix: '后缀', note: 'n'.repeat(240) }],
|
||||
})
|
||||
assert.equal(config.items[0]?.note.length, 200)
|
||||
})
|
||||
|
||||
test('打手端展示配置:只下发启用项且不带备注', () => {
|
||||
assert.deepEqual(
|
||||
buildWorkerProductNameSuffixDisplayConfig({
|
||||
items: [
|
||||
{ source: 'a', suffix: '甲', enabled: false, note: '停用' },
|
||||
{ source: 'b', suffix: '乙', enabled: true, note: '启用' },
|
||||
],
|
||||
}),
|
||||
{ items: [{ source: 'b', suffix: '乙' }] },
|
||||
)
|
||||
assert.deepEqual(buildWorkerProductNameSuffixDisplayConfig({ items: [] }), { items: [] })
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js'
|
||||
import { listWorkOrderSourceProviders } from '../../repositories/worker-platform/work-order-query-repo.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js'
|
||||
|
||||
/**
|
||||
* 商品名来源后缀配置。
|
||||
*
|
||||
* 只影响展示:工单表里的 `product_name` 永远保持原值。
|
||||
* `source` 与工单资料里的 `material.source.provider` 精确匹配(不区分大小写),
|
||||
* `suffix` 只存括号内的文字,前端统一按「商品名(后缀)」渲染。
|
||||
* 同一时间只允许启用一条配置,只有启用项会追加后缀。
|
||||
*/
|
||||
export type WorkerProductNameSuffixItem = {
|
||||
source: string
|
||||
suffix: string
|
||||
/** 是否启用;保存时只保留第一条启用项 */
|
||||
enabled: boolean
|
||||
/** 后台备注(仅后台配置页可见) */
|
||||
note: string
|
||||
}
|
||||
|
||||
export type WorkerProductNameSuffixConfig = {
|
||||
items: WorkerProductNameSuffixItem[]
|
||||
}
|
||||
|
||||
/** 打手端只需要启用的来源 -> 后缀映射,不暴露备注等信息。 */
|
||||
export type WorkerProductNameSuffixDisplayItem = {
|
||||
source: string
|
||||
suffix: string
|
||||
}
|
||||
|
||||
export type WorkerProductNameSuffixDisplayConfig = {
|
||||
items: WorkerProductNameSuffixDisplayItem[]
|
||||
}
|
||||
|
||||
const MAX_ITEMS = 50
|
||||
const MAX_SOURCE_LENGTH = 64
|
||||
const MAX_SUFFIX_LENGTH = 32
|
||||
const MAX_NOTE_LENGTH = 200
|
||||
|
||||
export function getWorkerProductNameSuffixConfig(): WorkerProductNameSuffixConfig {
|
||||
return readAppConfigEntry({
|
||||
configKey: APP_CONFIG_KEYS.workerProductNameSuffix,
|
||||
fallback: createDefaultWorkerProductNameSuffixConfig,
|
||||
normalize: normalizeWorkerProductNameSuffixConfig,
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveWorkerProductNameSuffixConfig(
|
||||
rawValue: unknown,
|
||||
): Promise<WorkerProductNameSuffixConfig> {
|
||||
return saveAppConfigEntry({
|
||||
configKey: APP_CONFIG_KEYS.workerProductNameSuffix,
|
||||
value: rawValue,
|
||||
normalize: normalizeWorkerProductNameSuffixConfig,
|
||||
})
|
||||
}
|
||||
|
||||
/** 默认不配置任何来源,保存前所有商品名都保持原样。 */
|
||||
export function createDefaultWorkerProductNameSuffixConfig(): WorkerProductNameSuffixConfig {
|
||||
return { items: [] }
|
||||
}
|
||||
|
||||
export function normalizeWorkerProductNameSuffixConfig(
|
||||
rawValue: unknown,
|
||||
): WorkerProductNameSuffixConfig {
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const rawItems = Array.isArray(source.items) ? source.items : []
|
||||
const seenSources = new Set<string>()
|
||||
const items: WorkerProductNameSuffixItem[] = []
|
||||
let hasEnabledItem = false
|
||||
|
||||
for (const rawItem of rawItems) {
|
||||
if (items.length >= MAX_ITEMS) break
|
||||
if (!isPlainObject(rawItem)) continue
|
||||
const sourceText = String(rawItem.source ?? '')
|
||||
.trim()
|
||||
.slice(0, MAX_SOURCE_LENGTH)
|
||||
const suffixText = normalizeProductNameSuffixText(rawItem.suffix)
|
||||
if (!sourceText || !suffixText) continue
|
||||
// 匹配时大小写不敏感,统一存小写,避免 91kaquan / 91Kaquan 配置出两条。
|
||||
const normalizedSource = sourceText.toLowerCase()
|
||||
if (seenSources.has(normalizedSource)) continue
|
||||
seenSources.add(normalizedSource)
|
||||
// 同时只能启用一个来源:按保存顺序保留第一条启用项,其余强制停用。
|
||||
const enabled = rawItem.enabled === true && !hasEnabledItem
|
||||
if (enabled) hasEnabledItem = true
|
||||
items.push({
|
||||
source: normalizedSource,
|
||||
suffix: suffixText,
|
||||
enabled,
|
||||
note: String(rawItem.note ?? '')
|
||||
.trim()
|
||||
.slice(0, MAX_NOTE_LENGTH),
|
||||
})
|
||||
}
|
||||
|
||||
return { items }
|
||||
}
|
||||
|
||||
/** 打手端展示配置:只下发启用项,未启用时返回空列表(商品名保持原样)。 */
|
||||
export function buildWorkerProductNameSuffixDisplayConfig(
|
||||
config: WorkerProductNameSuffixConfig,
|
||||
): WorkerProductNameSuffixDisplayConfig {
|
||||
return {
|
||||
items: config.items
|
||||
.filter((item) => item.enabled)
|
||||
.map((item) => ({ source: item.source, suffix: item.suffix })),
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkerProductNameSuffixDisplayConfig(): WorkerProductNameSuffixDisplayConfig {
|
||||
return buildWorkerProductNameSuffixDisplayConfig(getWorkerProductNameSuffixConfig())
|
||||
}
|
||||
|
||||
/** 后台来源下拉选项:工单资料里出现过的来源 + 已配置但暂时没有工单的来源。 */
|
||||
export async function listWorkerProductNameSuffixSourceOptions(): Promise<{ items: string[] }> {
|
||||
const providers = await listWorkOrderSourceProviders()
|
||||
const config = getWorkerProductNameSuffixConfig()
|
||||
const seen = new Map<string, string>()
|
||||
for (const value of [...providers, ...config.items.map((item) => item.source)]) {
|
||||
const text = String(value ?? '').trim()
|
||||
if (!text) continue
|
||||
const key = text.toLowerCase()
|
||||
if (!seen.has(key)) seen.set(key, text)
|
||||
}
|
||||
return { items: [...seen.values()].sort((a, b) => a.localeCompare(b)) }
|
||||
}
|
||||
|
||||
/** 后缀只保留括号内的文字:用户填了外层全角/半角括号时自动剥离。 */
|
||||
export function normalizeProductNameSuffixText(value: unknown): string {
|
||||
let text = String(value ?? '').trim()
|
||||
if (!text) return ''
|
||||
const wrappedFullWidth = text.startsWith('(') && text.endsWith(')')
|
||||
const wrappedHalfWidth = text.startsWith('(') && text.endsWith(')')
|
||||
if ((wrappedFullWidth || wrappedHalfWidth) && text.length >= 2) {
|
||||
text = text.slice(1, -1).trim()
|
||||
}
|
||||
return text.slice(0, MAX_SUFFIX_LENGTH)
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
Reference in New Issue
Block a user