merge: 合并远端开发分支
This commit is contained in:
@@ -7,6 +7,8 @@ export const APP_CONFIG_KEYS = {
|
|||||||
workerProductMatch: 'worker_product_match',
|
workerProductMatch: 'worker_product_match',
|
||||||
workerAnnouncement: 'worker_announcement',
|
workerAnnouncement: 'worker_announcement',
|
||||||
workerHall: 'worker_hall',
|
workerHall: 'worker_hall',
|
||||||
|
/** 商品名来源后缀:按 material.source.provider 在商品名后追加(文字),仅展示用 */
|
||||||
|
workerProductNameSuffix: 'worker_product_name_suffix',
|
||||||
workerRiskControl: 'worker_risk_control',
|
workerRiskControl: 'worker_risk_control',
|
||||||
workerMockShops: 'worker_mock_shops',
|
workerMockShops: 'worker_mock_shops',
|
||||||
kuaishouIndustrySource: 'kuaishou_industry_source',
|
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 绕过大厅队列。
|
* 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。
|
||||||
* 候选集与列表接口同源(FIFO 窗口 + 全部置顶单),所以置顶单即使排在窗口外也能正常抢单/拼单。
|
* 候选集与列表接口同源(FIFO 窗口 + 全部置顶单),所以置顶单即使排在窗口外也能正常抢单/拼单。
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getAdminWorkerPlatformNotificationConfig,
|
getAdminWorkerPlatformNotificationConfig,
|
||||||
getAdminWorkerPlatformSummary,
|
getAdminWorkerPlatformSummary,
|
||||||
getAdminWorkerProductMatchConfig,
|
getAdminWorkerProductMatchConfig,
|
||||||
|
getAdminWorkerProductNameSuffixConfig,
|
||||||
listAdminKuaishouMatchSources,
|
listAdminKuaishouMatchSources,
|
||||||
listAdminWorkCategories,
|
listAdminWorkCategories,
|
||||||
listAdminWorkProductMatchLogs,
|
listAdminWorkProductMatchLogs,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
listAdminWorkProductRuleMappings,
|
listAdminWorkProductRuleMappings,
|
||||||
listAdminWorkProductRules,
|
listAdminWorkProductRules,
|
||||||
listAdminWorkerLevels,
|
listAdminWorkerLevels,
|
||||||
|
listWorkerProductNameSuffixSourceOptions,
|
||||||
saveAdminWorkCategory,
|
saveAdminWorkCategory,
|
||||||
saveAdminWorkProductRule,
|
saveAdminWorkProductRule,
|
||||||
saveAdminWorkProductRuleMapping,
|
saveAdminWorkProductRuleMapping,
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
saveAdminWorkerLevel,
|
saveAdminWorkerLevel,
|
||||||
saveAdminWorkerPlatformNotificationConfig,
|
saveAdminWorkerPlatformNotificationConfig,
|
||||||
saveAdminWorkerProductMatchConfig,
|
saveAdminWorkerProductMatchConfig,
|
||||||
|
saveAdminWorkerProductNameSuffixConfig,
|
||||||
syncAdminWorkProductRulePrice,
|
syncAdminWorkProductRulePrice,
|
||||||
testAdminKuaishouProductMatch,
|
testAdminKuaishouProductMatch,
|
||||||
testAdminProductMatchByOrderItem,
|
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(
|
router.post(
|
||||||
'/worker-platform/product-match-config',
|
'/worker-platform/product-match-config',
|
||||||
requireAdminRoles(['admin', 'operator']),
|
requireAdminRoles(['admin', 'operator']),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
listInventoryMatchableOrders,
|
listInventoryMatchableOrders,
|
||||||
listWorkOrderInventoryMatchOptions,
|
listWorkOrderInventoryMatchOptions,
|
||||||
} from '../../../services/worker-platform/consignment-fulfillment-service.js'
|
} from '../../../services/worker-platform/consignment-fulfillment-service.js'
|
||||||
|
import { listPendingPistolMatches } from '../../../services/worker-platform/consignment-pending-match-service.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -266,6 +267,25 @@ router.post(
|
|||||||
|
|
||||||
// ---------------- 匹配与履约 ----------------
|
// ---------------- 匹配与履约 ----------------
|
||||||
|
|
||||||
|
// 全局待抢单匹配看板:只看非拼单的待抢单工单 × 在库待匹配现货,只读。
|
||||||
|
router.get(
|
||||||
|
'/worker-platform/consignment/pending-pistol-matches',
|
||||||
|
requireAdminPermission('consignment.view'),
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
listPendingPistolMatches({
|
||||||
|
keyword: String(req.query.keyword || ''),
|
||||||
|
includeConflicts: String(req.query.includeConflicts || '') === 'true',
|
||||||
|
limit: Number(req.query.limit || 0) || undefined,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取待匹配手枪订单失败',
|
||||||
|
scope: '[admin/consignment/pending-pistol-matches]',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/worker-platform/consignment/matches/:workOrderId',
|
'/worker-platform/consignment/matches/:workOrderId',
|
||||||
requireAdminPermission('consignment.view'),
|
requireAdminPermission('consignment.view'),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getWorkerMyOrderEvents,
|
getWorkerMyOrderEvents,
|
||||||
getWorkerMyOrderOverview,
|
getWorkerMyOrderOverview,
|
||||||
getWorkerOrderFeedbackSummary,
|
getWorkerOrderFeedbackSummary,
|
||||||
|
getWorkerProductNameSuffixDisplayConfig,
|
||||||
listWorkerMyOrders,
|
listWorkerMyOrders,
|
||||||
markWorkerOrderRead,
|
markWorkerOrderRead,
|
||||||
remindWorkerOrderAcceptance,
|
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(
|
router.get(
|
||||||
'/orders/feedbacks',
|
'/orders/feedbacks',
|
||||||
requireActiveWorker,
|
requireActiveWorker,
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
getWorkerProductMatchConfig,
|
getWorkerProductMatchConfig,
|
||||||
saveWorkerProductMatchConfig,
|
saveWorkerProductMatchConfig,
|
||||||
} from './worker-product-match-config-service.js'
|
} from './worker-product-match-config-service.js'
|
||||||
|
import {
|
||||||
|
getWorkerProductNameSuffixConfig,
|
||||||
|
saveWorkerProductNameSuffixConfig,
|
||||||
|
} from './worker-product-name-suffix-service.js'
|
||||||
|
|
||||||
export async function getAdminWorkerPlatformNotificationConfig() {
|
export async function getAdminWorkerPlatformNotificationConfig() {
|
||||||
return getWorkerPlatformNotificationConfig()
|
return getWorkerPlatformNotificationConfig()
|
||||||
@@ -37,6 +41,14 @@ export async function saveAdminWorkerProductMatchConfig(payload: JsonObject = {}
|
|||||||
return saveWorkerProductMatchConfig(payload)
|
return saveWorkerProductMatchConfig(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAdminWorkerProductNameSuffixConfig() {
|
||||||
|
return getWorkerProductNameSuffixConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAdminWorkerProductNameSuffixConfig(payload: JsonObject = {}) {
|
||||||
|
return saveWorkerProductNameSuffixConfig(payload)
|
||||||
|
}
|
||||||
|
|
||||||
export async function pruneAdminWorkProductMatchLogs(payload: JsonObject = {}) {
|
export async function pruneAdminWorkProductMatchLogs(payload: JsonObject = {}) {
|
||||||
const config = getWorkerProductMatchConfig()
|
const config = getWorkerProductMatchConfig()
|
||||||
const maxRows = payload.maxRows !== undefined ? Number(payload.maxRows) : config.maxMatchLogs
|
const maxRows = payload.maxRows !== undefined ? Number(payload.maxRows) : config.maxMatchLogs
|
||||||
|
|||||||
@@ -566,6 +566,122 @@ export function listConsignmentMatchOptions(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 全局待抢单匹配看板使用的细粒度评分结果。 */
|
||||||
|
export type ConsignmentDetailedMatchScore = {
|
||||||
|
matchType: 'exact' | 'partial' | 'conflict'
|
||||||
|
score: number
|
||||||
|
reason: string
|
||||||
|
conflicts: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 细粒度匹配打分(0-100),仅用于「待抢单手枪 × 在库现货」看板排序。
|
||||||
|
*
|
||||||
|
* 权重:系统 20 / 区服 20 / 系列 20 / 钻级 25 / 璀璨 15。
|
||||||
|
* 订单未写或无法确认的字段只给一半分;现货配置更高时钻级给 20、璀璨给 12。
|
||||||
|
* 品类、系统/区服/系列冲突、钻级/璀璨低于订单要求属于硬性不匹配,直接 0 分。
|
||||||
|
*/
|
||||||
|
export function scoreConsignmentMatchDetails(
|
||||||
|
order: ParsedOrderSpecs,
|
||||||
|
inventory: ConsignmentInventoryRow,
|
||||||
|
): ConsignmentDetailedMatchScore {
|
||||||
|
const conflicts = describeConsignmentSpecDifferences(order, inventory)
|
||||||
|
const conflict = (fallback: string): ConsignmentDetailedMatchScore => ({
|
||||||
|
matchType: 'conflict',
|
||||||
|
score: 0,
|
||||||
|
reason: conflicts.join(';') || fallback,
|
||||||
|
conflicts,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!inventory.allow_matching || inventory.status !== 'in_stock') {
|
||||||
|
return conflict('现货不在库或已暂停匹配')
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderCat = detectItemCategory(order.rawText)
|
||||||
|
const invCat = detectItemCategory(
|
||||||
|
`${inventory.item_name} ${inventory.series} ${inventory.category_name || ''}`,
|
||||||
|
)
|
||||||
|
if (orderCat !== 'other' && invCat !== 'other' && orderCat !== invCat) {
|
||||||
|
return conflict('品类不匹配')
|
||||||
|
}
|
||||||
|
if (invCat === 'pistol_suernan') {
|
||||||
|
const orderHasPistolSignal =
|
||||||
|
Boolean(order.series) ||
|
||||||
|
order.rawText.includes('苏尔南') ||
|
||||||
|
order.rawText.includes('手枪') ||
|
||||||
|
order.rawText.includes('枪皮')
|
||||||
|
if (!orderHasPistolSignal) {
|
||||||
|
return conflict('订单商品不是苏尔南手枪')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invSystem = normalizePeacekeeperSystem(inventory.system)
|
||||||
|
const invZone = normalizePeacekeeperServerZone(inventory.server_zone)
|
||||||
|
const invSeries = normalizePeacekeeperSeries(inventory.series)
|
||||||
|
|
||||||
|
const verdictSystem = compareSpec(order.system, invSystem)
|
||||||
|
const verdictZone = compareSpec(order.serverZone, invZone, ZONE_WILDCARDS)
|
||||||
|
const verdictSeries = compareSpec(order.series, invSeries)
|
||||||
|
if (verdictSystem === 'mismatch' || verdictZone === 'mismatch' || verdictSeries === 'mismatch') {
|
||||||
|
return conflict('系统/区服/系列规格冲突')
|
||||||
|
}
|
||||||
|
|
||||||
|
const invDiamond = Number(inventory.diamond_level || 1)
|
||||||
|
const invBrilliance = Number(inventory.brilliance_count || 0)
|
||||||
|
const orderDiamond = Number(order.diamondLevel || 0)
|
||||||
|
const orderBrilliance = Number(order.brillianceCount || 0)
|
||||||
|
|
||||||
|
if (orderDiamond > 0 && invDiamond < orderDiamond) {
|
||||||
|
return conflict('现货钻级低于订单要求')
|
||||||
|
}
|
||||||
|
if (invBrilliance < orderBrilliance) {
|
||||||
|
return conflict('现货璀璨低于订单要求')
|
||||||
|
}
|
||||||
|
|
||||||
|
const unknownFields: string[] = []
|
||||||
|
let score = 0
|
||||||
|
|
||||||
|
score += verdictSystem === 'match' ? 20 : 10
|
||||||
|
if (verdictSystem === 'unknown') unknownFields.push('系统')
|
||||||
|
score += verdictZone === 'match' ? 20 : 10
|
||||||
|
if (verdictZone === 'unknown') unknownFields.push('区服')
|
||||||
|
score += verdictSeries === 'match' ? 20 : 10
|
||||||
|
if (verdictSeries === 'unknown') unknownFields.push('系列')
|
||||||
|
|
||||||
|
if (orderDiamond <= 0) {
|
||||||
|
score += 12.5
|
||||||
|
unknownFields.push('钻级')
|
||||||
|
} else if (invDiamond === orderDiamond) {
|
||||||
|
score += 25
|
||||||
|
} else {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
|
||||||
|
score += invBrilliance === orderBrilliance ? 15 : 12
|
||||||
|
|
||||||
|
const reasons: string[] = []
|
||||||
|
if (unknownFields.length > 0) {
|
||||||
|
reasons.push(`订单规格信息不全(未写: ${unknownFields.join('、')}),需人工确认`)
|
||||||
|
}
|
||||||
|
if (orderDiamond > 0 && invDiamond > orderDiamond) {
|
||||||
|
reasons.push(`钻级更高(${invDiamond}钻充${orderDiamond}钻)`)
|
||||||
|
}
|
||||||
|
if (invBrilliance > orderBrilliance) {
|
||||||
|
reasons.push(`多出【${invBrilliance - orderBrilliance}璀璨】`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundedScore = Math.round(score)
|
||||||
|
if (roundedScore >= 100) {
|
||||||
|
return { matchType: 'exact', score: 100, reason: '规格完全一致', conflicts: [] }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matchType: 'partial',
|
||||||
|
score: roundedScore,
|
||||||
|
reason: reasons.length > 0 ? `需人工确认:${reasons.join(',')}` : '部分规格匹配,需人工确认',
|
||||||
|
conflicts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function findMatchingPeacekeeperInventories(
|
export function findMatchingPeacekeeperInventories(
|
||||||
rawRequirementText: string,
|
rawRequirementText: string,
|
||||||
inventories: ConsignmentInventoryRow[],
|
inventories: ConsignmentInventoryRow[],
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js'
|
||||||
|
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
||||||
|
import {
|
||||||
|
extractPeacekeeperSpecs,
|
||||||
|
scoreConsignmentMatchDetails,
|
||||||
|
} from './consignment-matching-service.js'
|
||||||
|
import {
|
||||||
|
buildPendingPistolMatchPairs,
|
||||||
|
type PendingPistolMatchPair,
|
||||||
|
} from './consignment-pending-match-service.js'
|
||||||
|
|
||||||
|
function buildInventory(overrides: Partial<ConsignmentInventoryRow> = {}): ConsignmentInventoryRow {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
inventory_no: 'INV001',
|
||||||
|
game_name: '和平精英',
|
||||||
|
category_id: 1,
|
||||||
|
item_name: '苹果微信区 暗潮 1钻 苏尔南',
|
||||||
|
system: '苹果',
|
||||||
|
server_zone: '微信区',
|
||||||
|
series: '暗潮',
|
||||||
|
diamond_level: 1,
|
||||||
|
brilliance_count: 0,
|
||||||
|
cost_price_fen: 10500,
|
||||||
|
buyer_name: '周杰雄',
|
||||||
|
inbound_batch: '8.30-1',
|
||||||
|
seller_contact: 'wx_123',
|
||||||
|
seller_game_id: 'UID8888',
|
||||||
|
seller_payment_info: '王大锤商行 331682...',
|
||||||
|
seller_phone: '13800000000',
|
||||||
|
credentials_json: {},
|
||||||
|
screenshots_json: [],
|
||||||
|
remark: '',
|
||||||
|
allow_matching: true,
|
||||||
|
status: 'in_stock',
|
||||||
|
allocation_status: '待安排',
|
||||||
|
bound_order_id: null,
|
||||||
|
bound_work_order_id: null,
|
||||||
|
bound_platform_order_id: '',
|
||||||
|
shop_name: '',
|
||||||
|
source_type: 'manual',
|
||||||
|
source_submission_id: null,
|
||||||
|
inbound_at: '2026-11-01T09:00:00.000Z',
|
||||||
|
consumed_at: null,
|
||||||
|
created_at: '2026-11-01T08:00:00.000Z',
|
||||||
|
updated_at: '2026-11-01T09:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrder(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
work_order_no: 'WK001',
|
||||||
|
platform_order_id: 'PLAT001',
|
||||||
|
product_name: '苹果微信区 苏尔南手枪 暗潮 1钻',
|
||||||
|
status: 'open',
|
||||||
|
active_share_quantity: 0,
|
||||||
|
material_json: JSON.stringify({
|
||||||
|
collect: { fields: { system: '苹果', serverZone: '微信区', series: '暗潮' } },
|
||||||
|
}),
|
||||||
|
created_at: '2026-11-01T10:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
} as unknown as WorkOrderRow
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairByInventory(result: { pairs: PendingPistolMatchPair[] }, inventoryId: number) {
|
||||||
|
return result.pairs.find((pair) => pair.inventoryId === inventoryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('细粒度评分:全部规格一致为 100 分', () => {
|
||||||
|
const order = extractPeacekeeperSpecs('苹果微信区 苏尔南手枪 暗潮 1钻')
|
||||||
|
const scored = scoreConsignmentMatchDetails(order, buildInventory())
|
||||||
|
assert.deepEqual(scored, {
|
||||||
|
matchType: 'exact',
|
||||||
|
score: 100,
|
||||||
|
reason: '规格完全一致',
|
||||||
|
conflicts: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('细粒度评分:钻级更高 95 分,璀璨更多 97 分', () => {
|
||||||
|
const order = extractPeacekeeperSpecs('苹果微信区 苏尔南手枪 暗潮 1钻')
|
||||||
|
const higherDiamond = scoreConsignmentMatchDetails(order, buildInventory({ diamond_level: 2 }))
|
||||||
|
assert.equal(higherDiamond.matchType, 'partial')
|
||||||
|
assert.equal(higherDiamond.score, 95)
|
||||||
|
assert.match(higherDiamond.reason, /钻级更高/)
|
||||||
|
|
||||||
|
const higherBrilliance = scoreConsignmentMatchDetails(
|
||||||
|
order,
|
||||||
|
buildInventory({ brilliance_count: 2 }),
|
||||||
|
)
|
||||||
|
assert.equal(higherBrilliance.matchType, 'partial')
|
||||||
|
assert.equal(higherBrilliance.score, 97)
|
||||||
|
assert.match(higherBrilliance.reason, /多出【2璀璨】/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('细粒度评分:订单未写系统时扣一半分', () => {
|
||||||
|
const order = extractPeacekeeperSpecs('微信区 苏尔南手枪 暗潮 1钻')
|
||||||
|
const scored = scoreConsignmentMatchDetails(order, buildInventory())
|
||||||
|
assert.equal(scored.matchType, 'partial')
|
||||||
|
assert.equal(scored.score, 90)
|
||||||
|
assert.match(scored.reason, /未写: 系统/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('细粒度评分:系统/区服/系列冲突或现货配置更低为 0 分', () => {
|
||||||
|
const order = extractPeacekeeperSpecs('苹果微信区 苏尔南手枪 暗潮 3钻')
|
||||||
|
assert.equal(scoreConsignmentMatchDetails(order, buildInventory({ system: '安卓' })).score, 0)
|
||||||
|
assert.equal(
|
||||||
|
scoreConsignmentMatchDetails(order, buildInventory({ server_zone: 'QQ区' })).score,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
assert.equal(scoreConsignmentMatchDetails(order, buildInventory({ series: '怒嚎' })).score, 0)
|
||||||
|
assert.equal(scoreConsignmentMatchDetails(order, buildInventory({ diamond_level: 1 })).score, 0)
|
||||||
|
assert.equal(
|
||||||
|
scoreConsignmentMatchDetails(order, buildInventory({ diamond_level: 3, brilliance_count: 0 }))
|
||||||
|
.matchType,
|
||||||
|
'exact',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
scoreConsignmentMatchDetails(
|
||||||
|
extractPeacekeeperSpecs('苹果微信区 苏尔南手枪 暗潮 1钻 2璀璨'),
|
||||||
|
buildInventory({ brilliance_count: 0 }),
|
||||||
|
).score,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('匹配看板:只保留非拼单待抢单 × 在库待匹配手枪', () => {
|
||||||
|
const result = buildPendingPistolMatchPairs({
|
||||||
|
orders: [
|
||||||
|
buildOrder({ id: 1 }),
|
||||||
|
buildOrder({ id: 2, active_share_quantity: 1 }),
|
||||||
|
buildOrder({
|
||||||
|
id: 3,
|
||||||
|
product_name: '指挥官密钥10个',
|
||||||
|
material_json: JSON.stringify({}),
|
||||||
|
}),
|
||||||
|
buildOrder({ id: 4, status: 'unassigned' }),
|
||||||
|
],
|
||||||
|
inventories: [
|
||||||
|
buildInventory({ id: 10 }),
|
||||||
|
buildInventory({ id: 11, allow_matching: false }),
|
||||||
|
buildInventory({ id: 12, status: 'consumed' }),
|
||||||
|
buildInventory({ id: 13, item_name: '指挥官密钥10个', series: '' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.pendingOrderCount, 1)
|
||||||
|
assert.equal(result.inStockInventoryCount, 1)
|
||||||
|
assert.equal(result.pairCount, 1)
|
||||||
|
assert.equal(result.pairs[0]?.workOrderId, 1)
|
||||||
|
assert.equal(result.pairs[0]?.inventoryId, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('匹配看板:不按时间过滤,寄售入库晚于工单创建仍参与匹配', () => {
|
||||||
|
const result = buildPendingPistolMatchPairs({
|
||||||
|
orders: [buildOrder({ created_at: '2026-11-01T10:00:00.000Z' })],
|
||||||
|
inventories: [buildInventory({ inbound_at: '2026-11-02T10:00:00.000Z' })],
|
||||||
|
})
|
||||||
|
assert.equal(result.pairCount, 1)
|
||||||
|
assert.equal(result.pairs[0]?.inventoryInboundAt, '2026-11-02T10:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('匹配看板:按分数降序,同分新工单优先', () => {
|
||||||
|
const result = buildPendingPistolMatchPairs({
|
||||||
|
orders: [buildOrder({ id: 1 }), buildOrder({ id: 2, work_order_no: 'WK002' })],
|
||||||
|
inventories: [
|
||||||
|
buildInventory({ id: 10 }),
|
||||||
|
buildInventory({ id: 11, diamond_level: 2, inbound_at: '2026-11-01T11:00:00.000Z' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
result.pairs.map((pair) => [pair.workOrderId, pair.inventoryId, pair.score]),
|
||||||
|
[
|
||||||
|
[2, 10, 100],
|
||||||
|
[1, 10, 100],
|
||||||
|
[2, 11, 95],
|
||||||
|
[1, 11, 95],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('匹配看板:关键字同时搜索工单号与寄售单号', () => {
|
||||||
|
const orders = [buildOrder({ id: 1, work_order_no: 'WK001' })]
|
||||||
|
const inventories = [
|
||||||
|
buildInventory({ id: 10, source_submission_no: 'SUB-AAA' }),
|
||||||
|
buildInventory({ id: 11, source_submission_no: 'SUB-BBB' }),
|
||||||
|
]
|
||||||
|
const byOrder = buildPendingPistolMatchPairs({ orders, inventories, keyword: 'wk001' })
|
||||||
|
assert.equal(byOrder.pairCount, 2)
|
||||||
|
|
||||||
|
const byConsignment = buildPendingPistolMatchPairs({
|
||||||
|
orders,
|
||||||
|
inventories,
|
||||||
|
keyword: 'sub-bbb',
|
||||||
|
})
|
||||||
|
assert.equal(byConsignment.pairCount, 1)
|
||||||
|
assert.equal(byConsignment.pairs[0]?.inventoryId, 11)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('匹配看板:默认隐藏 0 分冲突项,可显式包含', () => {
|
||||||
|
const orders = [buildOrder()]
|
||||||
|
const inventories = [buildInventory({ id: 10, system: '安卓' })]
|
||||||
|
const hidden = buildPendingPistolMatchPairs({ orders, inventories })
|
||||||
|
assert.equal(hidden.pairCount, 0)
|
||||||
|
assert.equal(hidden.conflictCount, 0)
|
||||||
|
|
||||||
|
const included = buildPendingPistolMatchPairs({
|
||||||
|
orders,
|
||||||
|
inventories,
|
||||||
|
includeConflicts: true,
|
||||||
|
})
|
||||||
|
assert.equal(included.pairCount, 1)
|
||||||
|
assert.equal(included.conflictCount, 1)
|
||||||
|
assert.equal(included.pairs[0]?.score, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('匹配看板:结果条数受 limit 限制并标记 truncated', () => {
|
||||||
|
const result = buildPendingPistolMatchPairs({
|
||||||
|
orders: [buildOrder({ id: 1 }), buildOrder({ id: 2, work_order_no: 'WK002' })],
|
||||||
|
inventories: [buildInventory({ id: 10 }), buildInventory({ id: 11 })],
|
||||||
|
limit: 2,
|
||||||
|
})
|
||||||
|
assert.equal(result.pairCount, 4)
|
||||||
|
assert.equal(result.pairs.length, 2)
|
||||||
|
assert.equal(result.truncated, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('匹配看板:配对明细包含平台号/工单号/商品名/寄售单号/寄售物品/分数', () => {
|
||||||
|
const result = buildPendingPistolMatchPairs({
|
||||||
|
orders: [buildOrder()],
|
||||||
|
inventories: [buildInventory({ id: 10, source_submission_no: 'SUB-001' })],
|
||||||
|
})
|
||||||
|
const pair = pairByInventory(result, 10)
|
||||||
|
assert.ok(pair)
|
||||||
|
assert.equal(pair.platformOrderId, 'PLAT001')
|
||||||
|
assert.equal(pair.workOrderNo, 'WK001')
|
||||||
|
assert.equal(pair.productName, '苹果微信区 苏尔南手枪 暗潮 1钻')
|
||||||
|
assert.equal(pair.consignmentNo, 'SUB-001')
|
||||||
|
assert.equal(pair.itemName, '苹果微信区 暗潮 1钻 苏尔南')
|
||||||
|
assert.equal(pair.score, 100)
|
||||||
|
})
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||||
|
import {
|
||||||
|
listActiveConsignmentInventoriesForMatching,
|
||||||
|
type ConsignmentInventoryRow,
|
||||||
|
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||||
|
import { listWorkOrders } from '../../repositories/worker-platform/work-order-query-repo.js'
|
||||||
|
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
||||||
|
import {
|
||||||
|
detectItemCategory,
|
||||||
|
extractPeacekeeperSpecs,
|
||||||
|
scoreConsignmentMatchDetails,
|
||||||
|
} from './consignment-matching-service.js'
|
||||||
|
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
|
||||||
|
|
||||||
|
export type PendingPistolMatchPair = {
|
||||||
|
workOrderId: number
|
||||||
|
workOrderNo: string
|
||||||
|
platformOrderId: string
|
||||||
|
productName: string
|
||||||
|
workOrderCreatedAt: string | null
|
||||||
|
inventoryId: number
|
||||||
|
consignmentNo: string
|
||||||
|
inventoryNo: string
|
||||||
|
itemName: string
|
||||||
|
inventoryInboundAt: string | null
|
||||||
|
matchType: 'exact' | 'partial' | 'conflict'
|
||||||
|
score: number
|
||||||
|
reason: string
|
||||||
|
conflicts: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PendingPistolMatchResult = {
|
||||||
|
/** 参与匹配的待抢单手枪单数量 */
|
||||||
|
pendingOrderCount: number
|
||||||
|
/** 参与匹配的在库待匹配现货数量 */
|
||||||
|
inStockInventoryCount: number
|
||||||
|
/** 命中关键字后的配对总数(未截断前) */
|
||||||
|
pairCount: number
|
||||||
|
exactCount: number
|
||||||
|
partialCount: number
|
||||||
|
conflictCount: number
|
||||||
|
/** 结果过多被截断,或待抢单工单本身超过单次拉取上限 */
|
||||||
|
truncated: boolean
|
||||||
|
pairs: PendingPistolMatchPair[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPEN_ORDER_FETCH_LIMIT = 500
|
||||||
|
const DEFAULT_RESULT_LIMIT = 300
|
||||||
|
const MAX_RESULT_LIMIT = 1000
|
||||||
|
|
||||||
|
function buildPairHaystack(pair: PendingPistolMatchPair): string {
|
||||||
|
return [
|
||||||
|
pair.workOrderNo,
|
||||||
|
pair.platformOrderId,
|
||||||
|
pair.productName,
|
||||||
|
pair.consignmentNo,
|
||||||
|
pair.inventoryNo,
|
||||||
|
pair.itemName,
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 纯函数:把待抢单工单与在库现货做笛卡尔匹配并排序,便于单测。 */
|
||||||
|
export function buildPendingPistolMatchPairs(input: {
|
||||||
|
orders: WorkOrderRow[]
|
||||||
|
inventories: ConsignmentInventoryRow[]
|
||||||
|
keyword?: string | undefined
|
||||||
|
includeConflicts?: boolean | undefined
|
||||||
|
limit?: number | undefined
|
||||||
|
ordersTruncated?: boolean | undefined
|
||||||
|
}): PendingPistolMatchResult {
|
||||||
|
const preparedOrders = input.orders
|
||||||
|
.filter((order) => String(order.status || '') === WORK_ORDER_STATUS.OPEN)
|
||||||
|
// 寄售只做整单:已有有效拼单份额的待抢单不参与匹配。
|
||||||
|
.filter((order) => Number(order.active_share_quantity || 0) <= 0)
|
||||||
|
.map((order) => {
|
||||||
|
const fields = resolveCollectSubmittedFieldValues(order)
|
||||||
|
const specs = extractPeacekeeperSpecs(order.product_name, {
|
||||||
|
system: fields.system,
|
||||||
|
serverZone: fields.serverZone,
|
||||||
|
series: fields.series,
|
||||||
|
})
|
||||||
|
return { order, specs }
|
||||||
|
})
|
||||||
|
.filter(({ order, specs }) => {
|
||||||
|
const text = [
|
||||||
|
String(order.product_name || ''),
|
||||||
|
specs.system,
|
||||||
|
specs.serverZone,
|
||||||
|
specs.series,
|
||||||
|
].join(' ')
|
||||||
|
return detectItemCategory(text) === 'pistol_suernan'
|
||||||
|
})
|
||||||
|
|
||||||
|
const preparedInventories = input.inventories.filter((inventory) => {
|
||||||
|
if (inventory.status !== 'in_stock' || inventory.allow_matching !== true) return false
|
||||||
|
return (
|
||||||
|
detectItemCategory(
|
||||||
|
`${inventory.item_name} ${inventory.series || ''} ${inventory.category_name || ''}`,
|
||||||
|
) === 'pistol_suernan'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyword = String(input.keyword || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
const includeConflicts = input.includeConflicts === true
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(Math.floor(Number(input.limit) || DEFAULT_RESULT_LIMIT), 1),
|
||||||
|
MAX_RESULT_LIMIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
const pairs: PendingPistolMatchPair[] = []
|
||||||
|
for (const { order, specs } of preparedOrders) {
|
||||||
|
for (const inventory of preparedInventories) {
|
||||||
|
const scored = scoreConsignmentMatchDetails(specs, inventory)
|
||||||
|
if (scored.matchType === 'conflict' && !includeConflicts) continue
|
||||||
|
|
||||||
|
const pair: PendingPistolMatchPair = {
|
||||||
|
workOrderId: Number(order.id),
|
||||||
|
workOrderNo: String(order.work_order_no || ''),
|
||||||
|
platformOrderId: String(order.platform_order_id || ''),
|
||||||
|
productName: String(order.product_name || ''),
|
||||||
|
workOrderCreatedAt: order.created_at ? String(order.created_at) : null,
|
||||||
|
inventoryId: Number(inventory.id),
|
||||||
|
consignmentNo: String(inventory.source_submission_no || inventory.inventory_no || ''),
|
||||||
|
inventoryNo: String(inventory.inventory_no || ''),
|
||||||
|
itemName: String(inventory.item_name || ''),
|
||||||
|
inventoryInboundAt: inventory.inbound_at
|
||||||
|
? String(inventory.inbound_at)
|
||||||
|
: inventory.created_at
|
||||||
|
? String(inventory.created_at)
|
||||||
|
: null,
|
||||||
|
matchType: scored.matchType,
|
||||||
|
score: scored.score,
|
||||||
|
reason: scored.reason,
|
||||||
|
conflicts: scored.conflicts,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyword && !buildPairHaystack(pair).includes(keyword)) continue
|
||||||
|
pairs.push(pair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactCount = pairs.filter((pair) => pair.matchType === 'exact').length
|
||||||
|
const partialCount = pairs.filter((pair) => pair.matchType === 'partial').length
|
||||||
|
const conflictCount = pairs.filter((pair) => pair.matchType === 'conflict').length
|
||||||
|
|
||||||
|
// 分数优先;同分时新工单优先,再按寄售入库时间早的优先。
|
||||||
|
pairs.sort((left, right) => {
|
||||||
|
if (right.score !== left.score) return right.score - left.score
|
||||||
|
if (right.workOrderId !== left.workOrderId) {
|
||||||
|
return right.workOrderId - left.workOrderId
|
||||||
|
}
|
||||||
|
const leftTime = Date.parse(left.inventoryInboundAt || '') || left.inventoryId
|
||||||
|
const rightTime = Date.parse(right.inventoryInboundAt || '') || right.inventoryId
|
||||||
|
if (leftTime !== rightTime) return leftTime - rightTime
|
||||||
|
return left.inventoryId - right.inventoryId
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingOrderCount: preparedOrders.length,
|
||||||
|
inStockInventoryCount: preparedInventories.length,
|
||||||
|
pairCount: pairs.length,
|
||||||
|
exactCount,
|
||||||
|
partialCount,
|
||||||
|
conflictCount,
|
||||||
|
truncated: pairs.length > limit || input.ordersTruncated === true,
|
||||||
|
pairs: pairs.slice(0, limit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台「寄售仓库 → 匹配」:待抢单手枪单 × 在库待匹配现货。 */
|
||||||
|
export async function listPendingPistolMatches(
|
||||||
|
input: {
|
||||||
|
keyword?: string | undefined
|
||||||
|
includeConflicts?: boolean | undefined
|
||||||
|
limit?: number | undefined
|
||||||
|
} = {},
|
||||||
|
): Promise<PendingPistolMatchResult> {
|
||||||
|
const { items, total } = await listWorkOrders({
|
||||||
|
statuses: [WORK_ORDER_STATUS.OPEN],
|
||||||
|
page: 1,
|
||||||
|
pageSize: OPEN_ORDER_FETCH_LIMIT,
|
||||||
|
includeTotal: true,
|
||||||
|
sort: 'id_desc',
|
||||||
|
})
|
||||||
|
const inventories = await listActiveConsignmentInventoriesForMatching()
|
||||||
|
|
||||||
|
return buildPendingPistolMatchPairs({
|
||||||
|
orders: items,
|
||||||
|
inventories,
|
||||||
|
keyword: input.keyword,
|
||||||
|
includeConflicts: input.includeConflicts,
|
||||||
|
limit: input.limit,
|
||||||
|
ordersTruncated: total > items.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -61,7 +61,9 @@ export {
|
|||||||
export * from './worker-product-match-config-service.js'
|
export * from './worker-product-match-config-service.js'
|
||||||
export * from './worker-announcement-config-service.js'
|
export * from './worker-announcement-config-service.js'
|
||||||
export * from './worker-hall-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 './worker-acceptance-config-service.js'
|
||||||
export * from './sync-worker-orders-service.js'
|
export * from './sync-worker-orders-service.js'
|
||||||
export * from './consignment-contact-service.js'
|
export * from './consignment-contact-service.js'
|
||||||
|
export * from './consignment-pending-match-service.js'
|
||||||
export * from './work-order-material-link-service.js'
|
export * from './work-order-material-link-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(
|
export function mapWorkOrderForWorker(
|
||||||
workOrder: WorkOrderRow,
|
workOrder: WorkOrderRow,
|
||||||
permissions: { depositFreeAmount: number },
|
permissions: { depositFreeAmount: number },
|
||||||
@@ -571,6 +579,8 @@ export function mapWorkOrderForWorker(
|
|||||||
productName: hasActiveShares
|
productName: hasActiveShares
|
||||||
? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity)
|
? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity)
|
||||||
: visibleOrder.productName,
|
: visibleOrder.productName,
|
||||||
|
/** 来源平台标识;前端按后台配置决定是否在商品名后追加(文字) */
|
||||||
|
sourceProvider: resolveSourceProviderFromMaterial(visibleOrder.material),
|
||||||
status: personalStatus,
|
status: personalStatus,
|
||||||
/** 行上展示的问题备注:母单问题单用母单备注,否则用被标记份额的备注 */
|
/** 行上展示的问题备注:母单问题单用母单备注,否则用被标记份额的备注 */
|
||||||
problemNote:
|
problemNote:
|
||||||
@@ -664,6 +674,7 @@ export function mapWorkOrderForHall(
|
|||||||
workOrderNo: mapped.workOrderNo,
|
workOrderNo: mapped.workOrderNo,
|
||||||
platformOrderId: mapped.platformOrderId,
|
platformOrderId: mapped.platformOrderId,
|
||||||
productName: mapped.productName,
|
productName: mapped.productName,
|
||||||
|
sourceProvider: mapped.sourceProvider,
|
||||||
categoryId: mapped.categoryId,
|
categoryId: mapped.categoryId,
|
||||||
categoryName: mapped.categoryName,
|
categoryName: mapped.categoryName,
|
||||||
parentCategoryId: workOrder.parent_category_id ? Number(workOrder.parent_category_id) : null,
|
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]'
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons'
|
||||||
|
import { Alert, Button, Checkbox, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
|
||||||
|
import type { TableColumnsType } from 'antd'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import PercentTable from '@/components/common/PercentTable'
|
||||||
|
import { fetchPendingPistolMatches } from '@/services/admin/consignment'
|
||||||
|
import type { PendingPistolMatchPair } from '@/types/consignment'
|
||||||
|
import { formatDateTime } from '@/utils/date-time'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
function resolveScoreTag(pair: PendingPistolMatchPair) {
|
||||||
|
if (pair.matchType === 'exact') return { color: 'green', label: '精确' }
|
||||||
|
if (pair.matchType === 'conflict') return { color: 'default', label: '不匹配' }
|
||||||
|
if (pair.score >= 90) return { color: 'blue', label: '非精确' }
|
||||||
|
if (pair.score >= 70) return { color: 'orange', label: '非精确' }
|
||||||
|
return { color: 'gold', label: '需核对' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConsignmentPendingMatchModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [includeConflicts, setIncludeConflicts] = useState(false)
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['admin-pending-pistol-matches', keyword, includeConflicts],
|
||||||
|
queryFn: () => fetchPendingPistolMatches({ keyword, includeConflicts, limit: 300 }),
|
||||||
|
enabled: open,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const data = query.data?.data
|
||||||
|
const pairs = data?.pairs || []
|
||||||
|
|
||||||
|
const columns: TableColumnsType<PendingPistolMatchPair> = [
|
||||||
|
{
|
||||||
|
title: '匹配打分',
|
||||||
|
key: 'score',
|
||||||
|
width: 90,
|
||||||
|
render: (_, record) => {
|
||||||
|
const tag = resolveScoreTag(record)
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={3} style={{ width: '100%' }}>
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 700, lineHeight: 1 }}>{record.score}</span>
|
||||||
|
<Tag color={tag.color} style={{ marginInlineEnd: 0 }}>
|
||||||
|
{tag.label}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '订单信息',
|
||||||
|
key: 'order',
|
||||||
|
width: 280,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||||
|
<Space size={4} align="center">
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
工单
|
||||||
|
</Text>
|
||||||
|
{record.workOrderNo ? (
|
||||||
|
<Text
|
||||||
|
copyable={{ text: record.workOrderNo }}
|
||||||
|
style={{ fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{record.workOrderNo}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">-</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
<Space size={4} align="center">
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
平台
|
||||||
|
</Text>
|
||||||
|
{record.platformOrderId ? (
|
||||||
|
<Text
|
||||||
|
copyable={{ text: record.platformOrderId }}
|
||||||
|
style={{ fontSize: 11, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{record.platformOrderId}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">-</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品名',
|
||||||
|
key: 'productName',
|
||||||
|
width: 220,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Text ellipsis={{ tooltip: record.productName }} style={{ maxWidth: '100%' }}>
|
||||||
|
{record.productName || '-'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '寄售信息',
|
||||||
|
key: 'consignment',
|
||||||
|
width: 280,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||||
|
<Space size={4} align="center">
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
寄售
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
copyable={{ text: record.consignmentNo || record.inventoryNo }}
|
||||||
|
style={{ fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{record.consignmentNo || record.inventoryNo || '-'}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
{record.inventoryNo && record.inventoryNo !== record.consignmentNo ? (
|
||||||
|
<Space size={4} align="center">
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
仓库
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
copyable={{ text: record.inventoryNo }}
|
||||||
|
style={{ fontSize: 11, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{record.inventoryNo}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
入库 {record.inventoryInboundAt ? formatDateTime(record.inventoryInboundAt) : '-'}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '寄售物品',
|
||||||
|
key: 'itemName',
|
||||||
|
width: 220,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Text ellipsis={{ tooltip: record.itemName }} style={{ maxWidth: '100%' }}>
|
||||||
|
{record.itemName || '-'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '匹配说明',
|
||||||
|
key: 'reason',
|
||||||
|
width: 300,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space direction="vertical" size={2}>
|
||||||
|
<Text style={{ fontSize: 12 }}>{record.reason || '-'}</Text>
|
||||||
|
{record.conflicts.length > 0 ? (
|
||||||
|
<Text type="danger" style={{ fontSize: 11 }}>
|
||||||
|
{record.conflicts.join(';')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="匹配 · 待抢单手枪 × 在库现货"
|
||||||
|
open={open}
|
||||||
|
width="min(1320px, 94vw)"
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="只查询非拼单的待抢单手枪单 × 在库待匹配现货,不直接改单。"
|
||||||
|
description="分数 0-100:系统 20 / 区服 20 / 系列 20 / 钻级 25 / 璀璨 15。非精确项仍需按现有规则确认;不按录单、入库时间先后过滤。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space wrap size={12}>
|
||||||
|
<Tag>待抢单 {data?.pendingOrderCount ?? 0} 单</Tag>
|
||||||
|
<Tag>在库待匹配 {data?.inStockInventoryCount ?? 0} 件</Tag>
|
||||||
|
<Tag color="blue">匹配组合 {data?.pairCount ?? 0} 条</Tag>
|
||||||
|
<Tag color="green">精确 {data?.exactCount ?? 0}</Tag>
|
||||||
|
<Tag color="orange">非精确 {data?.partialCount ?? 0}</Tag>
|
||||||
|
{includeConflicts ? <Tag>不匹配 {data?.conflictCount ?? 0}</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space wrap size={12}>
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索平台号 / 工单号 / 商品名 / 寄售单号 / 寄售物品"
|
||||||
|
style={{ width: 420 }}
|
||||||
|
onSearch={(value) => setKeyword(value.trim())}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={includeConflicts}
|
||||||
|
onChange={(event) => setIncludeConflicts(event.target.checked)}
|
||||||
|
>
|
||||||
|
显示 0 分不匹配项
|
||||||
|
</Checkbox>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={query.isFetching}
|
||||||
|
onClick={() => void query.refetch()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{query.isError ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="读取匹配结果失败"
|
||||||
|
description={query.error instanceof Error ? query.error.message : '请稍后重试'}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data?.truncated ? (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message={`结果较多,仅展示前 ${pairs.length} 条,请用搜索缩小范围`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<PercentTable<PendingPistolMatchPair>
|
||||||
|
rowKey={(record) => `${record.workOrderId}-${record.inventoryId}`}
|
||||||
|
size="small"
|
||||||
|
loading={query.isFetching}
|
||||||
|
dataSource={pairs}
|
||||||
|
columns={columns}
|
||||||
|
scroll={{ y: 460 }}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 20,
|
||||||
|
showSizeChanger: false,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<Empty
|
||||||
|
description={
|
||||||
|
includeConflicts
|
||||||
|
? '没有可展示的配对'
|
||||||
|
: '没有有分匹配;可勾选「显示 0 分不匹配项」查看冲突'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ReloadOutlined, SaveOutlined } from '@ant-design/icons'
|
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
App,
|
App,
|
||||||
@@ -9,12 +9,13 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addAdminMockCustomShop,
|
addAdminMockCustomShop,
|
||||||
@@ -22,14 +23,18 @@ import {
|
|||||||
fetchAdminMockCustomShops,
|
fetchAdminMockCustomShops,
|
||||||
fetchAdminScheduledJobsConfig,
|
fetchAdminScheduledJobsConfig,
|
||||||
fetchAdminWorkerAnnouncement,
|
fetchAdminWorkerAnnouncement,
|
||||||
|
fetchAdminWorkerProductNameSuffix,
|
||||||
|
fetchAdminWorkerProductNameSuffixSources,
|
||||||
renameAdminMockCustomShop,
|
renameAdminMockCustomShop,
|
||||||
runAdminScheduledJob,
|
runAdminScheduledJob,
|
||||||
saveAdminWorkerAnnouncement,
|
saveAdminWorkerAnnouncement,
|
||||||
|
saveAdminWorkerProductNameSuffix,
|
||||||
saveAdminScheduledJobsConfig,
|
saveAdminScheduledJobsConfig,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import type { AdminScheduledJobItem, AdminScheduledJobRuntimeState } from '@/types/admin'
|
import type { AdminScheduledJobItem, AdminScheduledJobRuntimeState } from '@/types/admin'
|
||||||
import type { WorkerAnnouncementConfig } from '@/types/worker-platform'
|
import type { WorkerAnnouncementConfig, WorkerProductNameSuffixItem } from '@/types/worker-platform'
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
import { normalizeProductNameSuffixText } from '@/utils/product-name-source-suffix'
|
||||||
|
|
||||||
import HallConfigPanel from './HallConfigPanel'
|
import HallConfigPanel from './HallConfigPanel'
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ export default function AnnouncementPanel() {
|
|||||||
return (
|
return (
|
||||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
<AnnouncementCard />
|
<AnnouncementCard />
|
||||||
|
<ProductNameSourceSuffixCard />
|
||||||
<DepositUnfreezeCard />
|
<DepositUnfreezeCard />
|
||||||
<MockCustomShopsCard />
|
<MockCustomShopsCard />
|
||||||
<HallConfigPanel />
|
<HallConfigPanel />
|
||||||
@@ -241,6 +247,233 @@ function AnnouncementCard() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProductNameSuffixDraft = WorkerProductNameSuffixItem & { rowKey: string }
|
||||||
|
|
||||||
|
let productNameSuffixRowSeed = 0
|
||||||
|
|
||||||
|
function createProductNameSuffixRow(
|
||||||
|
source = '',
|
||||||
|
suffix = '',
|
||||||
|
enabled = false,
|
||||||
|
note = '',
|
||||||
|
): ProductNameSuffixDraft {
|
||||||
|
productNameSuffixRowSeed += 1
|
||||||
|
return {
|
||||||
|
rowKey: `product-name-suffix-${productNameSuffixRowSeed}`,
|
||||||
|
source,
|
||||||
|
suffix,
|
||||||
|
enabled,
|
||||||
|
note,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商品名来源后缀:按工单来源在商品名后追加「(文字)」,只影响展示,不改库里的商品名。 */
|
||||||
|
function ProductNameSourceSuffixCard() {
|
||||||
|
const { message } = App.useApp()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [items, setItems] = useState<ProductNameSuffixDraft[]>([])
|
||||||
|
const suffixQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-product-name-suffix'],
|
||||||
|
queryFn: fetchAdminWorkerProductNameSuffix,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
const sourcesQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-product-name-suffix-sources'],
|
||||||
|
queryFn: fetchAdminWorkerProductNameSuffixSources,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const remoteItems = suffixQuery.data?.data.items || []
|
||||||
|
setItems(
|
||||||
|
remoteItems.map((item) =>
|
||||||
|
createProductNameSuffixRow(item.source, item.suffix, item.enabled, item.note),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}, [suffixQuery.data])
|
||||||
|
|
||||||
|
// 下拉选项来自订单来源字段;已配置但暂时没有工单的来源也保留,避免编辑时丢值。
|
||||||
|
const sourceOptions = useMemo(() => {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
for (const item of items) {
|
||||||
|
const text = String(item.source || '').trim()
|
||||||
|
if (!text) continue
|
||||||
|
const key = text.toLowerCase()
|
||||||
|
if (!values.has(key)) values.set(key, text)
|
||||||
|
}
|
||||||
|
for (const value of sourcesQuery.data?.data.items || []) {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (!text) continue
|
||||||
|
const key = text.toLowerCase()
|
||||||
|
if (!values.has(key)) values.set(key, text)
|
||||||
|
}
|
||||||
|
return [...values.values()]
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map((value) => ({ value, label: value }))
|
||||||
|
}, [items, sourcesQuery.data])
|
||||||
|
|
||||||
|
function patchRow(rowKey: string, patch: Partial<WorkerProductNameSuffixItem>) {
|
||||||
|
setItems((prev) => prev.map((item) => (item.rowKey === rowKey ? { ...item, ...patch } : item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRow(rowKey: string, enabled: boolean) {
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((item) => {
|
||||||
|
if (item.rowKey === rowKey) return { ...item, enabled }
|
||||||
|
// 同一时间只能启用一个来源:启用当前行时自动停用其他行。
|
||||||
|
return enabled ? { ...item, enabled: false } : item
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(rowKey: string) {
|
||||||
|
setItems((prev) => prev.filter((item) => item.rowKey !== rowKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const normalized: WorkerProductNameSuffixItem[] = []
|
||||||
|
const seenSources = new Set<string>()
|
||||||
|
let hasEnabledItem = false
|
||||||
|
for (const [index, item] of items.entries()) {
|
||||||
|
const source = String(item.source || '').trim()
|
||||||
|
const suffix = normalizeProductNameSuffixText(item.suffix)
|
||||||
|
if (!source && !suffix) continue
|
||||||
|
if (!source || !suffix) {
|
||||||
|
message.error(`第 ${index + 1} 行:来源和后缀都要填写`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalizedSource = source.toLowerCase()
|
||||||
|
if (seenSources.has(normalizedSource)) {
|
||||||
|
message.error(`来源「${source}」重复,一行只能配置一个来源`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seenSources.add(normalizedSource)
|
||||||
|
const enabled = item.enabled === true && !hasEnabledItem
|
||||||
|
if (enabled) hasEnabledItem = true
|
||||||
|
normalized.push({
|
||||||
|
source,
|
||||||
|
suffix,
|
||||||
|
enabled,
|
||||||
|
note: String(item.note || '').trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (normalized.length > 50) {
|
||||||
|
message.error('最多配置 50 条来源后缀')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await saveAdminWorkerProductNameSuffix({ items: normalized })
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-worker-product-name-suffix'] })
|
||||||
|
message.success('商品名来源后缀已保存')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '保存商品名来源后缀失败')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="商品名来源后缀"
|
||||||
|
bordered={false}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void save()}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
来源下拉数据取自工单的「来源」字段;选中来源后填写后缀,启用后展示为「商品名(后缀)」。
|
||||||
|
同一时间只能启用一个来源,启用新来源会自动停用其他行;未启用或没有匹配配置的来源不追加。
|
||||||
|
备注仅后台可见。只影响列表/详情的展示,不会改写工单里的商品名。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
{sourcesQuery.isError ? (
|
||||||
|
<Typography.Text type="danger" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
读取工单来源选项失败,已配置的来源仍可编辑;请稍后刷新重试。
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
还没有配置,所有商品名保持原样;点击「添加一行」开始配置。
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const suffix = normalizeProductNameSuffixText(item.suffix)
|
||||||
|
return (
|
||||||
|
<Space
|
||||||
|
key={item.rowKey}
|
||||||
|
size={8}
|
||||||
|
wrap
|
||||||
|
align="center"
|
||||||
|
style={{ width: '100%', padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={item.enabled === true}
|
||||||
|
checkedChildren="启用"
|
||||||
|
unCheckedChildren="停用"
|
||||||
|
onChange={(checked) => toggleRow(item.rowKey, checked)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
loading={sourcesQuery.isLoading}
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ width: 220 }}
|
||||||
|
placeholder="选择来源"
|
||||||
|
options={sourceOptions}
|
||||||
|
value={item.source || undefined}
|
||||||
|
onChange={(value) => patchRow(item.rowKey, { source: String(value || '') })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
addonBefore="后缀"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
maxLength={34}
|
||||||
|
placeholder="如 91卡券"
|
||||||
|
value={item.suffix}
|
||||||
|
onChange={(event) => patchRow(item.rowKey, { suffix: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
style={{ width: 220 }}
|
||||||
|
maxLength={200}
|
||||||
|
placeholder="备注(选填)"
|
||||||
|
value={item.note}
|
||||||
|
onChange={(event) => patchRow(item.rowKey, { note: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{suffix
|
||||||
|
? item.enabled
|
||||||
|
? `示例:商品名(${suffix})`
|
||||||
|
: '未启用,不追加'
|
||||||
|
: '未填写后缀时不追加'}
|
||||||
|
</Typography.Text>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
aria-label={`删除第 ${index + 1} 行`}
|
||||||
|
onClick={() => removeRow(item.rowKey)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
disabled={items.length >= 50}
|
||||||
|
onClick={() => setItems((prev) => [...prev, createProductNameSuffixRow()])}
|
||||||
|
>
|
||||||
|
添加一行
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MockCustomShopsCard() {
|
function MockCustomShopsCard() {
|
||||||
const { message, modal } = App.useApp()
|
const { message, modal } = App.useApp()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { EyeOutlined, ReloadOutlined, RollbackOutlined, StopOutlined } from '@ant-design/icons'
|
import {
|
||||||
|
EyeOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
RollbackOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -27,6 +33,7 @@ import type { TableColumnsType } from 'antd'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery'
|
import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery'
|
||||||
|
import ConsignmentPendingMatchModal from '@/components/admin/ConsignmentPendingMatchModal'
|
||||||
import {
|
import {
|
||||||
archiveAdminConsignmentInventory,
|
archiveAdminConsignmentInventory,
|
||||||
fetchAdminConsignmentContacts,
|
fetchAdminConsignmentContacts,
|
||||||
@@ -149,6 +156,7 @@ export default function ConsignmentInventoryPanel() {
|
|||||||
const [viewingItem, setViewingItem] = useState<ConsignmentInventory | null>(null)
|
const [viewingItem, setViewingItem] = useState<ConsignmentInventory | null>(null)
|
||||||
const [returnItem, setReturnItem] = useState<ConsignmentInventory | null>(null)
|
const [returnItem, setReturnItem] = useState<ConsignmentInventory | null>(null)
|
||||||
const [manualMatchItem, setManualMatchItem] = useState<ConsignmentInventory | null>(null)
|
const [manualMatchItem, setManualMatchItem] = useState<ConsignmentInventory | null>(null)
|
||||||
|
const [pendingMatchOpen, setPendingMatchOpen] = useState(false)
|
||||||
const [returnReason, setReturnReason] = useState<string>('')
|
const [returnReason, setReturnReason] = useState<string>('')
|
||||||
|
|
||||||
const [editForm] = Form.useForm()
|
const [editForm] = Form.useForm()
|
||||||
@@ -836,6 +844,10 @@ export default function ConsignmentInventoryPanel() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Button icon={<ThunderboltOutlined />} onClick={() => setPendingMatchOpen(true)}>
|
||||||
|
一键查询待匹配订单
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={refresh}>
|
<Button icon={<ReloadOutlined />} onClick={refresh}>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1305,6 +1317,12 @@ export default function ConsignmentInventoryPanel() {
|
|||||||
}
|
}
|
||||||
onMatched={() => void inventoriesQuery.refetch()}
|
onMatched={() => void inventoriesQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货 */}
|
||||||
|
<ConsignmentPendingMatchModal
|
||||||
|
open={pendingMatchOpen}
|
||||||
|
onClose={() => setPendingMatchOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react'
|
|||||||
|
|
||||||
import { updateAdminWorkOrder, updateAdminWorkOrderPaymentAmount } from '@/services/admin'
|
import { updateAdminWorkOrder, updateAdminWorkOrderPaymentAmount } from '@/services/admin'
|
||||||
import type { WorkOrder } from '@/types/worker-platform'
|
import type { WorkOrder } from '@/types/worker-platform'
|
||||||
|
import { appendProductNameSourceSuffix } from '@/utils/product-name-source-suffix'
|
||||||
|
|
||||||
import { formatMoney } from './shared'
|
import { formatMoney } from './shared'
|
||||||
import {
|
import {
|
||||||
@@ -15,10 +16,13 @@ import {
|
|||||||
/** 商品名:单击看详情,双击原地修改(仅未接走工单) */
|
/** 商品名:单击看详情,双击原地修改(仅未接走工单) */
|
||||||
export function ProductNameEditableCell({
|
export function ProductNameEditableCell({
|
||||||
order,
|
order,
|
||||||
|
sourceSuffix = '',
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
order: WorkOrder
|
order: WorkOrder
|
||||||
|
/** 按来源匹配到的后缀(不含括号):只用于展示,编辑态仍显示原始商品名 */
|
||||||
|
sourceSuffix?: string
|
||||||
onOpenDetail: () => void
|
onOpenDetail: () => void
|
||||||
onSaved: () => Promise<void> | void
|
onSaved: () => Promise<void> | void
|
||||||
}) {
|
}) {
|
||||||
@@ -69,13 +73,15 @@ export function ProductNameEditableCell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
|
const displayName =
|
||||||
|
appendProductNameSourceSuffix(order.productName, sourceSuffix) || '未命名商品'
|
||||||
return (
|
return (
|
||||||
<Typography.Link
|
<Typography.Link
|
||||||
title={editable ? '双击修改商品名' : undefined}
|
title={editable ? '双击修改商品名' : undefined}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
>
|
>
|
||||||
{order.productName || '未命名商品'}
|
{displayName}
|
||||||
</Typography.Link>
|
</Typography.Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
fetchAdminWorkOrderSharing,
|
fetchAdminWorkOrderSharing,
|
||||||
fetchAdminWorkOrderShopOptions,
|
fetchAdminWorkOrderShopOptions,
|
||||||
fetchAdminWorkerLevels,
|
fetchAdminWorkerLevels,
|
||||||
|
fetchAdminWorkerProductNameSuffix,
|
||||||
fetchAdminWorkerUsers,
|
fetchAdminWorkerUsers,
|
||||||
fetchAdminWorkOrders,
|
fetchAdminWorkOrders,
|
||||||
markAdminWorkOrderProblem,
|
markAdminWorkOrderProblem,
|
||||||
@@ -107,6 +108,10 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi
|
|||||||
import { getAdminUsername, hasAdminPermission, hasAdminRole } from '@/utils/admin-auth'
|
import { getAdminUsername, hasAdminPermission, hasAdminRole } from '@/utils/admin-auth'
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
import { buildCollectMaterialLinkUrl } from '@/utils/collect-link'
|
import { buildCollectMaterialLinkUrl } from '@/utils/collect-link'
|
||||||
|
import {
|
||||||
|
applyProductNameSourceSuffix,
|
||||||
|
resolveProductNameSourceSuffix,
|
||||||
|
} from '@/utils/product-name-source-suffix'
|
||||||
import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders'
|
import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders'
|
||||||
import {
|
import {
|
||||||
DeadlineCountdown,
|
DeadlineCountdown,
|
||||||
@@ -285,6 +290,13 @@ export default function WorkOrdersPanel() {
|
|||||||
queryFn: fetchAdminSession,
|
queryFn: fetchAdminSession,
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
|
// 商品名来源后缀(杂项配置维护):只影响列表/详情展示,编辑与复制仍用原始商品名。
|
||||||
|
const productNameSuffixQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-product-name-suffix'],
|
||||||
|
queryFn: fetchAdminWorkerProductNameSuffix,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
const productNameSuffixItems = productNameSuffixQuery.data?.data.items || []
|
||||||
const sessionPermissions = adminSessionQuery.data?.data.user.permissions
|
const sessionPermissions = adminSessionQuery.data?.data.user.permissions
|
||||||
const canReturnToHall = sessionPermissions
|
const canReturnToHall = sessionPermissions
|
||||||
? sessionPermissions.includes('worker_order.return_to_hall')
|
? sessionPermissions.includes('worker_order.return_to_hall')
|
||||||
@@ -436,6 +448,13 @@ export default function WorkOrdersPanel() {
|
|||||||
|
|
||||||
// 详情抽屉里的寄售履约信息(有寄售履约才展示寄卖单号)
|
// 详情抽屉里的寄售履约信息(有寄售履约才展示寄卖单号)
|
||||||
const detailConsignment = detailOrder ? getConsignmentFulfillment(detailOrder) : null
|
const detailConsignment = detailOrder ? getConsignmentFulfillment(detailOrder) : null
|
||||||
|
const detailProductName = detailOrder
|
||||||
|
? applyProductNameSourceSuffix(
|
||||||
|
detailOrder.productName,
|
||||||
|
asRecord(detailOrder.material?.source).provider,
|
||||||
|
productNameSuffixItems,
|
||||||
|
)
|
||||||
|
: ''
|
||||||
|
|
||||||
// 仅单独筛选「待验收」时提供批量验收入口(多选状态下不启用勾选,避免误操作)
|
// 仅单独筛选「待验收」时提供批量验收入口(多选状态下不启用勾选,避免误操作)
|
||||||
const isPendingAcceptanceOnly = statuses.length === 1 && statuses[0] === 'pending_acceptance'
|
const isPendingAcceptanceOnly = statuses.length === 1 && statuses[0] === 'pending_acceptance'
|
||||||
@@ -1676,11 +1695,13 @@ export default function WorkOrdersPanel() {
|
|||||||
const source = asRecord(row.material?.source)
|
const source = asRecord(row.material?.source)
|
||||||
const shopName = String(source.shopName || '').trim()
|
const shopName = String(source.shopName || '').trim()
|
||||||
const provider = String(source.provider || '').trim()
|
const provider = String(source.provider || '').trim()
|
||||||
|
const sourceSuffix = resolveProductNameSourceSuffix(productNameSuffixItems, provider)
|
||||||
const consignment = getConsignmentFulfillment(row)
|
const consignment = getConsignmentFulfillment(row)
|
||||||
return (
|
return (
|
||||||
<div className="cell-stack">
|
<div className="cell-stack">
|
||||||
<ProductNameEditableCell
|
<ProductNameEditableCell
|
||||||
order={row}
|
order={row}
|
||||||
|
sourceSuffix={sourceSuffix}
|
||||||
onOpenDetail={() => openDetailDrawer(row)}
|
onOpenDetail={() => openDetailDrawer(row)}
|
||||||
onSaved={refreshAll}
|
onSaved={refreshAll}
|
||||||
/>
|
/>
|
||||||
@@ -2360,7 +2381,7 @@ export default function WorkOrdersPanel() {
|
|||||||
className="worker-order-detail-drawer"
|
className="worker-order-detail-drawer"
|
||||||
title={
|
title={
|
||||||
detailOrder
|
detailOrder
|
||||||
? `${detailOrder.productName || '工单详情'} · ${getDisplayOrderNo(detailOrder)}`
|
? `${detailProductName || '工单详情'} · ${getDisplayOrderNo(detailOrder)}`
|
||||||
: '工单详情'
|
: '工单详情'
|
||||||
}
|
}
|
||||||
width={860}
|
width={860}
|
||||||
@@ -2408,7 +2429,7 @@ export default function WorkOrdersPanel() {
|
|||||||
<Descriptions.Item label="订单子项 ID">
|
<Descriptions.Item label="订单子项 ID">
|
||||||
{detailOrder.orderItemId || '-'}
|
{detailOrder.orderItemId || '-'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="商品">{detailOrder.productName || '-'}</Descriptions.Item>
|
<Descriptions.Item label="商品">{detailProductName || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="分类">
|
<Descriptions.Item label="分类">
|
||||||
{detailOrder.categoryName || '-'}
|
{detailOrder.categoryName || '-'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -33,7 +33,15 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { memo, useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from 'react'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
|
|
||||||
import { WorkerContactModal } from '@/components/worker/WorkerContactModal'
|
import { WorkerContactModal } from '@/components/worker/WorkerContactModal'
|
||||||
@@ -44,6 +52,7 @@ import {
|
|||||||
fetchWorkerHallLeaderboard,
|
fetchWorkerHallLeaderboard,
|
||||||
fetchWorkerMyOrders,
|
fetchWorkerMyOrders,
|
||||||
fetchWorkerMyOrderOverview,
|
fetchWorkerMyOrderOverview,
|
||||||
|
fetchWorkerProductNameSuffix,
|
||||||
fetchWorkerProfile,
|
fetchWorkerProfile,
|
||||||
grabWorkerOrder,
|
grabWorkerOrder,
|
||||||
joinWorkerSharingOrder,
|
joinWorkerSharingOrder,
|
||||||
@@ -67,6 +76,10 @@ import {
|
|||||||
resolveHallRemainingQuantity,
|
resolveHallRemainingQuantity,
|
||||||
resolveHallRewardAmount,
|
resolveHallRewardAmount,
|
||||||
} from '@/utils/worker-product-name'
|
} from '@/utils/worker-product-name'
|
||||||
|
import {
|
||||||
|
applyProductNameSourceSuffix,
|
||||||
|
type ProductNameSourceSuffixItem,
|
||||||
|
} from '@/utils/product-name-source-suffix'
|
||||||
import { formatDateTime, formatPublishedElapsedTime } from '@/utils/date-time'
|
import { formatDateTime, formatPublishedElapsedTime } from '@/utils/date-time'
|
||||||
import { createRequestId } from '@/utils/request-id'
|
import { createRequestId } from '@/utils/request-id'
|
||||||
|
|
||||||
@@ -161,6 +174,28 @@ export default function WorkerHallPage() {
|
|||||||
const orders = ordersQuery.data?.pages.flatMap((page) => page.data.items) || []
|
const orders = ordersQuery.data?.pages.flatMap((page) => page.data.items) || []
|
||||||
const firstPage = ordersQuery.data?.pages[0]?.data
|
const firstPage = ordersQuery.data?.pages[0]?.data
|
||||||
|
|
||||||
|
// 商品名来源后缀(后台「杂项配置」维护):只影响展示,不改商品名本身。
|
||||||
|
const productNameSuffixQuery = useQuery({
|
||||||
|
queryKey: ['worker-product-name-suffix'],
|
||||||
|
queryFn: fetchWorkerProductNameSuffix,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const productNameSuffixItems = useMemo(
|
||||||
|
() => productNameSuffixQuery.data?.data.items || [],
|
||||||
|
[productNameSuffixQuery.data],
|
||||||
|
)
|
||||||
|
const resolveDisplayProductName = useCallback(
|
||||||
|
(order: WorkOrder) =>
|
||||||
|
applyProductNameSourceSuffix(
|
||||||
|
resolveHallProductName(order),
|
||||||
|
order.sourceProvider,
|
||||||
|
productNameSuffixItems,
|
||||||
|
) || '未命名商品',
|
||||||
|
[productNameSuffixItems],
|
||||||
|
)
|
||||||
|
|
||||||
const [categoryMeta, setCategoryMeta] = useState<{
|
const [categoryMeta, setCategoryMeta] = useState<{
|
||||||
categories: WorkCategory[]
|
categories: WorkCategory[]
|
||||||
showSubcategories: boolean
|
showSubcategories: boolean
|
||||||
@@ -337,7 +372,7 @@ export default function WorkerHallPage() {
|
|||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: '确认抢单',
|
title: '确认抢单',
|
||||||
icon: <QuestionCircleOutlined />,
|
icon: <QuestionCircleOutlined />,
|
||||||
content: `确认抢下「${resolveHallProductName(order)}」?奖励 ${formatMoney(
|
content: `确认抢下「${resolveDisplayProductName(order)}」?奖励 ${formatMoney(
|
||||||
resolveHallRewardAmount(order),
|
resolveHallRewardAmount(order),
|
||||||
)}`,
|
)}`,
|
||||||
okText: '确认抢单',
|
okText: '确认抢单',
|
||||||
@@ -345,7 +380,7 @@ export default function WorkerHallPage() {
|
|||||||
onOk: () => submitGrab(order),
|
onOk: () => submitGrab(order),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[grabConfirmEnabled, modal, submitGrab],
|
[grabConfirmEnabled, modal, submitGrab, resolveDisplayProductName],
|
||||||
)
|
)
|
||||||
|
|
||||||
const openSharingModal = useCallback((order: WorkOrder) => {
|
const openSharingModal = useCallback((order: WorkOrder) => {
|
||||||
@@ -679,6 +714,7 @@ export default function WorkerHallPage() {
|
|||||||
remainingSlots={remainingSlots}
|
remainingSlots={remainingSlots}
|
||||||
isInternalWorker={isInternalWorker}
|
isInternalWorker={isInternalWorker}
|
||||||
grabbing={grabbingId === order.workOrderId}
|
grabbing={grabbingId === order.workOrderId}
|
||||||
|
productNameSuffixItems={productNameSuffixItems}
|
||||||
onGrab={requestGrab}
|
onGrab={requestGrab}
|
||||||
onOpenSharing={openSharingModal}
|
onOpenSharing={openSharingModal}
|
||||||
/>
|
/>
|
||||||
@@ -1017,6 +1053,7 @@ export default function WorkerHallPage() {
|
|||||||
remainingSlots={remainingSlots}
|
remainingSlots={remainingSlots}
|
||||||
isInternalWorker={isInternalWorker}
|
isInternalWorker={isInternalWorker}
|
||||||
grabbing={grabbingId === order.workOrderId}
|
grabbing={grabbingId === order.workOrderId}
|
||||||
|
productNameSuffixItems={productNameSuffixItems}
|
||||||
onGrab={requestGrab}
|
onGrab={requestGrab}
|
||||||
onOpenSharing={openSharingModal}
|
onOpenSharing={openSharingModal}
|
||||||
/>
|
/>
|
||||||
@@ -1031,7 +1068,7 @@ export default function WorkerHallPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={`拼单参与 · ${sharingOrder ? resolveHallProductName(sharingOrder) : ''}`}
|
title={`拼单参与 · ${sharingOrder ? resolveDisplayProductName(sharingOrder) : ''}`}
|
||||||
width={isMobile ? '92%' : 520}
|
width={isMobile ? '92%' : 520}
|
||||||
open={Boolean(sharingOrder)}
|
open={Boolean(sharingOrder)}
|
||||||
confirmLoading={submittingSharing}
|
confirmLoading={submittingSharing}
|
||||||
@@ -1169,6 +1206,8 @@ type HallCardProps = {
|
|||||||
remainingSlots: number | null
|
remainingSlots: number | null
|
||||||
isInternalWorker: boolean
|
isInternalWorker: boolean
|
||||||
grabbing: boolean
|
grabbing: boolean
|
||||||
|
/** 后台「杂项配置」维护的来源后缀;仅展示追加,不影响复制内容 */
|
||||||
|
productNameSuffixItems: ProductNameSourceSuffixItem[]
|
||||||
onGrab: (order: WorkOrder) => void
|
onGrab: (order: WorkOrder) => void
|
||||||
onOpenSharing: (order: WorkOrder) => void
|
onOpenSharing: (order: WorkOrder) => void
|
||||||
}
|
}
|
||||||
@@ -1202,11 +1241,17 @@ const HallDesktopCard = memo(function HallDesktopCard({
|
|||||||
remainingSlots,
|
remainingSlots,
|
||||||
isInternalWorker,
|
isInternalWorker,
|
||||||
grabbing,
|
grabbing,
|
||||||
|
productNameSuffixItems,
|
||||||
onGrab,
|
onGrab,
|
||||||
onOpenSharing,
|
onOpenSharing,
|
||||||
}: HallCardProps) {
|
}: HallCardProps) {
|
||||||
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
||||||
const productName = resolveHallProductName(order) || '未命名商品'
|
const productName =
|
||||||
|
applyProductNameSourceSuffix(
|
||||||
|
resolveHallProductName(order),
|
||||||
|
order.sourceProvider,
|
||||||
|
productNameSuffixItems,
|
||||||
|
) || '未命名商品'
|
||||||
const sharingRemaining = resolveHallRemainingQuantity(order)
|
const sharingRemaining = resolveHallRemainingQuantity(order)
|
||||||
const depositAmount = resolveHallDepositAmount(order)
|
const depositAmount = resolveHallDepositAmount(order)
|
||||||
const rewardAmount = resolveHallRewardAmount(order)
|
const rewardAmount = resolveHallRewardAmount(order)
|
||||||
@@ -1326,11 +1371,17 @@ const HallMobileCard = memo(function HallMobileCard({
|
|||||||
remainingSlots,
|
remainingSlots,
|
||||||
isInternalWorker,
|
isInternalWorker,
|
||||||
grabbing,
|
grabbing,
|
||||||
|
productNameSuffixItems,
|
||||||
onGrab,
|
onGrab,
|
||||||
onOpenSharing,
|
onOpenSharing,
|
||||||
}: HallCardProps) {
|
}: HallCardProps) {
|
||||||
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
||||||
const productName = resolveHallProductName(order) || '未命名商品'
|
const productName =
|
||||||
|
applyProductNameSourceSuffix(
|
||||||
|
resolveHallProductName(order),
|
||||||
|
order.sourceProvider,
|
||||||
|
productNameSuffixItems,
|
||||||
|
) || '未命名商品'
|
||||||
const sharingRemaining = resolveHallRemainingQuantity(order)
|
const sharingRemaining = resolveHallRemainingQuantity(order)
|
||||||
const myShareSummary = resolveHallMyShareSummary(order)
|
const myShareSummary = resolveHallMyShareSummary(order)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/
|
|||||||
import type { WorkOrder } from '@/types/worker-platform'
|
import type { WorkOrder } from '@/types/worker-platform'
|
||||||
import { formatDateTime } from '@/utils/date-time'
|
import { formatDateTime } from '@/utils/date-time'
|
||||||
import { resolveOrderConsignmentScreenshotUrls } from '@/utils/consignment-display'
|
import { resolveOrderConsignmentScreenshotUrls } from '@/utils/consignment-display'
|
||||||
|
import {
|
||||||
|
applyProductNameSourceSuffix,
|
||||||
|
type ProductNameSourceSuffixItem,
|
||||||
|
} from '@/utils/product-name-source-suffix'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
canEditAcceptanceImages,
|
canEditAcceptanceImages,
|
||||||
@@ -35,6 +39,7 @@ export function WorkerOrderDetailDrawer({
|
|||||||
isMobile,
|
isMobile,
|
||||||
order,
|
order,
|
||||||
orderTimelineVisible,
|
orderTimelineVisible,
|
||||||
|
productNameSuffixItems,
|
||||||
onClose,
|
onClose,
|
||||||
onOpenAcceptance,
|
onOpenAcceptance,
|
||||||
onOpenGiftMaterial,
|
onOpenGiftMaterial,
|
||||||
@@ -44,6 +49,8 @@ export function WorkerOrderDetailDrawer({
|
|||||||
isMobile: boolean
|
isMobile: boolean
|
||||||
order: WorkOrder | null
|
order: WorkOrder | null
|
||||||
orderTimelineVisible: boolean
|
orderTimelineVisible: boolean
|
||||||
|
/** 后台「杂项配置」维护的来源后缀;仅展示追加,不影响复制内容 */
|
||||||
|
productNameSuffixItems: ProductNameSourceSuffixItem[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onOpenAcceptance: (order: WorkOrder) => void
|
onOpenAcceptance: (order: WorkOrder) => void
|
||||||
onOpenGiftMaterial: (order: WorkOrder) => void
|
onOpenGiftMaterial: (order: WorkOrder) => void
|
||||||
@@ -51,6 +58,13 @@ export function WorkerOrderDetailDrawer({
|
|||||||
onOpenFeedback: (order: WorkOrder) => void
|
onOpenFeedback: (order: WorkOrder) => void
|
||||||
}) {
|
}) {
|
||||||
const isTimedOut = Boolean(order && (order.status === 'open' || order.timeoutNotice?.isTimedOut))
|
const isTimedOut = Boolean(order && (order.status === 'open' || order.timeoutNotice?.isTimedOut))
|
||||||
|
const displayProductName = order
|
||||||
|
? applyProductNameSourceSuffix(
|
||||||
|
order.productName,
|
||||||
|
order.sourceProvider,
|
||||||
|
productNameSuffixItems,
|
||||||
|
) || '-'
|
||||||
|
: '-'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
@@ -121,7 +135,7 @@ export function WorkerOrderDetailDrawer({
|
|||||||
</div>
|
</div>
|
||||||
<Card title="基础信息" size="small">
|
<Card title="基础信息" size="small">
|
||||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||||
<Descriptions.Item label="商品">{order.productName || '-'}</Descriptions.Item>
|
<Descriptions.Item label="商品">{displayProductName}</Descriptions.Item>
|
||||||
<Descriptions.Item label="分类">{order.categoryName || '-'}</Descriptions.Item>
|
<Descriptions.Item label="分类">{order.categoryName || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="工单编号">{getDisplayOrderNo(order)}</Descriptions.Item>
|
<Descriptions.Item label="工单编号">{getDisplayOrderNo(order)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import type {
|
|||||||
WorkOrder,
|
WorkOrder,
|
||||||
} from '@/types/worker-platform'
|
} from '@/types/worker-platform'
|
||||||
import { formatDateTime } from '@/utils/date-time'
|
import { formatDateTime } from '@/utils/date-time'
|
||||||
|
import {
|
||||||
|
applyProductNameSourceSuffix,
|
||||||
|
type ProductNameSourceSuffixItem,
|
||||||
|
} from '@/utils/product-name-source-suffix'
|
||||||
|
|
||||||
import type { WorkerOrderStatusOption } from './WorkerOrderDesktopList'
|
import type { WorkerOrderStatusOption } from './WorkerOrderDesktopList'
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +60,7 @@ export function WorkerOrderMobileList({
|
|||||||
pagination,
|
pagination,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
productNameSuffixItems,
|
||||||
renderShareTable,
|
renderShareTable,
|
||||||
loading,
|
loading,
|
||||||
searching,
|
searching,
|
||||||
@@ -85,6 +90,8 @@ export function WorkerOrderMobileList({
|
|||||||
pagination: PaginationData | undefined
|
pagination: PaginationData | undefined
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
|
/** 后台「杂项配置」维护的来源后缀;仅展示追加,不影响复制内容 */
|
||||||
|
productNameSuffixItems: ProductNameSourceSuffixItem[]
|
||||||
renderShareTable?: (order: WorkOrder) => ReactNode
|
renderShareTable?: (order: WorkOrder) => ReactNode
|
||||||
loading: boolean
|
loading: boolean
|
||||||
searching: boolean
|
searching: boolean
|
||||||
@@ -178,6 +185,7 @@ export function WorkerOrderMobileList({
|
|||||||
<WorkerOrderMobileCard
|
<WorkerOrderMobileCard
|
||||||
key={order.workOrderId}
|
key={order.workOrderId}
|
||||||
order={order}
|
order={order}
|
||||||
|
productNameSuffixItems={productNameSuffixItems}
|
||||||
renderShareTable={renderShareTable}
|
renderShareTable={renderShareTable}
|
||||||
onOpenDetail={onOpenDetail}
|
onOpenDetail={onOpenDetail}
|
||||||
onOpenAcceptance={onOpenAcceptance}
|
onOpenAcceptance={onOpenAcceptance}
|
||||||
@@ -209,6 +217,7 @@ export function WorkerOrderMobileList({
|
|||||||
|
|
||||||
function WorkerOrderMobileCard({
|
function WorkerOrderMobileCard({
|
||||||
order,
|
order,
|
||||||
|
productNameSuffixItems,
|
||||||
renderShareTable,
|
renderShareTable,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
onOpenAcceptance,
|
onOpenAcceptance,
|
||||||
@@ -251,6 +260,9 @@ function WorkerOrderMobileCard({
|
|||||||
const workerNote = String(order.workerNote || '').trim()
|
const workerNote = String(order.workerNote || '').trim()
|
||||||
const statusDisplay = getOrderStatusDisplay(order)
|
const statusDisplay = getOrderStatusDisplay(order)
|
||||||
const flagTags = getOrderFlagTags(order)
|
const flagTags = getOrderFlagTags(order)
|
||||||
|
const displayProductName =
|
||||||
|
applyProductNameSourceSuffix(order.productName, order.sourceProvider, productNameSuffixItems) ||
|
||||||
|
'未命名商品'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="worker-order-mobile-card">
|
<article className="worker-order-mobile-card">
|
||||||
@@ -308,7 +320,7 @@ function WorkerOrderMobileCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="worker-order-mobile-main-row">
|
<div className="worker-order-mobile-main-row">
|
||||||
<div className="worker-order-mobile-product-title">{order.productName || '未命名商品'}</div>
|
<div className="worker-order-mobile-product-title">{displayProductName}</div>
|
||||||
{order.status === 'open' || order.timeoutNotice?.isTimedOut ? (
|
{order.status === 'open' || order.timeoutNotice?.isTimedOut ? (
|
||||||
<div className="worker-order-mobile-price-badge">
|
<div className="worker-order-mobile-price-badge">
|
||||||
<span className="worker-order-mobile-price-label" style={{ color: '#cf1322' }}>
|
<span className="worker-order-mobile-price-label" style={{ color: '#cf1322' }}>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { DeadlineCountdown } from '@/components/DeadlineCountdown'
|
|||||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
import type { WorkOrder } from '@/types/worker-platform'
|
import type { WorkOrder } from '@/types/worker-platform'
|
||||||
import { formatDateTime } from '@/utils/date-time'
|
import { formatDateTime } from '@/utils/date-time'
|
||||||
|
import {
|
||||||
|
applyProductNameSourceSuffix,
|
||||||
|
type ProductNameSourceSuffixItem,
|
||||||
|
} from '@/utils/product-name-source-suffix'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
canEditAcceptanceImages,
|
canEditAcceptanceImages,
|
||||||
@@ -40,6 +44,7 @@ import {
|
|||||||
type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default'
|
type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default'
|
||||||
|
|
||||||
export function buildWorkerOrderTableColumns({
|
export function buildWorkerOrderTableColumns({
|
||||||
|
productNameSuffixItems,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
onOpenAcceptance,
|
onOpenAcceptance,
|
||||||
onOpenGiftMaterial,
|
onOpenGiftMaterial,
|
||||||
@@ -50,6 +55,7 @@ export function buildWorkerOrderTableColumns({
|
|||||||
onCopyOrderInfo,
|
onCopyOrderInfo,
|
||||||
onCopyMaterialImages,
|
onCopyMaterialImages,
|
||||||
}: {
|
}: {
|
||||||
|
productNameSuffixItems: ProductNameSourceSuffixItem[]
|
||||||
onOpenDetail: (order: WorkOrder) => void
|
onOpenDetail: (order: WorkOrder) => void
|
||||||
onOpenAcceptance: (order: WorkOrder) => void
|
onOpenAcceptance: (order: WorkOrder) => void
|
||||||
onOpenGiftMaterial: (order: WorkOrder) => void
|
onOpenGiftMaterial: (order: WorkOrder) => void
|
||||||
@@ -64,41 +70,49 @@ export function buildWorkerOrderTableColumns({
|
|||||||
{
|
{
|
||||||
title: '订单信息',
|
title: '订单信息',
|
||||||
width: 270,
|
width: 270,
|
||||||
render: (_, row) => (
|
render: (_, row) => {
|
||||||
<div className="cell-stack">
|
const displayProductName =
|
||||||
<Space size={4} align="center">
|
applyProductNameSourceSuffix(
|
||||||
{row.isUnread ? (
|
row.productName,
|
||||||
<Tag color="purple" className="worker-order-unread-tag">
|
row.sourceProvider,
|
||||||
新
|
productNameSuffixItems,
|
||||||
</Tag>
|
) || '未命名商品'
|
||||||
) : null}
|
return (
|
||||||
<Typography.Link
|
<div className="cell-stack">
|
||||||
strong
|
<Space size={4} align="center">
|
||||||
onClick={() => onOpenDetail(row)}
|
{row.isUnread ? (
|
||||||
title={row.productName || '未命名商品'}
|
<Tag color="purple" className="worker-order-unread-tag">
|
||||||
ellipsis
|
新
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
<Typography.Link
|
||||||
|
strong
|
||||||
|
onClick={() => onOpenDetail(row)}
|
||||||
|
title={displayProductName}
|
||||||
|
ellipsis
|
||||||
|
>
|
||||||
|
{displayProductName}
|
||||||
|
</Typography.Link>
|
||||||
|
</Space>
|
||||||
|
<Space size={[4, 4]} wrap>
|
||||||
|
{row.categoryName ? <Tag color="blue">{row.categoryName}</Tag> : null}
|
||||||
|
{row.myShares?.length || row.myShare ? (
|
||||||
|
<Tag color="purple">拼单 {row.myShares?.length || 1} 次</Tag>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
ellipsis={{ tooltip: getDisplayOrderNo(row) }}
|
||||||
>
|
>
|
||||||
{row.productName || '未命名商品'}
|
{getDisplayOrderNo(row)}
|
||||||
</Typography.Link>
|
</Typography.Text>
|
||||||
</Space>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
<Space size={[4, 4]} wrap>
|
接单:{formatDateTime(row.assignedAt)}
|
||||||
{row.categoryName ? <Tag color="blue">{row.categoryName}</Tag> : null}
|
</Typography.Text>
|
||||||
{row.myShares?.length || row.myShare ? (
|
</div>
|
||||||
<Tag color="purple">拼单 {row.myShares?.length || 1} 次</Tag>
|
)
|
||||||
) : null}
|
},
|
||||||
</Space>
|
|
||||||
<Typography.Text
|
|
||||||
type="secondary"
|
|
||||||
style={{ fontSize: 12 }}
|
|
||||||
ellipsis={{ tooltip: getDisplayOrderNo(row) }}
|
|
||||||
>
|
|
||||||
{getDisplayOrderNo(row)}
|
|
||||||
</Typography.Text>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
接单:{formatDateTime(row.assignedAt)}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd'
|
import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd'
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchWorkerAfterSalesCases,
|
fetchWorkerAfterSalesCases,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
fetchWorkerOrderFeedbacks,
|
fetchWorkerOrderFeedbacks,
|
||||||
fetchWorkerMyOrderOverview,
|
fetchWorkerMyOrderOverview,
|
||||||
fetchWorkerMyOrders,
|
fetchWorkerMyOrders,
|
||||||
|
fetchWorkerProductNameSuffix,
|
||||||
fetchWorkerProfile,
|
fetchWorkerProfile,
|
||||||
markWorkerOrderRead,
|
markWorkerOrderRead,
|
||||||
remindWorkerAcceptance,
|
remindWorkerAcceptance,
|
||||||
@@ -206,6 +207,18 @@ export default function WorkerOrdersPage() {
|
|||||||
const pagination = ordersQuery.data?.data.pagination
|
const pagination = ordersQuery.data?.data.pagination
|
||||||
const orders = ordersQuery.data?.data.items || []
|
const orders = ordersQuery.data?.data.items || []
|
||||||
|
|
||||||
|
// 商品名来源后缀(后台「杂项配置」维护):仅展示追加,复制资料仍用原始商品名。
|
||||||
|
const productNameSuffixQuery = useQuery({
|
||||||
|
queryKey: ['worker-product-name-suffix'],
|
||||||
|
queryFn: fetchWorkerProductNameSuffix,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const productNameSuffixItems = useMemo(
|
||||||
|
() => productNameSuffixQuery.data?.data.items || [],
|
||||||
|
[productNameSuffixQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
// 流转记录对打手是否可见(后台配置开关,默认关闭)
|
// 流转记录对打手是否可见(后台配置开关,默认关闭)
|
||||||
const profileQuery = useQuery({
|
const profileQuery = useQuery({
|
||||||
queryKey: ['worker-profile'],
|
queryKey: ['worker-profile'],
|
||||||
@@ -626,6 +639,7 @@ export default function WorkerOrdersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns = buildWorkerOrderTableColumns({
|
const columns = buildWorkerOrderTableColumns({
|
||||||
|
productNameSuffixItems,
|
||||||
onOpenDetail: openOrderDetail,
|
onOpenDetail: openOrderDetail,
|
||||||
onOpenAcceptance: openAcceptanceModal,
|
onOpenAcceptance: openAcceptanceModal,
|
||||||
onOpenGiftMaterial: openGiftMaterialModal,
|
onOpenGiftMaterial: openGiftMaterialModal,
|
||||||
@@ -686,6 +700,7 @@ export default function WorkerOrdersPage() {
|
|||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
page={page}
|
page={page}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
|
productNameSuffixItems={productNameSuffixItems}
|
||||||
renderShareTable={renderShareTable}
|
renderShareTable={renderShareTable}
|
||||||
loading={ordersQuery.isLoading}
|
loading={ordersQuery.isLoading}
|
||||||
searching={ordersQuery.isFetching}
|
searching={ordersQuery.isFetching}
|
||||||
@@ -767,6 +782,7 @@ export default function WorkerOrdersPage() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
order={detailOrder}
|
order={detailOrder}
|
||||||
orderTimelineVisible={orderTimelineVisible}
|
orderTimelineVisible={orderTimelineVisible}
|
||||||
|
productNameSuffixItems={productNameSuffixItems}
|
||||||
onClose={() => setDetailOrder(null)}
|
onClose={() => setDetailOrder(null)}
|
||||||
onOpenAcceptance={openAcceptanceModal}
|
onOpenAcceptance={openAcceptanceModal}
|
||||||
onOpenGiftMaterial={openGiftMaterialModal}
|
onOpenGiftMaterial={openGiftMaterialModal}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ConsignmentStatsSummary,
|
ConsignmentStatsSummary,
|
||||||
ConsignmentSubmission,
|
ConsignmentSubmission,
|
||||||
InventoryMatchableOrders,
|
InventoryMatchableOrders,
|
||||||
|
PendingPistolMatchResult,
|
||||||
WorkOrderInventoryMatchCheck,
|
WorkOrderInventoryMatchCheck,
|
||||||
WorkOrderInventoryMatchOptions,
|
WorkOrderInventoryMatchOptions,
|
||||||
} from '@/types/consignment'
|
} from '@/types/consignment'
|
||||||
@@ -158,6 +159,22 @@ export function fetchInventoryMatchableOrders(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货,按细粒度评分排序。 */
|
||||||
|
export function fetchPendingPistolMatches(params?: {
|
||||||
|
keyword?: string
|
||||||
|
includeConflicts?: boolean
|
||||||
|
limit?: number
|
||||||
|
}) {
|
||||||
|
return apiGet<PendingPistolMatchResult>(
|
||||||
|
'/api/v1/admin/worker-platform/consignment/pending-pistol-matches',
|
||||||
|
{
|
||||||
|
keyword: params?.keyword?.trim() || undefined,
|
||||||
|
includeConflicts: params?.includeConflicts ? 'true' : undefined,
|
||||||
|
limit: params?.limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** 履约/指派返回的电子凭证核销结果:ok=false 时必须提示运营人工核销,不能报成功 */
|
/** 履约/指派返回的电子凭证核销结果:ok=false 时必须提示运营人工核销,不能报成功 */
|
||||||
export type WorkOrderVoucherConsumeResult = {
|
export type WorkOrderVoucherConsumeResult = {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
WorkerPlatformNotificationConfig,
|
WorkerPlatformNotificationConfig,
|
||||||
WorkerProductMatchConfig,
|
WorkerProductMatchConfig,
|
||||||
WorkerAnnouncementConfig,
|
WorkerAnnouncementConfig,
|
||||||
|
WorkerProductNameSuffixConfig,
|
||||||
WorkerLevel,
|
WorkerLevel,
|
||||||
WorkerListResponse,
|
WorkerListResponse,
|
||||||
WorkerUserListResponse,
|
WorkerUserListResponse,
|
||||||
@@ -109,6 +110,22 @@ export function saveAdminWorkerAnnouncement(payload: WorkerAnnouncementConfig) {
|
|||||||
return apiPost<WorkerAnnouncementConfig>('/api/v1/admin/worker-platform/announcement', payload)
|
return apiPost<WorkerAnnouncementConfig>('/api/v1/admin/worker-platform/announcement', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAdminWorkerProductNameSuffix() {
|
||||||
|
return apiGet<WorkerProductNameSuffixConfig>('/api/v1/admin/worker-platform/product-name-suffix')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 来源下拉选项:来自工单资料里的 source.provider */
|
||||||
|
export function fetchAdminWorkerProductNameSuffixSources() {
|
||||||
|
return apiGet<{ items: string[] }>('/api/v1/admin/worker-platform/product-name-suffix/sources')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAdminWorkerProductNameSuffix(payload: WorkerProductNameSuffixConfig) {
|
||||||
|
return apiPost<WorkerProductNameSuffixConfig>(
|
||||||
|
'/api/v1/admin/worker-platform/product-name-suffix',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAdminWorkerCancelRequests(
|
export function fetchAdminWorkerCancelRequests(
|
||||||
status: 'pending' | 'approved' | 'rejected' = 'pending',
|
status: 'pending' | 'approved' | 'rejected' = 'pending',
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
WorkerCancelRequest,
|
WorkerCancelRequest,
|
||||||
WorkerCancelRequestRisk,
|
WorkerCancelRequestRisk,
|
||||||
WorkerAnnouncementConfig,
|
WorkerAnnouncementConfig,
|
||||||
|
WorkerProductNameSuffixConfig,
|
||||||
|
WorkerProductNameSuffixDisplayConfig,
|
||||||
WorkerAcceptanceConfig,
|
WorkerAcceptanceConfig,
|
||||||
WorkerAcceptanceVideo,
|
WorkerAcceptanceVideo,
|
||||||
WorkerOrderFeedback,
|
WorkerOrderFeedback,
|
||||||
@@ -152,6 +154,11 @@ export function fetchWorkerAnnouncement() {
|
|||||||
return apiGet<WorkerAnnouncementConfig>('/api/v1/worker/announcement')
|
return apiGet<WorkerAnnouncementConfig>('/api/v1/worker/announcement')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 商品名来源后缀映射(后台「杂项配置」维护,只返回已启用项),打手端列表/详情/大厅展示用。 */
|
||||||
|
export function fetchWorkerProductNameSuffix() {
|
||||||
|
return apiGet<WorkerProductNameSuffixDisplayConfig>('/api/v1/worker/product-name-suffix')
|
||||||
|
}
|
||||||
|
|
||||||
/** 验收视频配置(后台可改):时长上限、目标高度、码率等,压缩前读取。 */
|
/** 验收视频配置(后台可改):时长上限、目标高度、码率等,压缩前读取。 */
|
||||||
export function fetchWorkerAcceptanceConfig() {
|
export function fetchWorkerAcceptanceConfig() {
|
||||||
return apiGet<WorkerAcceptanceConfig>('/api/v1/worker/acceptance/config')
|
return apiGet<WorkerAcceptanceConfig>('/api/v1/worker/acceptance/config')
|
||||||
|
|||||||
@@ -240,3 +240,32 @@ export type WorkOrderInventoryMatchCheck = {
|
|||||||
partialCount: number
|
partialCount: number
|
||||||
matches: ConsignmentMatchResult[]
|
matches: ConsignmentMatchResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 后台「寄售仓库 → 匹配」:待抢单手枪 × 在库待匹配现货的配对明细 */
|
||||||
|
export type PendingPistolMatchPair = {
|
||||||
|
workOrderId: number
|
||||||
|
workOrderNo: string
|
||||||
|
platformOrderId: string
|
||||||
|
productName: string
|
||||||
|
workOrderCreatedAt: string | null
|
||||||
|
inventoryId: number
|
||||||
|
consignmentNo: string
|
||||||
|
inventoryNo: string
|
||||||
|
itemName: string
|
||||||
|
inventoryInboundAt: string | null
|
||||||
|
matchType: 'exact' | 'partial' | 'conflict'
|
||||||
|
score: number
|
||||||
|
reason: string
|
||||||
|
conflicts: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PendingPistolMatchResult = {
|
||||||
|
pendingOrderCount: number
|
||||||
|
inStockInventoryCount: number
|
||||||
|
pairCount: number
|
||||||
|
exactCount: number
|
||||||
|
partialCount: number
|
||||||
|
conflictCount: number
|
||||||
|
truncated: boolean
|
||||||
|
pairs: PendingPistolMatchPair[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -325,6 +325,26 @@ export type WorkerAnnouncementConfig = {
|
|||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkerProductNameSuffixItem = {
|
||||||
|
/** 来源标识(精确匹配、不区分大小写),如 91kaquan */
|
||||||
|
source: string
|
||||||
|
/** 括号内的后缀文字,如 91卡券 */
|
||||||
|
suffix: string
|
||||||
|
/** 是否启用;同一时间只允许启用一条 */
|
||||||
|
enabled: boolean
|
||||||
|
/** 后台备注(仅后台配置页可见) */
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkerProductNameSuffixConfig = {
|
||||||
|
items: WorkerProductNameSuffixItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打手端只拿启用的来源 -> 后缀映射 */
|
||||||
|
export type WorkerProductNameSuffixDisplayConfig = {
|
||||||
|
items: Array<Pick<WorkerProductNameSuffixItem, 'source' | 'suffix'>>
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkerHallConfig = {
|
export type WorkerHallConfig = {
|
||||||
maxVisibleOrders: number
|
maxVisibleOrders: number
|
||||||
showSubcategories?: boolean
|
showSubcategories?: boolean
|
||||||
@@ -622,6 +642,8 @@ export type WorkOrder = {
|
|||||||
workOrderNo: string
|
workOrderNo: string
|
||||||
platformOrderId: string
|
platformOrderId: string
|
||||||
productName: string
|
productName: string
|
||||||
|
/** 来源平台标识(如 91kaquan),仅打手端返回;用于按后台配置追加展示后缀 */
|
||||||
|
sourceProvider?: string
|
||||||
categoryId?: number | null
|
categoryId?: number | null
|
||||||
categoryName: string
|
categoryName: string
|
||||||
parentCategoryId?: number | null
|
parentCategoryId?: number | null
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export type ProductNameSourceSuffixItem = {
|
||||||
|
/** 来源标识,如 91kaquan */
|
||||||
|
source: string
|
||||||
|
/** 括号内的后缀文字,如 91卡券 */
|
||||||
|
suffix: string
|
||||||
|
/** 未启用(false)的配置不参与展示;缺省按启用处理,兼容打手端精简数据 */
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后缀只保留括号内的文字:填了外层全角/半角括号时自动剥离。 */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按来源取后缀:来源精确匹配、不区分大小写,空来源或空后缀不命中。
|
||||||
|
* 同一来源配置多条时以第一条有效配置为准。
|
||||||
|
*/
|
||||||
|
export function resolveProductNameSourceSuffix(
|
||||||
|
items: ProductNameSourceSuffixItem[] | null | undefined,
|
||||||
|
provider: unknown,
|
||||||
|
): string {
|
||||||
|
const normalizedProvider = String(provider ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
if (!normalizedProvider) return ''
|
||||||
|
|
||||||
|
for (const item of items ?? []) {
|
||||||
|
if (item?.enabled === false) continue
|
||||||
|
const source = String(item?.source ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
if (!source || source !== normalizedProvider) continue
|
||||||
|
const suffix = normalizeProductNameSuffixText(item?.suffix)
|
||||||
|
if (suffix) return suffix
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在商品名后追加「(后缀)」;没有后缀时原样返回。
|
||||||
|
* 商品名已以相同后缀结尾(全角/半角括号均可)时不重复追加。
|
||||||
|
*/
|
||||||
|
export function appendProductNameSourceSuffix(productName: unknown, suffix: unknown): string {
|
||||||
|
const name = String(productName ?? '').trim()
|
||||||
|
const normalizedSuffix = normalizeProductNameSuffixText(suffix)
|
||||||
|
if (!name || !normalizedSuffix) return name
|
||||||
|
|
||||||
|
if (name.endsWith(`(${normalizedSuffix})`) || name.endsWith(`(${normalizedSuffix})`)) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return `${name}(${normalizedSuffix})`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展示用:按来源匹配后一次性完成后缀追加;无匹配时返回原商品名。 */
|
||||||
|
export function applyProductNameSourceSuffix(
|
||||||
|
productName: unknown,
|
||||||
|
provider: unknown,
|
||||||
|
items: ProductNameSourceSuffixItem[] | null | undefined,
|
||||||
|
): string {
|
||||||
|
return appendProductNameSourceSuffix(productName, resolveProductNameSourceSuffix(items, provider))
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
appendProductNameSourceSuffix,
|
||||||
|
applyProductNameSourceSuffix,
|
||||||
|
normalizeProductNameSuffixText,
|
||||||
|
resolveProductNameSourceSuffix,
|
||||||
|
} from '../src/utils/product-name-source-suffix.ts'
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ source: '91kaquan', suffix: '91卡券' },
|
||||||
|
{ source: 'douyin', suffix: '抖音' },
|
||||||
|
]
|
||||||
|
|
||||||
|
test('来源后缀:空配置不追加', () => {
|
||||||
|
assert.equal(applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', []), '苏尔南手枪')
|
||||||
|
assert.equal(applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', null), '苏尔南手枪')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源后缀:精确匹配后追加全角括号后缀', () => {
|
||||||
|
assert.equal(
|
||||||
|
applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', items),
|
||||||
|
'苏尔南手枪(91卡券)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源后缀:来源匹配不区分大小写并去除首尾空格', () => {
|
||||||
|
assert.equal(resolveProductNameSourceSuffix(items, ' 91KAQUAN '), '91卡券')
|
||||||
|
assert.equal(
|
||||||
|
applyProductNameSourceSuffix('苏尔南手枪', ' 91Kaquan ', items),
|
||||||
|
'苏尔南手枪(91卡券)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源后缀:没有匹配来源时不追加', () => {
|
||||||
|
assert.equal(applyProductNameSourceSuffix('苏尔南手枪', 'kuaishou', items), '苏尔南手枪')
|
||||||
|
assert.equal(applyProductNameSourceSuffix('苏尔南手枪', '', items), '苏尔南手枪')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源后缀:商品名已带相同后缀时不重复追加', () => {
|
||||||
|
assert.equal(
|
||||||
|
appendProductNameSourceSuffix('苏尔南手枪(91卡券)', '91卡券'),
|
||||||
|
'苏尔南手枪(91卡券)',
|
||||||
|
)
|
||||||
|
assert.equal(appendProductNameSourceSuffix('苏尔南手枪(91卡券)', '91卡券'), '苏尔南手枪(91卡券)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源后缀:后缀为空或商品名为空时不做处理', () => {
|
||||||
|
assert.equal(appendProductNameSourceSuffix('苏尔南手枪', ''), '苏尔南手枪')
|
||||||
|
assert.equal(appendProductNameSourceSuffix('', '91卡券'), '')
|
||||||
|
assert.equal(applyProductNameSourceSuffix('', '91kaquan', items), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源后缀:配置里的外层括号会被剥离后再拼接', () => {
|
||||||
|
assert.equal(normalizeProductNameSuffixText('(91卡券)'), '91卡券')
|
||||||
|
assert.equal(normalizeProductNameSuffixText('(91卡券)'), '91卡券')
|
||||||
|
assert.equal(
|
||||||
|
applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', [
|
||||||
|
{ source: '91kaquan', suffix: '(91卡券)' },
|
||||||
|
]),
|
||||||
|
'苏尔南手枪(91卡券)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源后缀:停用的配置不参与展示,启用后才追加', () => {
|
||||||
|
assert.equal(
|
||||||
|
applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', [
|
||||||
|
{ source: '91kaquan', suffix: '91卡券', enabled: false },
|
||||||
|
]),
|
||||||
|
'苏尔南手枪',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', [
|
||||||
|
{ source: '91kaquan', suffix: '91卡券', enabled: true },
|
||||||
|
]),
|
||||||
|
'苏尔南手枪(91卡券)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源后缀:同一来源多条配置时取第一条有效后缀', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveProductNameSourceSuffix(
|
||||||
|
[
|
||||||
|
{ source: '91kaquan', suffix: '第一' },
|
||||||
|
{ source: '91kaquan', suffix: '第二' },
|
||||||
|
],
|
||||||
|
'91kaquan',
|
||||||
|
),
|
||||||
|
'第一',
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user