diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 548c1490..1ef10f67 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -41,6 +41,8 @@ export type ConsignmentInventoryRow = { source_submission_id: string | number | null /** 唯一锚点:来源提报单号(客服手工入库的现货没有) */ source_submission_no?: string | null + /** 来源提报单创建时间(打手提报时间) */ + source_submission_created_at?: string | null /** 提报凭证(卖家提报时上传的物品/主页截图) */ submission_screenshots_json?: string | unknown[] | null /** 赠送凭证:冷却结束后上传的赠送完成截图(存在绑定工单的验收资料里) */ @@ -331,6 +333,8 @@ export async function listConsignmentInventories(filters: { ci.*, wc.name AS category_name, cs.submission_no AS source_submission_no, + + cs.created_at AS source_submission_created_at, cs.screenshots_json AS submission_screenshots_json, COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, supplier_worker.phone AS live_worker_phone, @@ -496,6 +500,8 @@ export async function getConsignmentInventoryById( ci.*, wc.name AS category_name, cs.submission_no AS source_submission_no, + + cs.created_at AS source_submission_created_at, cs.screenshots_json AS submission_screenshots_json, COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, supplier_worker.phone AS live_worker_phone, @@ -531,6 +537,8 @@ export async function listConsignmentInventoriesByIds( ci.*, wc.name AS category_name, cs.submission_no AS source_submission_no, + + cs.created_at AS source_submission_created_at, cs.screenshots_json AS submission_screenshots_json, COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, supplier_worker.phone AS live_worker_phone, @@ -1166,6 +1174,8 @@ export async function listActiveConsignmentInventoriesForMatching(options?: { SELECT ci.*, cs.submission_no AS source_submission_no, + + cs.created_at AS source_submission_created_at, COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, supplier_worker.phone AS live_worker_phone, ccs.name AS contact_supporter_name @@ -1537,6 +1547,8 @@ export async function getConsignmentInventoryBySubmissionId( ci.*, wc.name AS category_name, cs.submission_no AS source_submission_no, + + cs.created_at AS source_submission_created_at, COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, supplier_worker.phone AS live_worker_phone, ccs.name AS contact_supporter_name diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index 50175cf0..7bcda1ad 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -289,6 +289,7 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) { sourceSubmissionId: row.source_submission_id ? Number(row.source_submission_id) : null, /** 唯一锚点:卖家端看到的「寄卖单号」就是它,客服端也用同一个号 */ sourceSubmissionNo: row.source_submission_no || '', + sourceSubmissionCreatedAt: row.source_submission_created_at || null, ...evidence, ...(showingMatchedBuyer ? {} diff --git a/apps/backend/src/services/worker-platform/consignment-before-publish-match.test.ts b/apps/backend/src/services/worker-platform/consignment-before-publish-match.test.ts index eddbebf7..e341f8f7 100644 --- a/apps/backend/src/services/worker-platform/consignment-before-publish-match.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-before-publish-match.test.ts @@ -66,3 +66,30 @@ test('超时重匹配:立即尝试一次,失败回大厅', async () => { assert.ok(source.includes('fallbackConsignmentWorkOrderToHall'), '失败必须显式回大厅') assert.ok(!source.includes('reenterConsignmentInternalMatching'), '不得再回内部匹配池') }) + +test('超时默认不限时:配置缺省 0 分钟,匹配引擎不得回落到 30 分钟', async () => { + const [engine, profile] = await Promise.all([ + readFile(ENGINE, 'utf8'), + readFile(new URL('./consignment-profile-service.ts', import.meta.url), 'utf8'), + ]) + assert.ok(engine.includes('config?.timeout.minutes ?? 0'), '匹配引擎缺省必须是不限时(0)') + assert.ok(!engine.includes('config?.timeout.minutes ?? 30'), '不得再默认 30 分钟') + assert.ok( + profile.includes('minutes: integer(timeout.minutes, 0,'), + '配置归一化的缺省值必须是不限时', + ) +}) + +test('手动匹配走内部现货:不指派供货打手、不生成截止时间', async () => { + const source = await readFile(ENGINE, 'utf8') + assert.ok( + source.includes('if (!manual && !inventory.worker_id) return false'), + '手动匹配必须允许没有打手账号的库存', + ) + assert.ok( + source.includes('parkWorkOrderWaitingFriendAddWithClient(client, current'), + '手动匹配必须停在待加好友,由客服履约', + ) + assert.ok(source.includes("fulfillmentType: 'internal_stock'"), 'material 必须标记内部现货') + assert.ok(source.includes('timeout_minutes = 0'), '内部现货不设时限') +}) diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index a6f193a5..195de3e7 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -6,6 +6,7 @@ import { isFriendGiftAcceptanceMode } from '../../domain/work-order-acceptance-m import { createWorkOrderEventWithClient, getWorkOrderById, + parkWorkOrderWaitingFriendAddWithClient, type WorkOrderRow, } from '../../repositories/worker-platform/index.js' import { @@ -783,9 +784,11 @@ async function applyWholeOrderMatch( ): Promise { const { workOrder, profile } = input const inventory = candidate.inventory - if (!workOrder.id || !inventory.worker_id) return false - const now = nowIso() const manual = context.matchType === 'manual' + if (!workOrder.id) return false + // 自动匹配仍只认有供货打手的库存;手动匹配允许无打手库存(内部现货,客服代履约)。 + if (!manual && !inventory.worker_id) return false + const now = nowIso() type ApplyResult = { applied: boolean; sms?: ConsignmentMatchSmsPayload } const result = await withTransaction(async (client: PoolClient) => { @@ -845,8 +848,9 @@ async function applyWholeOrderMatch( (unitPriceFen || Math.max(0, Number(current.reward_amount || 0))) * requiredQuantity const serviceFeeFen = config ? buildServiceFeeFen(config, grossRewardFen) : 0 const netRewardFen = Math.max(0, grossRewardFen - serviceFeeFen) - const timeoutMinutes = config?.timeout.minutes ?? 30 + const timeoutMinutes = config?.timeout.minutes ?? 0 const timeoutPolicy = config?.timeout.policy ?? 'reopen' + const consignmentWorkerId = Number(inventory.worker_id || 0) const nextMaterial = { ...material, consignment: { @@ -866,25 +870,114 @@ async function applyWholeOrderMatch( serviceFeeFen, netRewardFen, previousRewardAmountFen: originalRewardFen, - workerId: Number(inventory.worker_id), workerName: inventory.worker_name || '', matchedAt: now, matchType: manual ? 'manual' : 'auto', ...(manual ? { - manualMatch: true, + // 手动匹配 = 内部现货履约:不把供货打手指派成接单人, + // 工单停在「待加好友」,由客服使用库存账号完成加好友与赠送。 + workerPhone: inventory.worker_phone || '', + buyerName: inventory.buyer_name || '', + sellerGameId: inventory.seller_game_id || '', + sellerContact: inventory.seller_contact || '', + sellerPhone: inventory.seller_phone || '', + contactSupporterId: inventory.contact_supporter_id + ? Number(inventory.contact_supporter_id) + : null, + contactSupporterName: inventory.contact_supporter_name || '', + remark: inventory.remark || '', + inboundBatch: inventory.inbound_batch, + credentials: safeParseJson(inventory.credentials_json), + screenshots: safeParseJson( + inventory.screenshots_json || inventory.submission_screenshots_json || [], + ), + costPriceFen: inventory.cost_price_fen, + sourceSubmissionId: inventory.source_submission_id + ? Number(inventory.source_submission_id) + : null, + sourceSubmissionNo: inventory.source_submission_no || '', + fulfillmentType: 'internal_stock', + fulfilledAt: now, fulfilledBy: context.actorName || '', + manualMatch: true, ...(context.reason ? { matchNote: context.reason } : {}), ...(context.allowNonExact ? { allowNonExact: true } : {}), ...(context.specConflicts?.length ? { specConflicts: [...context.specConflicts] } : {}), } - : {}), + : { + workerId: consignmentWorkerId, + }), ...(context.partial ? { partialMatch: true } : {}), }, } + if (manual) { + // 内部现货履约:占库成功但不算已加好友,工单停在「待加好友」; + // 不写接单人、不生成截止时间,后续由客服确认好友并走赠送冷却。 + const parked = await parkWorkOrderWaitingFriendAddWithClient(client, current, { + now, + actorName: context.actorName || 'admin', + eventType: 'consignment_manual_matched', + materialJson: nextMaterial, + rewardAmountFen: netRewardFen, + captureOriginalReward: netRewardFen !== originalRewardFen, + extraPayload: { + inventoryId: Number(inventory.id), + inventoryNo: inventory.inventory_no, + profileId: profile?.profileId ?? null, + salePriceFen: inventorySalePriceFen(inventory), + grossRewardFen, + serviceFeeFen, + netRewardFen, + allocationId: allocation.id, + matchType: 'manual', + fulfillmentType: 'internal_stock', + actorName: context.actorName || '', + reason: context.reason || '', + allowNonExact: Boolean(context.allowNonExact), + specConflicts: context.specConflicts || [], + ...(context.partial ? { partialMatch: true } : {}), + }, + }) + if (!parked) return { applied: false } + + // 内部现货没有接单打手,也不参与超时扫描(deadline_at = NULL)。 + const refreshed = await client.query( + ` + UPDATE work_orders + SET assigned_worker_id = NULL, + assigned_at = NULL, + deadline_at = NULL, + timeout_minutes = 0, + timeout_policy = $2, + hall_visible_at = NULL, + internal_match_deadline_at = NULL, + internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1, + rematch_excluded_worker_id = NULL, + rematch_excluded_inventory_id = NULL, + updated_at = $1 + WHERE id = $3 + RETURNING * + `, + [now, timeoutPolicy, current.id], + ) + const updatedRow = refreshed.rows[0] || parked + return { + applied: true, + sms: { + workOrder: updatedRow, + inventory, + quantity: requiredQuantity, + priceFen: unitPriceFen, + matchType: 'manual', + matchedAt: now, + }, + } + } + const updated = await client.query( ` UPDATE work_orders @@ -1064,7 +1157,7 @@ async function applySharingMatch( } const config = profile?.config - const timeoutMinutes = config?.timeout.minutes ?? 30 + const timeoutMinutes = config?.timeout.minutes ?? 0 const timeoutPolicy = config?.timeout.policy ?? 'reopen' const deadline = addMinutesIso(timeoutMinutes, now) // 库存没有售价时退回订单单价,避免份额报酬为 0;有售价时始终以库存净售价为准。 diff --git a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts index 70ba7925..28ce37ee 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts @@ -791,7 +791,13 @@ async function main() { ]) const row = wo.rows[0]! assert(row.status === 'in_progress', `status=${row.status}`) - assert(Number(row.assigned_worker_id) === sellerId, `assigned=${row.assigned_worker_id}`) + assert(row.assigned_worker_id === null, '手动匹配走内部现货,不能把供货打手指派成接单人') + assert(row.acceptance_mode === 'friend_gift', `mode=${row.acceptance_mode}`) + assert(row.gift_phase === 'material_required', `gift=${row.gift_phase}`) + assert( + row.material_json.consignment.fulfillmentType === 'internal_stock', + 'material 未标记 internal_stock', + ) assert(Number(row.reward_amount) === 8550, `reward=${row.reward_amount}`) assert( Number(row.consignment_original_reward_fen) === 10000, diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts index a3657276..6677552e 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts @@ -15,13 +15,24 @@ test('寄卖配置:缺省值必须可安全用于发布(保证金/服务费 assert.equal(config.deposit.enabled, false) assert.equal(config.serviceFee.enabled, false) assert.equal(config.matching.autoMatchEnabled, false) - assert.equal(config.timeout.minutes, 30) + assert.equal(config.timeout.minutes, 0) assert.ok(Number.isFinite(Date.parse(config.matching.orderEffectiveFrom))) assert.equal(config.priceDrop.enabled, true) assert.equal(config.priceDrop.hours, 24) assert.equal(config.fields.quantity, true) }) +test('寄卖配置:超时默认不限时,显式分钟数原样保留', () => { + const unlimited = normalizeConsignmentProfileConfig({ timeout: { minutes: 0, policy: 'reopen' } }) + assert.equal(unlimited.timeout.minutes, 0) + + const limited = normalizeConsignmentProfileConfig({ + timeout: { minutes: 45, policy: 'cancel_release' }, + }) + assert.equal(limited.timeout.minutes, 45) + assert.equal(limited.timeout.policy, 'cancel_release') +}) + test('寄卖配置:非法枚举与越界数字回落到安全值', () => { const config = normalizeConsignmentProfileConfig({ deposit: { mode: 'bad', fixedAmountFen: -1, unitAmountFen: 'x' }, diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.ts index 33da1abc..41ff240b 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -180,7 +180,8 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = { orderEffectiveFrom: '', }, timeout: { - minutes: 30, + // 默认不限时(0 分钟):订单进入代练中后不设截止时间,也不会自动超时处置。 + minutes: 0, policy: 'reopen', }, priceDrop: { @@ -348,7 +349,7 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr orderEffectiveFrom, }, timeout: { - minutes: integer(timeout.minutes, 30, 0, 7 * 24 * 60), + minutes: integer(timeout.minutes, 0, 0, 7 * 24 * 60), policy: timeoutPolicy, }, priceDrop: { diff --git a/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts b/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts index 8713ebf3..ac5f31ea 100644 --- a/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts @@ -263,6 +263,24 @@ test('动态规格:物品名模板与规格展示文本', () => { assert.equal(fallback, '苏尔南手枪') }) +test('动态规格:璀璨数为 0 时物品名不残留「璀璨」单位', () => { + const specs = surnanSpecs() + const template = '{system}{series}{diamondLevel}钻{brillianceCount}璀璨 苏尔南' + const zero = buildConsignmentItemName({ + template, + specs, + values: { ...surnanValues, brillianceCount: '0' }, + }) + assert.equal(zero, '苹果暗潮3钻 苏尔南') + + const three = buildConsignmentItemName({ + template, + specs, + values: { ...surnanValues, brillianceCount: '3' }, + }) + assert.equal(three, '苹果暗潮3钻3璀璨 苏尔南', '璀璨数大于 0 时单位必须保留') +}) + test('动态规格:商品下拉不带进物品名时,用选中的商品名作物品名', () => { const productSpec: ConsignmentSpecField = { key: 'product', diff --git a/apps/backend/src/services/worker-platform/consignment-spec-service.ts b/apps/backend/src/services/worker-platform/consignment-spec-service.ts index ca711910..371fad00 100644 --- a/apps/backend/src/services/worker-platform/consignment-spec-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.ts @@ -752,6 +752,27 @@ export function buildConsignmentSpecText( .join(' / ') } +/** + * 物品名模板里跟在占位符后面的固定单位。 + * 数值字段为 0 时 formatConsignmentSpecValue 返回空串,单位必须一并省略, + * 否则会留下「2钻璀璨」这种没有数字的残句。 + */ +const ITEM_NAME_UNIT_SUFFIX: Record = { + brillianceCount: '璀璨', + diamondLevel: '钻', +} + +function fillItemNameTemplate(template: string, tokens: Map): string { + return template.replace( + /\{([a-zA-Z][a-zA-Z0-9_]*)\}(璀璨|钻)?/g, + (_, key: string, unit?: string) => { + const value = tokens.get(key) || '' + if (value) return unit ? `${value}${unit}` : value + return ITEM_NAME_UNIT_SUFFIX[key] === unit ? '' : unit || '' + }, + ) +} + export function buildConsignmentItemName(input: { template: string specs: ConsignmentSpecField[] @@ -772,10 +793,7 @@ export function buildConsignmentItemName(input: { let result = String(template || '').trim() if (result) { - result = result.replace( - /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, - (_, key: string) => tokenValues.get(key) || '', - ) + result = fillItemNameTemplate(result, tokenValues) } else { // 优先用显式勾选「进物品名」的字段;模板驱动的商品下拉没有维度可拼, // 退化为取第一个选择型字段的选项名(就是商品名本身),否则物品名只剩分类兜底。 diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts index 95a7f220..78903858 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts @@ -1,9 +1,10 @@ import assert from 'node:assert/strict' import test from 'node:test' -import type { WorkOrderShareRow } from '../../repositories/worker-platform/types.js' +import type { WorkOrderRow, WorkOrderShareRow } from '../../repositories/worker-platform/types.js' import { isWorkerOrderVipEvidencePending, + mapWorkOrderAdmin, mapWorkOrderMaterialWithConsignmentWorker, pickWorkerRepresentativeShare, resolveWorkerOrderStatusTabs, @@ -298,3 +299,81 @@ test('寄售供货人改名后,工单 material 展示使用库存实时昵称' ) as { consignment: { workerName: string } } assert.equal(noSnapshot.consignment.workerName, '') }) + +/** + * 手动匹配改走内部现货后,工单上没有 `assigned_worker_id`。 + * 后台操作区必须按这个口径收敛:关闭订单/撤单依赖接单打手,内部现货单不能出现; + * 客服仍需补资料、标问题、确认加好友。 + */ +function internalStockOrder(overrides: Record = {}): WorkOrderRow { + return { + id: 1001, + work_order_no: 'WK26111323404271', + platform_order_id: 'P-1001', + product_name: '安卓怒嚎2钻 苏尔南', + status: 'in_progress', + acceptance_mode: 'friend_gift', + gift_phase: 'material_required', + gift_cooldown_hours: 72, + gift_cooldown_minutes: 0, + assigned_worker_id: null, + reward_amount: 8550, + timeout_minutes: 0, + deadline_at: null, + material_json: JSON.stringify({ + consignment: { + inventoryId: 88, + inventoryNo: 'INV20261113001', + sourceSubmissionNo: 'SUB20261113001', + fulfillmentType: 'internal_stock', + workerName: '苏尔南2号小号', + fulfilledAt: '2026-11-13T10:00:00.000Z', + salePriceFen: 8550, + quantity: 1, + }, + }), + created_at: '2026-11-13T09:00:00.000Z', + updated_at: '2026-11-13T10:00:00.000Z', + ...overrides, + } as unknown as WorkOrderRow +} + +test('内部现货手动匹配:操作区不出现关闭订单/撤单,保留客服跟进动作', () => { + const mapped = mapWorkOrderAdmin(internalStockOrder()) + + assert.equal(mapped.status, 'in_progress') + assert.equal(mapped.worker, null, '内部现货单没有接单打手') + assert.equal(mapped.deadlineAt, null, '默认不限时') + + // 关闭订单与撤单都以接单打手为前提,内部现货单必须隐藏(与正确示例图一致) + assert.equal(mapped.capabilities.canCloseOrder, false) + assert.equal(mapped.capabilities.canCancel, false) + // 也不能再按「未匹配单」重新指派/编辑/删除 + assert.equal(mapped.capabilities.canAssign, false) + assert.equal(mapped.capabilities.canEdit, false) + assert.equal(mapped.capabilities.canDelete, false) + assert.equal(mapped.capabilities.canReopen, false) + + // 客服履约入口保留:更新资料、标记问题单、确认加好友 + assert.equal(mapped.capabilities.canUpdateMaterial, true) + assert.equal(mapped.capabilities.canMarkProblem, true) + assert.equal(mapped.capabilities.canConfirmFriend, true) + assert.equal(mapped.capabilities.canCompleteGiftCooldown, false) + assert.equal(mapped.capabilities.canAccept, false) +}) + +test('内部现货进入赠送冷却:只出现结束冷却,仍无关闭订单/撤单', () => { + const mapped = mapWorkOrderAdmin( + internalStockOrder({ + gift_phase: 'friend_countdown', + gift_available_at: '2099-01-01T00:00:00.000Z', + }), + ) + + assert.equal(mapped.capabilities.canCompleteGiftCooldown, true) + assert.equal(mapped.capabilities.canConfirmFriend, false) + assert.equal(mapped.capabilities.canCloseOrder, false) + assert.equal(mapped.capabilities.canCancel, false) + assert.equal(mapped.capabilities.canUpdateMaterial, true) + assert.equal(mapped.capabilities.canMarkProblem, true) +}) diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx index 5d7ff902..0134268f 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx @@ -101,6 +101,7 @@ type ConfigFormValues = { orderEffectiveFrom: Dayjs } timeout: { + enabled: boolean hours: number policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct' } @@ -216,7 +217,8 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues { orderEffectiveFrom: dayjs(config.matching.orderEffectiveFrom || undefined), }, timeout: { - hours: Math.max(0, Number(config.timeout.minutes ?? 30) / 60), + enabled: Number(config.timeout.minutes ?? 0) > 0, + hours: Number(config.timeout.minutes ?? 0) > 0 ? Number(config.timeout.minutes) / 60 : 0.5, policy: config.timeout.policy, }, priceDrop: { ...config.priceDrop }, @@ -255,7 +257,7 @@ function emptyFormValues(): ConfigFormValues { preferLowReward: true, orderEffectiveFrom: dayjs(), }, - timeout: { hours: 0.5, policy: 'reopen' }, + timeout: { enabled: false, hours: 0.5, policy: 'reopen' }, priceDrop: { enabled: true, hours: 24, mode: 'percent', value: 10 }, } } @@ -338,7 +340,10 @@ function buildPayload( values.matching.orderEffectiveFrom?.toISOString() || new Date().toISOString(), }, timeout: { - minutes: Math.max(0, Math.round(Number(values.timeout.hours || 0) * 60)), + // 开关关闭 = 不限时(minutes=0),后端不会生成截止时间,也不会触发超时处置。 + minutes: values.timeout.enabled + ? Math.max(1, Math.round(Number(values.timeout.hours || 0) * 60)) + : 0, policy: values.timeout.policy, }, priceDrop: { @@ -1027,6 +1032,7 @@ export default function ConsignmentConfigPanel() { const serviceFeeMode = Form.useWatch(['serviceFee', 'mode'], form) const inventoryLimitEnabled = Form.useWatch(['inventoryLimit', 'enabled'], form) const autoMatchEnabled = Form.useWatch(['matching', 'autoMatchEnabled'], form) + const timeoutEnabled = Form.useWatch(['timeout', 'enabled'], form) const priceDropEnabled = Form.useWatch(['priceDrop', 'enabled'], form) const priceDropMode = Form.useWatch(['priceDrop', 'mode'], form) const headerStatus: ProfileStatusInfo = selectedProfile @@ -1977,47 +1983,62 @@ export default function ConsignmentConfigPanel() {
- - - - + + + ) : null}
- + {timeoutEnabled ? ( + + ) : null}
diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index 36bbc238..f3310a33 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -1428,7 +1428,7 @@ export default function ConsignmentInventoryPanel() { ) : null} {viewingItem.sourceType === 'worker_portal' && viewingItem.createdAt ? ( - {formatDateTime(viewingItem.createdAt)} + {formatDateTime(viewingItem.sourceSubmissionCreatedAt || viewingItem.createdAt)} ) : null} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 35ffb66c..a2d1b26c 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -98,6 +98,7 @@ import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-pla import { formatConsignmentContactSupporter } from '@/utils/consignment-contact' import { isConsignmentOrder, + isConsignmentStockOrder, isInternalConsignmentOrder, resolveConsignmentScreenshotUrls, } from '@/utils/consignment-display' @@ -245,13 +246,11 @@ async function copyTextToClipboard(text: string): Promise { } /** - * 寄卖单号 = 来源提报单号(提报单号即寄卖单号),客服界面只认这一个号。 - * - * 内部仓库号 INV… 仍留在数据里(`info.inventoryNo`,撤销/解绑链路还在用), - * 只是不再展示;历史老单没有 sourceSubmissionNo 时明确写出来,不给内部编号。 + * 寄卖单号:优先来源提报单号(提报即寄卖);历史老单没有提报号时回退库存编号 INV…, + * 保证客服手里始终是一个可用于定位的真实单号。 */ function resolveConsignmentNo(info: ConsignmentFulfillmentInfo) { - return info.sourceSubmissionNo || (info.inventoryNo ? '老单无提报单号' : '') + return info.sourceSubmissionNo || info.inventoryNo || '' } /** @@ -1885,7 +1884,7 @@ export default function WorkOrdersPanel() { 置顶 ) : null} - {isInternalConsignmentOrder(row) ? ( + {isConsignmentStockOrder(row) ? ( 内部匹配 ) : row.acceptanceMode === 'friend_gift' ? ( 好友赠送 diff --git a/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx b/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx index 9304d7f8..a966a173 100644 --- a/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx +++ b/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx @@ -29,7 +29,36 @@ export function setSkipPublishConfirm() { /** 渲染工单关联打手:普通打手 / 拼单参与打手 / 寄卖供货打手与现货资料 */ export function renderWorkOrderWorkers(order: WorkOrder) { - if (order.worker) return order.worker.displayName || order.worker.username || '-' + const consignmentDisplay = resolveConsignmentWorkerDisplay(order) + const assignedName = order.worker + ? order.worker.displayName || order.worker.username || `打手#${order.worker.workerId}` + : '' + + if (order.worker) { + const supplierId = consignmentDisplay?.workerId || 0 + const assignedId = Number(order.worker.workerId || 0) + // 押金履约:接单人就是库存供货打手,仍按「供货打手」口径展示。 + if (consignmentDisplay?.tagText === '供货打手' && supplierId > 0 && supplierId === assignedId) { + return renderConsignmentWorkerBlock(consignmentDisplay) + } + // 指派了别的打手:显示实际接单人,并补一行供货打手,避免丢掉货源信息。 + if (consignmentDisplay?.tagText === '供货打手') { + const supplierText = `供货打手:${consignmentDisplay.primaryName}` + return ( +
+ {assignedName} + + {supplierText} + +
+ ) + } + return assignedName + } if (order.sharing?.enabled && order.sharingWorkers?.length) { const names = order.sharingWorkers @@ -49,38 +78,40 @@ export function renderWorkOrderWorkers(order: WorkOrder) { ) } - // 寄卖库存 / 内部现货履约单:回显供货打手或卖家现货资料 - const consignmentDisplay = resolveConsignmentWorkerDisplay(order) if (consignmentDisplay) { - return ( -
- - - {consignmentDisplay.tagText} - - - {consignmentDisplay.primaryName} - - - {consignmentDisplay.secondaryInfo ? ( - - {consignmentDisplay.secondaryInfo} - - ) : null} -
- ) + return renderConsignmentWorkerBlock(consignmentDisplay) } return '-' } +/** 寄卖库存履约:供货打手标签 + 姓名 + 卖家资料 */ +function renderConsignmentWorkerBlock( + display: NonNullable>, +) { + return ( +
+ + + {display.tagText} + + + {display.primaryName} + + + {display.secondaryInfo ? ( + + {display.secondaryInfo} + + ) : null} +
+ ) +} + export function getDisplayOrderNo( order: Pick | null | undefined, ): string { diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index cfbf0203..73810607 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -139,6 +139,8 @@ export type ConsignmentInventory = ConsignmentEvidenceFields & sourceSubmissionId: number | null /** 来源提报单号(提报单号即寄卖单号);客服手工入库的现货为空字符串。 */ sourceSubmissionNo?: string + /** 来源提报单创建时间(打手提报时间) */ + sourceSubmissionCreatedAt?: string | null /** 分类级寄卖配置 */ profileId?: number | null profileVersionId?: number | null diff --git a/apps/frontend/src/utils/consignment-display.ts b/apps/frontend/src/utils/consignment-display.ts index 68821294..ea6f8bad 100644 --- a/apps/frontend/src/utils/consignment-display.ts +++ b/apps/frontend/src/utils/consignment-display.ts @@ -31,6 +31,8 @@ export type ConsignmentWorkerDisplayInfo = { tagText: '供货打手' | '内部现货' primaryName: string secondaryInfo: string + /** 库存登记的供货打手 id;只用于判断「接单人是不是供货本人」 */ + workerId: number | null } /** @@ -65,6 +67,20 @@ export function isConsignmentOrder(row: { material?: unknown; gift?: unknown }): ) } +/** + * 是否由寄卖库存匹配产生的工单(纯展示口径)。 + * + * 只看工单物料里有没有库存在场,不要求「没有指派打手」: + * 押金履约(worker_deposit)同样是寄卖库存匹配,后台状态列也要显示「内部匹配」。 + * 操作能力判断仍用 isInternalConsignmentOrder,不要混用。 + */ +export function isConsignmentStockOrder(row: { material?: unknown; gift?: unknown }): boolean { + const consignment = asRecord(asRecord(row.material).consignment) + if (consignment.inventoryId || consignment.inventoryNo) return true + const boosterFields = asRecord(asRecord(asRecord(row.gift).boosterMaterial).fields) + return Boolean(boosterFields.inventoryNo || boosterFields.sourceType === 'consignment') +} + /** 解析寄卖库存/内部现货履约单的供货人与现货资料信息 */ export function resolveConsignmentWorkerDisplay( order: WorkOrder, @@ -107,5 +123,6 @@ export function resolveConsignmentWorkerDisplay( tagText: workerName ? '供货打手' : '内部现货', primaryName, secondaryInfo, + workerId: Number(consignment.workerId || 0) > 0 ? Number(consignment.workerId) : null, } } diff --git a/apps/frontend/src/utils/consignment-spec.ts b/apps/frontend/src/utils/consignment-spec.ts index 08366cfc..e6711274 100644 --- a/apps/frontend/src/utils/consignment-spec.ts +++ b/apps/frontend/src/utils/consignment-spec.ts @@ -169,6 +169,27 @@ export function buildSpecText( .join(' / ') } +/** + * 物品名模板里跟在占位符后面的固定单位。 + * 数值字段为 0 时 formatSpecValue 返回空串,单位必须一并省略, + * 否则会留下「2钻璀璨」这种没有数字的残句。 + */ +const ITEM_NAME_UNIT_SUFFIX: Record = { + brillianceCount: '璀璨', + diamondLevel: '钻', +} + +function fillItemNameTemplate(template: string, tokens: Map): string { + return template.replace( + /\{([a-zA-Z][a-zA-Z0-9_]*)\}(璀璨|钻)?/g, + (_, key: string, unit?: string) => { + const value = tokens.get(key) || '' + if (value) return unit ? `${value}${unit}` : value + return ITEM_NAME_UNIT_SUFFIX[key] === unit ? '' : unit || '' + }, + ) +} + export function buildSpecItemName( template: string, specs: ConsignmentSpecField[], @@ -183,10 +204,7 @@ export function buildSpecItemName( tokens.set('category', categoryName) let result = String(template || '').trim() if (result) { - result = result.replace( - /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, - (_, key: string) => tokens.get(key) || '', - ) + result = fillItemNameTemplate(result, tokens) } else { result = specs .filter((field) => field.showInItemName) diff --git a/apps/frontend/tests/consignment-admin-dynamic-specs.test.ts b/apps/frontend/tests/consignment-admin-dynamic-specs.test.ts index 7319a5fc..ecbc5220 100644 --- a/apps/frontend/tests/consignment-admin-dynamic-specs.test.ts +++ b/apps/frontend/tests/consignment-admin-dynamic-specs.test.ts @@ -58,3 +58,25 @@ test('寄售仓库:列表/详情按模板渲染,编辑不再写死旧字段' assert.ok(page.includes('...(hasDynamicSpecs'), '有模板规格时不得再提交旧规格字段') assert.ok(page.includes('请填写「'), '动态必填字段必须在表单层校验') }) + +test('物品详情的打手提报时间取来源提报创建时间', () => { + const inventoryPage = readPanel('ConsignmentInventoryPanel.tsx') + assert.ok( + inventoryPage.includes('viewingItem.sourceSubmissionCreatedAt || viewingItem.createdAt'), + '打手提报时间必须优先取来源提报创建时间', + ) + + const repo = readFileSync( + fileURLToPath( + new URL( + '../../backend/src/repositories/worker-platform/consignment-repo.ts', + import.meta.url, + ), + ), + 'utf8', + ) + assert.ok( + repo.includes('cs.created_at AS source_submission_created_at'), + '库存查询必须带来源提报创建时间', + ) +}) diff --git a/apps/frontend/tests/consignment-internal-match-display.test.ts b/apps/frontend/tests/consignment-internal-match-display.test.ts new file mode 100644 index 00000000..41d231d7 --- /dev/null +++ b/apps/frontend/tests/consignment-internal-match-display.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +import type { WorkOrder } from '../src/types/worker-platform.ts' +import { + isConsignmentStockOrder, + isInternalConsignmentOrder, + resolveConsignmentWorkerDisplay, +} from '../src/utils/consignment-display.ts' + +function readPanel(name: string) { + return readFileSync( + fileURLToPath(new URL(`../src/pages/admin/panels/${name}`, import.meta.url)), + 'utf8', + ) +} + +test('内部匹配展示:押金履约也算寄卖库存匹配', () => { + const deposit = { + material: { + consignment: { inventoryId: 12, fulfillmentType: 'worker_deposit', workerId: 7 }, + }, + } + assert.equal(isConsignmentStockOrder(deposit), true) + assert.equal( + isConsignmentStockOrder({ material: { consignment: { inventoryNo: 'INV1' } } }), + true, + ) + assert.equal(isConsignmentStockOrder({ material: {} }), false) + assert.equal( + isInternalConsignmentOrder(deposit), + false, + '操作判定仍按原口径,不影响加好友文案与撤销入口', + ) +}) + +test('内部匹配展示:供货打手 id 会随展示信息返回', () => { + const display = resolveConsignmentWorkerDisplay({ + material: { consignment: { inventoryId: 1, workerId: 7, workerName: '苏尔南2号' } }, + } as unknown as WorkOrder) + assert.equal(display?.tagText, '供货打手') + assert.equal(display?.workerId, 7) + assert.equal(display?.primaryName, '苏尔南2号') +}) + +test('手动匹配内部现货:workerName 只是供货人快照,不算接单打手', () => { + const manualMatched = { + material: { + consignment: { + inventoryId: 88, + inventoryNo: 'INV20261113001', + sourceSubmissionNo: 'SUB20261113001', + fulfillmentType: 'internal_stock', + workerName: '苏尔南2号小号', + }, + }, + } + // 撤销寄卖入口依赖「没有接单打手」这一口径,供货人快照不能把它挡掉 + assert.equal(isInternalConsignmentOrder(manualMatched), true) + assert.equal(isConsignmentStockOrder(manualMatched), true) + const display = resolveConsignmentWorkerDisplay(manualMatched as unknown as WorkOrder) + assert.equal(display?.tagText, '供货打手') + assert.equal(display?.primaryName, '苏尔南2号小号') + assert.equal(display?.workerId, null) +}) + +test('工单列表:内部匹配标签用展示口径,打手列保留供货打手', () => { + const orders = readPanel('WorkOrdersPanel.tsx') + assert.ok(orders.includes('isConsignmentStockOrder(row)'), '状态列必须用寄卖库存展示判定') + assert.ok( + orders.includes("return info.sourceSubmissionNo || info.inventoryNo || ''"), + '寄卖单号必须回退库存编号', + ) + + const utils = readFileSync( + fileURLToPath(new URL('../src/pages/admin/panels/work-order-panel-utils.tsx', import.meta.url)), + 'utf8', + ) + assert.ok(utils.includes('supplierId === assignedId'), '接单人就是供货打手时必须保留供货打手标签') + assert.ok( + utils.includes('供货打手:${consignmentDisplay.primaryName}'), + '指派其他打手时必须补一行供货打手信息', + ) +}) + +test('配置面板:超时开关默认关闭,关闭即不限时', () => { + const panel = readPanel('ConsignmentConfigPanel.tsx') + assert.ok(panel.includes("name={['timeout', 'enabled']}"), '超时设置必须有启用开关') + assert.ok( + panel.includes("timeout: { enabled: false, hours: 0.5, policy: 'reopen' }"), + '新建配置默认不限时', + ) + assert.ok(panel.includes('minutes: values.timeout.enabled'), '保存时必须按开关决定 0 或分钟') + assert.ok(panel.includes('默认关闭(不限时)'), '配置区文案必须说明默认不限时') +}) diff --git a/apps/frontend/tests/consignment-item-name.test.ts b/apps/frontend/tests/consignment-item-name.test.ts new file mode 100644 index 00000000..7877a249 --- /dev/null +++ b/apps/frontend/tests/consignment-item-name.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { ConsignmentSpecField } from '../src/types/consignment.ts' +import { buildSpecItemName } from '../src/utils/consignment-spec.ts' + +function makeSpec( + overrides: Partial & Pick, +): ConsignmentSpecField { + return { + type: 'text', + required: false, + options: [], + placeholder: '', + helpText: '', + matchMode: 'exact', + wildcardValues: [], + showInItemName: true, + sortOrder: 0, + ...overrides, + } +} + +const surnanSpecs = [ + makeSpec({ key: 'system', label: '操作系统', sortOrder: 10 }), + makeSpec({ key: 'series', label: '系列', sortOrder: 20 }), + makeSpec({ key: 'diamondLevel', label: '钻石等级', type: 'number', sortOrder: 30 }), + makeSpec({ key: 'brillianceCount', label: '璀璨数', type: 'number', sortOrder: 40 }), +] + +const surnanTemplate = '{system}{series}{diamondLevel}钻{brillianceCount}璀璨 苏尔南' + +test('物品名模板:璀璨数为 0 时不残留「璀璨」单位', () => { + assert.equal( + buildSpecItemName(surnanTemplate, surnanSpecs, { + system: '安卓', + series: '怒嚎', + diamondLevel: '2', + brillianceCount: '0', + }), + '安卓怒嚎2钻 苏尔南', + ) +}) + +test('物品名模板:璀璨数大于 0 时单位正常保留', () => { + assert.equal( + buildSpecItemName(surnanTemplate, surnanSpecs, { + system: '安卓', + series: '怒嚎', + diamondLevel: '2', + brillianceCount: '3', + }), + '安卓怒嚎2钻3璀璨 苏尔南', + ) +}) + +test('物品名模板:钻级为空时「钻」也不会单独残留', () => { + assert.equal( + buildSpecItemName(surnanTemplate, surnanSpecs, { + system: '安卓', + series: '怒嚎', + diamondLevel: '', + brillianceCount: '0', + }), + '安卓怒嚎 苏尔南', + ) +})