feat(consignment): 寄卖配置价格/匹配/超时/收货字段全面改造

- 价格:固定价格改为模板价固定下调,移除绝对固定价;没有模板价时统一阻止发布
- 配置面板:按下拉选项动态显隐,超时与降价提示拆成两张卡片,超时单位改为小时(默认 0.5)
- 匹配:新增关闭匹配并设为默认,移除自动匹配开关;关闭后新单直接进大厅
- 超时:新增重新匹配退/扣押金,回内部匹配池并排除原卖家与原库存(迁移 147)
- 日志:新增 consignment_match_logs,自动/手动匹配成功、失败、跳过统一留痕,后台新增匹配日志抽屉
- 打手端:顶部分类下拉替换多 tab,默认苏尔南手枪;联系方式与游戏凭据改为动态字段(默认 7 项,手机号必填)
- 降价提示默认开启、默认 24 小时
This commit is contained in:
2026-11-06 13:32:24 +08:00
parent e0a85359b6
commit 3aa01136a5
31 changed files with 2311 additions and 568 deletions
@@ -0,0 +1,52 @@
-- 147_consignment_match_and_rematch.sql
-- 1) 超时「重新匹配」排除原卖家/原库存;
-- 2) 手动/自动匹配统一留痕,供后台排查问题。
-- 全部语句幂等,可安全重跑;不改动历史迁移。
ALTER TABLE work_orders
ADD COLUMN IF NOT EXISTS rematch_excluded_worker_id BIGINT,
ADD COLUMN IF NOT EXISTS rematch_excluded_inventory_id BIGINT;
COMMENT ON COLUMN work_orders.rematch_excluded_worker_id IS
'超时重新匹配时排除的原接单打手;匹配成功后清空';
COMMENT ON COLUMN work_orders.rematch_excluded_inventory_id IS
'超时重新匹配时排除的原寄卖库存;匹配成功后清空';
CREATE TABLE IF NOT EXISTS consignment_match_logs (
id BIGSERIAL PRIMARY KEY,
work_order_id BIGINT,
work_order_no TEXT NOT NULL DEFAULT '',
profile_id BIGINT,
category_id BIGINT,
trigger_type TEXT NOT NULL,
result TEXT NOT NULL,
reason_code TEXT NOT NULL DEFAULT '',
reason_text TEXT NOT NULL DEFAULT '',
candidate_count INTEGER NOT NULL DEFAULT 0,
matched_count INTEGER NOT NULL DEFAULT 0,
actor_type TEXT NOT NULL DEFAULT 'system',
actor_id TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT consignment_match_logs_trigger_check
CHECK (trigger_type IN ('auto', 'manual', 'rematch')),
CONSTRAINT consignment_match_logs_result_check
CHECK (result IN ('matched', 'failed', 'skipped'))
);
CREATE INDEX IF NOT EXISTS idx_consignment_match_logs_created
ON consignment_match_logs (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_consignment_match_logs_work_order
ON consignment_match_logs (work_order_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_consignment_match_logs_profile
ON consignment_match_logs (profile_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_consignment_match_logs_type_result
ON consignment_match_logs (trigger_type, result, created_at DESC);
COMMENT ON TABLE consignment_match_logs IS
'寄卖匹配日志:手动/自动/重新匹配的每次尝试(成功、失败、跳过)与原因,用于留痕和问题分析';
COMMENT ON COLUMN consignment_match_logs.trigger_type IS
'触发方式:auto 自动调度 / manual 后台手动 / rematch 超时重新匹配';
COMMENT ON COLUMN consignment_match_logs.reason_code IS
'机器可读原因码,例如 no_candidate / matching_disabled / spec_mismatch / exception';
@@ -425,6 +425,9 @@ export type WorkOrderRow = {
internal_match_deadline_at?: string | null
hall_visible_at?: string | null
internal_match_attempts?: number
/** 超时「重新匹配」时排除的原卖家与原库存,避免同一卖家立刻再次接回同一单。 */
rematch_excluded_worker_id?: number | null
rematch_excluded_inventory_id?: number | null
acceptance_json: string | Record<string, unknown>
draft_acceptance_json: string | Record<string, unknown>
admin_note: string
@@ -573,7 +573,7 @@ export async function disposeWorkOrderShareProblem(input: {
export async function settleOverdueWorkOrder(input: {
workOrderId: number
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
now: string
}): Promise<{
order: WorkOrderRow | null
@@ -613,13 +613,15 @@ export async function settleOverdueWorkOrder(input: {
now: input.now,
})
}
const releasePolicies = ['reopen', 'cancel_release']
const releasePolicies = ['reopen', 'cancel_release', 'rematch_release']
const isRematch = input.policy === 'rematch_release' || input.policy === 'rematch_deduct'
const shouldReleaseDeposit = releasePolicies.includes(input.policy) && !forfeitGiftDeposit
const shouldDeductDeposit =
input.policy === 'cancel_deduct' ||
input.policy === 'rematch_deduct' ||
(releasePolicies.includes(input.policy) && forfeitGiftDeposit)
const effectivePolicy =
input.policy === 'cancel_release' && forfeitGiftDeposit ? 'cancel_deduct' : input.policy
releasePolicies.includes(input.policy) && forfeitGiftDeposit ? 'cancel_deduct' : input.policy
const workerId = Number(workOrder.assigned_worker_id || 0)
let resolvedDepositAmount = 0
@@ -672,7 +674,11 @@ export async function settleOverdueWorkOrder(input: {
}
}
const targetStatus = input.policy === 'reopen' ? 'open' : 'cancelled'
const targetStatus = isRematch
? 'internal_matching'
: input.policy === 'reopen'
? 'open'
: 'cancelled'
await client.query(
`
@@ -685,6 +691,8 @@ export async function settleOverdueWorkOrder(input: {
${CLEAR_DELIVERY_RESIDUE_SQL},
published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END,
hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END,
internal_match_deadline_at = CASE WHEN $1 = 'internal_matching' THEN NULL ELSE internal_match_deadline_at END,
hall_visible_at = CASE WHEN $1 = 'internal_matching' THEN NULL ELSE hall_visible_at END,
${RESET_GIFT_FLOW_SQL},
updated_at = $2
WHERE id = $3
@@ -893,6 +893,8 @@ export interface OverdueWorkOrderShareItem {
timeoutMinutes: number
timeoutPolicy: string
shareCreatedAt: string
/** 寄卖份额对应的库存;重新匹配时用于排除原库存 */
consignmentInventoryId: number | null
}
/**
@@ -921,6 +923,7 @@ export async function listOverdueWorkOrderShares({
timeout_minutes: number
timeout_policy: string
share_created_at: string
consignment_inventory_id: string | null
}>(
`SELECT
wos.id AS share_id,
@@ -930,7 +933,8 @@ export async function listOverdueWorkOrderShares({
wos.share_deposit,
wos.timeout_minutes,
wos.timeout_policy,
wos.created_at AS share_created_at
wos.created_at AS share_created_at,
wos.consignment_inventory_id
FROM work_order_shares wos
INNER JOIN work_orders wo ON wo.id = wos.work_order_id
WHERE wos.status = 'joined'
@@ -951,6 +955,9 @@ export async function listOverdueWorkOrderShares({
timeoutMinutes: Number(row.timeout_minutes),
timeoutPolicy: String(row.timeout_policy || 'reopen'),
shareCreatedAt: String(row.share_created_at),
consignmentInventoryId: row.consignment_inventory_id
? Number(row.consignment_inventory_id)
: null,
}))
}
@@ -990,7 +990,7 @@ export async function cancelWorkOrderShare(input: {
export async function settleOverdueWorkOrderShare(input: {
workOrderId: number
shareId: number
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
now: string
}): Promise<{
order: WorkOrderRow | null
@@ -1065,7 +1065,7 @@ export async function settleOverdueWorkOrderShare(input: {
Number(wallet?.frozen_deposit_amount || 0),
)
if (frozenDeposit > 0) {
if (input.policy === 'cancel_deduct') {
if (input.policy === 'cancel_deduct' || input.policy === 'rematch_deduct') {
deductedDepositAmount = frozenDeposit
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - frozenDeposit)
await client.query(
@@ -43,6 +43,7 @@ import {
listWorkOrderInventoryMatchOptions,
} from '../../../services/worker-platform/consignment-fulfillment-service.js'
import { listPendingConsignmentMatches } from '../../../services/worker-platform/consignment-pending-match-service.js'
import { listConsignmentMatchLogs } from '../../../services/worker-platform/consignment-match-log-service.js'
import {
listConsignmentMatchCandidatesForWorkOrder,
matchConsignmentWorkOrderManually,
@@ -140,6 +141,16 @@ router.post(
),
)
router.get(
'/worker-platform/consignment/match-logs',
requireAdminPermission('consignment.view'),
createJsonHandler((req) => listConsignmentMatchLogs(req.query), {
successMessage: 'ok',
errorMessage: '读取匹配日志失败',
scope: '[admin/consignment/match-logs]',
}),
)
// ---------------- 库存管理与统计 ----------------
router.get(
'/worker-platform/consignment/inventories',
@@ -325,6 +325,14 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
const profileConfig = row.profile_snapshot_json
? normalizeConsignmentProfileConfig(row.profile_snapshot_json)
: null
const contentJson =
row.content_json && typeof row.content_json === 'object'
? (row.content_json as Record<string, unknown>)
: safeParseJson(row.content_json)
const intakeValues =
contentJson?.intakeValues && typeof contentJson.intakeValues === 'object'
? (contentJson.intakeValues as Record<string, unknown>)
: {}
return {
id: Number(row.id),
submissionNo: row.submission_no,
@@ -334,6 +342,9 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
profileId: row.profile_id ? Number(row.profile_id) : null,
profileVersionId: row.profile_version_id ? Number(row.profile_version_id) : null,
specs: profileConfig?.specs || [],
/** 收货信息字段定义(按发布快照);历史提报没有快照时返回空,前端回退旧列展示。 */
intakeFields: profileConfig?.intake.fields || [],
intakeValues,
specValues: parseConsignmentSpecValues(row.spec_values_json),
specText: row.spec_text || '',
priceCapFen: Number(row.price_cap_fen || 0),
@@ -349,10 +360,7 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
sellerPaymentInfo: row.seller_payment_info || '',
sellerPhone: row.seller_phone || '',
contactInfo: row.contact_info || '',
contentJson:
row.content_json && typeof row.content_json === 'object'
? (row.content_json as Record<string, unknown>)
: safeParseJson(row.content_json),
contentJson,
screenshotsJson: refreshScreenshotList(
Array.isArray(row.screenshots_json)
? row.screenshots_json
@@ -7,6 +7,7 @@ import {
extractPeacekeeperSpecs,
listConsignmentMatchOptions,
} from './consignment-matching-service.js'
import { isRematchExcludedInventory } from './consignment-match-engine-service.js'
import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js'
function inventory(overrides: Partial<ConsignmentInventoryRow> = {}): ConsignmentInventoryRow {
@@ -41,6 +42,37 @@ test('手动指定现货:连不符合规格的也要列出来,但必须标
assert.ok(options[2]!.conflicts.some((item) => item.includes('系列')))
})
test('重新匹配排除:原库存与原卖家都不再参与,且匹配成功后清空', () => {
const excluded = {
rematch_excluded_worker_id: 7,
rematch_excluded_inventory_id: 11,
} as never
assert.equal(isRematchExcludedInventory(excluded, { id: 11, worker_id: 99 }), true)
assert.equal(isRematchExcludedInventory(excluded, { id: 12, worker_id: 7 }), true)
assert.equal(isRematchExcludedInventory(excluded, { id: 12, worker_id: 8 }), false)
assert.equal(isRematchExcludedInventory({} as never, { id: 12, worker_id: 7 }), false)
})
test('超时重新匹配与匹配日志:链路必须接线到调度与后台接口', async () => {
const { readFile } = await import('node:fs/promises')
const [timeoutSrc, engineSrc, routes, logSrc] = await Promise.all([
readFile(new URL('./worker-order-timeout-service.ts', import.meta.url), 'utf8'),
readFile(new URL('./consignment-match-engine-service.ts', import.meta.url), 'utf8'),
readFile(new URL('../../routes/admin/worker-platform/consignment.ts', import.meta.url), 'utf8'),
readFile(new URL('./consignment-match-log-service.ts', import.meta.url), 'utf8'),
])
assert.ok(timeoutSrc.includes('startConsignmentRematch('), '超时服务必须触发重新匹配')
assert.ok(timeoutSrc.includes('isRematchTimeoutPolicy(policy)'), '必须识别重新匹配策略')
assert.ok(
engineSrc.includes('rematch_excluded_worker_id = $3') &&
engineSrc.includes('rematch_excluded_worker_id = NULL'),
'重新匹配必须写入并清空排除字段',
)
assert.ok(engineSrc.includes('recordConsignmentMatchLog('), '自动/手动匹配必须写日志')
assert.ok(routes.includes("'/worker-platform/consignment/match-logs'"), '必须提供匹配日志接口')
assert.ok(logSrc.includes('INSERT INTO consignment_match_logs'), '日志表必须落库')
})
test('字段级差异:订单没写规格时不报差异(那是"非精确",不是冲突)', () => {
const specs = extractPeacekeeperSpecs('和平精英 暗潮 2钻', {})
assert.deepEqual(describeConsignmentSpecDifferences(specs, inventory({})), [])
@@ -37,12 +37,26 @@ import {
extractPeacekeeperSpecs,
} from './consignment-matching-service.js'
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
import { recordConsignmentMatchLog } from './consignment-match-log-service.js'
type MatchCandidate = {
workOrder: WorkOrderRow
profile: ConsignmentProfileSnapshot | null
}
/** 自动匹配的「无候选」跳过日志按工单做 10 分钟节流,避免 30 秒调度刷爆日志表。 */
const matchSkipLogThrottle = new Map<string, number>()
const MATCH_SKIP_LOG_WINDOW_MS = 10 * 60 * 1000
function shouldLogMatchSkip(key: string): boolean {
const now = Date.now()
const last = matchSkipLogThrottle.get(key) || 0
if (now - last < MATCH_SKIP_LOG_WINDOW_MS) return false
matchSkipLogThrottle.set(key, now)
if (matchSkipLogThrottle.size > 5000) matchSkipLogThrottle.clear()
return true
}
function parseMaterial(value: unknown): Record<string, unknown> {
const parsed = safeParseJson(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
@@ -75,6 +89,22 @@ export function inventoryMatchesProfile(
return !profile.config.specs.some((spec) => !isBuiltInConsignmentSpecField(spec))
}
/**
* 超时「重新匹配」排除的原卖家/原库存:
* 同一单重新进入匹配池后,不允许原卖家或原库存立刻再次匹配,避免死循环。
*/
export function isRematchExcludedInventory(
workOrder: Pick<WorkOrderRow, 'rematch_excluded_worker_id' | 'rematch_excluded_inventory_id'>,
inventory: Pick<ConsignmentInventoryRow, 'id' | 'worker_id'>,
): boolean {
const excludedInventoryId = Number(workOrder.rematch_excluded_inventory_id || 0)
const excludedWorkerId = Number(workOrder.rematch_excluded_worker_id || 0)
if (!excludedInventoryId && !excludedWorkerId) return false
if (excludedInventoryId && Number(inventory.id) === excludedInventoryId) return true
if (excludedWorkerId && Number(inventory.worker_id || 0) === excludedWorkerId) return true
return false
}
function compareInventoryCandidates(
a: { inventory: ConsignmentInventoryRow; evaluation: InventoryMatchEvaluation },
b: { inventory: ConsignmentInventoryRow; evaluation: InventoryMatchEvaluation },
@@ -236,6 +266,8 @@ export async function enterConsignmentInternalMatching(
if (!workOrder || workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) return false
const profile = await resolveProfileForWorkOrder(workOrder)
if (!profile) return false
// 关闭匹配:新单直接进大厅,不进入内部匹配队列。
if (profile.config.matching.mode === 'off') return false
const delayMinutes = Math.max(0, profile.config.internalMatch.delayMinutes)
if (delayMinutes <= 0 && !profile.config.internalMatch.fallbackToHall) return false
const deadline = addMinutesIso(delayMinutes, now)
@@ -283,6 +315,8 @@ export async function reenterConsignmentInternalMatching(
}
const profile = await resolveProfileForWorkOrder(workOrder)
if (!profile) return false
// 关闭匹配:退回大厅后不再重新排队等内部库存。
if (profile.config.matching.mode === 'off') return false
const delayMinutes = Math.max(0, profile.config.internalMatch.delayMinutes)
if (delayMinutes <= 0) return false
const deadline = addMinutesIso(delayMinutes, now)
@@ -318,6 +352,91 @@ export async function reenterConsignmentInternalMatching(
return true
}
/**
* 超时「重新匹配」:把工单重新放回内部匹配池,并排除原卖家/原库存。
* 分类关闭匹配或找不到配置时,退回大厅,避免工单卡在无人处理的状态。
*/
export async function startConsignmentRematch(
workOrderId: number,
input: { excludeWorkerId?: number | null; excludeInventoryId?: number | null } = {},
now = nowIso(),
): Promise<boolean> {
const workOrder = await getWorkOrderById(workOrderId)
if (!workOrder) return false
const profile = await resolveProfileForWorkOrder(workOrder)
const fallbackToHall = async () => {
await query(
`
UPDATE work_orders
SET status = 'open',
assigned_worker_id = NULL,
assigned_at = NULL,
deadline_at = NULL,
published_at = COALESCE(published_at, $1),
internal_match_deadline_at = NULL,
hall_visible_at = NULL,
updated_at = $1
WHERE id = $2 AND status IN ('internal_matching', 'open', 'unassigned', 'in_progress')
`,
[now, workOrderId],
)
return false
}
if (!profile || profile.config.matching.mode === 'off') {
return fallbackToHall()
}
const delayMinutes = Math.max(1, Number(profile.config.internalMatch.delayMinutes || 30))
const deadline = addMinutesIso(delayMinutes, now)
const result = await query<WorkOrderRow>(
`
UPDATE work_orders
SET status = 'internal_matching',
assigned_worker_id = NULL,
assigned_at = NULL,
deadline_at = NULL,
consignment_profile_id = $1,
internal_match_deadline_at = $2,
hall_visible_at = $2,
internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1,
rematch_excluded_worker_id = $3,
rematch_excluded_inventory_id = $4,
updated_at = $5
WHERE id = $6 AND status IN ('internal_matching', 'open', 'unassigned', 'in_progress')
RETURNING *
`,
[
profile.profileId,
deadline,
input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
now,
workOrderId,
],
)
if (!result.rows[0]) return false
await query(
`
INSERT INTO work_order_events (
work_order_id, actor_type, actor_id, event_type, from_status, to_status,
payload_json, created_at
) VALUES ($1, 'system', 'consignment_match', 'consignment_rematch_started', $2, 'internal_matching', $3::jsonb, $4)
`,
[
workOrderId,
workOrder.status,
JSON.stringify({
profileId: profile.profileId,
delayMinutes,
deadline,
excludedWorkerId: input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
excludedInventoryId: input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
}),
now,
],
)
return true
}
function pickCandidateInventories(
workOrder: WorkOrderRow,
profile: ConsignmentProfileSnapshot | null,
@@ -329,6 +448,7 @@ function pickCandidateInventories(
return inventories
.filter((inventory) => inventoryMatchesProfile(inventory, profile))
.filter((inventory) => Number(inventory.remaining_quantity ?? 1) > 0)
.filter((inventory) => !isRematchExcludedInventory(workOrder, inventory))
.map((inventory) => ({
inventory,
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
@@ -447,6 +567,8 @@ async function applyWholeOrderMatch(
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 = $3
WHERE id = $9 AND status = 'internal_matching'
RETURNING *
@@ -627,6 +749,8 @@ async function applySharingMatch(
internal_match_deadline_at = CASE WHEN $1 = 'in_progress' THEN NULL ELSE internal_match_deadline_at END,
hall_visible_at = CASE WHEN $1 = 'in_progress' THEN NULL ELSE hall_visible_at END,
internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1,
rematch_excluded_worker_id = CASE WHEN $1 = 'in_progress' THEN NULL ELSE rematch_excluded_worker_id END,
rematch_excluded_inventory_id = CASE WHEN $1 = 'in_progress' THEN NULL ELSE rematch_excluded_inventory_id END,
updated_at = $3
WHERE id = $4
`,
@@ -714,19 +838,41 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
)
let matched = 0
for (const workOrder of orders.rows) {
const startedAt = Date.now()
const baseLog = {
workOrderId: Number(workOrder.id),
workOrderNo: workOrder.work_order_no || '',
triggerType: 'auto' as const,
actorType: 'system',
actorId: 'consignment_match',
}
let profileRef: ConsignmentProfileSnapshot | null = null
try {
const profile = await resolveProfileForWorkOrder(workOrder)
profileRef = profile
if (!profile) continue
if (profile.config.matching.mode !== 'auto' || !profile.config.matching.autoMatchEnabled) {
continue
}
const logBase = { ...baseLog, profileId: profile.profileId, categoryId: profile.categoryId }
if (profile.config.matching.mode !== 'auto') continue
const inventories = await listActiveConsignmentInventoriesForMatching()
const matching = profile.config.matching
const candidates = pickCandidateInventories(workOrder, profile, inventories, {
allowPartial: matching.exactOnly === false,
preferLowReward: matching.preferLowReward !== false,
})
if (candidates.length === 0) continue
if (candidates.length === 0) {
if (shouldLogMatchSkip(`no_candidate:${workOrder.id}`)) {
await recordConsignmentMatchLog({
...logBase,
result: 'skipped',
reasonCode: 'no_candidate',
reasonText: '没有符合条件的在库库存',
candidateCount: inventories.length,
durationMs: Date.now() - startedAt,
payload: { inventoryCount: inventories.length },
})
}
continue
}
if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) {
const context: ConsignmentMatchContext = {
@@ -738,7 +884,31 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
]),
}
const created = await applySharingMatch({ workOrder, profile }, candidates, context)
if (created > 0) matched += 1
if (created > 0) {
matched += 1
await recordConsignmentMatchLog({
...logBase,
result: 'matched',
reasonCode: 'auto_sharing_matched',
candidateCount: candidates.length,
matchedCount: created,
durationMs: Date.now() - startedAt,
payload: {
partial: context.partial,
specConflicts: context.specConflicts,
inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)),
},
})
} else {
await recordConsignmentMatchLog({
...logBase,
result: 'failed',
reasonCode: 'sharing_not_applied',
reasonText: '拼单匹配未创建任何份额(库存可能已被其它任务占用)',
candidateCount: candidates.length,
durationMs: Date.now() - startedAt,
})
}
} else {
// 整单必须由一条库存一次性满足订单总数量,不能只占 1 件。
const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
@@ -747,16 +917,63 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
const available = Number(candidate.inventory.remaining_quantity ?? 1)
return available >= requiredQuantity && (!friendGift || available === requiredQuantity)
})
if (!chosen) continue
if (!chosen) {
if (shouldLogMatchSkip(`quantity_insufficient:${workOrder.id}`)) {
await recordConsignmentMatchLog({
...logBase,
result: 'skipped',
reasonCode: 'quantity_insufficient',
reasonText: `没有库存能一次满足订单数量 ${requiredQuantity}`,
candidateCount: candidates.length,
durationMs: Date.now() - startedAt,
payload: { requiredQuantity },
})
}
continue
}
const applied = await applyWholeOrderMatch({ workOrder, profile }, chosen, {
matchType: 'auto',
partial: !chosen.evaluation.exact,
specConflicts: [...chosen.evaluation.conflicts, ...chosen.evaluation.unknowns],
})
if (applied) matched += 1
if (applied) {
matched += 1
await recordConsignmentMatchLog({
...logBase,
result: 'matched',
reasonCode: 'auto_matched',
candidateCount: candidates.length,
matchedCount: 1,
durationMs: Date.now() - startedAt,
payload: {
partial: !chosen.evaluation.exact,
specConflicts: [...chosen.evaluation.conflicts, ...chosen.evaluation.unknowns],
inventoryId: Number(chosen.inventory.id),
},
})
} else {
await recordConsignmentMatchLog({
...logBase,
result: 'failed',
reasonCode: 'apply_failed',
reasonText: '匹配事务未生效(工单状态或库存数量已变化)',
candidateCount: candidates.length,
durationMs: Date.now() - startedAt,
payload: { inventoryId: Number(chosen.inventory.id) },
})
}
}
} catch {
} catch (error) {
// 单笔失败不影响同批其它工单;下一次调度会重试。
await recordConsignmentMatchLog({
...baseLog,
profileId: profileRef?.profileId ?? null,
categoryId: profileRef?.categoryId ?? null,
result: 'failed',
reasonCode: 'exception',
reasonText: error instanceof Error ? error.message : String(error),
durationMs: Date.now() - startedAt,
})
}
}
return { scanned: orders.rows.length, matched }
@@ -860,6 +1077,11 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
}
}
function extractConsignmentErrorCode(error: unknown): string {
const record = error as { errorCode?: unknown; code?: unknown } | null
return String(record?.errorCode || record?.code || 'exception').slice(0, 80)
}
/** 后台手动匹配:选中一条库存走整单;拼单工单可多选库存,每份一条份额。 */
export async function matchConsignmentWorkOrderManually(input: {
workOrderId: number
@@ -868,6 +1090,53 @@ export async function matchConsignmentWorkOrderManually(input: {
/** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */
allowNonExact?: boolean
reason?: string
}): Promise<{ matched: boolean; matchedCount: number }> {
const startedAt = Date.now()
const workOrder = await getWorkOrderById(input.workOrderId).catch(() => null)
const logBase = {
workOrderId: Number(input.workOrderId),
workOrderNo: workOrder?.work_order_no || '',
profileId: workOrder?.consignment_profile_id ? Number(workOrder.consignment_profile_id) : null,
categoryId: workOrder?.category_id ? Number(workOrder.category_id) : null,
triggerType: 'manual' as const,
actorType: 'admin',
actorId: String(input.actorName || '').trim() || 'admin',
payload: {
inventoryIds: input.inventoryIds,
allowNonExact: Boolean(input.allowNonExact),
reason: String(input.reason || '').trim(),
},
}
try {
const result = await runManualConsignmentMatch(input)
await recordConsignmentMatchLog({
...logBase,
result: result.matched ? 'matched' : 'failed',
reasonCode: result.matched ? 'manual_matched' : 'manual_not_applied',
reasonText: result.matched ? '' : '手动匹配未生效(工单状态或库存数量已变化)',
matchedCount: result.matchedCount,
durationMs: Date.now() - startedAt,
})
return result
} catch (error) {
await recordConsignmentMatchLog({
...logBase,
result: 'failed',
reasonCode: extractConsignmentErrorCode(error),
reasonText: error instanceof Error ? error.message : String(error),
durationMs: Date.now() - startedAt,
})
throw error
}
}
async function runManualConsignmentMatch(input: {
workOrderId: number
inventoryIds: number[]
actorName?: string
/** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */
allowNonExact?: boolean
reason?: string
}): Promise<{ matched: boolean; matchedCount: number }> {
const workOrder = await getWorkOrderById(input.workOrderId)
if (!workOrder) {
@@ -888,7 +1157,9 @@ export async function matchConsignmentWorkOrderManually(input: {
const profile = await resolveProfileForWorkOrder(workOrder)
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
(inventory) =>
inventoryMatchesProfile(inventory, profile) && Number(inventory.remaining_quantity ?? 1) > 0,
inventoryMatchesProfile(inventory, profile) &&
Number(inventory.remaining_quantity ?? 1) > 0 &&
!isRematchExcludedInventory(workOrder, inventory),
)
const selected = input.inventoryIds
.map((id) => inventories.find((inventory) => Number(inventory.id) === Number(id)))
@@ -0,0 +1,189 @@
import { query } from '../../db/client.js'
import { nowIso } from '../../utils/time.js'
export type ConsignmentMatchLogTrigger = 'auto' | 'manual' | 'rematch'
export type ConsignmentMatchLogResult = 'matched' | 'failed' | 'skipped'
export type ConsignmentMatchLogRow = {
id: number
work_order_id: number | null
work_order_no: string
profile_id: number | null
category_id: number | null
trigger_type: string
result: string
reason_code: string
reason_text: string
candidate_count: number
matched_count: number
actor_type: string
actor_id: string
duration_ms: number
payload_json: unknown
created_at: string
}
export type RecordConsignmentMatchLogInput = {
workOrderId?: number | null
workOrderNo?: string
profileId?: number | null
categoryId?: number | null
triggerType: ConsignmentMatchLogTrigger
result: ConsignmentMatchLogResult
reasonCode?: string
reasonText?: string
candidateCount?: number
matchedCount?: number
actorType?: string
actorId?: string
durationMs?: number
payload?: Record<string, unknown>
createdAt?: string
}
/** 写匹配日志:任何失败都不影响主流程。 */
export async function recordConsignmentMatchLog(
input: RecordConsignmentMatchLogInput,
): Promise<void> {
try {
await query(
`
INSERT INTO consignment_match_logs (
work_order_id, work_order_no, profile_id, category_id, trigger_type, result,
reason_code, reason_text, candidate_count, matched_count,
actor_type, actor_id, duration_ms, payload_json, created_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
$11, $12, $13, $14::jsonb, $15
)
`,
[
input.workOrderId ?? null,
String(input.workOrderNo || '').slice(0, 80),
input.profileId ?? null,
input.categoryId ?? null,
input.triggerType,
input.result,
String(input.reasonCode || '').slice(0, 80),
String(input.reasonText || '').slice(0, 500),
Math.max(0, Math.round(Number(input.candidateCount || 0))),
Math.max(0, Math.round(Number(input.matchedCount || 0))),
String(input.actorType || 'system').slice(0, 30),
String(input.actorId || '').slice(0, 120),
Math.max(0, Math.round(Number(input.durationMs || 0))),
JSON.stringify(input.payload || {}),
input.createdAt || nowIso(),
],
)
} catch {
// 日志失败不影响匹配主流程。
}
}
function mapLog(row: ConsignmentMatchLogRow) {
let payload: Record<string, unknown> = {}
if (row.payload_json && typeof row.payload_json === 'object') {
payload = row.payload_json as Record<string, unknown>
} else if (typeof row.payload_json === 'string') {
try {
const parsed = JSON.parse(row.payload_json)
payload = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}
} catch {
payload = {}
}
}
return {
logId: Number(row.id),
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
workOrderNo: row.work_order_no || '',
profileId: row.profile_id ? Number(row.profile_id) : null,
categoryId: row.category_id ? Number(row.category_id) : null,
triggerType: row.trigger_type as ConsignmentMatchLogTrigger,
result: row.result as ConsignmentMatchLogResult,
reasonCode: row.reason_code || '',
reasonText: row.reason_text || '',
candidateCount: Number(row.candidate_count || 0),
matchedCount: Number(row.matched_count || 0),
actorType: row.actor_type || '',
actorId: row.actor_id || '',
durationMs: Number(row.duration_ms || 0),
payload,
createdAt: row.created_at,
}
}
export async function listConsignmentMatchLogs(
input: {
page?: unknown
pageSize?: unknown
profileId?: unknown
triggerType?: unknown
result?: unknown
keyword?: unknown
from?: unknown
to?: unknown
} = {},
) {
const page = Math.max(1, Number(input.page || 1))
const pageSize = Math.min(100, Math.max(1, Number(input.pageSize || 20)))
const values: unknown[] = []
const where: string[] = []
const profileId = Number(input.profileId || 0)
if (profileId > 0) {
values.push(profileId)
where.push(`profile_id = $${values.length}`)
}
const triggerType = String(input.triggerType || '').trim()
if (triggerType === 'auto' || triggerType === 'manual' || triggerType === 'rematch') {
values.push(triggerType)
where.push(`trigger_type = $${values.length}`)
}
const result = String(input.result || '').trim()
if (result === 'matched' || result === 'failed' || result === 'skipped') {
values.push(result)
where.push(`result = $${values.length}`)
}
const keyword = String(input.keyword || '').trim()
if (keyword) {
values.push(`%${keyword}%`)
where.push(`(work_order_no ILIKE $${values.length} OR work_order_id::text = $${values.length})`)
}
const from = String(input.from || '').trim()
if (from) {
values.push(from)
where.push(`created_at >= $${values.length}::timestamptz`)
}
const to = String(input.to || '').trim()
if (to) {
values.push(to)
where.push(`created_at <= $${values.length}::timestamptz`)
}
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
const countResult = await query<{ total: number | string }>(
`SELECT COUNT(*)::int AS total FROM consignment_match_logs ${whereSql}`,
values,
)
const total = Number(countResult.rows[0]?.total || 0)
const listValues = [...values, pageSize, (page - 1) * pageSize]
const rows = await query<ConsignmentMatchLogRow>(
`
SELECT *
FROM consignment_match_logs
${whereSql}
ORDER BY id DESC
LIMIT $${values.length + 1} OFFSET $${values.length + 2}
`,
listValues,
)
return {
items: rows.rows.map(mapLog),
total,
page,
pageSize,
}
}
@@ -91,10 +91,10 @@ const suffix = `${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`
const pricing = {
priceType: 'template_discount' as const,
discount: 10,
fixedPriceFen: 0,
minusFen: 0,
allowWorkerPrice: true,
minPriceFen: 1000,
missingPricePolicy: 'manual' as const,
missingPricePolicy: 'block' as const,
}
const specs = [
@@ -204,7 +204,6 @@ const profileConfig = {
inventoryLimit: { enabled: false, maxActiveQuantity: 0 },
matching: {
mode: 'auto',
autoMatchEnabled: true,
exactOnly: true,
preferLowReward: true,
},
@@ -436,12 +435,18 @@ async function main() {
assert(bad.errors.length > 0, '高于建议价必须报错')
})
await check('价格:固定价 12000 高于模板价 10000 必须拒绝', () => {
const result = resolveWorkerConsignmentPrice({
pricing: { ...pricing, priceType: 'fixed', fixedPriceFen: 12000 },
await check('价格:固定下调 2000 分后售价 8000,高于建议价必须拒绝', () => {
const result = computeConsignmentPrice({
pricing: { ...pricing, priceType: 'template_minus', minusFen: 2000 },
templatePriceFen: 10000,
})
assert(result.errors.length > 0, '固定价高于模板价必须报错')
assert(result.priceFen === 8000, `实际 ${result.priceFen}`)
const above = resolveWorkerConsignmentPrice({
pricing: { ...pricing, priceType: 'template_minus', minusFen: 2000 },
templatePriceFen: 10000,
submittedPriceFen: 9000,
})
assert(above.errors.length > 0, '高于建议价必须报错')
})
await check('价格:allowWorkerPrice=false 时按系统建议价发布不应被拦截', () => {
@@ -506,6 +511,7 @@ async function main() {
brillianceCount: '1',
},
sellerContact: `wx_${suffix}`,
sellerPhone: '13800138000',
characterName: '测试角色',
characterId: '123456',
screenshotsJson: ['https://example.com/screenshot.png'],
@@ -567,13 +573,14 @@ async function main() {
profileId,
quantity: 1,
specValues: {
system: '安卓',
system: '苹果',
serverZone: '微信区',
series: '暗潮',
diamondLevel: '1',
brillianceCount: '1',
},
sellerContact: `wx_${suffix}`,
sellerPhone: '13800138000',
characterName: '测试角色2',
characterId: '654321',
screenshotsJson: ['https://example.com/screenshot2.png'],
@@ -615,6 +622,7 @@ async function main() {
brillianceCount: '1',
},
sellerContact: `wx_${suffix}`,
sellerPhone: '13800138000',
characterName: '穷角色',
characterId: '999999',
screenshotsJson: ['https://example.com/poor.png'],
@@ -1081,6 +1089,7 @@ async function main() {
brillianceCount: '1',
},
sellerContact: `wx_gift_${label}_${suffix}`,
sellerPhone: '13800138000',
characterName: `赠礼卖家${label}`,
characterId: `GIFT${label}${suffix}`.slice(0, 20),
screenshotsJson: ['https://example.com/gift-item.png'],
@@ -13,6 +13,7 @@ import {
import {
evaluateInventoryMatch,
inventoryMatchesProfile,
isRematchExcludedInventory,
resolveProfileForWorkOrder,
} from './consignment-match-engine-service.js'
import type { ConsignmentProfileSnapshot } from './consignment-profile-service.js'
@@ -302,6 +303,8 @@ export function buildPendingConsignmentMatchPairs(input: {
const matchedInventoryIds = new Set<number>()
for (const { order, profile, remainingQuantity, totalQuantity } of preparedOrders) {
for (const inventory of inventories) {
// 超时重新匹配的排除项:看板同样不能把它算成可匹配。
if (isRematchExcludedInventory(order, inventory)) continue
let matchType: 'exact' | 'partial' | 'conflict'
let score: number
let reason: string
@@ -13,9 +13,11 @@ test('寄卖配置:缺省值必须可安全用于发布(保证金/服务费
const config = normalizeConsignmentProfileConfig({})
assert.equal(config.deposit.enabled, false)
assert.equal(config.serviceFee.enabled, false)
assert.equal(config.matching.mode, 'manual')
assert.equal(config.matching.mode, 'off')
assert.equal(config.timeout.minutes, 30)
assert.equal(config.internalMatch.delayMinutes, 30)
assert.equal(config.priceDrop.enabled, true)
assert.equal(config.priceDrop.hours, 24)
assert.equal(config.fields.quantity, true)
})
@@ -32,7 +34,7 @@ test('寄卖配置:非法枚举与越界数字回落到安全值', () => {
assert.equal(config.deposit.unitAmountFen, 0)
assert.equal(config.serviceFee.mode, 'percent')
assert.equal(config.serviceFee.percent, 100)
assert.equal(config.matching.mode, 'manual')
assert.equal(config.matching.mode, 'off')
assert.equal(config.timeout.policy, 'reopen')
assert.equal(config.timeout.minutes, 0)
assert.equal(config.priceDrop.hours, 1)
@@ -4,15 +4,24 @@ import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { listAllWorkCategories } from '../../repositories/worker-platform/work-category-repo.js'
import {
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
DEFAULT_CONSIGNMENT_PRICING,
DEFAULT_CONSIGNMENT_SPECS,
normalizeConsignmentIntakeFields,
normalizeConsignmentPricing,
normalizeConsignmentSpecs,
type ConsignmentIntakeField,
type ConsignmentPricingConfig,
type ConsignmentSpecField,
} from './consignment-spec-service.js'
export type { ConsignmentPricingConfig, ConsignmentSpecField } from './consignment-spec-service.js'
export type {
ConsignmentIntakeField,
ConsignmentIntakeFieldType,
ConsignmentIntakeValues,
ConsignmentPricingConfig,
ConsignmentSpecField,
} from './consignment-spec-service.js'
export type ConsignmentDepositConfig = {
enabled: boolean
@@ -34,9 +43,8 @@ export type ConsignmentInventoryLimitConfig = {
}
export type ConsignmentMatchingConfig = {
/** manual = 只允许人工匹配;auto = 调度器自动匹配 */
mode: 'manual' | 'auto'
autoMatchEnabled: boolean
/** off = 完全关闭匹配(新单直接进大厅);manual = 只允许人工匹配;auto = 调度器自动匹配 */
mode: 'off' | 'manual' | 'auto'
exactOnly: boolean
preferLowReward: boolean
}
@@ -48,9 +56,9 @@ export type ConsignmentInternalMatchConfig = {
}
export type ConsignmentTimeoutConfig = {
/** 匹配成功进入代练中后的时限(分钟),默认 30 */
/** 匹配成功进入代练中后的时限(分钟),默认 30(= 0.5 小时) */
minutes: number
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
}
export type ConsignmentPriceDropConfig = {
@@ -87,6 +95,10 @@ export type ConsignmentProfileConfig = {
internalMatch: ConsignmentInternalMatchConfig
timeout: ConsignmentTimeoutConfig
priceDrop: ConsignmentPriceDropConfig
/** 联系方式与游戏凭据:按配置动态渲染;没有该字段时使用默认 7 项 */
intake: {
fields: ConsignmentIntakeField[]
}
}
export type ConsignmentProfileRow = {
@@ -148,8 +160,7 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
maxActiveQuantity: 0,
},
matching: {
mode: 'manual',
autoMatchEnabled: false,
mode: 'off',
exactOnly: true,
preferLowReward: true,
},
@@ -162,11 +173,14 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
policy: 'reopen',
},
priceDrop: {
enabled: false,
hours: 72,
enabled: true,
hours: 24,
mode: 'percent',
value: 10,
},
intake: {
fields: DEFAULT_CONSIGNMENT_INTAKE_FIELDS.map((field) => ({ ...field })),
},
}
function record(value: unknown): Record<string, unknown> {
@@ -221,15 +235,29 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
const timeout = record(source.timeout)
const priceDrop = record(source.priceDrop)
const matchingMode = matching.mode === 'auto' ? 'auto' : 'manual'
const matchingMode: ConsignmentMatchingConfig['mode'] =
matching.mode === 'auto'
? bool(matching.autoMatchEnabled, true)
? 'auto'
: 'manual'
: matching.mode === 'manual'
? 'manual'
: 'off'
const depositMode = deposit.mode === 'per_quantity' ? 'per_quantity' : 'fixed'
const serviceFeeMode = serviceFee.mode === 'fixed' ? 'fixed' : 'percent'
const priceDropMode = priceDrop.mode === 'fixed' ? 'fixed' : 'percent'
const timeoutPolicy =
timeout.policy === 'cancel_release' || timeout.policy === 'cancel_deduct'
const timeoutPolicy: ConsignmentTimeoutConfig['policy'] =
timeout.policy === 'cancel_release' ||
timeout.policy === 'cancel_deduct' ||
timeout.policy === 'rematch_release' ||
timeout.policy === 'rematch_deduct'
? timeout.policy
: 'reopen'
const specKeys = new Set(specs.map((spec) => spec.key))
const intakeSource = record(source.intake)
const intakeFields = normalizeConsignmentIntakeFields(
intakeSource.fields ?? source.intakeFields ?? source.intake_fields,
)
return {
version: 1,
@@ -267,7 +295,6 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
},
matching: {
mode: matchingMode,
autoMatchEnabled: matchingMode === 'auto' ? bool(matching.autoMatchEnabled, true) : false,
exactOnly: bool(matching.exactOnly, true),
preferLowReward: bool(matching.preferLowReward, true),
},
@@ -280,11 +307,14 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
policy: timeoutPolicy,
},
priceDrop: {
enabled: bool(priceDrop.enabled, false),
hours: integer(priceDrop.hours, 72, 1, 24 * 365),
enabled: bool(priceDrop.enabled, true),
hours: integer(priceDrop.hours, 24, 1, 24 * 365),
mode: priceDropMode,
value: integer(priceDrop.value, 10, 0, 100_000_000),
},
intake: {
fields: intakeFields,
},
}
}
@@ -9,6 +9,7 @@ import {
buildConsignmentSpecText,
computeConsignmentPrice,
evaluateConsignmentSpecMatch,
normalizeConsignmentIntakeValues,
normalizeConsignmentSpecValues,
normalizeConsignmentSpecs,
parseConsignmentSpecValues,
@@ -22,10 +23,10 @@ function buildPricing(overrides: Partial<ConsignmentPricingConfig> = {}): Consig
return {
priceType: 'template_discount',
discount: 10,
fixedPriceFen: 0,
minusFen: 0,
allowWorkerPrice: true,
minPriceFen: 0,
missingPricePolicy: 'manual',
missingPricePolicy: 'block',
...overrides,
}
}
@@ -121,7 +122,7 @@ test('动态规格:默认配置没有任何字段,价格默认十折', () =>
assert.equal(config.pricing.priceType, 'template_discount')
assert.equal(config.pricing.discount, 10)
assert.equal(config.pricing.allowWorkerPrice, true)
assert.equal(config.pricing.missingPricePolicy, 'manual')
assert.equal(config.pricing.missingPricePolicy, 'block')
assert.equal(config.fields.quantity, true)
assert.equal(config.fields.system, false)
})
@@ -272,27 +273,29 @@ test('动态价格:模板折扣价与改价上限等于建议价', () => {
assert.equal(result.source, 'template')
})
test('动态价格:固定价格可用,且不能高于接单模板价', () => {
test('动态价格:模板价固定下调,且无模板价时不可寄卖', () => {
const normal = computeConsignmentPrice({
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 10000 }),
pricing: buildPricing({ priceType: 'template_minus', minusFen: 3000 }),
templatePriceFen: 13000,
})
assert.equal(normal.priceFen, 10000)
assert.equal(normal.capFen, 10000)
assert.equal(normal.source, 'fixed')
const above = resolveWorkerConsignmentPrice({
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 15000 }),
templatePriceFen: 13000,
})
assert.ok(above.errors.some((error) => error.includes('固定价格不能高于接单模板价')))
assert.equal(normal.source, 'template')
const noTemplate = resolveWorkerConsignmentPrice({
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 8000 }),
pricing: buildPricing({ priceType: 'template_minus', minusFen: 3000 }),
templatePriceFen: 0,
submittedPriceFen: 8000,
})
assert.deepEqual(noTemplate.errors, [])
assert.equal(noTemplate.priceFen, 8000)
assert.equal(noTemplate.priceFen, 0)
assert.ok(noTemplate.errors.some((error) => error.includes('暂无模板价')))
const overDown = resolveWorkerConsignmentPrice({
pricing: buildPricing({ priceType: 'template_minus', minusFen: 13000 }),
templatePriceFen: 13000,
submittedPriceFen: 1000,
})
assert.ok(overDown.errors.length > 0)
})
test('动态价格:打手改价只能低于系统建议价,且受最低价约束', () => {
@@ -321,21 +324,52 @@ test('动态价格:打手改价只能低于系统建议价,且受最低价
assert.ok(belowMin.errors.some((error) => error.includes('不能少于')))
})
test('动态价格:没有模板价时允许手填,block 策略则拦截', () => {
const manual = resolveWorkerConsignmentPrice({
pricing: buildPricing({ missingPricePolicy: 'manual' }),
templatePriceFen: 0,
submittedPriceFen: 8000,
})
assert.deepEqual(manual.errors, [])
assert.equal(manual.priceFen, 8000)
test('动态价格:没有模板价时一律阻止发布(不再允许手填)', () => {
for (const pricing of [buildPricing(), buildPricing({ missingPricePolicy: 'manual' })]) {
const blocked = resolveWorkerConsignmentPrice({
pricing,
templatePriceFen: 0,
submittedPriceFen: 8000,
})
assert.equal(blocked.priceFen, 0)
assert.ok(blocked.errors.some((error) => error.includes('暂无模板价')))
}
})
const blocked = resolveWorkerConsignmentPrice({
pricing: buildPricing({ missingPricePolicy: 'block' }),
templatePriceFen: 0,
submittedPriceFen: 8000,
test('收货信息:默认 7 个字段,手机号必填校验,显式空数组表示删除全部', () => {
const config = normalizeConsignmentProfileConfig({})
assert.equal(config.intake.fields.length, 7)
assert.ok(
config.intake.fields.some(
(field) => field.key === 'sellerPhone' && field.type === 'phone' && field.required,
),
)
const missing = normalizeConsignmentIntakeValues(config.intake.fields, {})
assert.ok(missing.errors.some((error) => error.includes('对接客服')))
const ok = normalizeConsignmentIntakeValues(config.intake.fields, {
intakeValues: {
contactSupporterId: 3,
sellerContact: 'wx_1',
sellerPhone: '13800138000',
characterName: '角色',
characterId: '123456',
screenshots: ['https://example.com/1.png'],
},
})
assert.ok(blocked.errors.length > 0)
assert.deepEqual(ok.errors, [])
assert.equal(ok.values.sellerPhone, '13800138000')
assert.deepEqual(ok.values.screenshots, ['https://example.com/1.png'])
const badPhone = normalizeConsignmentIntakeValues(config.intake.fields, {
...ok.values,
sellerPhone: '123',
})
assert.ok(badPhone.errors.some((error) => error.includes('手机号')))
const empty = normalizeConsignmentProfileConfig({ intake: { fields: [] } })
assert.equal(empty.intake.fields.length, 0)
})
test('通用匹配:精确、部分、冲突、未知与通配', () => {
@@ -5,7 +5,7 @@
* - 没有任何「内置字段」:配置初始 specs 为空,字段全部由运营在寄卖配置里维护;
* 苏尔南手枪的五个字段由迁移 131 作为种子配置写入;
* - 字段类型支持:文本 / 长文本 / 下拉 / 单选 / 数字 / 日期;
* - 价格只有两种:接单模板价打折、固定价格;打手只能按系统建议价往下调;
* - 价格只有两种:接单模板价打折、模板价固定下调;打手只能按系统建议价往下调;
* - 旧列兼容:字段 key 仍为 system/serverZone/series/diamondLevel/brillianceCount 时,
* 继续同步旧列并沿用旧匹配器,保证存量库存与工单不回归。
*/
@@ -44,17 +44,123 @@ export type ConsignmentSpecField = {
}
export type ConsignmentPricingConfig = {
/** template_discount = 接单模板价的几折;fixed = 固定价格 */
priceType: 'template_discount' | 'fixed'
/** template_discount = 接单模板价的几折;template_minus = 模板价固定下调 */
priceType: 'template_discount' | 'template_minus'
/** 几折,范围 0.1 - 10,例如 9.5 表示 95% */
discount: number
fixedPriceFen: number
/** 固定下调金额(分):售价 = 接单模板价 - minusFen */
minusFen: number
allowWorkerPrice: boolean
minPriceFen: number
/** 没有模板价时:允许打手手填 / 阻止发布 */
/** 历史字段:现在统一按 block 处理(没有模板价一律阻止发布) */
missingPricePolicy: 'block' | 'manual'
}
/** 收货信息(联系方式与游戏凭据)动态字段类型。 */
export type ConsignmentIntakeFieldType =
| 'text'
| 'textarea'
| 'phone'
| 'number'
| 'image'
| 'contact_supporter'
export type ConsignmentIntakeField = {
key: string
label: string
type: ConsignmentIntakeFieldType
required: boolean
placeholder: string
helpText: string
/** 图片字段最多上传张数 */
maxCount: number
sortOrder: number
}
export type ConsignmentIntakeValues = Record<string, string | number | string[]>
export const DEFAULT_CONSIGNMENT_INTAKE_FIELDS: ConsignmentIntakeField[] = [
{
key: 'contactSupporterId',
label: '对接客服',
type: 'contact_supporter',
required: true,
placeholder: '请选择对接客服',
helpText: '用于后续提成归属与联系',
maxCount: 0,
sortOrder: 10,
},
{
key: 'sellerContact',
label: '微信号 / QQ号',
type: 'text',
required: true,
placeholder: '方便派单与紧急联系',
helpText: '',
maxCount: 0,
sortOrder: 20,
},
{
key: 'sellerPhone',
label: '联系电话',
type: 'phone',
required: true,
placeholder: '手机号',
helpText: '默认使用当前接单账号绑定手机号',
maxCount: 0,
sortOrder: 30,
},
{
key: 'characterName',
label: '游戏内角色名',
type: 'text',
required: true,
placeholder: '局内加好友丢枪用',
helpText: '',
maxCount: 0,
sortOrder: 40,
},
{
key: 'characterId',
label: '角色数字ID',
type: 'text',
required: true,
placeholder: '如 1089238472',
helpText: '',
maxCount: 0,
sortOrder: 50,
},
{
key: 'screenshots',
label: '游戏主页截图',
type: 'image',
required: true,
placeholder: '',
helpText: '须包含角色名、ID 及仓库/手枪详情,最多 4 张',
maxCount: 4,
sortOrder: 60,
},
{
key: 'sellerNote',
label: '其他留言与补充',
type: 'textarea',
required: false,
placeholder: '配件、子弹、涂装或在线时段等(选填)',
helpText: '',
maxCount: 0,
sortOrder: 70,
},
]
const INTAKE_FIELD_TYPES: ConsignmentIntakeFieldType[] = [
'text',
'textarea',
'phone',
'number',
'image',
'contact_supporter',
]
export type ConsignmentSpecValues = Record<string, string | number>
export type ConsignmentSpecValidationResult = {
@@ -133,10 +239,10 @@ export const DEFAULT_CONSIGNMENT_SPECS: ConsignmentSpecField[] = []
export const DEFAULT_CONSIGNMENT_PRICING: ConsignmentPricingConfig = {
priceType: 'template_discount',
discount: 10,
fixedPriceFen: 0,
minusFen: 0,
allowWorkerPrice: true,
minPriceFen: 0,
missingPricePolicy: 'manual',
missingPricePolicy: 'block',
}
function record(value: unknown): Record<string, unknown> {
@@ -301,30 +407,136 @@ export function normalizeConsignmentSpecs(
export function normalizeConsignmentPricing(value: unknown): ConsignmentPricingConfig {
const source = record(value)
const rawPriceType = text(source.priceType ?? source.price_type, 30)
// 兼容上一版:source=template 视为 10 折;source=profile 尽量映射成固定价。
const legacySource = text(source.source, 20)
// 绝对固定价已下线:旧配置(fixed / source=profile)统一按 10 折兜底,
// 历史快照里的旧价格不会被改写,只影响后续新保存/新发布的配置。
const priceType: ConsignmentPricingConfig['priceType'] =
rawPriceType === 'fixed' || legacySource === 'profile' ? 'fixed' : 'template_discount'
const legacyFixedPriceFen = integer(
source.fixedPriceFen ?? source.fixed_price_fen ?? source.basePriceFen ?? source.base_price_fen,
0,
0,
100_000_000,
)
const missingPricePolicyRaw = text(source.missingPricePolicy ?? source.missing_price_policy, 30)
rawPriceType === 'template_minus' ? 'template_minus' : 'template_discount'
return {
priceType,
discount: decimal(source.discount, 10, 0.1, 10),
fixedPriceFen: legacyFixedPriceFen,
minusFen: integer(source.minusFen ?? source.minus_fen, 0, 0, 100_000_000),
allowWorkerPrice: bool(
source.allowWorkerPrice ?? source.allow_worker_price,
DEFAULT_CONSIGNMENT_PRICING.allowWorkerPrice,
),
minPriceFen: integer(source.minPriceFen ?? source.min_price_fen, 0, 0, 100_000_000),
missingPricePolicy: missingPricePolicyRaw === 'block' ? 'block' : 'manual',
// 没有模板价一律阻止发布,历史 manual 配置也会被收敛为 block。
missingPricePolicy: 'block',
}
}
function normalizeIntakeFieldType(value: unknown): ConsignmentIntakeFieldType {
const raw = text(value, 30).toLowerCase()
return INTAKE_FIELD_TYPES.includes(raw as ConsignmentIntakeFieldType)
? (raw as ConsignmentIntakeFieldType)
: 'text'
}
function normalizeIntakeField(value: unknown, index: number): ConsignmentIntakeField | null {
const source = record(value)
const key = text(source.key, 40)
if (!key || !/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(key)) return null
const type = normalizeIntakeFieldType(source.type)
return {
key,
label: text(source.label, 80) || key,
type,
required: bool(source.required, true),
placeholder: text(source.placeholder, 120),
helpText: text(source.helpText ?? source.help_text, 300),
maxCount: type === 'image' ? integer(source.maxCount ?? source.max_count, 4, 1, 12) : 0,
sortOrder: integer(source.sortOrder ?? source.sort_order, (index + 1) * 10, 0, 100_000),
}
}
/**
* 归一化「联系方式与游戏凭据」动态字段。
* - 配置里没有 intake 字段(历史配置)→ 使用 7 个默认字段;
* - 显式传空数组 → 视为运营删光了所有字段。
*/
export function normalizeConsignmentIntakeFields(value: unknown): ConsignmentIntakeField[] {
if (!Array.isArray(value)) {
return DEFAULT_CONSIGNMENT_INTAKE_FIELDS.map((field) => ({ ...field }))
}
const seen = new Set<string>()
const result: ConsignmentIntakeField[] = []
value.forEach((item, index) => {
const field = normalizeIntakeField(item, index)
if (!field || seen.has(field.key)) return
seen.add(field.key)
result.push(field)
})
return result.sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key))
}
export type ConsignmentIntakeValidationResult = {
values: ConsignmentIntakeValues
errors: string[]
}
/** 校验并归一化打手填写的收货信息;必填与手机号格式在这里统一收口。 */
export function normalizeConsignmentIntakeValues(
fields: ConsignmentIntakeField[],
input: unknown,
): ConsignmentIntakeValidationResult {
const source = record(input)
const nested = record(source.intakeValues ?? source.intake_values ?? source.intakeValuesJson)
const values: ConsignmentIntakeValues = {}
const errors: string[] = []
for (const field of fields) {
const nestedValue = nested[field.key]
const raw =
nestedValue !== undefined && nestedValue !== null && String(nestedValue).trim() !== ''
? nestedValue
: source[field.key]
if (field.type === 'image') {
const list = Array.isArray(raw) ? raw : []
const urls = list
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
.map((item) => item.trim())
.slice(0, Math.max(1, field.maxCount || 4))
if (urls.length === 0) {
if (field.required) errors.push(`请上传「${field.label}」`)
continue
}
values[field.key] = urls
continue
}
const textValue = raw === undefined || raw === null ? '' : String(raw).trim()
if (!textValue) {
if (field.required) errors.push(`请填写「${field.label}」`)
continue
}
if (field.type === 'phone') {
if (!/^1[3-9]\d{9}$/.test(textValue)) {
errors.push(`「${field.label}」请填写正确的 11 位手机号`)
continue
}
values[field.key] = textValue
continue
}
if (field.type === 'number' || field.type === 'contact_supporter') {
const numeric = Number(textValue)
if (!Number.isFinite(numeric) || numeric <= 0) {
errors.push(`「${field.label}」必须填写有效数字`)
continue
}
values[field.key] = Math.round(numeric)
continue
}
values[field.key] =
field.type === 'textarea' ? textValue.slice(0, 2000) : textValue.slice(0, 200)
}
return { values, errors }
}
function builtInAliases(key: string): string[] {
if (key === 'system') return ['system', 'os']
if (key === 'serverZone') return ['serverZone', 'server_zone', 'zone']
@@ -549,7 +761,8 @@ export function buildConsignmentItemName(input: {
/**
* 计算系统建议价与改价上限。
* - template_discount:接单模板价 × 折扣;
* - fixed:固定价格;
* - template_minus:接单模板价 − 固定下调金额;
* - 没有模板价时一律返回 0(发布侧会直接阻止)。
* - 改价上限 = 配置后的建议价(打手只能往下调)。
*/
export function computeConsignmentPrice(input: {
@@ -562,20 +775,23 @@ export function computeConsignmentPrice(input: {
let priceFen = 0
let source: 'template' | 'fixed' | 'none' = 'none'
if (pricing.priceType === 'fixed') {
priceFen = Math.max(0, Math.round(Number(pricing.fixedPriceFen || 0)))
if (priceFen > 0) {
source = 'fixed'
breakdown.push({ label: '固定价格', amountFen: priceFen })
}
} else if (templatePriceFen > 0) {
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
priceFen = Math.round((templatePriceFen * discount) / 10)
if (templatePriceFen > 0) {
source = 'template'
breakdown.push({ label: '接单模板价', amountFen: templatePriceFen })
if (discount !== 10) {
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - templatePriceFen })
if (pricing.priceType === 'template_minus') {
const minusFen = Math.max(0, Math.round(Number(pricing.minusFen || 0)))
priceFen = Math.max(0, templatePriceFen - minusFen)
if (minusFen > 0) {
breakdown.push({ label: '固定下调', amountFen: -minusFen })
}
} else {
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
priceFen = Math.round((templatePriceFen * discount) / 10)
if (discount !== 10) {
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - templatePriceFen })
}
}
if (priceFen <= 0) source = 'none'
}
priceFen = Math.max(0, Math.round(priceFen))
@@ -590,9 +806,9 @@ export function computeConsignmentPrice(input: {
/**
* 打手提交价格时决定最终售价:
* - 默认用系统建议价(模板折扣价 / 固定价);
* - 默认用系统建议价(模板折扣价 / 模板价固定下调);
* - 允许改价时只能下调,不能高于建议价;
* - 没有建议价时按 missingPricePolicy 处理(manual=手填,仍受最低价约束)。
* - 没有模板价时一律阻止发布(不再允许手填兜底)。
*/
export function resolveWorkerConsignmentPrice(input: {
pricing: ConsignmentPricingConfig
@@ -605,33 +821,19 @@ export function resolveWorkerConsignmentPrice(input: {
const errors: string[] = []
let priceFen = computed.priceFen
if (
input.pricing.priceType === 'fixed' &&
computed.templatePriceFen > 0 &&
computed.priceFen > computed.templatePriceFen
) {
errors.push(`固定价格不能高于接单模板价 ¥${(computed.templatePriceFen / 100).toFixed(2)}`)
}
if (priceFen > 0) {
if (submittedPriceFen > 0) {
// 前端发布时会回填系统建议价;禁用改价只应拦截“偏离建议价”的提交,
// 不能把按建议价直接发布也一起拦掉。
const usesSystemSuggestedPrice = submittedPriceFen === computed.capFen
if (!input.pricing.allowWorkerPrice && !usesSystemSuggestedPrice) {
errors.push('当前寄卖配置不允许修改售价')
} else if (submittedPriceFen > computed.capFen) {
errors.push(`寄售价格不能高于系统建议价 ¥${(computed.capFen / 100).toFixed(2)}`)
} else {
priceFen = submittedPriceFen
}
if (priceFen <= 0) {
errors.push('该规格暂无模板价,暂不能寄卖,请联系客服补充模板价格')
} else if (submittedPriceFen > 0) {
// 前端发布时会回填系统建议价;禁用改价只应拦截“偏离建议价”的提交,
// 不能把按建议价直接发布也一起拦掉。
const usesSystemSuggestedPrice = submittedPriceFen === computed.capFen
if (!input.pricing.allowWorkerPrice && !usesSystemSuggestedPrice) {
errors.push('当前寄卖配置不允许修改售价')
} else if (submittedPriceFen > computed.capFen) {
errors.push(`寄售价格不能高于系统建议价 ¥${(computed.capFen / 100).toFixed(2)}`)
} else {
priceFen = submittedPriceFen
}
} else if (input.pricing.missingPricePolicy === 'block') {
errors.push('该规格暂无价格,请联系客服补充价格后再提报')
} else if (submittedPriceFen <= 0) {
errors.push('该规格暂无模板价,请填写寄售价格')
} else {
priceFen = submittedPriceFen
}
if (priceFen > 0 && priceFen < minPriceFen) {
@@ -36,10 +36,10 @@ function radioField(
const PRICING: ConsignmentPricingConfig = {
priceType: 'template_discount',
discount: 10,
fixedPriceFen: 0,
minusFen: 0,
allowWorkerPrice: true,
minPriceFen: 0,
missingPricePolicy: 'manual',
missingPricePolicy: 'block',
}
function baseSpecs(): ConsignmentSpecField[] {
@@ -33,11 +33,13 @@ import {
} from './consignment-profile-service.js'
import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js'
import {
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
DEFAULT_CONSIGNMENT_PRICING,
buildConsignmentItemName,
buildConsignmentSpecKey,
buildConsignmentSpecText,
computeConsignmentPrice,
normalizeConsignmentIntakeValues,
normalizeConsignmentSpecValues,
resolveLegacyConsignmentColumns,
resolveWorkerConsignmentPrice,
@@ -294,9 +296,6 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
})
}
// 对接客服必填:发布时选定,后续按人统计提成 / 方便联系。
const contactSupporter = await resolveEnabledConsignmentContact(payload.contactSupporterId)
// 分类级寄卖配置:新链路必须带 profileId;存量调用不带则沿用旧逻辑。
const profileIdInput = Number(payload.profileId ?? payload.profile_id ?? 0)
const profile =
@@ -348,39 +347,50 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
const { system, serverZone, series, diamondLevel, brillianceCount } =
resolveLegacyConsignmentColumns(specValues)
const contactInfo = String(payload.sellerContact || payload.contactInfo || '').trim()
if (!contactInfo) {
throw createHttpError('请填写微信号或QQ号以便联系与核实', {
// 联系方式与游戏凭据:完全按寄卖配置动态渲染/校验;历史调用回退默认 7 项。
const intakeFields = profile?.config.intake.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
const intakeInput = {
...payload,
sellerPhone: String(payload.sellerPhone || worker.phone || '').trim(),
intakeValues: {
...(payload.intakeValues &&
typeof payload.intakeValues === 'object' &&
!Array.isArray(payload.intakeValues)
? (payload.intakeValues as Record<string, unknown>)
: {}),
...(Array.isArray(payload.screenshotsJson) ? { screenshots: payload.screenshotsJson } : {}),
},
}
const intakeValidation = normalizeConsignmentIntakeValues(intakeFields, intakeInput)
if (intakeValidation.errors.length > 0) {
throw createHttpError(intakeValidation.errors[0]!, {
statusCode: 400,
errorCode: 'contact_required',
errorCode: 'consignment_intake_invalid',
})
}
const intakeValues = intakeValidation.values
const contactSupporterIdInput = Number(
intakeValues.contactSupporterId || payload.contactSupporterId || 0,
)
// 对接客服字段被运营删掉时不校验提成归属。
const contactSupporter =
contactSupporterIdInput > 0
? await resolveEnabledConsignmentContact(contactSupporterIdInput)
: null
const contactInfo = String(
intakeValues.sellerContact ?? payload.sellerContact ?? payload.contactInfo ?? '',
).trim()
const characterName = String(intakeValues.characterName ?? payload.characterName ?? '').trim()
const characterId = String(intakeValues.characterId ?? payload.characterId ?? '').trim()
const screenshots = Array.isArray(intakeValues.screenshots)
? intakeValues.screenshots
: Array.isArray(payload.screenshotsJson)
? payload.screenshotsJson
: []
const sellerNote = String(intakeValues.sellerNote ?? payload.sellerNote ?? '').trim()
const characterName = String(payload.characterName || '').trim()
const characterId = String(payload.characterId || '').trim()
if (!characterName) {
throw createHttpError('请填写游戏内角色名', {
statusCode: 400,
errorCode: 'character_name_required',
})
}
if (!characterId) {
throw createHttpError('请填写角色数字ID', {
statusCode: 400,
errorCode: 'character_id_required',
})
}
const screenshots = Array.isArray(payload.screenshotsJson) ? payload.screenshotsJson : []
if (screenshots.length === 0) {
throw createHttpError('请上传游戏主页截图以供核验', {
statusCode: 400,
errorCode: 'screenshot_required',
})
}
// 寄售价:按配置算出系统建议价(模板折扣价 / 固定价),打手只能往下调。
// 没有建议价时按 missingPricePolicy 处理(manual = 允许打手手填,仍受最低价约束)。
// 寄售价:按配置算出系统建议价(模板折扣价 / 模板价固定下调),打手只能往下调。
// 没有模板价时一律阻止发布。
const priceSuggestion = await getWorkerConsignmentPriceSuggestion({
profileId: profile?.profileId,
specs,
@@ -408,7 +418,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
const depositAmountFen = profile?.config.deposit.enabled
? buildDepositAmountFen(profile.config, quantity)
: getConsignmentPublishDepositFen()
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
const sellerPhone = String(
intakeValues.sellerPhone ?? payload.sellerPhone ?? worker.phone ?? '',
).trim()
const defaultItemName = buildConsignmentItemName({
template: profile?.config.nameTemplate || '',
specs,
@@ -441,7 +453,8 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
contentJson: {
characterName,
characterId,
sellerNote: String(payload.sellerNote || '').trim(),
sellerNote,
intakeValues,
channel: 'worker_portal',
publishDepositFen: depositAmountFen,
profileId: profile?.profileId ?? null,
@@ -476,7 +489,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
workerName: worker.display_name || worker.username,
workerPhone: sellerPhone || worker.phone || '',
depositAmountFen,
contactSupporterId: Number(contactSupporter.id),
contactSupporterId: contactSupporter ? Number(contactSupporter.id) : null,
now,
})
@@ -521,7 +534,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
contactSupporterId: submission.contact_supporter_id
? Number(submission.contact_supporter_id)
: null,
contactSupporterName: contactSupporter.name,
contactSupporterName: contactSupporter?.name || '',
createdAt: submission.created_at,
}
} catch (error) {
@@ -1,4 +1,10 @@
export const WORK_ORDER_TIMEOUT_POLICIES = ['reopen', 'cancel_release', 'cancel_deduct'] as const
export const WORK_ORDER_TIMEOUT_POLICIES = [
'reopen',
'cancel_release',
'cancel_deduct',
'rematch_release',
'rematch_deduct',
] as const
export type WorkOrderTimeoutPolicy = (typeof WORK_ORDER_TIMEOUT_POLICIES)[number]
@@ -6,6 +12,10 @@ export function isWorkOrderTimeoutPolicy(value: unknown): value is WorkOrderTime
return WORK_ORDER_TIMEOUT_POLICIES.includes(value as WorkOrderTimeoutPolicy)
}
export function isRematchTimeoutPolicy(value: unknown): boolean {
return value === 'rematch_release' || value === 'rematch_deduct'
}
export function normalizeWorkOrderTimeoutPolicy(value: unknown): WorkOrderTimeoutPolicy {
return isWorkOrderTimeoutPolicy(value) ? value : 'reopen'
}
@@ -10,8 +10,31 @@ import {
publishWorkerWalletRealtimeChange,
publishWorkOrderRealtimeChange,
} from '../realtime/realtime-event-service.js'
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
import { reenterConsignmentInternalMatching } from './consignment-match-engine-service.js'
import {
isRematchTimeoutPolicy,
normalizeWorkOrderTimeoutPolicy,
} from './work-order-timeout-policy.js'
import {
reenterConsignmentInternalMatching,
startConsignmentRematch,
} from './consignment-match-engine-service.js'
/** 从工单物料里取本次匹配占用的寄卖库存;重新匹配时用于排除原库存。 */
function resolveConsignmentInventoryId(material: unknown): number | null {
let source = material
if (typeof material === 'string') {
try {
source = JSON.parse(material)
} catch {
return null
}
}
if (!source || typeof source !== 'object' || Array.isArray(source)) return null
const consignment = (source as Record<string, unknown>).consignment
if (!consignment || typeof consignment !== 'object' || Array.isArray(consignment)) return null
const inventoryId = Number((consignment as Record<string, unknown>).inventoryId || 0)
return inventoryId > 0 ? inventoryId : null
}
export async function settleOverdueWorkOrders(options: { limit?: number; workerId?: number } = {}) {
const limit = Math.max(1, Number(options.limit || 50))
@@ -29,8 +52,18 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
})
if (result.order) {
processedCount += 1
// 退回大厅的寄卖单先尝试重新进入内部匹配;匹配不到再由内部匹配超时任务发回大厅。
if (policy === 'reopen') {
if (isRematchTimeoutPolicy(policy)) {
// 重新匹配:回内部匹配池,并排除原卖家/原库存,避免同一卖家立刻接回同一单。
await startConsignmentRematch(
Number(workOrder.id),
{
excludeWorkerId: Number(workOrder.assigned_worker_id || 0) || null,
excludeInventoryId: resolveConsignmentInventoryId(workOrder.material_json),
},
nowIso(),
).catch(() => false)
} else if (policy === 'reopen') {
// 退回大厅的寄卖单先尝试重新进入内部匹配;匹配不到再由内部匹配超时任务发回大厅。
await reenterConsignmentInternalMatching(Number(workOrder.id)).catch(() => false)
}
publishWorkOrderRealtimeChange({
@@ -51,14 +84,26 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
// 处置超时未提交材料的拼单份额(status=joined)
const overdueShares = await listOverdueWorkOrderShares({ limit, workerId })
for (const shareItem of overdueShares) {
const policy = normalizeWorkOrderTimeoutPolicy(shareItem.timeoutPolicy)
const result = await settleOverdueWorkOrderShare({
workOrderId: shareItem.workOrderId,
shareId: shareItem.shareId,
policy: normalizeWorkOrderTimeoutPolicy(shareItem.timeoutPolicy),
policy,
now: nowIso(),
})
if (result.share) {
processedCount += 1
if (isRematchTimeoutPolicy(policy)) {
// 份额超时后空出的份数重新进入匹配池;排除该份额的卖家和库存。
await startConsignmentRematch(
Number(shareItem.workOrderId),
{
excludeWorkerId: Number(shareItem.workerId || 0) || null,
excludeInventoryId: shareItem.consignmentInventoryId,
},
nowIso(),
).catch(() => false)
}
publishWorkOrderRealtimeChange({
workOrderId: Number(shareItem.workOrderId),
workerIds: Number(shareItem.workerId) ? [Number(shareItem.workerId)] : [],
@@ -0,0 +1,196 @@
import { ReloadOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Input, Modal, Select, Space, Tag, Typography } from 'antd'
import { useState } from 'react'
import PercentTable from '@/components/common/PercentTable'
import { fetchAdminConsignmentMatchLogs } from '@/services/admin/consignment'
import type { ConsignmentMatchLogItem } from '@/types/consignment-match-log'
import { formatDateTime } from '@/utils/date-time'
const { Text } = Typography
const TRIGGER_LABELS: Record<string, string> = {
auto: '自动匹配',
manual: '手动匹配',
rematch: '重新匹配',
}
const RESULT_LABELS: Record<string, { label: string; color: string }> = {
matched: { label: '已匹配', color: 'green' },
failed: { label: '失败', color: 'red' },
skipped: { label: '跳过', color: 'default' },
}
const PAGE_SIZE = 20
export default function ConsignmentMatchLogsModal({
open,
onClose,
}: {
open: boolean
onClose: () => void
}) {
const [page, setPage] = useState(1)
const [triggerType, setTriggerType] = useState<string | undefined>()
const [result, setResult] = useState<string | undefined>()
const [keyword, setKeyword] = useState('')
const [keywordInput, setKeywordInput] = useState('')
const logsQuery = useQuery({
queryKey: ['admin-consignment-match-logs', page, triggerType, result, keyword],
queryFn: () =>
fetchAdminConsignmentMatchLogs({
page,
pageSize: PAGE_SIZE,
triggerType,
result,
keyword: keyword || undefined,
}),
enabled: open,
})
const data = logsQuery.data?.data
const items = data?.items || []
const total = Number(data?.total || 0)
const columns = [
{
title: '时间',
dataIndex: 'createdAt',
width: 160,
render: (value: string) => (value ? formatDateTime(value) : '-'),
},
{
title: '工单',
dataIndex: 'workOrderNo',
width: 150,
render: (_: string, record: ConsignmentMatchLogItem) =>
record.workOrderNo || (record.workOrderId ? `#${record.workOrderId}` : '-'),
},
{
title: '触发',
dataIndex: 'triggerType',
width: 100,
render: (value: string) => (
<Tag color={value === 'manual' ? 'blue' : value === 'rematch' ? 'orange' : 'purple'}>
{TRIGGER_LABELS[value] || value}
</Tag>
),
},
{
title: '结果',
dataIndex: 'result',
width: 90,
render: (value: string) => {
const config = RESULT_LABELS[value] || { label: value, color: 'default' }
return <Tag color={config.color}>{config.label}</Tag>
},
},
{
title: '候选 / 成功',
width: 100,
render: (_: unknown, record: ConsignmentMatchLogItem) =>
`${record.candidateCount} / ${record.matchedCount}`,
},
{
title: '原因',
dataIndex: 'reasonText',
render: (value: string, record: ConsignmentMatchLogItem) => (
<div style={{ display: 'grid', gap: 2 }}>
<Text>{value || '-'}</Text>
{record.reasonCode ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{record.reasonCode}
</Text>
) : null}
</div>
),
},
{
title: '操作人',
dataIndex: 'actorId',
width: 120,
render: (value: string, record: ConsignmentMatchLogItem) =>
record.actorType === 'system' ? '系统' : value || '-',
},
{
title: '耗时',
dataIndex: 'durationMs',
width: 80,
render: (value: number) => `${Number(value || 0)} ms`,
},
]
return (
<Modal
title="寄卖匹配日志"
open={open}
onCancel={onClose}
footer={null}
width={1080}
destroyOnClose
>
<Space wrap style={{ marginBottom: 12 }}>
<Select
allowClear
placeholder="触发方式"
style={{ width: 130 }}
value={triggerType}
onChange={(value) => {
setTriggerType(value)
setPage(1)
}}
options={[
{ value: 'auto', label: '自动匹配' },
{ value: 'manual', label: '手动匹配' },
{ value: 'rematch', label: '重新匹配' },
]}
/>
<Select
allowClear
placeholder="结果"
style={{ width: 120 }}
value={result}
onChange={(value) => {
setResult(value)
setPage(1)
}}
options={[
{ value: 'matched', label: '已匹配' },
{ value: 'failed', label: '失败' },
{ value: 'skipped', label: '跳过' },
]}
/>
<Input.Search
allowClear
placeholder="搜索工单号"
style={{ width: 220 }}
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onSearch={(value) => {
setKeyword(value.trim())
setPage(1)
}}
/>
<Text type="secondary">共 {total} 条</Text>
<ReloadOutlined style={{ cursor: 'pointer' }} onClick={() => void logsQuery.refetch()} />
</Space>
<PercentTable<ConsignmentMatchLogItem>
size="small"
rowKey="logId"
loading={logsQuery.isFetching}
dataSource={items}
columns={columns}
scroll={{ x: 820 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
showSizeChanger: false,
onChange: (nextPage) => setPage(nextPage),
}}
/>
</Modal>
)
}
@@ -39,12 +39,18 @@ import {
} from '@/services/admin/consignment'
import type {
AdminConsignmentProfilesResponse,
ConsignmentIntakeField,
ConsignmentProfile,
ConsignmentSpecField,
ConsignmentSpecFieldType,
ConsignmentSpecOption,
} from '@/types/consignment'
import { SPEC_FIELD_TYPE_OPTIONS, isOptionSpecField } from '@/utils/consignment-spec'
import {
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
INTAKE_FIELD_TYPE_OPTIONS,
SPEC_FIELD_TYPE_OPTIONS,
isOptionSpecField,
} from '@/utils/consignment-spec'
const { Text, Title } = Typography
@@ -58,9 +64,9 @@ type ConfigFormValues = {
tabTitle: string
fields: { quantity: boolean }
pricing: {
priceType: 'template_discount' | 'fixed'
priceType: 'template_discount' | 'template_minus'
discount: number
fixedPriceYuan: number
minusYuan: number
allowWorkerPrice: boolean
minPriceYuan: number
missingPricePolicy: 'block' | 'manual'
@@ -75,13 +81,15 @@ type ConfigFormValues = {
serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountYuan: number }
inventoryLimit: { enabled: boolean; maxActiveQuantity: number }
matching: {
mode: 'manual' | 'auto'
autoMatchEnabled: boolean
mode: 'off' | 'manual' | 'auto'
exactOnly: boolean
preferLowReward: boolean
}
internalMatch: { delayMinutes: number; fallbackToHall: boolean }
timeout: { minutes: number; policy: 'reopen' | 'cancel_release' | 'cancel_deduct' }
timeout: {
hours: number
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
}
priceDrop: { enabled: boolean; hours: number; mode: 'percent' | 'fixed'; value: number }
}
@@ -106,6 +114,29 @@ function cloneSpecs(specs: ConsignmentSpecField[]): ConsignmentSpecField[] {
}))
}
function cloneIntakeFields(fields: ConsignmentIntakeField[]): ConsignmentIntakeField[] {
return fields.map((field) => ({ ...field }))
}
function nextIntakeKey(fields: ConsignmentIntakeField[]): string {
let index = 1
while (fields.some((field) => field.key === `custom_${index}`)) index += 1
return `custom_${index}`
}
function newIntakeField(fields: ConsignmentIntakeField[]): ConsignmentIntakeField {
return {
key: nextIntakeKey(fields),
label: '新字段',
type: 'text',
required: false,
placeholder: '',
helpText: '',
maxCount: 0,
sortOrder: (fields.length + 1) * 10,
}
}
function nextCustomSpecKey(specs: ConsignmentSpecField[]): string {
let index = 1
while (specs.some((spec) => spec.key === `custom_${index}`)) index += 1
@@ -137,12 +168,13 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues {
tabTitle: profile.tabTitle,
fields: { quantity: config.fields.quantity },
pricing: {
priceType: config.pricing.priceType || 'template_discount',
priceType:
config.pricing.priceType === 'template_minus' ? 'template_minus' : 'template_discount',
discount: Number(config.pricing.discount ?? 10),
fixedPriceYuan: yuan(config.pricing.fixedPriceFen),
minusYuan: yuan(config.pricing.minusFen),
allowWorkerPrice: config.pricing.allowWorkerPrice !== false,
minPriceYuan: yuan(config.pricing.minPriceFen),
missingPricePolicy: config.pricing.missingPricePolicy || 'manual',
missingPricePolicy: 'block',
},
nameTemplate: config.nameTemplate || '',
deposit: {
@@ -158,9 +190,16 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues {
amountYuan: yuan(config.serviceFee.amountFen),
},
inventoryLimit: { ...config.inventoryLimit },
matching: { ...config.matching },
matching: {
mode: config.matching.mode || 'off',
exactOnly: config.matching.exactOnly,
preferLowReward: config.matching.preferLowReward,
},
internalMatch: { ...config.internalMatch },
timeout: { ...config.timeout },
timeout: {
hours: Math.max(0, Number(config.timeout.minutes ?? 30) / 60),
policy: config.timeout.policy,
},
priceDrop: { ...config.priceDrop },
}
}
@@ -175,28 +214,32 @@ function emptyFormValues(): ConfigFormValues {
pricing: {
priceType: 'template_discount',
discount: 10,
fixedPriceYuan: 0,
minusYuan: 0,
allowWorkerPrice: true,
minPriceYuan: 0,
missingPricePolicy: 'manual',
missingPricePolicy: 'block',
},
nameTemplate: '',
deposit: { enabled: false, mode: 'fixed', fixedAmountYuan: 0, unitAmountYuan: 0 },
serviceFee: { enabled: false, mode: 'percent', percent: 0, amountYuan: 0 },
inventoryLimit: { enabled: false, maxActiveQuantity: 0 },
matching: {
mode: 'manual',
autoMatchEnabled: false,
mode: 'off',
exactOnly: true,
preferLowReward: true,
},
internalMatch: { delayMinutes: 30, fallbackToHall: true },
timeout: { minutes: 30, policy: 'reopen' },
priceDrop: { enabled: false, hours: 72, mode: 'percent', value: 10 },
timeout: { hours: 0.5, policy: 'reopen' },
priceDrop: { enabled: true, hours: 24, mode: 'percent', value: 10 },
}
}
function buildPayload(categoryId: number, values: ConfigFormValues, specs: ConsignmentSpecField[]) {
function buildPayload(
categoryId: number,
values: ConfigFormValues,
specs: ConsignmentSpecField[],
intakeFields: ConsignmentIntakeField[],
) {
const hasSpec = (key: string) => specs.some((spec) => spec.key === key)
return {
categoryId,
@@ -217,32 +260,57 @@ function buildPayload(categoryId: number, values: ConfigFormValues, specs: Consi
quantity: values.fields.quantity,
},
specs,
intake: { fields: intakeFields },
nameTemplate: values.nameTemplate,
pricing: {
priceType: values.pricing.priceType,
discount: Number(values.pricing.discount || 0),
fixedPriceFen: fen(values.pricing.fixedPriceYuan),
minusFen: fen(values.pricing.minusYuan),
allowWorkerPrice: values.pricing.allowWorkerPrice,
minPriceFen: fen(values.pricing.minPriceYuan),
missingPricePolicy: values.pricing.missingPricePolicy,
// 没有模板价一律阻止发布,不再提供手填兜底。
missingPricePolicy: 'block',
},
deposit: {
enabled: values.deposit.enabled,
mode: values.deposit.mode,
fixedAmountFen: fen(values.deposit.fixedAmountYuan),
unitAmountFen: fen(values.deposit.unitAmountYuan),
mode: values.deposit.enabled ? values.deposit.mode : 'fixed',
fixedAmountFen: values.deposit.enabled ? fen(values.deposit.fixedAmountYuan) : 0,
unitAmountFen: values.deposit.enabled ? fen(values.deposit.unitAmountYuan) : 0,
},
serviceFee: {
enabled: values.serviceFee.enabled,
mode: values.serviceFee.mode,
percent: values.serviceFee.percent,
amountFen: fen(values.serviceFee.amountYuan),
mode: values.serviceFee.enabled ? values.serviceFee.mode : 'percent',
percent: values.serviceFee.enabled ? values.serviceFee.percent : 0,
amountFen: values.serviceFee.enabled ? fen(values.serviceFee.amountYuan) : 0,
},
inventoryLimit: {
enabled: values.inventoryLimit.enabled,
maxActiveQuantity: values.inventoryLimit.enabled
? Number(values.inventoryLimit.maxActiveQuantity || 0)
: 0,
},
matching: {
mode: values.matching.mode,
exactOnly: values.matching.exactOnly,
preferLowReward: values.matching.preferLowReward,
},
internalMatch: {
// 关闭匹配时不再等待内部库存,直接进大厅。
delayMinutes:
values.matching.mode === 'off' ? 0 : Number(values.internalMatch.delayMinutes || 0),
fallbackToHall:
values.matching.mode === 'off' ? false : values.internalMatch.fallbackToHall,
},
timeout: {
minutes: Math.max(0, Math.round(Number(values.timeout.hours || 0) * 60)),
policy: values.timeout.policy,
},
priceDrop: {
enabled: values.priceDrop.enabled,
hours: Number(values.priceDrop.hours || 24),
mode: values.priceDrop.enabled ? values.priceDrop.mode : 'percent',
value: values.priceDrop.enabled ? Number(values.priceDrop.value || 0) : 0,
},
inventoryLimit: values.inventoryLimit,
matching: values.matching,
internalMatch: values.internalMatch,
timeout: values.timeout,
priceDrop: values.priceDrop,
},
}
}
@@ -329,6 +397,7 @@ export default function ConsignmentConfigPanel() {
const [expandedCategoryIds, setExpandedCategoryIds] = useState<Set<number>>(() => new Set())
const [saving, setSaving] = useState(false)
const [specDrafts, setSpecDrafts] = useState<ConsignmentSpecField[]>([])
const [intakeDrafts, setIntakeDrafts] = useState<ConsignmentIntakeField[]>([])
const [form] = Form.useForm<ConfigFormValues>()
const profilesQuery = useQuery({
@@ -406,6 +475,11 @@ export default function ConsignmentConfigPanel() {
if (selectedProfile) {
form.setFieldsValue(toFormValues(selectedProfile))
setSpecDrafts(cloneSpecs(selectedProfile.config.specs))
setIntakeDrafts(
cloneIntakeFields(
selectedProfile.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
),
)
} else {
form.setFieldsValue({
...emptyFormValues(),
@@ -413,6 +487,7 @@ export default function ConsignmentConfigPanel() {
tabTitle: selectedCategory?.name || '',
})
setSpecDrafts([])
setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS))
}
}, [selectedCategoryId, selectedProfile, selectedCategory?.name, form])
@@ -424,6 +499,11 @@ export default function ConsignmentConfigPanel() {
if (selectedProfile) {
form.setFieldsValue(toFormValues(selectedProfile))
setSpecDrafts(cloneSpecs(selectedProfile.config.specs))
setIntakeDrafts(
cloneIntakeFields(
selectedProfile.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
),
)
return
}
form.setFieldsValue({
@@ -432,6 +512,7 @@ export default function ConsignmentConfigPanel() {
tabTitle: selectedCategory?.name || '',
})
setSpecDrafts([])
setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS))
}
async function handleSave() {
@@ -463,10 +544,24 @@ export default function ConsignmentConfigPanel() {
return
}
}
const intakeKeys = new Set<string>()
for (const field of intakeDrafts) {
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(field.key)) {
message.error(`收货字段 Key 不合法:${field.key || '(空)'}`)
return
}
if (intakeKeys.has(field.key)) {
message.error(`收货字段 Key 重复:${field.key}`)
return
}
intakeKeys.add(field.key)
}
const values = await form.validateFields()
setSaving(true)
try {
await saveAdminConsignmentProfile(buildPayload(selectedCategoryId, values, specs))
await saveAdminConsignmentProfile(
buildPayload(selectedCategoryId, values, specs, intakeDrafts),
)
message.success('寄卖配置已保存')
await refresh()
} catch (error) {
@@ -560,7 +655,45 @@ export default function ConsignmentConfigPanel() {
setSpecDrafts((current) => [...current, newCustomSpec(current)])
}
function updateIntakeField(index: number, patch: Partial<ConsignmentIntakeField>) {
setIntakeDrafts((current) =>
current.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)),
)
}
function removeIntakeField(index: number) {
setIntakeDrafts((current) =>
current
.filter((_, fieldIndex) => fieldIndex !== index)
.map((field, fieldIndex) => ({ ...field, sortOrder: (fieldIndex + 1) * 10 })),
)
}
function moveIntakeField(index: number, direction: -1 | 1) {
setIntakeDrafts((current) => {
const next = [...current]
const target = index + direction
if (target < 0 || target >= next.length) return current
const [item] = next.splice(index, 1)
if (!item) return current
next.splice(target, 0, item)
return next.map((field, fieldIndex) => ({ ...field, sortOrder: (fieldIndex + 1) * 10 }))
})
}
function addIntakeField() {
setIntakeDrafts((current) => [...current, newIntakeField(current)])
}
const priceType = Form.useWatch(['pricing', 'priceType'], form)
const depositEnabled = Form.useWatch(['deposit', 'enabled'], form)
const depositMode = Form.useWatch(['deposit', 'mode'], form)
const serviceFeeEnabled = Form.useWatch(['serviceFee', 'enabled'], form)
const serviceFeeMode = Form.useWatch(['serviceFee', 'mode'], form)
const inventoryLimitEnabled = Form.useWatch(['inventoryLimit', 'enabled'], form)
const matchingMode = Form.useWatch(['matching', 'mode'], form)
const priceDropEnabled = Form.useWatch(['priceDrop', 'enabled'], form)
const priceDropMode = Form.useWatch(['priceDrop', 'mode'], form)
const headerStatus: ProfileStatusInfo = selectedProfile
? resolveProfileStatus(selectedProfile)
: runtimeInheritsParent
@@ -984,9 +1117,15 @@ export default function ConsignmentConfigPanel() {
<ConsignmentSectionTitle
index="03"
title="价格配置"
description="模板折扣价或固定价格;固定价不得高于接单模板价"
description="按接单模板价打折,或按模板价固定下调;没有模板价一律阻止发布"
/>
</div>
<Alert
type="info"
showIcon
message="寄售价始终基于接单模板价计算;该规格没有模板价时,打手端会直接阻止发布。"
style={{ marginBottom: 12 }}
/>
<div style={gridStyle}>
<Form.Item
label="价格类型"
@@ -998,15 +1137,16 @@ export default function ConsignmentConfigPanel() {
buttonStyle="solid"
options={[
{ value: 'template_discount', label: '接单模板价打折' },
{ value: 'fixed', label: '固定价格' },
{ value: 'template_minus', label: '模板价固定下调' },
]}
/>
</Form.Item>
{priceType === 'fixed' ? (
{priceType === 'template_minus' ? (
<Form.Item
label="固定价格(元)"
name={['pricing', 'fixedPriceYuan']}
extra="发布时会按接单模板价校验,超过模板价会被后端拒绝"
label="固定下调(元)"
name={['pricing', 'minusYuan']}
rules={[{ type: 'number', min: 0 }]}
extra="售价 = 接单模板价 − 固定下调金额;下调后不足 1 元会被拒绝"
style={{ marginBottom: 0 }}
>
<InputNumber
@@ -1055,19 +1195,6 @@ export default function ConsignmentConfigPanel() {
addonBefore="¥"
/>
</Form.Item>
<Form.Item
label="没有模板价时"
name={['pricing', 'missingPricePolicy']}
extra="固定价格不受此项影响"
style={{ marginBottom: 0 }}
>
<Select
options={[
{ value: 'manual', label: '允许打手手填' },
{ value: 'block', label: '阻止发布' },
]}
/>
</Form.Item>
</div>
</section>
@@ -1088,42 +1215,48 @@ export default function ConsignmentConfigPanel() {
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item
label="保证金模式"
name={['deposit', 'mode']}
style={{ marginBottom: 0 }}
>
<Select
options={[
{ value: 'fixed', label: '固定金额' },
{ value: 'per_quantity', label: '按数量' },
]}
/>
</Form.Item>
<Form.Item
label="固定金额(元)"
name={['deposit', 'fixedAmountYuan']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={2}
style={{ width: '100%' }}
addonBefore="¥"
/>
</Form.Item>
<Form.Item
label="每件金额(元)"
name={['deposit', 'unitAmountYuan']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={2}
style={{ width: '100%' }}
addonBefore="¥"
/>
</Form.Item>
{depositEnabled ? (
<Form.Item
label="保证金模式"
name={['deposit', 'mode']}
style={{ marginBottom: 0 }}
>
<Select
options={[
{ value: 'fixed', label: '固定金额' },
{ value: 'per_quantity', label: '按数量' },
]}
/>
</Form.Item>
) : null}
{depositEnabled && depositMode === 'fixed' ? (
<Form.Item
label="固定金额(元)"
name={['deposit', 'fixedAmountYuan']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={2}
style={{ width: '100%' }}
addonBefore="¥"
/>
</Form.Item>
) : null}
{depositEnabled && depositMode === 'per_quantity' ? (
<Form.Item
label="每件金额(元)"
name={['deposit', 'unitAmountYuan']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={2}
style={{ width: '100%' }}
addonBefore="¥"
/>
</Form.Item>
) : null}
</div>
</section>
@@ -1144,37 +1277,43 @@ export default function ConsignmentConfigPanel() {
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item
label="服务费模式"
name={['serviceFee', 'mode']}
style={{ marginBottom: 0 }}
>
<Select
options={[
{ value: 'percent', label: '按比例 %' },
{ value: 'fixed', label: '固定金额' },
]}
/>
</Form.Item>
<Form.Item
label="比例(%)"
name={['serviceFee', 'percent']}
style={{ marginBottom: 0 }}
>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="固定金额(元)"
name={['serviceFee', 'amountYuan']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={2}
style={{ width: '100%' }}
addonBefore="¥"
/>
</Form.Item>
{serviceFeeEnabled ? (
<Form.Item
label="服务费模式"
name={['serviceFee', 'mode']}
style={{ marginBottom: 0 }}
>
<Select
options={[
{ value: 'percent', label: '按比例 %' },
{ value: 'fixed', label: '固定金额' },
]}
/>
</Form.Item>
) : null}
{serviceFeeEnabled && serviceFeeMode === 'percent' ? (
<Form.Item
label="比例(%)"
name={['serviceFee', 'percent']}
style={{ marginBottom: 0 }}
>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
</Form.Item>
) : null}
{serviceFeeEnabled && serviceFeeMode === 'fixed' ? (
<Form.Item
label="固定金额(元)"
name={['serviceFee', 'amountYuan']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={2}
style={{ width: '100%' }}
addonBefore="¥"
/>
</Form.Item>
) : null}
</div>
</section>
@@ -1195,18 +1334,20 @@ export default function ConsignmentConfigPanel() {
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item
label="最多在售数量"
name={['inventoryLimit', 'maxActiveQuantity']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={0}
style={{ width: '100%' }}
addonAfter="件"
/>
</Form.Item>
{inventoryLimitEnabled ? (
<Form.Item
label="最多在售数量"
name={['inventoryLimit', 'maxActiveQuantity']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={0}
style={{ width: '100%' }}
addonAfter="件"
/>
</Form.Item>
) : null}
</div>
</section>
@@ -1215,7 +1356,7 @@ export default function ConsignmentConfigPanel() {
<ConsignmentSectionTitle
index="07"
title="匹配设置"
description="自动/手动匹配、精确匹配与内部匹配等待策略"
description="匹配模式决定新单是否进入内部库存匹配队列"
/>
</div>
<div style={gridStyle}>
@@ -1226,77 +1367,85 @@ export default function ConsignmentConfigPanel() {
>
<Select
options={[
{ value: 'off', label: '关闭匹配' },
{ value: 'manual', label: '手动匹配' },
{ value: 'auto', label: '自动匹配' },
]}
/>
</Form.Item>
<Form.Item
label="自动匹配开关"
name={['matching', 'autoMatchEnabled']}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item
label="仅精确匹配"
name={['matching', 'exactOnly']}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Switch checkedChildren="仅精确" unCheckedChildren="允许高配" />
</Form.Item>
<Form.Item
label="低价订单优先"
name={['matching', 'preferLowReward']}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Switch checkedChildren="优先" unCheckedChildren="不优先" />
</Form.Item>
<Form.Item
label="内部匹配等待(分钟)"
name={['internalMatch', 'delayMinutes']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={0}
style={{ width: '100%' }}
addonAfter="分钟"
/>
</Form.Item>
<Form.Item
label="超时发回大厅"
name={['internalMatch', 'fallbackToHall']}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
{matchingMode !== 'off' ? (
<>
<Form.Item
label="仅精确匹配"
name={['matching', 'exactOnly']}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Switch checkedChildren="仅精确" unCheckedChildren="允许高配" />
</Form.Item>
<Form.Item
label="低价订单优先"
name={['matching', 'preferLowReward']}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Switch checkedChildren="优先" unCheckedChildren="不优先" />
</Form.Item>
<Form.Item
label="内部匹配等待(分钟)"
name={['internalMatch', 'delayMinutes']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={0}
style={{ width: '100%' }}
addonAfter="分钟"
/>
</Form.Item>
<Form.Item
label="超时发回大厅"
name={['internalMatch', 'fallbackToHall']}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</>
) : null}
</div>
{matchingMode === 'off' ? (
<Alert
type="warning"
showIcon
style={{ marginTop: 12 }}
message="关闭匹配后:新单直接进入接单大厅,不等待内部库存,也不会自动匹配。"
/>
) : null}
</section>
<section className="order-service-section">
<div className="order-service-section-head">
<ConsignmentSectionTitle
index="08"
title="超时与降价提示"
description="代练超时动作与寄卖长时间未售出的降价提醒"
title="超时设置"
description="匹配成功进入代练中后的时限与超时处置动作"
/>
</div>
<div style={gridStyle}>
<Form.Item
label="代练超时(分钟)"
name={['timeout', 'minutes']}
label="代练超时(小时)"
name={['timeout', 'hours']}
rules={[{ type: 'number', min: 0 }]}
extra="默认 0.5 小时;到点后按下方超时动作处置"
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={0}
step={0.5}
precision={1}
style={{ width: '100%' }}
addonAfter="分钟"
addonAfter="小时"
/>
</Form.Item>
<Form.Item
@@ -1306,12 +1455,32 @@ export default function ConsignmentConfigPanel() {
>
<Select
options={[
{ value: 'reopen', label: '退回大厅' },
{ value: 'cancel_release', label: '取消并释放' },
{ value: 'cancel_deduct', label: '取消并扣押金' },
{ value: 'reopen', label: '退回大厅(退还押金)' },
{ value: 'cancel_release', label: '取消并释放押金' },
{ value: 'cancel_deduct', label: '取消并扣除押金' },
{ value: 'rematch_release', label: '重新匹配并退还押金' },
{ value: 'rematch_deduct', label: '重新匹配并扣除押金' },
]}
/>
</Form.Item>
</div>
<Alert
type="info"
showIcon
style={{ marginTop: 12 }}
message="重新匹配:订单回到内部匹配池,并排除原卖家与原库存,避免同一卖家立刻接回同一单。"
/>
</section>
<section className="order-service-section">
<div className="order-service-section-head">
<ConsignmentSectionTitle
index="09"
title="降价提示"
description="寄卖长时间未售出时,在后台给出建议售价(仅提示,不自动改价)"
/>
</div>
<div style={gridStyle}>
<Form.Item
label="开启降价提示"
name={['priceDrop', 'enabled']}
@@ -1320,40 +1489,186 @@ export default function ConsignmentConfigPanel() {
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item
label="未售出时长(小时)"
name={['priceDrop', 'hours']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={1}
precision={0}
style={{ width: '100%' }}
addonAfter="小时"
/>
</Form.Item>
<Form.Item
label="降幅模式"
name={['priceDrop', 'mode']}
style={{ marginBottom: 0 }}
>
<Select
options={[
{ value: 'percent', label: '按比例 %' },
{ value: 'fixed', label: '固定金额' },
]}
/>
</Form.Item>
<Form.Item
label="降幅数值"
name={['priceDrop', 'value']}
style={{ marginBottom: 0 }}
>
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
</Form.Item>
{priceDropEnabled ? (
<>
<Form.Item
label="未售出时长(小时)"
name={['priceDrop', 'hours']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={1}
precision={0}
style={{ width: '100%' }}
addonAfter="小时"
/>
</Form.Item>
<Form.Item
label="降幅模式"
name={['priceDrop', 'mode']}
style={{ marginBottom: 0 }}
>
<Select
options={[
{ value: 'percent', label: '按比例 %' },
{ value: 'fixed', label: '固定金额' },
]}
/>
</Form.Item>
<Form.Item
label={priceDropMode === 'fixed' ? '下调金额(元)' : '下调比例(%)'}
name={['priceDrop', 'value']}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
max={priceDropMode === 'fixed' ? undefined : 100}
precision={priceDropMode === 'fixed' ? 2 : 0}
style={{ width: '100%' }}
addonBefore={priceDropMode === 'fixed' ? '¥' : undefined}
addonAfter={priceDropMode === 'fixed' ? undefined : '%'}
/>
</Form.Item>
</>
) : null}
</div>
</section>
<section className="order-service-section">
<div className="order-service-section-head">
<ConsignmentSectionTitle
index="10"
title="联系方式与游戏凭据"
description="打手发布页按这里配置的字段动态渲染;字段为空时该模块不显示"
/>
<Button
type="primary"
ghost
size="small"
icon={<PlusOutlined />}
onClick={addIntakeField}
>
添加字段
</Button>
</div>
<Text type="secondary">
默认字段与旧发布页一致;手机号字段会校验 11
位手机号。对接客服字段被删除后不再校验提成归属。
</Text>
{intakeDrafts.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="还没有收货字段,点击右上角「添加字段」"
style={{ margin: '16px 0 4px' }}
/>
) : (
<div className="order-service-field-list" style={{ marginTop: 12 }}>
{intakeDrafts.map((field, index) => (
<div className="order-service-field-row" key={`${field.key}-${index}`}>
<div className="order-service-field-main">
<div style={fieldCellStyle}>
<Text type="secondary">字段 Key</Text>
<Input
value={field.key}
placeholder="如 sellerPhone"
onChange={(event) =>
updateIntakeField(index, { key: event.target.value })
}
/>
</div>
<div style={fieldCellStyle}>
<Text type="secondary">显示名称</Text>
<Input
value={field.label}
placeholder="如 联系电话"
onChange={(event) =>
updateIntakeField(index, { label: event.target.value })
}
/>
</div>
<div style={fieldCellStyle}>
<Text type="secondary">类型</Text>
<Select
value={field.type}
options={INTAKE_FIELD_TYPE_OPTIONS}
onChange={(value) => updateIntakeField(index, { type: value })}
/>
</div>
<div style={fieldCellStyle} className="order-service-field-required">
<Text type="secondary">必填</Text>
<Switch
checked={field.required}
checkedChildren="必填"
unCheckedChildren="选填"
onChange={(checked) =>
updateIntakeField(index, { required: checked })
}
/>
</div>
<div className="order-service-field-actions">
<Button
size="small"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={() => moveIntakeField(index, -1)}
/>
<Button
size="small"
icon={<ArrowDownOutlined />}
disabled={index === intakeDrafts.length - 1}
onClick={() => moveIntakeField(index, 1)}
/>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => removeIntakeField(index)}
/>
</div>
</div>
<div className="order-service-field-extra">
<div style={fieldCellStyle}>
<Text type="secondary">占位提示</Text>
<Input
value={field.placeholder}
placeholder="输入框提示文案"
onChange={(event) =>
updateIntakeField(index, { placeholder: event.target.value })
}
/>
</div>
<div style={fieldCellStyle}>
<Text type="secondary">填写说明</Text>
<Input
value={field.helpText}
placeholder="给打手看的说明"
onChange={(event) =>
updateIntakeField(index, { helpText: event.target.value })
}
/>
</div>
{field.type === 'image' ? (
<div style={fieldCellStyle}>
<Text type="secondary">最多上传张数</Text>
<InputNumber
min={1}
max={12}
precision={0}
style={{ width: '100%' }}
value={field.maxCount}
onChange={(value) =>
updateIntakeField(index, { maxCount: Number(value || 1) })
}
/>
</div>
) : null}
</div>
</div>
))}
</div>
)}
</section>
<div className="order-service-save-bar">
<Text type="secondary">保存只更新草稿,发布后才会影响新工单。</Text>
<Space>
@@ -1,5 +1,6 @@
import {
EyeOutlined,
FileSearchOutlined,
ReloadOutlined,
RollbackOutlined,
StopOutlined,
@@ -39,6 +40,7 @@ import { useState } from 'react'
import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery'
import ConsignmentPendingMatchModal from '@/components/admin/ConsignmentPendingMatchModal'
import ConsignmentMatchLogsModal from '@/components/admin/ConsignmentMatchLogsModal'
import {
archiveAdminConsignmentInventory,
fetchAdminConsignmentContacts,
@@ -163,6 +165,7 @@ export default function ConsignmentInventoryPanel() {
const [returnItem, setReturnItem] = useState<ConsignmentInventory | null>(null)
const [manualMatchItem, setManualMatchItem] = useState<ConsignmentInventory | null>(null)
const [pendingMatchOpen, setPendingMatchOpen] = useState(false)
const [matchLogsOpen, setMatchLogsOpen] = useState(false)
const [returnReason, setReturnReason] = useState<string>('')
const [editForm] = Form.useForm()
@@ -891,6 +894,10 @@ export default function ConsignmentInventoryPanel() {
一键查询待匹配订单
</Button>
<Button icon={<FileSearchOutlined />} onClick={() => setMatchLogsOpen(true)}>
匹配日志
</Button>
<Button icon={<ReloadOutlined />} onClick={refresh}>
刷新
</Button>
@@ -1409,6 +1416,8 @@ export default function ConsignmentInventoryPanel() {
open={pendingMatchOpen}
onClose={() => setPendingMatchOpen(false)}
/>
<ConsignmentMatchLogsModal open={matchLogsOpen} onClose={() => setMatchLogsOpen(false)} />
</div>
)
}
@@ -897,15 +897,53 @@ export default function ConsignmentSubmissionsPanel() {
¥{(viewingSubmission.askingPriceFen / 100).toFixed(2)}
</Text>
</Descriptions.Item>
<Descriptions.Item label="卖家微信号">
<Text copyable>{viewingSubmission.contactInfo}</Text>
</Descriptions.Item>
<Descriptions.Item label="卖家电话">
<Text copyable>{viewingSubmission.sellerPhone || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="卖家游戏ID" span={2}>
<Text copyable>{viewingSubmission.sellerGameId || '-'}</Text>
</Descriptions.Item>
{viewingSubmission.intakeFields && viewingSubmission.intakeFields.length > 0 ? (
<>
{viewingSubmission.intakeFields.map((field) => {
const raw = viewingSubmission.intakeValues?.[field.key]
if (field.type === 'image') {
const images = Array.isArray(raw)
? raw.filter((item): item is string => typeof item === 'string')
: []
return (
<Descriptions.Item label={field.label} span={2} key={field.key}>
<ConsignmentEvidenceGallery
images={images}
emptyText="未上传"
max={8}
size={80}
/>
</Descriptions.Item>
)
}
const display =
raw === undefined || raw === null || String(raw).trim() === ''
? '-'
: String(raw)
return (
<Descriptions.Item
label={field.label}
span={field.type === 'textarea' ? 2 : 1}
key={field.key}
>
<Text copyable={field.type !== 'textarea'}>{display}</Text>
</Descriptions.Item>
)
})}
</>
) : (
<>
<Descriptions.Item label="卖家微信号">
<Text copyable>{viewingSubmission.contactInfo}</Text>
</Descriptions.Item>
<Descriptions.Item label="卖家电话">
<Text copyable>{viewingSubmission.sellerPhone || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="卖家游戏ID" span={2}>
<Text copyable>{viewingSubmission.sellerGameId || '-'}</Text>
</Descriptions.Item>
</>
)}
<Descriptions.Item label="关联接单打手">
{viewingSubmission.workerId ? (
<Tag color="blue">
@@ -55,12 +55,13 @@ import {
} from '@/services/worker'
import type { WorkerConsignmentRecord } from '@/services/worker'
import type {
ConsignmentIntakeField,
ConsignmentSpecField,
ConsignmentSpecValues,
WorkerConsignmentProfile,
} from '@/types/consignment'
import type { UploadedFile } from '@/types/worker-platform'
import { buildSpecItemName } from '@/utils/consignment-spec'
import { DEFAULT_CONSIGNMENT_INTAKE_FIELDS, buildSpecItemName } from '@/utils/consignment-spec'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
import { canWorkerPublishConsignment } from '@/utils/consignment-whitelist'
@@ -151,8 +152,9 @@ export default function WorkerConsignmentPage() {
}
}
}
const [screenshots, setScreenshots] = useState<UploadedFile[]>([])
const [submitting, setSubmitting] = useState(false)
/** 收货信息图片字段:key -> 已上传文件 */
const [intakeImages, setIntakeImages] = useState<Record<string, UploadedFile[]>>({})
const [evidenceItem, setEvidenceItem] = useState<WorkerConsignmentRecord | null>(null)
const [evidenceFiles, setEvidenceFiles] = useState<UploadedFile[]>([])
const [evidenceNote, setEvidenceNote] = useState('')
@@ -203,9 +205,16 @@ export default function WorkerConsignmentPage() {
consignmentProfiles[0] ||
null
const selectedSpecs: ConsignmentSpecField[] = selectedProfile?.config.specs ?? []
const intakeFields: ConsignmentIntakeField[] =
selectedProfile?.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
useEffect(() => {
if (selectedProfileId === null && consignmentProfiles.length > 0) {
setSelectedProfileId(consignmentProfiles[0]!.profileId)
// 默认苏尔南手枪;没有该分类时退回第一个已发布配置。
const preferred =
consignmentProfiles.find((profile) =>
/苏尔南|苏里南/.test(`${profile.categoryName}${profile.tabTitle}${profile.name}`),
) || consignmentProfiles[0]
if (preferred) setSelectedProfileId(preferred.profileId)
}
}, [consignmentProfiles, selectedProfileId])
@@ -339,6 +348,18 @@ export default function WorkerConsignmentPage() {
return values
}
function handleProfileChange(profileId: number) {
setSelectedProfileId(profileId)
form.resetFields()
setPriceState({ status: 'idle' })
lastPriceQueryKeyRef.current = ''
setIntakeImages({})
if (worker?.phone) form.setFieldsValue({ sellerPhone: worker.phone })
if (firstContactSupporterId != null) {
form.setFieldsValue({ contactSupporterId: firstContactSupporterId })
}
}
function handleFormValuesChange(changed: Record<string, unknown>) {
// 钻级改变时:璀璨数不能超过钻石等级(如 5钻调成 2钻,原 5璀自动收敛到 2璀)
if (changed && changed.diamondLevel !== undefined) {
@@ -417,30 +438,67 @@ export default function WorkerConsignmentPage() {
return
}
if (contactSupporters.length === 0) {
message.error('暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布')
return
// 联系方式与游戏凭据完全按寄卖配置动态收集;图片字段用独立 state 保存。
const activeIntakeFields =
selectedProfile?.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
const intakeValues: Record<string, unknown> = {}
for (const field of activeIntakeFields) {
if (field.type === 'image') {
const urls = (intakeImages[field.key] || []).map((file) => file.url).filter(Boolean)
if (urls.length > 0) {
intakeValues[field.key] = urls
} else if (field.required) {
message.error(`请上传「${field.label}」`)
return
}
continue
}
const raw = values[field.key]
if (raw === undefined || raw === null || String(raw).trim() === '') {
if (field.required) {
message.error(`请填写「${field.label}」`)
return
}
continue
}
intakeValues[field.key] =
field.type === 'number' || field.type === 'contact_supporter'
? Number(raw)
: String(raw).trim()
}
if (values.contactSupporterId == null) {
message.error('请选择对接客服')
return
const phoneField = activeIntakeFields.find((field) => field.type === 'phone')
if (phoneField) {
const phoneValue = String(intakeValues[phoneField.key] || '').trim()
if (phoneValue && !/^1[3-9]\d{9}$/.test(phoneValue)) {
message.error(`「${phoneField.label}」请填写正确的 11 位手机号`)
return
}
}
const sellerContact = String(intakeValues.sellerContact ?? '').trim()
const sellerPhone = String(intakeValues.sellerPhone ?? worker?.phone ?? '').trim()
const characterName = String(intakeValues.characterName ?? '').trim()
const characterId = String(intakeValues.characterId ?? '').trim()
const sellerNote = String(intakeValues.sellerNote ?? '').trim()
const publishScreenshots = Array.isArray(intakeValues.screenshots)
? (intakeValues.screenshots as string[])
: []
const urls = screenshots.map((f) => f.url).filter(Boolean)
if (urls.length === 0) {
message.error('请上传游戏主页截图以供核验')
return
}
// 寄售价:按后台配置带出系统建议价;允许改价时可下调,但不能高于建议价。
// 寄售价:必须命中接单模板价;允许改价时可下调,但不能高于建议价。
const minPriceFen = Math.max(0, selectedProfile?.config.pricing.minPriceFen || 0)
const suggestedPriceFen = priceState.status === 'matched' ? priceState.fen : 0
const capPriceFen = priceState.status === 'matched' ? priceState.capFen : 0
let askingPriceFen = suggestedPriceFen
if (priceState.status === 'loading') {
message.warning('正在计算系统建议价,请稍候')
return
}
if (priceState.status !== 'matched' || priceState.fen <= 0) {
message.error('该规格暂无模板价,暂不能寄卖,请联系客服补充模板价格')
return
}
const capPriceFen = priceState.capFen > 0 ? priceState.capFen : priceState.fen
let askingPriceFen = priceState.fen
if (manualPriceAllowed) {
const submittedPriceFen = Math.max(0, Math.round(Number(values.askingPrice || 0) * 100))
if (submittedPriceFen > 0) {
if (capPriceFen > 0 && submittedPriceFen > capPriceFen) {
if (submittedPriceFen > capPriceFen) {
message.error(`寄售价格不能高于系统建议价 ¥${(capPriceFen / 100).toFixed(2)}`)
return
}
@@ -449,24 +507,12 @@ export default function WorkerConsignmentPage() {
return
}
askingPriceFen = submittedPriceFen
} else if (suggestedPriceFen <= 0) {
message.error('该规格暂无价格,请填写寄售价格')
return
}
} else {
if (priceState.status === 'loading') {
message.warning('正在计算系统建议价,请稍候')
return
}
if (priceState.status !== 'matched') {
message.error('该规格暂无价格,请联系客服补充配置后再提报')
return
}
if (askingPriceFen < 100) {
message.error('寄售价格不能少于1元')
return
}
}
if (askingPriceFen < 100) {
message.error('寄售价格不能少于1元')
return
}
const specValues = collectSpecValues()
const itemName =
@@ -491,14 +537,17 @@ export default function WorkerConsignmentPage() {
diamondLevel: Number(specValues.diamondLevel || 1),
brillianceCount: Number(specValues.brillianceCount || 0),
askingPriceFen,
sellerContact: values.sellerContact,
sellerPhone: values.sellerPhone || worker?.phone || '',
characterName: values.characterName,
characterId: values.characterId,
screenshotsJson: urls,
sellerNote: values.sellerNote,
sellerContact,
sellerPhone,
characterName,
characterId,
screenshotsJson: publishScreenshots,
sellerNote,
intakeValues,
depositAmountFen: publishDepositFen,
contactSupporterId: Number(values.contactSupporterId),
contactSupporterId: intakeValues.contactSupporterId
? Number(intakeValues.contactSupporterId)
: null,
})
message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`)
@@ -508,7 +557,7 @@ export default function WorkerConsignmentPage() {
}
setPriceState({ status: 'idle' })
lastPriceQueryKeyRef.current = ''
setScreenshots([])
setIntakeImages({})
await Promise.all([myConsignmentsQuery.refetch(), profileQuery.refetch()])
setRecordFilter('reviewing')
setActiveTab('records')
@@ -654,6 +703,31 @@ export default function WorkerConsignmentPage() {
onFinish={handlePublishSubmit}
onValuesChange={handleFormValuesChange}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
marginBottom: 16,
}}
>
<Typography.Text strong>寄卖分类</Typography.Text>
<Select
value={selectedProfile?.profileId}
style={{ minWidth: 220 }}
placeholder={consignmentProfiles.length === 0 ? '暂无可发布的寄卖分类' : '请选择寄卖分类'}
options={consignmentProfiles.map((profile) => ({
value: profile.profileId,
label: profile.tabTitle || profile.categoryName || profile.name,
}))}
onChange={(value) => handleProfileChange(Number(value))}
/>
{selectedProfile?.tabDescription ? (
<Typography.Text type="secondary">{selectedProfile.tabDescription}</Typography.Text>
) : null}
</div>
<Alert
type={canPublish ? 'info' : 'warning'}
showIcon
@@ -846,84 +920,94 @@ export default function WorkerConsignmentPage() {
联系方式与游戏凭据
</Divider>
<Row gutter={[12, 0]}>
<Col xs={24} sm={12}>
<Form.Item
name="contactSupporterId"
label="对接客服"
rules={[{ required: true, message: '请选择对接客服' }]}
extra={
contactSupporters.length === 0
? '暂无可选客服,请联系管理员配置'
: '用于后续提成归属与联系'
{intakeFields.length === 0 ? (
<Alert type="info" showIcon message="当前寄卖配置未启用收货信息字段。" />
) : (
<Row gutter={[12, 0]}>
{intakeFields.map((field) => {
if (field.type === 'image') {
return (
<Col span={24} key={field.key}>
<Form.Item
label={field.label}
required={field.required}
extra={field.helpText || undefined}
>
<ImageUpload
value={intakeImages[field.key] || []}
onChange={(files) =>
setIntakeImages((current) => ({ ...current, [field.key]: files }))
}
scene="consignment_screenshot"
scope="worker"
maxCount={field.maxCount || 4}
showPasteHint={!isMobile}
/>
</Form.Item>
</Col>
)
}
>
<Select
placeholder="请选择对接客服"
options={contactSupporters.map((item) => ({
value: item.id,
label: item.name,
}))}
notFoundContent="暂无启用中的对接客服"
showSearch
optionFilterProp="label"
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="sellerContact"
label="微信号 / QQ号"
rules={[{ required: true, message: '请填写微信号或QQ' }]}
>
<Input placeholder="方便派单与紧急联系" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="sellerPhone" label="联系电话" tooltip="默认使用当前接单账号绑定手机号">
<Input placeholder="手机号" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="characterName"
label="游戏内角色名"
rules={[{ required: true, message: '请填写角色名' }]}
>
<Input placeholder="局内加好友丢枪用" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="characterId"
label="角色数字ID"
rules={[{ required: true, message: '请填写数字ID' }]}
>
<Input placeholder="如 1089238472" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
label="游戏主页截图"
required
extra="须包含角色名、ID 及仓库/手枪详情,最多 4 张"
>
<ImageUpload
value={screenshots}
onChange={setScreenshots}
scene="consignment_screenshot"
scope="worker"
maxCount={4}
showPasteHint={!isMobile}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="sellerNote" label="其他留言与补充">
<Input.TextArea rows={2} placeholder="配件、子弹、涂装或在线时段等(选填)" />
</Form.Item>
</Col>
</Row>
const rules = [
...(field.required ? [{ required: true, message: `请填写${field.label}` }] : []),
...(field.type === 'phone'
? [{ pattern: /^1[3-9]\d{9}$/, message: '请填写正确的 11 位手机号' }]
: []),
]
return (
<Col xs={24} sm={12} key={field.key}>
<Form.Item
name={field.key}
label={field.label}
rules={rules}
extra={field.helpText || undefined}
>
{field.type === 'contact_supporter' ? (
<Select
placeholder={field.placeholder || '请选择对接客服'}
options={contactSupporters.map((item) => ({
value: item.id,
label: item.name,
}))}
notFoundContent="暂无启用中的对接客服"
showSearch
optionFilterProp="label"
/>
) : field.type === 'textarea' ? (
<Input.TextArea
rows={2}
placeholder={field.placeholder || undefined}
maxLength={2000}
/>
) : field.type === 'number' ? (
<InputNumber
min={0}
precision={0}
placeholder={field.placeholder || undefined}
style={{ width: '100%' }}
/>
) : (
<Input
placeholder={field.placeholder || undefined}
maxLength={field.type === 'phone' ? 11 : 200}
/>
)}
</Form.Item>
</Col>
)
})}
{intakeFields.some((field) => field.type === 'contact_supporter') &&
!consignmentContactsQuery.isLoading &&
contactSupporters.length === 0 ? (
<Col span={24}>
<Alert
type="warning"
showIcon
message="暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布"
/>
</Col>
) : null}
</Row>
)}
<div className="worker-consignment-actions">
<Button
@@ -945,28 +1029,17 @@ export default function WorkerConsignmentPage() {
</Form>
)
const publishTabs =
consignmentProfiles.length > 0
? consignmentProfiles.map((profile) => ({
key: `publish-${profile.profileId}`,
label: (
<span>
<DollarCircleOutlined /> {profile.tabTitle || '发布寄卖'}
</span>
),
children: publishTabContent,
}))
: [
{
key: 'publish',
label: (
<span>
<DollarCircleOutlined /> 发布寄卖
</span>
),
children: publishTabContent,
},
]
const publishTabs = [
{
key: 'publish',
label: (
<span>
<DollarCircleOutlined /> 发布寄卖
</span>
),
children: publishTabContent,
},
]
return (
<section className="page-stack worker-consignment-page">
@@ -12,6 +12,7 @@ import type {
WorkOrderInventoryMatchCheck,
WorkOrderInventoryMatchOptions,
} from '@/types/consignment'
import type { ConsignmentMatchLogsResponse } from '@/types/consignment-match-log'
// --- 分类级寄卖配置 ---
@@ -63,6 +64,23 @@ export function matchAdminConsignmentWorkOrder(payload: {
)
}
/** 匹配日志:自动/手动/重新匹配的每次尝试(含失败/跳过原因)。 */
export function fetchAdminConsignmentMatchLogs(params?: {
page?: number
pageSize?: number
profileId?: number
triggerType?: string
result?: string
keyword?: string
from?: string
to?: string
}) {
return apiGet<ConsignmentMatchLogsResponse>(
'/api/v1/admin/worker-platform/consignment/match-logs',
params,
)
}
// --- 对接客服名单 ---
export function fetchAdminConsignmentContacts(params?: { keyword?: string; enabled?: string }) {
+8 -6
View File
@@ -387,15 +387,17 @@ export type WorkerConsignmentPublishPayload = {
diamondLevel?: number
brillianceCount?: number
askingPriceFen: number
sellerContact: string
sellerContact?: string
sellerPhone?: string
characterName: string
characterId: string
screenshotsJson: string[]
characterName?: string
characterId?: string
screenshotsJson?: string[]
sellerNote?: string
/** 动态收货信息(联系方式与游戏凭据)字段值 */
intakeValues?: Record<string, unknown>
depositAmountFen?: number
/** 对接客服名单 id(必填,发布时选定) */
contactSupporterId: number
/** 对接客服名单 id;配置里删除该字段时可为空 */
contactSupporterId?: number | null
}
export type WorkerConsignmentItem = {
@@ -0,0 +1,32 @@
/**
* 寄卖匹配日志(后台「匹配日志」抽屉)
* 记录自动/手动/重新匹配的每次尝试,用于留痕和问题分析。
*/
export type ConsignmentMatchLogTrigger = 'auto' | 'manual' | 'rematch'
export type ConsignmentMatchLogResult = 'matched' | 'failed' | 'skipped'
export type ConsignmentMatchLogItem = {
logId: number
workOrderId: number | null
workOrderNo: string
profileId: number | null
categoryId: number | null
triggerType: ConsignmentMatchLogTrigger
result: ConsignmentMatchLogResult
reasonCode: string
reasonText: string
candidateCount: number
matchedCount: number
actorType: string
actorId: string
durationMs: number
payload: Record<string, unknown>
createdAt: string
}
export type ConsignmentMatchLogsResponse = {
items: ConsignmentMatchLogItem[]
total: number
page: number
pageSize: number
}
+39 -6
View File
@@ -53,6 +53,28 @@ export type ConsignmentSpecField = {
sortOrder: number
}
/** 联系方式与游戏凭据:按寄卖配置动态渲染的收货信息字段 */
export type ConsignmentIntakeFieldType =
| 'text'
| 'textarea'
| 'phone'
| 'number'
| 'image'
| 'contact_supporter'
export type ConsignmentIntakeField = {
key: string
label: string
type: ConsignmentIntakeFieldType
required: boolean
placeholder: string
helpText: string
maxCount: number
sortOrder: number
}
export type ConsignmentIntakeValues = Record<string, string | number | string[]>
/**
* 客服列表 / 详情接口额外返回的寄卖凭证字段(提报列表与库存列表都有)。
*
@@ -216,6 +238,9 @@ export type ConsignmentSubmission = ConsignmentEvidenceFields &
specValues?: ConsignmentSpecValues
specText?: string
specs?: ConsignmentSpecField[]
/** 收货信息动态字段与提交值(按发布快照) */
intakeFields?: ConsignmentIntakeField[]
intakeValues?: Record<string, unknown>
priceCapFen?: number
quantity?: number
requestedSalePriceFen?: number
@@ -363,11 +388,12 @@ export type ConsignmentProfileConfig = {
/** 物品名模板,支持 {system}/{series}/{category} 等占位符 */
nameTemplate: string
pricing: {
/** 接单模板价打折 / 固定价格 */
priceType: 'template_discount' | 'fixed'
/** 接单模板价打折 / 模板价固定下调 */
priceType: 'template_discount' | 'template_minus'
/** 几折,0.1 - 10,例如 9.5 */
discount: number
fixedPriceFen: number
/** 固定下调金额(分):售价 = 模板价 - minusFen */
minusFen: number
allowWorkerPrice: boolean
minPriceFen: number
missingPricePolicy: 'block' | 'manual'
@@ -381,19 +407,26 @@ export type ConsignmentProfileConfig = {
serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountFen: number }
inventoryLimit: { enabled: boolean; maxActiveQuantity: number }
matching: {
mode: 'manual' | 'auto'
autoMatchEnabled: boolean
/** off = 完全关闭匹配;manual = 人工匹配;auto = 自动调度 */
mode: 'off' | 'manual' | 'auto'
exactOnly: boolean
preferLowReward: boolean
}
internalMatch: { delayMinutes: number; fallbackToHall: boolean }
timeout: { minutes: number; policy: 'reopen' | 'cancel_release' | 'cancel_deduct' }
timeout: {
minutes: number
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
}
priceDrop: {
enabled: boolean
hours: number
mode: 'percent' | 'fixed'
value: number
}
/** 联系方式与游戏凭据动态字段 */
intake: {
fields: ConsignmentIntakeField[]
}
}
export type ConsignmentPricingConfig = ConsignmentProfileConfig['pricing']
+103 -8
View File
@@ -1,4 +1,6 @@
import type {
ConsignmentIntakeField,
ConsignmentIntakeFieldType,
ConsignmentPricingConfig,
ConsignmentSpecField,
ConsignmentSpecFieldType,
@@ -38,6 +40,96 @@ export function isOptionSpecField(field: Pick<ConsignmentSpecField, 'type'>): bo
return field.type === 'select' || field.type === 'radio'
}
export const INTAKE_FIELD_TYPE_OPTIONS: Array<{
label: string
value: ConsignmentIntakeFieldType
}> = [
{ label: '文本', value: 'text' },
{ label: '多行文本', value: 'textarea' },
{ label: '手机号', value: 'phone' },
{ label: '数字', value: 'number' },
{ label: '图片', value: 'image' },
{ label: '对接客服', value: 'contact_supporter' },
]
export function intakeFieldTypeLabel(type: ConsignmentIntakeFieldType): string {
return INTAKE_FIELD_TYPE_OPTIONS.find((item) => item.value === type)?.label || '文本'
}
/** 与后端 DEFAULT_CONSIGNMENT_INTAKE_FIELDS 保持一致。 */
export const DEFAULT_CONSIGNMENT_INTAKE_FIELDS: ConsignmentIntakeField[] = [
{
key: 'contactSupporterId',
label: '对接客服',
type: 'contact_supporter',
required: true,
placeholder: '请选择对接客服',
helpText: '用于后续提成归属与联系',
maxCount: 0,
sortOrder: 10,
},
{
key: 'sellerContact',
label: '微信号 / QQ号',
type: 'text',
required: true,
placeholder: '方便派单与紧急联系',
helpText: '',
maxCount: 0,
sortOrder: 20,
},
{
key: 'sellerPhone',
label: '联系电话',
type: 'phone',
required: true,
placeholder: '手机号',
helpText: '默认使用当前接单账号绑定手机号',
maxCount: 0,
sortOrder: 30,
},
{
key: 'characterName',
label: '游戏内角色名',
type: 'text',
required: true,
placeholder: '局内加好友丢枪用',
helpText: '',
maxCount: 0,
sortOrder: 40,
},
{
key: 'characterId',
label: '角色数字ID',
type: 'text',
required: true,
placeholder: '如 1089238472',
helpText: '',
maxCount: 0,
sortOrder: 50,
},
{
key: 'screenshots',
label: '游戏主页截图',
type: 'image',
required: true,
placeholder: '',
helpText: '须包含角色名、ID 及仓库/手枪详情,最多 4 张',
maxCount: 4,
sortOrder: 60,
},
{
key: 'sellerNote',
label: '其他留言与补充',
type: 'textarea',
required: false,
placeholder: '配件、子弹、涂装或在线时段等(选填)',
helpText: '',
maxCount: 0,
sortOrder: 70,
},
]
export function formatSpecValue(
field: ConsignmentSpecField,
value: unknown,
@@ -122,15 +214,18 @@ export function computeSpecPrice(
const breakdown: Array<{ label: string; amountFen: number }> = []
let priceFen = 0
if (pricing.priceType === 'fixed') {
priceFen = Math.max(0, Math.round(Number(pricing.fixedPriceFen || 0)))
if (priceFen > 0) breakdown.push({ label: '固定价格', amountFen: priceFen })
} else if (template > 0) {
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
priceFen = Math.round((template * discount) / 10)
if (template > 0) {
breakdown.push({ label: '接单模板价', amountFen: template })
if (discount !== 10) {
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - template })
if (pricing.priceType === 'template_minus') {
const minusFen = Math.max(0, Math.round(Number(pricing.minusFen || 0)))
priceFen = Math.max(0, template - minusFen)
if (minusFen > 0) breakdown.push({ label: '固定下调', amountFen: -minusFen })
} else {
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
priceFen = Math.round((template * discount) / 10)
if (discount !== 10) {
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - template })
}
}
}
@@ -33,9 +33,10 @@ test('寄卖配置面板:必须覆盖保证金/服务费/数量限制/匹配/
'开启服务费',
'开启单人寄售上限',
'匹配模式',
'自动匹配开关',
'关闭匹配',
'内部匹配等待(分钟)',
'代练超时(分钟)',
'代练超时(小时)',
'重新匹配并退还押金',
'开启降价提示',
]) {
assert.ok(panel.includes(label), `寄卖配置面板缺少「${label}」`)
@@ -75,9 +76,11 @@ test('寄卖配置面板:订单服务风格分类树、独立分区与价格
'服务费',
'库存数量限制',
'匹配设置',
'超时与降价提示',
'超时设置',
'降价提示',
'联系方式与游戏凭据',
'接单模板价打折',
'固定价格',
'模板价固定下调',
'模板折扣',
'物品名模板',
]) {