寄卖手动匹配改走内部现货并统一展示与超时口径

- 手动匹配不再指派供货打手:停在待加好友,清空截止时间与超时分钟
- 物品名模板去掉「0 璀璨」残留;寄卖单号无提报号时回退库存编号
- 状态列按寄卖库存显示内部匹配,打手列保留供货打手标签
- 超时配置增加启用开关,默认关闭(不限时)
- 补充前后端回归测试
This commit is contained in:
2026-11-14 13:15:41 +08:00
parent a0c7c90edf
commit 0dff00de97
20 changed files with 631 additions and 91 deletions
@@ -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
@@ -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
? {}
@@ -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'), '内部现货不设时限')
})
@@ -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<boolean> {
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<ApplyResult>(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<WorkOrderRow>(
`
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<WorkOrderRow>(
`
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;有售价时始终以库存净售价为准。
@@ -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,
@@ -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' },
@@ -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: {
@@ -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',
@@ -752,6 +752,27 @@ export function buildConsignmentSpecText(
.join(' / ')
}
/**
* 物品名模板里跟在占位符后面的固定单位。
* 数值字段为 0 时 formatConsignmentSpecValue 返回空串,单位必须一并省略,
* 否则会留下「2钻璀璨」这种没有数字的残句。
*/
const ITEM_NAME_UNIT_SUFFIX: Record<string, string> = {
brillianceCount: '璀璨',
diamondLevel: '钻',
}
function fillItemNameTemplate(template: string, tokens: Map<string, string>): 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 {
// 优先用显式勾选「进物品名」的字段;模板驱动的商品下拉没有维度可拼,
// 退化为取第一个选择型字段的选项名(就是商品名本身),否则物品名只剩分类兜底。
@@ -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<string, unknown> = {}): 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)
})
@@ -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() {
<ConsignmentSectionTitle
index="09"
title="超时设置"
description="匹配成功进入代练中后的时限与超时处置动作"
description="默认关闭(不限时);开启后按小时计时并执行超时动作"
/>
</div>
<div style={gridStyle}>
<Form.Item
label="代练超时(小时)"
name={['timeout', 'hours']}
rules={[{ type: 'number', min: 0 }]}
extra="默认 0.5 小时;到点后按下方超时动作处置"
style={{ marginBottom: 0 }}
label="启用超时"
name={['timeout', 'enabled']}
valuePropName="checked"
extra="关闭后订单进入代练中不设截止时间(不限时),也不会自动超时处置"
style={{ marginBottom: 0, gridColumn: '1 / -1' }}
>
<InputNumber
min={0}
step={0.5}
precision={1}
style={{ width: '100%' }}
addonAfter="小时"
/>
</Form.Item>
<Form.Item
label="超时动作"
name={['timeout', 'policy']}
style={{ marginBottom: 0 }}
>
<Select
options={[
{ value: 'reopen', label: '退回大厅(退还押金)' },
{ value: 'cancel_release', label: '取消并释放押金' },
{ value: 'cancel_deduct', label: '取消并扣除押金' },
{ value: 'rematch_release', label: '重新匹配并退还押金' },
{ value: 'rematch_deduct', label: '重新匹配并扣除押金' },
]}
/>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
{timeoutEnabled ? (
<>
<Form.Item
label="代练超时(小时)"
name={['timeout', 'hours']}
rules={[{ type: 'number', min: 0.1, message: '请填写大于 0 的小时数' }]}
extra="开启后按此小时数生成截止时间"
style={{ marginBottom: 0 }}
>
<InputNumber
min={0.1}
step={0.5}
precision={1}
style={{ width: '100%' }}
addonAfter="小时"
/>
</Form.Item>
<Form.Item
label="超时动作"
name={['timeout', 'policy']}
style={{ marginBottom: 0 }}
>
<Select
options={[
{ value: 'reopen', label: '退回大厅(退还押金)' },
{ value: 'cancel_release', label: '取消并释放押金' },
{ value: 'cancel_deduct', label: '取消并扣除押金' },
{ value: 'rematch_release', label: '重新匹配并退还押金' },
{ value: 'rematch_deduct', label: '重新匹配并扣除押金' },
]}
/>
</Form.Item>
</>
) : null}
</div>
<Alert
type="info"
showIcon
style={{ marginTop: 12 }}
message="重新匹配:订单回到内部匹配池,并排除原卖家与原库存,避免同一卖家立刻接回同一单。"
/>
{timeoutEnabled ? (
<Alert
type="info"
showIcon
style={{ marginTop: 12 }}
message="重新匹配:订单回到内部匹配池,并排除原卖家与原库存,避免同一卖家立刻接回同一单。"
/>
) : null}
</section>
<section className="order-service-section">
@@ -1428,7 +1428,7 @@ export default function ConsignmentInventoryPanel() {
) : null}
{viewingItem.sourceType === 'worker_portal' && viewingItem.createdAt ? (
<Descriptions.Item label="打手提报时间">
{formatDateTime(viewingItem.createdAt)}
{formatDateTime(viewingItem.sourceSubmissionCreatedAt || viewingItem.createdAt)}
</Descriptions.Item>
) : null}
<Descriptions.Item label="卖家微信号">
@@ -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<boolean> {
}
/**
* 寄卖单号 = 来源提报单号(提报单号即寄卖单号),客服界面只认这一个号。
*
* 内部仓库号 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() {
置顶
</Tag>
) : null}
{isInternalConsignmentOrder(row) ? (
{isConsignmentStockOrder(row) ? (
<Tag color="cyan">内部匹配</Tag>
) : row.acceptanceMode === 'friend_gift' ? (
<Tag color="purple">好友赠送</Tag>
@@ -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 (
<div className="cell-stack">
<Typography.Text ellipsis={{ tooltip: assignedName }}>{assignedName}</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
ellipsis={{ tooltip: supplierText }}
>
{supplierText}
</Typography.Text>
</div>
)
}
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 (
<div className="cell-stack">
<Space size={4} align="center" wrap>
<Tag
color="cyan"
style={{ margin: 0, fontSize: 11, padding: '0 4px', lineHeight: '18px' }}
>
{consignmentDisplay.tagText}
</Tag>
<Typography.Text ellipsis={{ tooltip: consignmentDisplay.primaryName }}>
{consignmentDisplay.primaryName}
</Typography.Text>
</Space>
{consignmentDisplay.secondaryInfo ? (
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
ellipsis={{ tooltip: consignmentDisplay.secondaryInfo }}
>
{consignmentDisplay.secondaryInfo}
</Typography.Text>
) : null}
</div>
)
return renderConsignmentWorkerBlock(consignmentDisplay)
}
return '-'
}
/** 寄卖库存履约:供货打手标签 + 姓名 + 卖家资料 */
function renderConsignmentWorkerBlock(
display: NonNullable<ReturnType<typeof resolveConsignmentWorkerDisplay>>,
) {
return (
<div className="cell-stack">
<Space size={4} align="center" wrap>
<Tag color="cyan" style={{ margin: 0, fontSize: 11, padding: '0 4px', lineHeight: '18px' }}>
{display.tagText}
</Tag>
<Typography.Text ellipsis={{ tooltip: display.primaryName }}>
{display.primaryName}
</Typography.Text>
</Space>
{display.secondaryInfo ? (
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
ellipsis={{ tooltip: display.secondaryInfo }}
>
{display.secondaryInfo}
</Typography.Text>
) : null}
</div>
)
}
export function getDisplayOrderNo(
order: Pick<WorkOrder, 'workOrderNo' | 'platformOrderId'> | null | undefined,
): string {
+2
View File
@@ -139,6 +139,8 @@ export type ConsignmentInventory = ConsignmentEvidenceFields &
sourceSubmissionId: number | null
/** 来源提报单号(提报单号即寄卖单号);客服手工入库的现货为空字符串。 */
sourceSubmissionNo?: string
/** 来源提报单创建时间(打手提报时间) */
sourceSubmissionCreatedAt?: string | null
/** 分类级寄卖配置 */
profileId?: number | null
profileVersionId?: number | null
@@ -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,
}
}
+22 -4
View File
@@ -169,6 +169,27 @@ export function buildSpecText(
.join(' / ')
}
/**
* 物品名模板里跟在占位符后面的固定单位。
* 数值字段为 0 时 formatSpecValue 返回空串,单位必须一并省略,
* 否则会留下「2钻璀璨」这种没有数字的残句。
*/
const ITEM_NAME_UNIT_SUFFIX: Record<string, string> = {
brillianceCount: '璀璨',
diamondLevel: '钻',
}
function fillItemNameTemplate(template: string, tokens: Map<string, string>): 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)
@@ -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'),
'库存查询必须带来源提报创建时间',
)
})
@@ -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('默认关闭(不限时)'), '配置区文案必须说明默认不限时')
})
@@ -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<ConsignmentSpecField> & Pick<ConsignmentSpecField, 'key' | 'label'>,
): 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',
}),
'安卓怒嚎 苏尔南',
)
})