From 13869d345f04ef2f65378d01466aa1af1d9e80d9 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 2 Nov 2026 14:25:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(worker-platform):=20=E5=95=86=E5=93=81?= =?UTF-8?q?=E5=90=8D=E6=8C=89=E6=9D=A5=E6=BA=90=E8=BF=BD=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=90=8E=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增来源后缀配置(来源下拉、启停、备注,同一时间仅启用一个) - 管理端列表/详情与打手端列表/详情/大厅展示「商品名(后缀)」 - 打手端 DTO 透出 sourceProvider,仅展示,不改写商品名 - 新增来源选项接口与前后端单测 --- apps/backend/src/config/app-config-keys.ts | 2 + .../worker-platform/work-order-query-repo.ts | 18 ++ .../routes/admin/worker-platform/config.ts | 41 +++ apps/backend/src/routes/worker/orders.ts | 12 + .../admin-worker-config-service.ts | 12 + .../src/services/worker-platform/index.ts | 1 + .../work-order-display-mappers.ts | 11 + ...worker-product-name-suffix-service.test.ts | 143 +++++++++++ .../worker-product-name-suffix-service.ts | 145 +++++++++++ .../pages/admin/panels/AnnouncementPanel.tsx | 239 +++++++++++++++++- .../admin/panels/WorkOrderTableCells.tsx | 8 +- .../pages/admin/panels/WorkOrdersPanel.tsx | 25 +- .../src/pages/worker/WorkerHallPage.tsx | 63 ++++- .../pages/worker/WorkerOrderDetailDrawer.tsx | 16 +- .../pages/worker/WorkerOrderMobileList.tsx | 14 +- .../pages/worker/WorkerOrderTableColumns.tsx | 82 +++--- .../src/pages/worker/WorkerOrdersPage.tsx | 18 +- .../src/services/admin/worker-platform.ts | 17 ++ apps/frontend/src/services/worker.ts | 7 + apps/frontend/src/types/worker-platform.ts | 22 ++ .../src/utils/product-name-source-suffix.ts | 69 +++++ .../tests/product-name-source-suffix.test.ts | 92 +++++++ 22 files changed, 1008 insertions(+), 49 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/worker-product-name-suffix-service.test.ts create mode 100644 apps/backend/src/services/worker-platform/worker-product-name-suffix-service.ts create mode 100644 apps/frontend/src/utils/product-name-source-suffix.ts create mode 100644 apps/frontend/tests/product-name-source-suffix.test.ts diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index 9be4b937..27dc02eb 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -7,6 +7,8 @@ export const APP_CONFIG_KEYS = { workerProductMatch: 'worker_product_match', workerAnnouncement: 'worker_announcement', workerHall: 'worker_hall', + /** 商品名来源后缀:按 material.source.provider 在商品名后追加(文字),仅展示用 */ + workerProductNameSuffix: 'worker_product_name_suffix', workerRiskControl: 'worker_risk_control', workerMockShops: 'worker_mock_shops', kuaishouIndustrySource: 'kuaishou_industry_source', diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 177da82c..afc592eb 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -1689,6 +1689,24 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh ` } +/** + * 后台「商品名来源后缀」下拉选项:取工单资料里的来源标识。 + * 去重、去空、限制数量,避免脏数据把下拉撑爆。 + */ +export async function listWorkOrderSourceProviders(limit = 200): Promise { + const result = await query<{ provider: string }>( + ` + SELECT DISTINCT NULLIF(BTRIM(material_json->'source'->>'provider'), '') AS provider + FROM work_orders + WHERE NULLIF(BTRIM(material_json->'source'->>'provider'), '') IS NOT NULL + ORDER BY provider ASC + LIMIT $1 + `, + [limit], + ) + return result.rows.map((row) => row.provider).filter(Boolean) +} + /** * 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。 * 候选集与列表接口同源(FIFO 窗口 + 全部置顶单),所以置顶单即使排在窗口外也能正常抢单/拼单。 diff --git a/apps/backend/src/routes/admin/worker-platform/config.ts b/apps/backend/src/routes/admin/worker-platform/config.ts index fdacc2c2..0020091f 100644 --- a/apps/backend/src/routes/admin/worker-platform/config.ts +++ b/apps/backend/src/routes/admin/worker-platform/config.ts @@ -13,6 +13,7 @@ import { getAdminWorkerPlatformNotificationConfig, getAdminWorkerPlatformSummary, getAdminWorkerProductMatchConfig, + getAdminWorkerProductNameSuffixConfig, listAdminKuaishouMatchSources, listAdminWorkCategories, listAdminWorkProductMatchLogs, @@ -20,6 +21,7 @@ import { listAdminWorkProductRuleMappings, listAdminWorkProductRules, listAdminWorkerLevels, + listWorkerProductNameSuffixSourceOptions, saveAdminWorkCategory, saveAdminWorkProductRule, saveAdminWorkProductRuleMapping, @@ -29,6 +31,7 @@ import { saveAdminWorkerLevel, saveAdminWorkerPlatformNotificationConfig, saveAdminWorkerProductMatchConfig, + saveAdminWorkerProductNameSuffixConfig, syncAdminWorkProductRulePrice, testAdminKuaishouProductMatch, testAdminProductMatchByOrderItem, @@ -309,6 +312,44 @@ router.post( }), ) +router.get( + '/worker-platform/product-name-suffix', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler(() => getAdminWorkerProductNameSuffixConfig(), { + successMessage: 'ok', + errorMessage: '读取商品名来源后缀配置失败', + scope: '[admin/worker-platform/product-name-suffix]', + }), +) + +// 下拉选项来自工单资料里的来源标识,低频配置,给浏览器 5 分钟缓存。 +router.get( + '/worker-platform/product-name-suffix/sources', + requireAdminRoles(['admin', 'operator', 'support']), + setHttpCache(300), + createJsonHandler(() => listWorkerProductNameSuffixSourceOptions(), { + successMessage: 'ok', + errorMessage: '读取工单来源选项失败', + scope: '[admin/worker-platform/product-name-suffix/sources]', + }), +) + +router.post( + '/worker-platform/product-name-suffix', + requireAdminRoles(['admin']), + createJsonHandler((req) => saveAdminWorkerProductNameSuffixConfig(req.body || {}), { + successMessage: '商品名来源后缀已保存', + errorMessage: '保存商品名来源后缀失败', + scope: '[admin/worker-platform/product-name-suffix]', + audit: (_req, data) => ({ + action: 'worker_product_name_suffix_saved', + targetType: 'worker_product_name_suffix', + targetId: 'worker_product_name_suffix', + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), +) + router.post( '/worker-platform/product-match-config', requireAdminRoles(['admin', 'operator']), diff --git a/apps/backend/src/routes/worker/orders.ts b/apps/backend/src/routes/worker/orders.ts index 7a46de55..cc1db343 100644 --- a/apps/backend/src/routes/worker/orders.ts +++ b/apps/backend/src/routes/worker/orders.ts @@ -8,6 +8,7 @@ import { getWorkerMyOrderEvents, getWorkerMyOrderOverview, getWorkerOrderFeedbackSummary, + getWorkerProductNameSuffixDisplayConfig, listWorkerMyOrders, markWorkerOrderRead, remindWorkerOrderAcceptance, @@ -90,6 +91,17 @@ router.get( }), ) +// 商品名来源后缀映射:只下发已启用配置,打手端列表/详情/大厅统一按来源追加(文字),只读。 +router.get( + '/product-name-suffix', + requireActiveWorker, + createRouteHandler(() => getWorkerProductNameSuffixDisplayConfig(), { + successMessage: 'ok', + errorMessage: '读取商品名来源后缀配置失败', + scope: '[worker/product-name-suffix]', + }), +) + router.get( '/orders/feedbacks', requireActiveWorker, diff --git a/apps/backend/src/services/worker-platform/admin-worker-config-service.ts b/apps/backend/src/services/worker-platform/admin-worker-config-service.ts index df870d1d..3cf996f8 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-config-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-config-service.ts @@ -12,6 +12,10 @@ import { getWorkerProductMatchConfig, saveWorkerProductMatchConfig, } from './worker-product-match-config-service.js' +import { + getWorkerProductNameSuffixConfig, + saveWorkerProductNameSuffixConfig, +} from './worker-product-name-suffix-service.js' export async function getAdminWorkerPlatformNotificationConfig() { return getWorkerPlatformNotificationConfig() @@ -37,6 +41,14 @@ export async function saveAdminWorkerProductMatchConfig(payload: JsonObject = {} return saveWorkerProductMatchConfig(payload) } +export async function getAdminWorkerProductNameSuffixConfig() { + return getWorkerProductNameSuffixConfig() +} + +export async function saveAdminWorkerProductNameSuffixConfig(payload: JsonObject = {}) { + return saveWorkerProductNameSuffixConfig(payload) +} + export async function pruneAdminWorkProductMatchLogs(payload: JsonObject = {}) { const config = getWorkerProductMatchConfig() const maxRows = payload.maxRows !== undefined ? Number(payload.maxRows) : config.maxMatchLogs diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index ca46aaa8..60b9a547 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -61,6 +61,7 @@ export { export * from './worker-product-match-config-service.js' export * from './worker-announcement-config-service.js' export * from './worker-hall-config-service.js' +export * from './worker-product-name-suffix-service.js' export * from './worker-acceptance-config-service.js' export * from './sync-worker-orders-service.js' export * from './consignment-contact-service.js' diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 26e3fbec..f32a1b16 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -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).source + if (!source || typeof source !== 'object' || Array.isArray(source)) return '' + return String((source as Record).provider || '').trim() +} + export function mapWorkOrderForWorker( workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }, @@ -571,6 +579,8 @@ export function mapWorkOrderForWorker( productName: hasActiveShares ? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity) : visibleOrder.productName, + /** 来源平台标识;前端按后台配置决定是否在商品名后追加(文字) */ + sourceProvider: resolveSourceProviderFromMaterial(visibleOrder.material), status: personalStatus, /** 行上展示的问题备注:母单问题单用母单备注,否则用被标记份额的备注 */ problemNote: @@ -664,6 +674,7 @@ export function mapWorkOrderForHall( workOrderNo: mapped.workOrderNo, platformOrderId: mapped.platformOrderId, productName: mapped.productName, + sourceProvider: mapped.sourceProvider, categoryId: mapped.categoryId, categoryName: mapped.categoryName, parentCategoryId: workOrder.parent_category_id ? Number(workOrder.parent_category_id) : null, diff --git a/apps/backend/src/services/worker-platform/worker-product-name-suffix-service.test.ts b/apps/backend/src/services/worker-platform/worker-product-name-suffix-service.test.ts new file mode 100644 index 00000000..036a6511 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-product-name-suffix-service.test.ts @@ -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: [] }) +}) diff --git a/apps/backend/src/services/worker-platform/worker-product-name-suffix-service.ts b/apps/backend/src/services/worker-platform/worker-product-name-suffix-service.ts new file mode 100644 index 00000000..6d519726 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-product-name-suffix-service.ts @@ -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 { + 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() + 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() + 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]' +} diff --git a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx index 9cb5a206..93b76890 100644 --- a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' -import { ReloadOutlined, SaveOutlined } from '@ant-design/icons' +import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons' import { Alert, App, @@ -9,12 +9,13 @@ import { Input, InputNumber, Popconfirm, + Select, Space, Switch, Tag, Typography, } from 'antd' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { addAdminMockCustomShop, @@ -22,14 +23,18 @@ import { fetchAdminMockCustomShops, fetchAdminScheduledJobsConfig, fetchAdminWorkerAnnouncement, + fetchAdminWorkerProductNameSuffix, + fetchAdminWorkerProductNameSuffixSources, renameAdminMockCustomShop, runAdminScheduledJob, saveAdminWorkerAnnouncement, + saveAdminWorkerProductNameSuffix, saveAdminScheduledJobsConfig, } from '@/services/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 { normalizeProductNameSuffixText } from '@/utils/product-name-source-suffix' import HallConfigPanel from './HallConfigPanel' @@ -41,6 +46,7 @@ export default function AnnouncementPanel() { return ( + @@ -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([]) + 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() + 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) { + 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() + 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 ( + } loading={saving} onClick={() => void save()}> + 保存 + + } + > + + 来源下拉数据取自工单的「来源」字段;选中来源后填写后缀,启用后展示为「商品名(后缀)」。 + 同一时间只能启用一个来源,启用新来源会自动停用其他行;未启用或没有匹配配置的来源不追加。 + 备注仅后台可见。只影响列表/详情的展示,不会改写工单里的商品名。 + + {sourcesQuery.isError ? ( + + 读取工单来源选项失败,已配置的来源仍可编辑;请稍后刷新重试。 + + ) : null} + {items.length === 0 ? ( + + 还没有配置,所有商品名保持原样;点击「添加一行」开始配置。 + + ) : null} + + {items.map((item, index) => { + const suffix = normalizeProductNameSuffixText(item.suffix) + return ( + + toggleRow(item.rowKey, checked)} + /> + patchRow(item.rowKey, { suffix: event.target.value })} + /> + patchRow(item.rowKey, { note: event.target.value })} + /> + + {suffix + ? item.enabled + ? `示例:商品名(${suffix})` + : '未启用,不追加' + : '未填写后缀时不追加'} + + + + ) + })} + + + + ) +} + function MockCustomShopsCard() { const { message, modal } = App.useApp() const queryClient = useQueryClient() diff --git a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx index 7cf892d8..64ebbd28 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from 'react' import { updateAdminWorkOrder, updateAdminWorkOrderPaymentAmount } from '@/services/admin' import type { WorkOrder } from '@/types/worker-platform' +import { appendProductNameSourceSuffix } from '@/utils/product-name-source-suffix' import { formatMoney } from './shared' import { @@ -15,10 +16,13 @@ import { /** 商品名:单击看详情,双击原地修改(仅未接走工单) */ export function ProductNameEditableCell({ order, + sourceSuffix = '', onOpenDetail, onSaved, }: { order: WorkOrder + /** 按来源匹配到的后缀(不含括号):只用于展示,编辑态仍显示原始商品名 */ + sourceSuffix?: string onOpenDetail: () => void onSaved: () => Promise | void }) { @@ -69,13 +73,15 @@ export function ProductNameEditableCell({ } if (!editing) { + const displayName = + appendProductNameSourceSuffix(order.productName, sourceSuffix) || '未命名商品' return ( - {order.productName || '未命名商品'} + {displayName} ) } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index c41b8855..06bb7470 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -68,6 +68,7 @@ import { fetchAdminWorkOrderSharing, fetchAdminWorkOrderShopOptions, fetchAdminWorkerLevels, + fetchAdminWorkerProductNameSuffix, fetchAdminWorkerUsers, fetchAdminWorkOrders, markAdminWorkOrderProblem, @@ -107,6 +108,10 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi import { getAdminUsername, hasAdminPermission, hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' 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 { DeadlineCountdown, @@ -285,6 +290,13 @@ export default function WorkOrdersPanel() { queryFn: fetchAdminSession, 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 canReturnToHall = sessionPermissions ? sessionPermissions.includes('worker_order.return_to_hall') @@ -436,6 +448,13 @@ export default function WorkOrdersPanel() { // 详情抽屉里的寄售履约信息(有寄售履约才展示寄卖单号) 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' @@ -1676,11 +1695,13 @@ export default function WorkOrdersPanel() { const source = asRecord(row.material?.source) const shopName = String(source.shopName || '').trim() const provider = String(source.provider || '').trim() + const sourceSuffix = resolveProductNameSourceSuffix(productNameSuffixItems, provider) const consignment = getConsignmentFulfillment(row) return (
openDetailDrawer(row)} onSaved={refreshAll} /> @@ -2360,7 +2381,7 @@ export default function WorkOrdersPanel() { className="worker-order-detail-drawer" title={ detailOrder - ? `${detailOrder.productName || '工单详情'} · ${getDisplayOrderNo(detailOrder)}` + ? `${detailProductName || '工单详情'} · ${getDisplayOrderNo(detailOrder)}` : '工单详情' } width={860} @@ -2408,7 +2429,7 @@ export default function WorkOrdersPanel() { {detailOrder.orderItemId || '-'} - {detailOrder.productName || '-'} + {detailProductName || '-'} {detailOrder.categoryName || '-'} diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx index ab4e9a7d..1cea384d 100644 --- a/apps/frontend/src/pages/worker/WorkerHallPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -33,7 +33,15 @@ import { Tooltip, Typography, } 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 { WorkerContactModal } from '@/components/worker/WorkerContactModal' @@ -44,6 +52,7 @@ import { fetchWorkerHallLeaderboard, fetchWorkerMyOrders, fetchWorkerMyOrderOverview, + fetchWorkerProductNameSuffix, fetchWorkerProfile, grabWorkerOrder, joinWorkerSharingOrder, @@ -67,6 +76,10 @@ import { resolveHallRemainingQuantity, resolveHallRewardAmount, } from '@/utils/worker-product-name' +import { + applyProductNameSourceSuffix, + type ProductNameSourceSuffixItem, +} from '@/utils/product-name-source-suffix' import { formatDateTime, formatPublishedElapsedTime } from '@/utils/date-time' 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 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<{ categories: WorkCategory[] showSubcategories: boolean @@ -337,7 +372,7 @@ export default function WorkerHallPage() { modal.confirm({ title: '确认抢单', icon: , - content: `确认抢下「${resolveHallProductName(order)}」?奖励 ${formatMoney( + content: `确认抢下「${resolveDisplayProductName(order)}」?奖励 ${formatMoney( resolveHallRewardAmount(order), )}`, okText: '确认抢单', @@ -345,7 +380,7 @@ export default function WorkerHallPage() { onOk: () => submitGrab(order), }) }, - [grabConfirmEnabled, modal, submitGrab], + [grabConfirmEnabled, modal, submitGrab, resolveDisplayProductName], ) const openSharingModal = useCallback((order: WorkOrder) => { @@ -679,6 +714,7 @@ export default function WorkerHallPage() { remainingSlots={remainingSlots} isInternalWorker={isInternalWorker} grabbing={grabbingId === order.workOrderId} + productNameSuffixItems={productNameSuffixItems} onGrab={requestGrab} onOpenSharing={openSharingModal} /> @@ -1017,6 +1053,7 @@ export default function WorkerHallPage() { remainingSlots={remainingSlots} isInternalWorker={isInternalWorker} grabbing={grabbingId === order.workOrderId} + productNameSuffixItems={productNameSuffixItems} onGrab={requestGrab} onOpenSharing={openSharingModal} /> @@ -1031,7 +1068,7 @@ export default function WorkerHallPage() { )} void onOpenSharing: (order: WorkOrder) => void } @@ -1202,11 +1241,17 @@ const HallDesktopCard = memo(function HallDesktopCard({ remainingSlots, isInternalWorker, grabbing, + productNameSuffixItems, onGrab, onOpenSharing, }: HallCardProps) { const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) - const productName = resolveHallProductName(order) || '未命名商品' + const productName = + applyProductNameSourceSuffix( + resolveHallProductName(order), + order.sourceProvider, + productNameSuffixItems, + ) || '未命名商品' const sharingRemaining = resolveHallRemainingQuantity(order) const depositAmount = resolveHallDepositAmount(order) const rewardAmount = resolveHallRewardAmount(order) @@ -1326,11 +1371,17 @@ const HallMobileCard = memo(function HallMobileCard({ remainingSlots, isInternalWorker, grabbing, + productNameSuffixItems, onGrab, onOpenSharing, }: HallCardProps) { const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) - const productName = resolveHallProductName(order) || '未命名商品' + const productName = + applyProductNameSourceSuffix( + resolveHallProductName(order), + order.sourceProvider, + productNameSuffixItems, + ) || '未命名商品' const sharingRemaining = resolveHallRemainingQuantity(order) const myShareSummary = resolveHallMyShareSummary(order) diff --git a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx index 074d350d..33de9c2a 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx @@ -7,6 +7,10 @@ import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/ import type { WorkOrder } from '@/types/worker-platform' import { formatDateTime } from '@/utils/date-time' import { resolveOrderConsignmentScreenshotUrls } from '@/utils/consignment-display' +import { + applyProductNameSourceSuffix, + type ProductNameSourceSuffixItem, +} from '@/utils/product-name-source-suffix' import { canEditAcceptanceImages, @@ -35,6 +39,7 @@ export function WorkerOrderDetailDrawer({ isMobile, order, orderTimelineVisible, + productNameSuffixItems, onClose, onOpenAcceptance, onOpenGiftMaterial, @@ -44,6 +49,8 @@ export function WorkerOrderDetailDrawer({ isMobile: boolean order: WorkOrder | null orderTimelineVisible: boolean + /** 后台「杂项配置」维护的来源后缀;仅展示追加,不影响复制内容 */ + productNameSuffixItems: ProductNameSourceSuffixItem[] onClose: () => void onOpenAcceptance: (order: WorkOrder) => void onOpenGiftMaterial: (order: WorkOrder) => void @@ -51,6 +58,13 @@ export function WorkerOrderDetailDrawer({ onOpenFeedback: (order: WorkOrder) => void }) { const isTimedOut = Boolean(order && (order.status === 'open' || order.timeoutNotice?.isTimedOut)) + const displayProductName = order + ? applyProductNameSourceSuffix( + order.productName, + order.sourceProvider, + productNameSuffixItems, + ) || '-' + : '-' return ( - {order.productName || '-'} + {displayProductName} {order.categoryName || '-'} {getDisplayOrderNo(order)} diff --git a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx index 139da7fd..ce4263f3 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx @@ -20,6 +20,10 @@ import type { WorkOrder, } from '@/types/worker-platform' import { formatDateTime } from '@/utils/date-time' +import { + applyProductNameSourceSuffix, + type ProductNameSourceSuffixItem, +} from '@/utils/product-name-source-suffix' import type { WorkerOrderStatusOption } from './WorkerOrderDesktopList' import { @@ -56,6 +60,7 @@ export function WorkerOrderMobileList({ pagination, page, pageSize, + productNameSuffixItems, renderShareTable, loading, searching, @@ -85,6 +90,8 @@ export function WorkerOrderMobileList({ pagination: PaginationData | undefined page: number pageSize: number + /** 后台「杂项配置」维护的来源后缀;仅展示追加,不影响复制内容 */ + productNameSuffixItems: ProductNameSourceSuffixItem[] renderShareTable?: (order: WorkOrder) => ReactNode loading: boolean searching: boolean @@ -178,6 +185,7 @@ export function WorkerOrderMobileList({ @@ -308,7 +320,7 @@ function WorkerOrderMobileCard({
-
{order.productName || '未命名商品'}
+
{displayProductName}
{order.status === 'open' || order.timeoutNotice?.isTimedOut ? (
diff --git a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx index 461e602c..2b2d4eb3 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx @@ -12,6 +12,10 @@ import { DeadlineCountdown } from '@/components/DeadlineCountdown' import ImagePreviewList from '@/components/files/ImagePreviewList' import type { WorkOrder } from '@/types/worker-platform' import { formatDateTime } from '@/utils/date-time' +import { + applyProductNameSourceSuffix, + type ProductNameSourceSuffixItem, +} from '@/utils/product-name-source-suffix' import { canEditAcceptanceImages, @@ -40,6 +44,7 @@ import { type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default' export function buildWorkerOrderTableColumns({ + productNameSuffixItems, onOpenDetail, onOpenAcceptance, onOpenGiftMaterial, @@ -50,6 +55,7 @@ export function buildWorkerOrderTableColumns({ onCopyOrderInfo, onCopyMaterialImages, }: { + productNameSuffixItems: ProductNameSourceSuffixItem[] onOpenDetail: (order: WorkOrder) => void onOpenAcceptance: (order: WorkOrder) => void onOpenGiftMaterial: (order: WorkOrder) => void @@ -64,41 +70,49 @@ export function buildWorkerOrderTableColumns({ { title: '订单信息', width: 270, - render: (_, row) => ( -
- - {row.isUnread ? ( - - 新 - - ) : null} - onOpenDetail(row)} - title={row.productName || '未命名商品'} - ellipsis + render: (_, row) => { + const displayProductName = + applyProductNameSourceSuffix( + row.productName, + row.sourceProvider, + productNameSuffixItems, + ) || '未命名商品' + return ( +
+ + {row.isUnread ? ( + + 新 + + ) : null} + onOpenDetail(row)} + title={displayProductName} + ellipsis + > + {displayProductName} + + + + {row.categoryName ? {row.categoryName} : null} + {row.myShares?.length || row.myShare ? ( + 拼单 {row.myShares?.length || 1} 次 + ) : null} + + - {row.productName || '未命名商品'} - - - - {row.categoryName ? {row.categoryName} : null} - {row.myShares?.length || row.myShare ? ( - 拼单 {row.myShares?.length || 1} 次 - ) : null} - - - {getDisplayOrderNo(row)} - - - 接单:{formatDateTime(row.assignedAt)} - -
- ), + {getDisplayOrderNo(row)} + + + 接单:{formatDateTime(row.assignedAt)} + +
+ ) + }, }, { title: '状态', diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 4f1b1094..ff216238 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -1,7 +1,7 @@ import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router' import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { fetchWorkerAfterSalesCases, @@ -9,6 +9,7 @@ import { fetchWorkerOrderFeedbacks, fetchWorkerMyOrderOverview, fetchWorkerMyOrders, + fetchWorkerProductNameSuffix, fetchWorkerProfile, markWorkerOrderRead, remindWorkerAcceptance, @@ -206,6 +207,18 @@ export default function WorkerOrdersPage() { const pagination = ordersQuery.data?.data.pagination 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({ queryKey: ['worker-profile'], @@ -626,6 +639,7 @@ export default function WorkerOrdersPage() { } const columns = buildWorkerOrderTableColumns({ + productNameSuffixItems, onOpenDetail: openOrderDetail, onOpenAcceptance: openAcceptanceModal, onOpenGiftMaterial: openGiftMaterialModal, @@ -686,6 +700,7 @@ export default function WorkerOrdersPage() { pagination={pagination} page={page} pageSize={pageSize} + productNameSuffixItems={productNameSuffixItems} renderShareTable={renderShareTable} loading={ordersQuery.isLoading} searching={ordersQuery.isFetching} @@ -767,6 +782,7 @@ export default function WorkerOrdersPage() { isMobile={isMobile} order={detailOrder} orderTimelineVisible={orderTimelineVisible} + productNameSuffixItems={productNameSuffixItems} onClose={() => setDetailOrder(null)} onOpenAcceptance={openAcceptanceModal} onOpenGiftMaterial={openGiftMaterialModal} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index cd867027..ab9cc411 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -29,6 +29,7 @@ import type { WorkerPlatformNotificationConfig, WorkerProductMatchConfig, WorkerAnnouncementConfig, + WorkerProductNameSuffixConfig, WorkerLevel, WorkerListResponse, WorkerUserListResponse, @@ -109,6 +110,22 @@ export function saveAdminWorkerAnnouncement(payload: WorkerAnnouncementConfig) { return apiPost('/api/v1/admin/worker-platform/announcement', payload) } +export function fetchAdminWorkerProductNameSuffix() { + return apiGet('/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( + '/api/v1/admin/worker-platform/product-name-suffix', + payload, + ) +} + export function fetchAdminWorkerCancelRequests( status: 'pending' | 'approved' | 'rejected' = 'pending', ) { diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index c6130c8f..6ef1b4ad 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -11,6 +11,8 @@ import type { WorkerCancelRequest, WorkerCancelRequestRisk, WorkerAnnouncementConfig, + WorkerProductNameSuffixConfig, + WorkerProductNameSuffixDisplayConfig, WorkerAcceptanceConfig, WorkerAcceptanceVideo, WorkerOrderFeedback, @@ -152,6 +154,11 @@ export function fetchWorkerAnnouncement() { return apiGet('/api/v1/worker/announcement') } +/** 商品名来源后缀映射(后台「杂项配置」维护,只返回已启用项),打手端列表/详情/大厅展示用。 */ +export function fetchWorkerProductNameSuffix() { + return apiGet('/api/v1/worker/product-name-suffix') +} + /** 验收视频配置(后台可改):时长上限、目标高度、码率等,压缩前读取。 */ export function fetchWorkerAcceptanceConfig() { return apiGet('/api/v1/worker/acceptance/config') diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index f7f8b30b..542fb12d 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -325,6 +325,26 @@ export type WorkerAnnouncementConfig = { 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> +} + export type WorkerHallConfig = { maxVisibleOrders: number showSubcategories?: boolean @@ -622,6 +642,8 @@ export type WorkOrder = { workOrderNo: string platformOrderId: string productName: string + /** 来源平台标识(如 91kaquan),仅打手端返回;用于按后台配置追加展示后缀 */ + sourceProvider?: string categoryId?: number | null categoryName: string parentCategoryId?: number | null diff --git a/apps/frontend/src/utils/product-name-source-suffix.ts b/apps/frontend/src/utils/product-name-source-suffix.ts new file mode 100644 index 00000000..b074ef75 --- /dev/null +++ b/apps/frontend/src/utils/product-name-source-suffix.ts @@ -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)) +} diff --git a/apps/frontend/tests/product-name-source-suffix.test.ts b/apps/frontend/tests/product-name-source-suffix.test.ts new file mode 100644 index 00000000..524ebaa3 --- /dev/null +++ b/apps/frontend/tests/product-name-source-suffix.test.ts @@ -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', + ), + '第一', + ) +}) From 53fe841460d90e20221f93c4bec4437f7205b1d5 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 2 Nov 2026 14:59:46 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(consignment):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=BE=85=E6=8A=A2=E5=8D=95=E6=89=8B=E6=9E=AA=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=8C=B9=E9=85=8D=E7=9C=8B=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 寄售仓库刷新前新增「一键查询待匹配订单」入口 - 待抢单非拼单工单 × 在库待匹配现货,按系统/区服/系列/钻级/璀璨细粒度评分 - 支持关键字搜索、冲突项开关、刷新与分页 - 只读查询,不自动履约、不改工单 --- .../admin/worker-platform/consignment.ts | 20 ++ .../consignment-matching-service.ts | 116 ++++++++ .../consignment-pending-match-service.test.ts | 247 ++++++++++++++++ .../consignment-pending-match-service.ts | 199 +++++++++++++ .../src/services/worker-platform/index.ts | 1 + .../admin/ConsignmentPendingMatchModal.tsx | 266 ++++++++++++++++++ .../panels/ConsignmentInventoryPanel.tsx | 20 +- .../src/services/admin/consignment.ts | 17 ++ apps/frontend/src/types/consignment.ts | 29 ++ 9 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts create mode 100644 apps/backend/src/services/worker-platform/consignment-pending-match-service.ts create mode 100644 apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index c5d8d58d..d5bcac43 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -36,6 +36,7 @@ import { listInventoryMatchableOrders, listWorkOrderInventoryMatchOptions, } from '../../../services/worker-platform/consignment-fulfillment-service.js' +import { listPendingPistolMatches } from '../../../services/worker-platform/consignment-pending-match-service.js' 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( '/worker-platform/consignment/matches/:workOrderId', requireAdminPermission('consignment.view'), diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.ts index 6a4b6af9..c7e82541 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.ts @@ -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( rawRequirementText: string, inventories: ConsignmentInventoryRow[], diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts new file mode 100644 index 00000000..51bb2504 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts @@ -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 { + 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 { + 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) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts new file mode 100644 index 00000000..af3a0730 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts @@ -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 { + 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, + }) +} diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 60b9a547..29aaf7b4 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -65,4 +65,5 @@ export * from './worker-product-name-suffix-service.js' export * from './worker-acceptance-config-service.js' export * from './sync-worker-orders-service.js' export * from './consignment-contact-service.js' +export * from './consignment-pending-match-service.js' export * from './work-order-material-link-service.js' diff --git a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx new file mode 100644 index 00000000..14d71e83 --- /dev/null +++ b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx @@ -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 = [ + { + title: '匹配打分', + key: 'score', + width: 90, + render: (_, record) => { + const tag = resolveScoreTag(record) + return ( + + {record.score} + + {tag.label} + + + ) + }, + }, + { + title: '订单信息', + key: 'order', + width: 280, + render: (_, record) => ( + + + + 工单 + + {record.workOrderNo ? ( + + {record.workOrderNo} + + ) : ( + - + )} + + + + 平台 + + {record.platformOrderId ? ( + + {record.platformOrderId} + + ) : ( + - + )} + + + 录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'} + + + ), + }, + { + title: '商品名', + key: 'productName', + width: 220, + render: (_, record) => ( + + {record.productName || '-'} + + ), + }, + { + title: '寄售信息', + key: 'consignment', + width: 280, + render: (_, record) => ( + + + + 寄售 + + + {record.consignmentNo || record.inventoryNo || '-'} + + + {record.inventoryNo && record.inventoryNo !== record.consignmentNo ? ( + + + 仓库 + + + {record.inventoryNo} + + + ) : null} + + 入库 {record.inventoryInboundAt ? formatDateTime(record.inventoryInboundAt) : '-'} + + + ), + }, + { + title: '寄售物品', + key: 'itemName', + width: 220, + render: (_, record) => ( + + {record.itemName || '-'} + + ), + }, + { + title: '匹配说明', + key: 'reason', + width: 300, + render: (_, record) => ( + + {record.reason || '-'} + {record.conflicts.length > 0 ? ( + + {record.conflicts.join(';')} + + ) : null} + + ), + }, + ] + + return ( + + + + + + 待抢单 {data?.pendingOrderCount ?? 0} 单 + 在库待匹配 {data?.inStockInventoryCount ?? 0} 件 + 匹配组合 {data?.pairCount ?? 0} 条 + 精确 {data?.exactCount ?? 0} + 非精确 {data?.partialCount ?? 0} + {includeConflicts ? 不匹配 {data?.conflictCount ?? 0} : null} + + + + setKeyword(value.trim())} + /> + setIncludeConflicts(event.target.checked)} + > + 显示 0 分不匹配项 + + + + + {query.isError ? ( + + ) : null} + + {data?.truncated ? ( + + ) : null} + + + 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: ( + + ), + }} + /> + + + ) +} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index 0ba696af..42cf0567 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -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 { Alert, @@ -27,6 +33,7 @@ import type { TableColumnsType } from 'antd' import { useState } from 'react' import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery' +import ConsignmentPendingMatchModal from '@/components/admin/ConsignmentPendingMatchModal' import { archiveAdminConsignmentInventory, fetchAdminConsignmentContacts, @@ -149,6 +156,7 @@ export default function ConsignmentInventoryPanel() { const [viewingItem, setViewingItem] = useState(null) const [returnItem, setReturnItem] = useState(null) const [manualMatchItem, setManualMatchItem] = useState(null) + const [pendingMatchOpen, setPendingMatchOpen] = useState(false) const [returnReason, setReturnReason] = useState('') const [editForm] = Form.useForm() @@ -836,6 +844,10 @@ export default function ConsignmentInventoryPanel() { }} /> + + @@ -1305,6 +1317,12 @@ export default function ConsignmentInventoryPanel() { } onMatched={() => void inventoriesQuery.refetch()} /> + + {/* 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货 */} + setPendingMatchOpen(false)} + />
) } diff --git a/apps/frontend/src/services/admin/consignment.ts b/apps/frontend/src/services/admin/consignment.ts index c6399dfa..cb676d25 100644 --- a/apps/frontend/src/services/admin/consignment.ts +++ b/apps/frontend/src/services/admin/consignment.ts @@ -5,6 +5,7 @@ import type { ConsignmentStatsSummary, ConsignmentSubmission, InventoryMatchableOrders, + PendingPistolMatchResult, WorkOrderInventoryMatchCheck, WorkOrderInventoryMatchOptions, } from '@/types/consignment' @@ -158,6 +159,22 @@ export function fetchInventoryMatchableOrders( ) } +/** 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货,按细粒度评分排序。 */ +export function fetchPendingPistolMatches(params?: { + keyword?: string + includeConflicts?: boolean + limit?: number +}) { + return apiGet( + '/api/v1/admin/worker-platform/consignment/pending-pistol-matches', + { + keyword: params?.keyword?.trim() || undefined, + includeConflicts: params?.includeConflicts ? 'true' : undefined, + limit: params?.limit, + }, + ) +} + /** 履约/指派返回的电子凭证核销结果:ok=false 时必须提示运营人工核销,不能报成功 */ export type WorkOrderVoucherConsumeResult = { ok: boolean diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index f1669404..19c04b2d 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -240,3 +240,32 @@ export type WorkOrderInventoryMatchCheck = { partialCount: number 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[] +}