feat(consignment): 寄卖配置价格/匹配/超时/收货字段全面改造
- 价格:固定价格改为模板价固定下调,移除绝对固定价;没有模板价时统一阻止发布 - 配置面板:按下拉选项动态显隐,超时与降价提示拆成两张卡片,超时单位改为小时(默认 0.5) - 匹配:新增关闭匹配并设为默认,移除自动匹配开关;关闭后新单直接进大厅 - 超时:新增重新匹配退/扣押金,回内部匹配池并排除原卖家与原库存(迁移 147) - 日志:新增 consignment_match_logs,自动/手动匹配成功、失败、跳过统一留痕,后台新增匹配日志抽屉 - 打手端:顶部分类下拉替换多 tab,默认苏尔南手枪;联系方式与游戏凭据改为动态字段(默认 7 项,手机号必填) - 降价提示默认开启、默认 24 小时
This commit is contained in:
@@ -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)] : [],
|
||||
|
||||
Reference in New Issue
Block a user