feat(consignment): 寄卖规格与价格动态配置重构

This commit is contained in:
2026-11-05 17:09:05 +08:00
parent 2ef16ddcc7
commit d41ec44679
22 changed files with 3761 additions and 621 deletions
@@ -28,6 +28,14 @@ import {
normalizeConsignmentProfileConfig,
resolvePriceDropSuggestion,
} from './consignment-profile-service.js'
import {
buildConsignmentSpecKey,
buildConsignmentSpecText,
normalizeConsignmentSpecValues,
parseConsignmentSpecValues,
resolveLegacyConsignmentColumns,
type ConsignmentSpecField,
} from './consignment-spec-service.js'
import {
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
getConsignmentDepositErrorCode,
@@ -36,6 +44,55 @@ import {
import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js'
import { extractConsignmentBuyerInfo } from './work-order-material-mappers.js'
function resolveSpecsFromSnapshot(value: unknown): ConsignmentSpecField[] {
const config = value ? normalizeConsignmentProfileConfig(value) : null
return config?.specs ?? []
}
function hasSpecValuesPayload(value: unknown): boolean {
return Boolean(
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value as Record<string, unknown>).length > 0,
)
}
function mergeLegacySpecValues(
specs: ConsignmentSpecField[],
baseValues: Record<string, string | number>,
payload: JsonObject,
): { values: Record<string, string | number>; hasLegacyEdit: boolean } {
const values = { ...baseValues }
let hasLegacyEdit = false
if (payload.system !== undefined) {
values.system = String(payload.system).trim()
hasLegacyEdit = true
}
if (payload.serverZone !== undefined) {
values.serverZone = String(payload.serverZone).trim()
hasLegacyEdit = true
}
if (payload.series !== undefined) {
values.series = String(payload.series).trim()
hasLegacyEdit = true
}
if (payload.diamondLevel !== undefined) {
values.diamondLevel = Math.max(1, Math.min(7, Number(payload.diamondLevel)))
hasLegacyEdit = true
}
if (payload.brillianceCount !== undefined) {
values.brillianceCount = Math.max(0, Math.min(7, Number(payload.brillianceCount)))
hasLegacyEdit = true
}
if (!hasLegacyEdit) return { values, hasLegacyEdit }
// 只保留配置里存在的内置字段,避免把已移除的字段重新写回 specKey。
for (const key of ['system', 'serverZone', 'series', 'diamondLevel', 'brillianceCount']) {
if (!specs.some((spec) => spec.key === key)) delete values[key]
}
return { values, hasLegacyEdit }
}
function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never {
const code = getConsignmentDepositErrorCode(error)
if (code === 'consignment_deposit_insufficient') {
@@ -191,12 +248,16 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
listedAt: row.listed_at || row.inbound_at,
priceUpdatedAt: row.price_updated_at || null,
priceDropSuggestion,
specs: profileConfig?.specs || [],
itemName: row.item_name,
system: row.system,
serverZone: row.server_zone,
series: row.series,
diamondLevel: Number(row.diamond_level || 1),
brillianceCount: Number(row.brilliance_count || 0),
specValues: parseConsignmentSpecValues(row.spec_values_json),
specText: row.spec_text || '',
priceCapFen: Number(row.price_cap_fen || 0),
costPriceFen: Number(row.cost_price_fen || 0),
buyerName: row.buyer_name || '',
shopName: showingMatchedBuyer ? row.shop_name || '' : '',
@@ -261,12 +322,23 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
}
export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
const profileConfig = row.profile_snapshot_json
? normalizeConsignmentProfileConfig(row.profile_snapshot_json)
: null
return {
id: Number(row.id),
submissionNo: row.submission_no,
gameName: row.game_name,
categoryId: row.category_id ? Number(row.category_id) : null,
categoryName: row.category_name,
profileId: row.profile_id ? Number(row.profile_id) : null,
profileVersionId: row.profile_version_id ? Number(row.profile_version_id) : null,
specs: profileConfig?.specs || [],
specValues: parseConsignmentSpecValues(row.spec_values_json),
specText: row.spec_text || '',
priceCapFen: Number(row.price_cap_fen || 0),
quantity: Math.max(1, Number(row.quantity || 1)),
requestedSalePriceFen: Number(row.requested_sale_price_fen || 0),
itemName: row.item_name,
system: row.system,
serverZone: row.server_zone,
@@ -393,6 +465,69 @@ export async function updateAdminConsignmentInventory(
errorCode: 'consignment_allocation_status_update_forbidden',
})
}
let specPatch: {
specValuesJson?: Record<string, unknown>
specKey?: string
specText?: string
system?: string
serverZone?: string
series?: string
diamondLevel?: number
brillianceCount?: number
} = {}
if (hasSpecValuesPayload(payload.specValues)) {
const inventory = await getConsignmentInventoryById(id)
if (!inventory) {
throw createHttpError('寄卖库存不存在', {
statusCode: 404,
errorCode: 'consignment_inventory_not_found',
})
}
const config = inventory.profile_snapshot_json
? normalizeConsignmentProfileConfig(inventory.profile_snapshot_json)
: null
const specs = config?.specs ?? []
const validation = normalizeConsignmentSpecValues(specs, { specValues: payload.specValues })
if (validation.errors.length > 0) {
throw createHttpError(validation.errors[0]!, {
statusCode: 400,
errorCode: 'consignment_specs_invalid',
})
}
const legacy = resolveLegacyConsignmentColumns(validation.values)
specPatch = {
specValuesJson: validation.values,
specKey: buildConsignmentSpecKey(specs, validation.values),
specText: buildConsignmentSpecText(specs, validation.values),
...(specs.some((spec) => spec.key === 'system') ? { system: legacy.system } : {}),
...(specs.some((spec) => spec.key === 'serverZone') ? { serverZone: legacy.serverZone } : {}),
...(specs.some((spec) => spec.key === 'series') ? { series: legacy.series } : {}),
...(specs.some((spec) => spec.key === 'diamondLevel')
? { diamondLevel: legacy.diamondLevel }
: {}),
...(specs.some((spec) => spec.key === 'brillianceCount')
? { brillianceCount: legacy.brillianceCount }
: {}),
}
} else {
// 客服只改旧版内置字段时,也要同步 spec_values/spec_key,避免 JSON 与旧列不一致。
const inventory = await getConsignmentInventoryById(id)
if (inventory) {
const specs = resolveSpecsFromSnapshot(inventory.profile_snapshot_json)
const merged = mergeLegacySpecValues(
specs,
parseConsignmentSpecValues(inventory.spec_values_json),
payload,
)
if (merged.hasLegacyEdit) {
specPatch = {
specValuesJson: merged.values,
specKey: buildConsignmentSpecKey(specs, merged.values),
specText: buildConsignmentSpecText(specs, merged.values),
}
}
}
}
const updated = await updateConsignmentInventory(id, {
itemName: payload.itemName !== undefined ? String(payload.itemName).trim() : undefined,
system: payload.system !== undefined ? String(payload.system).trim() : undefined,
@@ -446,6 +581,7 @@ export async function updateAdminConsignmentInventory(
? Number(payload.contactSupporterId)
: null
: undefined,
...specPatch,
now,
})
@@ -665,23 +801,90 @@ export async function approveAdminConsignmentSubmission(
? Number(payload.depositAmountFen)
: Number(submission.deposit_amount_fen || 0)
let approvalSpec: {
specValuesJson?: Record<string, unknown>
specKey?: string
specText?: string
system?: string
serverZone?: string
series?: string
diamondLevel?: number
brillianceCount?: number
} = {}
if (hasSpecValuesPayload(payload.specValues)) {
const config = submission.profile_snapshot_json
? normalizeConsignmentProfileConfig(submission.profile_snapshot_json)
: null
const specs = config?.specs ?? []
const validation = normalizeConsignmentSpecValues(specs, { specValues: payload.specValues })
if (validation.errors.length > 0) {
throw createHttpError(validation.errors[0]!, {
statusCode: 400,
errorCode: 'consignment_specs_invalid',
})
}
const legacy = resolveLegacyConsignmentColumns(validation.values)
approvalSpec = {
specValuesJson: validation.values,
specKey: buildConsignmentSpecKey(specs, validation.values),
specText: buildConsignmentSpecText(specs, validation.values),
...(specs.some((spec) => spec.key === 'system') ? { system: legacy.system } : {}),
...(specs.some((spec) => spec.key === 'serverZone') ? { serverZone: legacy.serverZone } : {}),
...(specs.some((spec) => spec.key === 'series') ? { series: legacy.series } : {}),
...(specs.some((spec) => spec.key === 'diamondLevel')
? { diamondLevel: legacy.diamondLevel }
: {}),
...(specs.some((spec) => spec.key === 'brillianceCount')
? { brillianceCount: legacy.brillianceCount }
: {}),
}
} else {
const specs = resolveSpecsFromSnapshot(submission.profile_snapshot_json)
const merged = mergeLegacySpecValues(
specs,
parseConsignmentSpecValues(submission.spec_values_json),
payload,
)
if (merged.hasLegacyEdit) {
const legacy = resolveLegacyConsignmentColumns(merged.values)
approvalSpec = {
specValuesJson: merged.values,
specKey: buildConsignmentSpecKey(specs, merged.values),
specText: buildConsignmentSpecText(specs, merged.values),
...(specs.some((spec) => spec.key === 'system') ? { system: legacy.system } : {}),
...(specs.some((spec) => spec.key === 'serverZone')
? { serverZone: legacy.serverZone }
: {}),
...(specs.some((spec) => spec.key === 'series') ? { series: legacy.series } : {}),
...(specs.some((spec) => spec.key === 'diamondLevel')
? { diamondLevel: legacy.diamondLevel }
: {}),
...(specs.some((spec) => spec.key === 'brillianceCount')
? { brillianceCount: legacy.brillianceCount }
: {}),
}
}
}
const res = await approveConsignmentSubmissionWithInbound({
submissionId: id,
inventoryNo,
buyerName: String(payload.buyerName || actorName || '').trim(),
inboundBatch: String(payload.inboundBatch || '').trim(),
itemName: String(payload.itemName || submission.item_name).trim(),
system: String(payload.system || submission.system).trim(),
serverZone: String(payload.serverZone || submission.server_zone).trim(),
series: String(payload.series || submission.series).trim(),
diamondLevel: Math.max(
1,
Math.min(7, Number(payload.diamondLevel ?? submission.diamond_level ?? 1)),
),
brillianceCount: Math.max(
0,
Math.min(7, Number(payload.brillianceCount ?? submission.brilliance_count ?? 0)),
),
system: approvalSpec.system ?? String(payload.system || submission.system).trim(),
serverZone:
approvalSpec.serverZone ?? String(payload.serverZone || submission.server_zone).trim(),
series: approvalSpec.series ?? String(payload.series || submission.series).trim(),
diamondLevel:
approvalSpec.diamondLevel ??
Math.max(1, Math.min(7, Number(payload.diamondLevel ?? submission.diamond_level ?? 1))),
brillianceCount:
approvalSpec.brillianceCount ??
Math.max(0, Math.min(7, Number(payload.brillianceCount ?? submission.brilliance_count ?? 0))),
specValuesJson: approvalSpec.specValuesJson,
specKey: approvalSpec.specKey,
specText: approvalSpec.specText,
costPriceFen,
sellerContact: String(payload.sellerContact || submission.contact_info).trim(),
sellerGameId: String(payload.sellerGameId || submission.seller_game_id).trim(),
@@ -20,9 +20,17 @@ import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import {
buildServiceFeeFen,
normalizeConsignmentProfileConfig,
resolveConsignmentProfileForCategory,
type ConsignmentProfileSnapshot,
} from './consignment-profile-service.js'
import {
evaluateConsignmentSpecMatch,
isBuiltInConsignmentSpecField,
parseConsignmentSpecValues,
type ConsignmentSpecField,
type ConsignmentSpecValues,
} from './consignment-spec-service.js'
import {
classifyConsignmentMatch,
extractPeacekeeperSpecs,
@@ -62,20 +70,114 @@ function inventoryMatchesProfile(
return Number(inventory.category_id || 0) === profile.categoryId
}
function inventoryMatchesWorkOrder(
workOrder: WorkOrderRow,
type InventoryMatchEvaluation = {
specs: ConsignmentSpecField[]
values: ConsignmentSpecValues
orderValues: ConsignmentSpecValues
exact: boolean
partial: boolean
unknown: boolean
conflicts: string[]
unknowns: string[]
details: ReturnType<typeof evaluateConsignmentSpecMatch>['details']
}
function resolveInventorySpecs(
inventory: ConsignmentInventoryRow,
): boolean {
if (Number(inventory.remaining_quantity ?? 1) <= 0) return false
if (!inventory.allow_matching) return false
if (inventory.status !== 'in_stock') return false
profile: ConsignmentProfileSnapshot | null,
): ConsignmentSpecField[] {
const config = inventory.profile_snapshot_json
? normalizeConsignmentProfileConfig(inventory.profile_snapshot_json)
: profile?.config
return config?.specs ?? []
}
function resolveInventorySpecValues(inventory: ConsignmentInventoryRow): ConsignmentSpecValues {
const parsed = parseConsignmentSpecValues(inventory.spec_values_json)
if (Object.keys(parsed).length > 0) return parsed
return {
system: inventory.system,
serverZone: inventory.server_zone,
series: inventory.series,
diamondLevel: Number(inventory.diamond_level || 1),
brillianceCount: Number(inventory.brilliance_count || 0),
}
}
function resolveOrderSpecValues(workOrder: WorkOrderRow): ConsignmentSpecValues {
const fields = resolveCollectSubmittedFieldValues(workOrder)
const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, {
const specs = extractPeacekeeperSpecs(workOrder.product_name, {
system: fields.system,
serverZone: fields.serverZone,
series: fields.series,
})
return classifyConsignmentMatch(orderSpecs, inventory).matchType === 'exact'
const values: ConsignmentSpecValues = {}
for (const [key, value] of Object.entries(fields)) {
if (typeof value === 'string' || typeof value === 'number') values[key] = value
}
if (specs.system) values.system = specs.system
if (specs.serverZone) values.serverZone = specs.serverZone
if (specs.series) values.series = specs.series
if (Number(specs.diamondLevel || 0) > 0) values.diamondLevel = Number(specs.diamondLevel)
values.brillianceCount = Number(specs.brillianceCount || 0)
return values
}
function evaluateInventoryMatch(
workOrder: WorkOrderRow,
inventory: ConsignmentInventoryRow,
profile: ConsignmentProfileSnapshot | null,
): InventoryMatchEvaluation {
const specs = resolveInventorySpecs(inventory, profile)
const orderValues = resolveOrderSpecValues(workOrder)
const values = resolveInventorySpecValues(inventory)
const hasCustomSpecs = specs.some((spec) => !isBuiltInConsignmentSpecField(spec))
if (!hasCustomSpecs) {
// 内置 5 规格沿用原有苏尔南匹配器(通配、低配不能充高配、品类门禁);
// 只有出现自定义规格时才走通用匹配,避免存量行为回归。
const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, {
system: String(orderValues.system || ''),
serverZone: String(orderValues.serverZone || ''),
series: String(orderValues.series || ''),
})
const strict = classifyConsignmentMatch(orderSpecs, inventory)
return {
specs,
values,
orderValues,
exact: strict.matchType === 'exact',
partial: strict.matchType === 'partial',
unknown: false,
conflicts: strict.matchType === 'conflict' ? strict.conflicts : [],
unknowns: [],
details: [],
}
}
const evaluation = evaluateConsignmentSpecMatch({ specs, orderValues, inventoryValues: values })
return {
specs,
values,
orderValues,
exact: evaluation.exact,
partial: evaluation.partial,
unknown: evaluation.unknown,
conflicts: evaluation.conflicts,
unknowns: evaluation.unknowns,
details: evaluation.details,
}
}
function inventoryMatchesWorkOrder(
workOrder: WorkOrderRow,
inventory: ConsignmentInventoryRow,
profile: ConsignmentProfileSnapshot | null = null,
): boolean {
if (Number(inventory.remaining_quantity ?? 1) <= 0) return false
if (!inventory.allow_matching) return false
if (inventory.status !== 'in_stock') return false
return evaluateInventoryMatch(workOrder, inventory, profile).exact
}
async function resolveProfileForWorkOrder(
@@ -196,7 +298,7 @@ function pickCandidateInventories(
.filter(
(inventory) =>
inventoryMatchesProfile(inventory, profile) &&
inventoryMatchesWorkOrder(workOrder, inventory),
inventoryMatchesWorkOrder(workOrder, inventory, profile),
)
.sort((a, b) => {
const priceDiff = inventorySalePriceFen(a) - inventorySalePriceFen(b)
@@ -364,7 +466,7 @@ async function applySharingMatch(
if (remaining <= 0) break
if (!inventory.worker_id) continue
if (!inventoryMatchesProfile(inventory, profile)) continue
if (!inventoryMatchesWorkOrder(current, inventory)) continue
if (!inventoryMatchesWorkOrder(current, inventory, profile)) continue
const allocation = await reserveConsignmentInventoryWithClient(client, {
inventoryId: Number(inventory.id),
@@ -548,17 +650,36 @@ export async function expireConsignmentInternalMatching(options: { now?: string
return { expired: expired.rows.length }
}
/** 后台手动匹配弹窗:列出该工单可精确匹配的库存。 */
/** 后台手动匹配弹窗:列出同配置在库库存,并标注精确/差异/冲突。 */
export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: number) {
const workOrder = await getWorkOrderById(workOrderId)
if (!workOrder) {
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
}
const profile = await resolveProfileForWorkOrder(workOrder)
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter((inventory) =>
inventoryMatchesProfile(inventory, profile),
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
(inventory) =>
inventoryMatchesProfile(inventory, profile) &&
Number(inventory.remaining_quantity ?? 1) > 0 &&
inventory.status === 'in_stock' &&
inventory.allow_matching,
)
const options = pickCandidateInventories(workOrder, profile, inventories).map((inventory) => ({
const evaluated = inventories
.map((inventory) => ({
inventory,
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
}))
.sort((a, b) => {
if (a.evaluation.exact !== b.evaluation.exact) return a.evaluation.exact ? -1 : 1
const priceDiff = inventorySalePriceFen(a.inventory) - inventorySalePriceFen(b.inventory)
if (priceDiff !== 0) return priceDiff
const listedA = Date.parse(String(a.inventory.listed_at || a.inventory.inbound_at || '')) || 0
const listedB = Date.parse(String(b.inventory.listed_at || b.inventory.inbound_at || '')) || 0
if (listedA !== listedB) return listedA - listedB
return Number(a.inventory.id) - Number(b.inventory.id)
})
const options = evaluated.map(({ inventory, evaluation }) => ({
inventoryId: Number(inventory.id),
inventoryNo: inventory.inventory_no,
itemName: inventory.item_name,
@@ -567,6 +688,19 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
series: inventory.series,
diamondLevel: Number(inventory.diamond_level || 1),
brillianceCount: Number(inventory.brilliance_count || 0),
specValues: evaluation.values,
specText: inventory.spec_text || '',
matchType: evaluation.exact
? 'exact'
: evaluation.conflicts.length > 0
? 'conflict'
: evaluation.unknown
? 'unknown'
: evaluation.partial
? 'partial'
: 'unknown',
matchReasons: [...evaluation.conflicts, ...evaluation.unknowns],
matchDetails: evaluation.details,
quantity: Number(inventory.quantity || 1),
remainingQuantity: Number(inventory.remaining_quantity ?? 1),
salePriceFen: inventorySalePriceFen(inventory),
@@ -593,6 +727,9 @@ export async function matchConsignmentWorkOrderManually(input: {
workOrderId: number
inventoryIds: number[]
actorName?: string
/** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */
allowNonExact?: boolean
reason?: string
}): Promise<{ matched: boolean; matchedCount: number }> {
const workOrder = await getWorkOrderById(input.workOrderId)
if (!workOrder) {
@@ -611,8 +748,9 @@ export async function matchConsignmentWorkOrderManually(input: {
})
}
const profile = await resolveProfileForWorkOrder(workOrder)
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter((inventory) =>
inventoryMatchesProfile(inventory, profile),
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
(inventory) =>
inventoryMatchesProfile(inventory, profile) && Number(inventory.remaining_quantity ?? 1) > 0,
)
const selected = input.inventoryIds
.map((id) => inventories.find((inventory) => Number(inventory.id) === Number(id)))
@@ -625,6 +763,33 @@ export async function matchConsignmentWorkOrderManually(input: {
})
}
const nonExact = selected
.map((inventory) => ({
inventory,
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
}))
.filter((item) => !item.evaluation.exact)
if (nonExact.length > 0) {
if (!input.allowNonExact) {
const details = nonExact
.map((item) => {
const reasons = [...item.evaluation.conflicts, ...item.evaluation.unknowns]
return `${item.inventory.inventory_no}(${reasons.join('、') || '规格不一致'})`
})
.join(';')
throw createHttpError(`所选库存与订单规格不一致:${details}。请确认后再匹配`, {
statusCode: 409,
errorCode: 'consignment_spec_mismatch',
})
}
if (!String(input.reason || '').trim()) {
throw createHttpError('非精确匹配必须填写原因', {
statusCode: 400,
errorCode: 'consignment_match_reason_required',
})
}
}
const appliedWorkOrder = { ...workOrder, status: WORK_ORDER_STATUS.INTERNAL_MATCHING }
if (!workOrder.sharing_enabled) {
await query(
@@ -3,12 +3,16 @@ import type { JsonObject } from '../../types/json.js'
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_PRICING,
DEFAULT_CONSIGNMENT_SPECS,
normalizeConsignmentPricing,
normalizeConsignmentSpecs,
type ConsignmentPricingConfig,
type ConsignmentSpecField,
} from './consignment-spec-service.js'
export type ConsignmentPricingConfig = {
useTemplatePrice: boolean
allowWorkerPrice: boolean
minPriceFen: number
}
export type { ConsignmentPricingConfig, ConsignmentSpecField } from './consignment-spec-service.js'
export type ConsignmentDepositConfig = {
enabled: boolean
@@ -69,7 +73,12 @@ export type ConsignmentProfileConfig = {
version: 1
tabTitle: string
tabDescription: string
/** 旧版发布开关:由 specs 派生,quantity 仍独立控制 */
fields: ConsignmentPublishFields
/** 动态规格字段(完全由运营配置;默认空) */
specs: ConsignmentSpecField[]
/** 物品名模板,支持 {system}/{series}/{category} 等占位符 */
nameTemplate: string
pricing: ConsignmentPricingConfig
deposit: ConsignmentDepositConfig
serviceFee: ConsignmentServiceFeeConfig
@@ -101,6 +110,7 @@ export type ConsignmentProfileRow = {
export type ConsignmentProfileSnapshot = {
profileId: number
categoryId: number
categoryName: string
versionId: number
versionNo: number
config: ConsignmentProfileConfig
@@ -111,18 +121,16 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
tabTitle: '',
tabDescription: '',
fields: {
system: true,
serverZone: true,
series: true,
diamondLevel: true,
brillianceCount: true,
system: false,
serverZone: false,
series: false,
diamondLevel: false,
brillianceCount: false,
quantity: true,
},
pricing: {
useTemplatePrice: true,
allowWorkerPrice: false,
minPriceFen: 0,
},
specs: DEFAULT_CONSIGNMENT_SPECS,
nameTemplate: '',
pricing: DEFAULT_CONSIGNMENT_PRICING,
deposit: {
enabled: false,
mode: 'fixed',
@@ -197,9 +205,14 @@ function integer(value: unknown, fallback: number, min: number, max: number): nu
}
export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentProfileConfig {
const source = record(value)
const outer = record(value)
// 兼容直接存 config 的场景,以及库存/提报里 { config: {...}, profileId... } 的快照包装。
const source =
outer.config && typeof outer.config === 'object' && !Array.isArray(outer.config)
? record(outer.config)
: outer
const fields = record(source.fields)
const pricing = record(source.pricing)
const specs = normalizeConsignmentSpecs(source.specs ?? source.specs_json, fields)
const deposit = record(source.deposit)
const serviceFee = record(source.serviceFee)
const inventoryLimit = record(source.inventoryLimit)
@@ -216,36 +229,26 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
timeout.policy === 'cancel_release' || timeout.policy === 'cancel_deduct'
? timeout.policy
: 'reopen'
const specKeys = new Set(specs.map((spec) => spec.key))
return {
version: 1,
tabTitle: text(source.tabTitle, 30),
tabDescription: text(source.tabDescription, 200),
// 旧字段保留给旧代码/旧页面;有 specs 时以内置规格是否存在为准。
fields: {
system: bool(fields.system, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.system),
serverZone: bool(fields.serverZone, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.serverZone),
series: bool(fields.series, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.series),
diamondLevel: bool(
fields.diamondLevel,
DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.diamondLevel,
),
brillianceCount: bool(
fields.brillianceCount,
DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.brillianceCount,
),
system: specKeys.has('system'),
serverZone: specKeys.has('serverZone'),
series: specKeys.has('series'),
diamondLevel: specKeys.has('diamondLevel'),
brillianceCount: specKeys.has('brillianceCount'),
quantity: bool(fields.quantity, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.quantity),
},
pricing: {
useTemplatePrice: bool(
pricing.useTemplatePrice,
DEFAULT_CONSIGNMENT_PROFILE_CONFIG.pricing.useTemplatePrice,
),
allowWorkerPrice: bool(
pricing.allowWorkerPrice,
DEFAULT_CONSIGNMENT_PROFILE_CONFIG.pricing.allowWorkerPrice,
),
minPriceFen: integer(pricing.minPriceFen, 0, 0, 100_000_000),
},
specs,
nameTemplate:
text(source.nameTemplate ?? source.name_template, 300) ||
DEFAULT_CONSIGNMENT_PROFILE_CONFIG.nameTemplate,
pricing: normalizeConsignmentPricing(source.pricing),
deposit: {
enabled: bool(deposit.enabled, false),
mode: depositMode,
@@ -525,6 +528,7 @@ export async function resolveConsignmentProfileForCategory(
return {
profileId: Number(row.id),
categoryId: Number(row.category_id),
categoryName: row.category_name || '',
versionId: Number(row.current_version_id),
versionNo: Number(row.current_version_no || 1),
config: normalizeConsignmentProfileConfig(row.config_json),
@@ -547,6 +551,7 @@ export async function getPublishedConsignmentProfileById(
return {
profileId: Number(row.id),
categoryId: Number(row.category_id),
categoryName: row.category_name || '',
versionId: Number(row.current_version_id),
versionNo: Number(row.current_version_no || 1),
config: normalizeConsignmentProfileConfig(row.config_json),
@@ -0,0 +1,457 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { normalizeConsignmentProfileConfig } from './consignment-profile-service.js'
import {
buildConsignmentItemName,
buildConsignmentSpecKey,
buildConsignmentSpecText,
computeConsignmentPrice,
evaluateConsignmentSpecMatch,
normalizeConsignmentSpecValues,
normalizeConsignmentSpecs,
parseConsignmentSpecValues,
resolveLegacyConsignmentColumns,
resolveWorkerConsignmentPrice,
type ConsignmentPricingConfig,
type ConsignmentSpecField,
} from './consignment-spec-service.js'
function buildPricing(overrides: Partial<ConsignmentPricingConfig> = {}): ConsignmentPricingConfig {
return {
priceType: 'template_discount',
discount: 10,
fixedPriceFen: 0,
allowWorkerPrice: true,
minPriceFen: 0,
missingPricePolicy: 'manual',
...overrides,
}
}
function option(label: string, value: string) {
return { label, value, enabled: true }
}
/** 苏尔南手枪种子配置的五个字段(与迁移 131 一致)。 */
function surnanSpecs(): ConsignmentSpecField[] {
return [
{
key: 'system',
label: '操作系统',
type: 'radio',
required: true,
options: [option('苹果', '苹果'), option('安卓', '安卓')],
placeholder: '',
helpText: '',
matchMode: 'exact',
wildcardValues: [],
showInItemName: true,
sortOrder: 10,
},
{
key: 'serverZone',
label: '游戏区服',
type: 'radio',
required: true,
options: [option('互通(QQ/微信)', '互通')],
placeholder: '',
helpText: '',
matchMode: 'wildcard',
wildcardValues: ['互通', '通用', '全区', '不限'],
showInItemName: false,
sortOrder: 20,
},
{
key: 'series',
label: '系列 / 枪系',
type: 'radio',
required: true,
options: [option('暗潮', '暗潮'), option('怒嚎', '怒嚎'), option('血宴', '血宴')],
placeholder: '',
helpText: '',
matchMode: 'exact',
wildcardValues: [],
showInItemName: true,
sortOrder: 30,
},
{
key: 'diamondLevel',
label: '钻石等级',
type: 'radio',
required: true,
options: Array.from({ length: 7 }, (_, index) => option(`${index + 1}钻`, String(index + 1))),
placeholder: '',
helpText: '',
matchMode: 'minimum',
wildcardValues: [],
showInItemName: true,
sortOrder: 40,
},
{
key: 'brillianceCount',
label: '璀璨数',
type: 'radio',
required: true,
options: Array.from({ length: 8 }, (_, index) =>
index === 0 ? option('无', '0') : option(`${index}璀`, String(index)),
),
placeholder: '',
helpText: '',
matchMode: 'minimum',
wildcardValues: [],
showInItemName: true,
sortOrder: 50,
},
]
}
const surnanValues = {
system: '苹果',
serverZone: '互通',
series: '暗潮',
diamondLevel: '3',
brillianceCount: '1',
}
test('动态规格:默认配置没有任何字段,价格默认十折', () => {
const config = normalizeConsignmentProfileConfig({})
assert.equal(config.specs.length, 0)
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.fields.quantity, true)
assert.equal(config.fields.system, false)
})
test('动态规格:支持 6 种字段类型,并保留选项字段语义', () => {
const config = normalizeConsignmentProfileConfig({
specs: [
...surnanSpecs(),
{
key: 'note',
label: '备注',
type: 'textarea',
required: false,
options: [],
},
{
key: 'joinedAt',
label: '入坑日期',
type: 'date',
required: false,
options: [],
},
{ key: 'bad key', label: '非法', type: 'text' },
],
})
assert.equal(config.specs.length, 7)
assert.equal(config.specs.find((spec) => spec.key === 'note')?.type, 'textarea')
assert.equal(config.specs.find((spec) => spec.key === 'joinedAt')?.type, 'date')
assert.equal(config.specs.find((spec) => spec.key === 'system')?.type, 'radio')
assert.equal(config.fields.system, true)
assert.equal(config.fields.series, true)
})
test('动态规格:旧版 fields 开关不再生成任何默认字段', () => {
const specs = normalizeConsignmentSpecs(undefined, {
system: true,
serverZone: false,
series: true,
diamondLevel: false,
brillianceCount: true,
})
assert.deepEqual(specs, [])
})
test('动态规格:必填、选项枚举、璀璨上限与日期格式校验', () => {
const specs = [
...surnanSpecs(),
{
key: 'note',
label: '备注',
type: 'textarea',
required: false,
options: [],
placeholder: '',
helpText: '',
matchMode: 'exact' as const,
wildcardValues: [],
showInItemName: false,
sortOrder: 60,
},
{
key: 'joinedAt',
label: '入坑日期',
type: 'date',
required: false,
options: [],
placeholder: '',
helpText: '',
matchMode: 'exact' as const,
wildcardValues: [],
showInItemName: false,
sortOrder: 70,
},
]
const missing = normalizeConsignmentSpecValues(specs, { system: '苹果' })
assert.ok(missing.errors.some((error) => error.includes('系列')))
const invalid = normalizeConsignmentSpecValues(specs, {
...surnanValues,
system: '鸿蒙',
})
assert.ok(invalid.errors.some((error) => error.includes('选项无效')))
const tooMany = normalizeConsignmentSpecValues(specs, {
...surnanValues,
diamondLevel: 2,
brillianceCount: 3,
})
assert.ok(tooMany.errors.some((error) => error.includes('璀璨数不能超过钻石等级')))
const badDate = normalizeConsignmentSpecValues(specs, {
...surnanValues,
joinedAt: '2025-02-30',
})
assert.ok(badDate.errors.some((error) => error.includes('有效日期')))
const longNote = 'x'.repeat(2200)
const normalized = normalizeConsignmentSpecValues(specs, {
...surnanValues,
joinedAt: '2025-02-28',
note: longNote,
})
assert.deepEqual(normalized.errors, [])
assert.equal(String(normalized.values.note).length, 2000)
assert.equal(normalized.values.joinedAt, '2025-02-28')
})
test('动态规格:spec_key 按字段排序,只包含参与匹配的字段', () => {
const specs: ConsignmentSpecField[] = [
{ ...surnanSpecs()[0]!, key: 'zField', label: 'Z', sortOrder: 1 },
{ ...surnanSpecs()[0]!, key: 'aField', label: 'A', sortOrder: 2 },
{ ...surnanSpecs()[0]!, key: 'ignored', label: '忽略', matchMode: 'ignore' },
]
const key = buildConsignmentSpecKey(specs, { zField: '2', aField: '1', ignored: 'x' })
assert.equal(key, 'aField=1|zField=2')
})
test('动态规格:物品名模板与规格展示文本', () => {
const specs = surnanSpecs()
const name = buildConsignmentItemName({
template: '{system}{serverZone}{series}{diamondLevel}钻{brillianceCount}璀璨 {category}',
specs,
values: surnanValues,
categoryName: '苏尔南手枪',
})
assert.equal(name, '苹果暗潮3钻1璀璨 苏尔南手枪')
const text = buildConsignmentSpecText(specs, surnanValues)
assert.ok(text.includes('操作系统 苹果'))
assert.ok(text.includes('游戏区服 互通'))
const fallback = buildConsignmentItemName({
template: '',
specs: [],
values: {},
categoryName: '苏尔南手枪',
})
assert.equal(fallback, '苏尔南手枪')
})
test('动态价格:模板折扣价与改价上限等于建议价', () => {
const result = computeConsignmentPrice({
pricing: buildPricing({ discount: 9.5 }),
templatePriceFen: 13000,
})
assert.equal(result.priceFen, 12350)
assert.equal(result.capFen, 12350)
assert.equal(result.templatePriceFen, 13000)
assert.equal(result.source, 'template')
})
test('动态价格:固定价格可用,且不能高于接单模板价', () => {
const normal = computeConsignmentPrice({
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 10000 }),
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('固定价格不能高于接单模板价')))
const noTemplate = resolveWorkerConsignmentPrice({
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 8000 }),
templatePriceFen: 0,
})
assert.deepEqual(noTemplate.errors, [])
assert.equal(noTemplate.priceFen, 8000)
})
test('动态价格:打手改价只能低于系统建议价,且受最低价约束', () => {
const pricing = buildPricing({ discount: 9.5 })
const tooHigh = resolveWorkerConsignmentPrice({
pricing,
templatePriceFen: 13000,
submittedPriceFen: 13000,
})
assert.ok(tooHigh.errors.some((error) => error.includes('不能高于系统建议价')))
const lower = resolveWorkerConsignmentPrice({
pricing,
templatePriceFen: 13000,
submittedPriceFen: 12000,
})
assert.deepEqual(lower.errors, [])
assert.equal(lower.priceFen, 12000)
assert.equal(lower.capFen, 12350)
const belowMin = resolveWorkerConsignmentPrice({
pricing: buildPricing({ discount: 9.5, minPriceFen: 10000 }),
templatePriceFen: 13000,
submittedPriceFen: 9000,
})
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)
const blocked = resolveWorkerConsignmentPrice({
pricing: buildPricing({ missingPricePolicy: 'block' }),
templatePriceFen: 0,
submittedPriceFen: 8000,
})
assert.ok(blocked.errors.length > 0)
})
test('通用匹配:精确、部分、冲突、未知与通配', () => {
const specs = surnanSpecs()
const inventory = { ...surnanValues }
const exact = evaluateConsignmentSpecMatch({
specs,
orderValues: { ...inventory },
inventoryValues: inventory,
})
assert.equal(exact.exact, true)
const partial = evaluateConsignmentSpecMatch({
specs,
orderValues: { ...inventory, diamondLevel: '2' },
inventoryValues: inventory,
})
assert.equal(partial.exact, false)
assert.equal(partial.partial, true)
const conflict = evaluateConsignmentSpecMatch({
specs,
orderValues: { ...inventory, series: '血宴' },
inventoryValues: inventory,
})
assert.equal(conflict.exact, false)
assert.ok(conflict.conflicts.includes('系列 / 枪系'))
const unknown = evaluateConsignmentSpecMatch({
specs,
orderValues: { system: '苹果' },
inventoryValues: inventory,
})
assert.equal(unknown.exact, false)
assert.ok(unknown.unknowns.length > 0)
})
test('通用匹配:自定义字段必须双方同 key 且值一致才算精确', () => {
const config = normalizeConsignmentProfileConfig({
specs: [
...surnanSpecs(),
{
key: 'map',
label: '地图',
type: 'select',
required: true,
options: [
{ label: '海岛', value: '海岛' },
{ label: '沙漠', value: '沙漠' },
],
},
],
})
const inventory = { ...surnanValues, map: '海岛' }
const exact = evaluateConsignmentSpecMatch({
specs: config.specs,
orderValues: { ...inventory },
inventoryValues: inventory,
})
assert.equal(exact.exact, true)
const missingOrderField = evaluateConsignmentSpecMatch({
specs: config.specs,
orderValues: { ...inventory, map: '' },
inventoryValues: inventory,
})
assert.equal(missingOrderField.exact, false)
assert.ok(missingOrderField.unknowns.includes('地图'))
})
test('动态规格:旧列仍由兼容 key 同步', () => {
const legacy = resolveLegacyConsignmentColumns({
...surnanValues,
diamondLevel: '5',
brillianceCount: '2',
map: '海岛',
})
assert.deepEqual(legacy, {
system: '苹果',
serverZone: '互通',
series: '暗潮',
diamondLevel: 5,
brillianceCount: 2,
})
})
test('迁移 130 必须包含 JSON、匹配 key、文本与改价上限', async () => {
const sql = await readFile(
new URL('../../db/migrations/130_consignment_dynamic_specs.sql', import.meta.url),
'utf8',
)
for (const fragment of [
'spec_values_json',
'spec_key',
'spec_text',
'price_cap_fen',
'idx_consignment_inventories_spec_key',
]) {
assert.ok(sql.includes(fragment), `迁移 130 缺少 ${fragment}`)
}
assert.ok(parseConsignmentSpecValues({ system: '苹果', diamondLevel: 3 }).system === '苹果')
})
test('迁移 131 必须为苏尔南手枪写入已发布的五个单选字段配置', async () => {
const sql = await readFile(
new URL('../../db/migrations/131_consignment_surnan_default_profile.sql', import.meta.url),
'utf8',
)
for (const fragment of [
"category_key = 'shouqiang'",
'"type": "radio"',
'"priceType": "template_discount"',
"'published'",
'"key": "brillianceCount"',
]) {
assert.ok(sql.includes(fragment), `迁移 131 缺少 ${fragment}`)
}
})
@@ -0,0 +1,789 @@
/**
* 寄卖动态规格与价格(单一事实来源)。
*
* 设计约定:
* - 没有任何「内置字段」:配置初始 specs 为空,字段全部由运营在寄卖配置里维护;
* 苏尔南手枪的五个字段由迁移 131 作为种子配置写入;
* - 字段类型支持:文本 / 长文本 / 下拉 / 单选 / 数字 / 日期;
* - 价格只有两种:接单模板价打折、固定价格;打手只能按系统建议价往下调;
* - 旧列兼容:字段 key 仍为 system/serverZone/series/diamondLevel/brillianceCount 时,
* 继续同步旧列并沿用旧匹配器,保证存量库存与工单不回归。
*/
export const BUILT_IN_CONSIGNMENT_SPEC_KEYS = [
'system',
'serverZone',
'series',
'diamondLevel',
'brillianceCount',
] as const
export type BuiltInConsignmentSpecKey = (typeof BUILT_IN_CONSIGNMENT_SPEC_KEYS)[number]
export type ConsignmentSpecFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
export type ConsignmentSpecMatchMode = 'exact' | 'minimum' | 'wildcard' | 'ignore'
export type ConsignmentSpecOption = {
label: string
value: string
enabled: boolean
}
export type ConsignmentSpecField = {
key: string
label: string
type: ConsignmentSpecFieldType
required: boolean
options: ConsignmentSpecOption[]
placeholder: string
helpText: string
matchMode: ConsignmentSpecMatchMode
wildcardValues: string[]
showInItemName: boolean
sortOrder: number
}
export type ConsignmentPricingConfig = {
/** template_discount = 接单模板价的几折;fixed = 固定价格 */
priceType: 'template_discount' | 'fixed'
/** 几折,范围 0.1 - 10,例如 9.5 表示 95% */
discount: number
fixedPriceFen: number
allowWorkerPrice: boolean
minPriceFen: number
/** 没有模板价时:允许打手手填 / 阻止发布 */
missingPricePolicy: 'block' | 'manual'
}
export type ConsignmentSpecValues = Record<string, string | number>
export type ConsignmentSpecValidationResult = {
values: ConsignmentSpecValues
errors: string[]
}
export type ConsignmentPriceResult = {
priceFen: number
capFen: number
templatePriceFen: number
source: 'template' | 'fixed' | 'none'
breakdown: { label: string; amountFen: number }[]
}
export type WorkerConsignmentPriceResult = {
priceFen: number
capFen: number
templatePriceFen: number
source: 'template' | 'fixed' | 'none'
errors: string[]
}
export type ConsignmentSpecMatchDetail = {
key: string
label: string
orderValue: string
inventoryValue: string
status: 'match' | 'partial' | 'conflict' | 'unknown' | 'ignored'
}
export type ConsignmentSpecMatchEvaluation = {
exact: boolean
partial: boolean
unknown: boolean
conflicts: string[]
unknowns: string[]
details: ConsignmentSpecMatchDetail[]
}
const BUILT_IN_KEY_SET = new Set<string>(BUILT_IN_CONSIGNMENT_SPEC_KEYS)
const ZONE_WILDCARD_VALUES = ['互通', '通用', '全区', '不限']
const SYSTEM_WILDCARD_VALUES = ['通用', '双端', '全端']
const SYSTEM_ALIASES: Record<string, string> = {
苹果: '苹果',
安卓: '安卓',
通用: '通用',
双端: '通用',
全端: '通用',
}
const ZONE_ALIASES: Record<string, string> = {
微信区: '微信区',
QQ区: 'QQ区',
qq区: 'QQ区',
互通: '互通',
通用: '互通',
全区: '互通',
不限: '互通',
}
const SPEC_TYPES: ConsignmentSpecFieldType[] = [
'text',
'textarea',
'select',
'radio',
'number',
'date',
]
/** 兼容旧导入名;新配置不再默认带任何字段。 */
export const DEFAULT_CONSIGNMENT_SPECS: ConsignmentSpecField[] = []
export const DEFAULT_CONSIGNMENT_PRICING: ConsignmentPricingConfig = {
priceType: 'template_discount',
discount: 10,
fixedPriceFen: 0,
allowWorkerPrice: true,
minPriceFen: 0,
missingPricePolicy: 'manual',
}
function record(value: unknown): Record<string, unknown> {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {}
} catch {
return {}
}
}
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function text(value: unknown, maxLength: number): string {
return String(value ?? '')
.trim()
.slice(0, maxLength)
}
function bool(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') return value
if (value === 'true' || value === 1 || value === '1') return true
if (value === 'false' || value === 0 || value === '0') return false
return fallback
}
function integer(value: unknown, fallback: number, min: number, max: number): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return fallback
return Math.min(max, Math.max(min, Math.round(parsed)))
}
function decimal(value: unknown, fallback: number, min: number, max: number): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return fallback
return Math.min(max, Math.max(min, parsed))
}
function stringArray(value: unknown, maxItems = 30): string[] {
if (!Array.isArray(value)) return []
const seen = new Set<string>()
const result: string[] = []
for (const item of value) {
const normalized = text(item, 60)
if (!normalized || seen.has(normalized)) continue
seen.add(normalized)
result.push(normalized)
if (result.length >= maxItems) break
}
return result
}
function isBuiltInSpecKey(key: string): key is BuiltInConsignmentSpecKey {
return BUILT_IN_KEY_SET.has(key)
}
function resolveSpecType(value: unknown): ConsignmentSpecFieldType {
const raw = text(value, 20).toLowerCase()
if (SPEC_TYPES.includes(raw as ConsignmentSpecFieldType)) {
return raw as ConsignmentSpecFieldType
}
// 兼容旧配置与常见别名
if (raw === 'longtext' || raw === 'long_text' || raw === 'textarea') return 'textarea'
if (raw === 'dropdown' || raw === 'dropdown_select') return 'select'
if (raw === 'single' || raw === 'single_select' || raw === 'radiogroup') return 'radio'
if (raw === 'int' || raw === 'decimal') return 'number'
return 'text'
}
function normalizeOption(value: unknown): ConsignmentSpecOption | null {
const source = record(value)
const optionValue = text(source.value ?? source.key ?? source.label, 100)
if (!optionValue) return null
return {
label: text(source.label, 80) || optionValue,
value: optionValue,
enabled: bool(source.enabled, true),
}
}
function normalizeOptions(value: unknown, key: string): ConsignmentSpecOption[] {
const list = Array.isArray(value) ? value : []
const normalized: ConsignmentSpecOption[] = []
const seen = new Set<string>()
for (const item of list) {
const option = normalizeOption(item)
if (!option || seen.has(option.value)) continue
seen.add(option.value)
normalized.push(option)
if (normalized.length >= 100) break
}
if (normalized.length > 0) return normalized
return [{ label: key, value: key, enabled: true }]
}
function normalizeSpecField(value: unknown, index: number): ConsignmentSpecField | 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 = resolveSpecType(source.type)
const options = type === 'select' || type === 'radio' ? normalizeOptions(source.options, key) : []
const rawMatchMode = text(source.matchMode ?? source.match_mode, 20)
const matchMode: ConsignmentSpecMatchMode =
rawMatchMode === 'minimum'
? 'minimum'
: rawMatchMode === 'wildcard'
? 'wildcard'
: rawMatchMode === 'ignore'
? 'ignore'
: 'exact'
const wildcardValues = stringArray(source.wildcardValues ?? source.wildcard_values)
const fallbackWildcards =
wildcardValues.length > 0
? wildcardValues
: key === 'serverZone'
? [...ZONE_WILDCARD_VALUES]
: key === 'system'
? [...SYSTEM_WILDCARD_VALUES]
: []
return {
key,
label: text(source.label, 80) || key,
type,
required: bool(source.required, true),
options,
placeholder: text(source.placeholder, 120),
helpText: text(source.helpText ?? source.help_text, 300),
matchMode,
wildcardValues: fallbackWildcards,
showInItemName: bool(source.showInItemName ?? source.show_in_item_name, true),
sortOrder: integer(source.sortOrder ?? source.sort_order, (index + 1) * 10, 0, 100_000),
}
}
/**
* 归一化规格定义:有 specs 数组就按数组处理;没有就是空配置。
* 不再自动补任何「内置字段」。
*/
export function normalizeConsignmentSpecs(
value: unknown,
_legacyFields?: Record<string, unknown> | null,
): ConsignmentSpecField[] {
if (!Array.isArray(value) || value.length === 0) return []
const seen = new Set<string>()
const result: ConsignmentSpecField[] = []
value.forEach((item, index) => {
const field = normalizeSpecField(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 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)
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)
return {
priceType,
discount: decimal(source.discount, 10, 0.1, 10),
fixedPriceFen: legacyFixedPriceFen,
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',
}
}
function builtInAliases(key: string): string[] {
if (key === 'system') return ['system', 'os']
if (key === 'serverZone') return ['serverZone', 'server_zone', 'zone']
if (key === 'series') return ['series', 'gunSeries']
if (key === 'diamondLevel') return ['diamondLevel', 'diamond_level', 'diamond']
if (key === 'brillianceCount') return ['brillianceCount', 'brilliance_count', 'brilliance']
return [key]
}
function readRawSpecValue(input: Record<string, unknown>, key: string): unknown {
for (const alias of builtInAliases(key)) {
if (input[alias] !== undefined && input[alias] !== null && String(input[alias]).trim() !== '') {
return input[alias]
}
}
return undefined
}
function optionMatches(option: ConsignmentSpecOption, raw: string): boolean {
return option.value === raw || option.value.toLowerCase() === raw.toLowerCase()
}
function findOption(field: ConsignmentSpecField, raw: string): ConsignmentSpecOption | null {
return field.options.find((option) => optionMatches(option, raw)) || null
}
function isValidDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
const parsed = Date.parse(`${value}T00:00:00Z`)
if (!Number.isFinite(parsed)) return false
return new Date(parsed).toISOString().slice(0, 10) === value
}
/**
* 校验并归一化一组规格值。错误信息直接给前端展示。
*/
export function normalizeConsignmentSpecValues(
specs: ConsignmentSpecField[],
input: unknown,
): ConsignmentSpecValidationResult {
const source = record(input)
const nested = record(source.specValues ?? source.spec_values)
const values: ConsignmentSpecValues = {}
const errors: string[] = []
for (const field of specs) {
const rawValue =
nested[field.key] !== undefined &&
nested[field.key] !== null &&
String(nested[field.key]).trim() !== ''
? nested[field.key]
: readRawSpecValue(source, field.key)
const raw = rawValue === undefined || rawValue === null ? '' : String(rawValue).trim()
if (!raw) {
if (field.required) errors.push(`请选择或填写「${field.label}」`)
continue
}
if (field.type === 'select' || field.type === 'radio') {
const option = findOption(field, raw)
if (!option || option.enabled === false) {
errors.push(`「${field.label}」的选项无效:${raw}`)
continue
}
values[field.key] = option.value
continue
}
if (field.type === 'number') {
const parsed = Number(raw)
if (!Number.isFinite(parsed)) {
errors.push(`「${field.label}」必须是数字`)
continue
}
values[field.key] = Math.round(parsed)
continue
}
if (field.type === 'date') {
if (!isValidDate(raw)) {
errors.push(`「${field.label}」必须是有效日期(YYYY-MM-DD)`)
continue
}
values[field.key] = raw
continue
}
const maxLength = field.type === 'textarea' ? 2000 : 200
values[field.key] = raw.slice(0, maxLength)
}
const diamond = Number(values.diamondLevel)
const brilliance = Number(values.brillianceCount)
if (
Number.isFinite(diamond) &&
Number.isFinite(brilliance) &&
values.diamondLevel !== undefined &&
values.brillianceCount !== undefined &&
brilliance > diamond
) {
errors.push('璀璨数不能超过钻石等级(如1钻最多1璀、2钻最多2璀)')
}
return { values, errors }
}
function normalizeComparable(field: ConsignmentSpecField, value: unknown): string {
const raw = String(value ?? '').trim()
if (!raw) return ''
if (field.key === 'system') return SYSTEM_ALIASES[raw] || raw
if (field.key === 'serverZone') return ZONE_ALIASES[raw] || ZONE_ALIASES[raw.toLowerCase()] || raw
if (field.type === 'number' || field.key === 'diamondLevel' || field.key === 'brillianceCount') {
const numeric = Number(raw)
if (Number.isFinite(numeric)) return String(Math.round(numeric))
}
return raw.toLowerCase()
}
function isNumericSpecField(field: ConsignmentSpecField): boolean {
return field.type === 'number' || field.key === 'diamondLevel' || field.key === 'brillianceCount'
}
/** 只取参与匹配的字段生成稳定 key(按字段 key 排序,避免字段顺序影响)。 */
export function buildConsignmentSpecKey(
specs: ConsignmentSpecField[],
values: ConsignmentSpecValues,
): string {
return specs
.filter((field) => field.matchMode !== 'ignore')
.map((field) => [field.key, normalizeComparable(field, values[field.key])] as const)
.filter(([, value]) => value !== '')
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join('|')
}
export function formatConsignmentSpecValue(
field: ConsignmentSpecField,
value: unknown,
options: { forItemName?: boolean } = {},
): string {
const raw = String(value ?? '').trim()
if (!raw) return ''
// 物品名模板自己会拼「钻 / 璀璨」后缀,这里返回纯数字,避免「3钻钻」「1璀璀璨」。
if (options.forItemName && (field.key === 'diamondLevel' || field.key === 'brillianceCount')) {
const numeric = Number(raw)
if (!Number.isFinite(numeric)) return raw
if (field.key === 'brillianceCount' && numeric <= 0) return ''
return String(Math.round(numeric))
}
if (field.type === 'select' || field.type === 'radio') {
const option = findOption(field, raw)
const label = option?.label || raw
if (
options.forItemName &&
field.key === 'serverZone' &&
field.wildcardValues.some(
(item) => normalizeComparable(field, item) === normalizeComparable(field, raw),
)
) {
return ''
}
return label
}
return raw
}
export function buildConsignmentSpecText(
specs: ConsignmentSpecField[],
values: ConsignmentSpecValues,
): string {
return specs
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key))
.map((field) => {
const display = formatConsignmentSpecValue(field, values[field.key])
return display ? `${field.label} ${display}` : ''
})
.filter(Boolean)
.join(' / ')
}
export function buildConsignmentItemName(input: {
template: string
specs: ConsignmentSpecField[]
values: ConsignmentSpecValues
categoryName?: string
fallbackSuffix?: string
}): string {
const { template, specs, values, categoryName = '', fallbackSuffix = '' } = input
const tokenValues = new Map<string, string>()
for (const field of specs) {
tokenValues.set(
field.key,
formatConsignmentSpecValue(field, values[field.key], { forItemName: true }),
)
}
tokenValues.set('category', categoryName)
tokenValues.set('suffix', fallbackSuffix)
let result = String(template || '').trim()
if (result) {
result = result.replace(
/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g,
(_, key: string) => tokenValues.get(key) || '',
)
} else {
result = specs
.filter((field) => field.showInItemName)
.map((field) => formatConsignmentSpecValue(field, values[field.key], { forItemName: true }))
.filter(Boolean)
.join('')
if (fallbackSuffix) result = `${result} ${fallbackSuffix}`
}
const normalized = result.replace(/\s+/g, ' ').trim()
if (normalized) return normalized
// 配置没有字段、模板也为空时,至少用分类名兜底,避免物品名为空。
return categoryName || fallbackSuffix || '寄卖物品'
}
/**
* 计算系统建议价与改价上限。
* - template_discount:接单模板价 × 折扣;
* - fixed:固定价格;
* - 改价上限 = 配置后的建议价(打手只能往下调)。
*/
export function computeConsignmentPrice(input: {
pricing: ConsignmentPricingConfig
templatePriceFen?: number
}): ConsignmentPriceResult {
const { pricing } = input
const templatePriceFen = Math.max(0, Math.round(Number(input.templatePriceFen || 0)))
const breakdown: { label: string; amountFen: number }[] = []
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)
source = 'template'
breakdown.push({ label: '接单模板价', amountFen: templatePriceFen })
if (discount !== 10) {
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - templatePriceFen })
}
}
priceFen = Math.max(0, Math.round(priceFen))
return {
priceFen,
capFen: priceFen,
templatePriceFen,
source,
breakdown,
}
}
/**
* 打手提交价格时决定最终售价:
* - 默认用系统建议价(模板折扣价 / 固定价);
* - 允许改价时只能下调,不能高于建议价;
* - 没有建议价时按 missingPricePolicy 处理(manual=手填,仍受最低价约束)。
*/
export function resolveWorkerConsignmentPrice(input: {
pricing: ConsignmentPricingConfig
templatePriceFen?: number
submittedPriceFen?: number
}): WorkerConsignmentPriceResult {
const computed = computeConsignmentPrice(input)
const submittedPriceFen = Math.max(0, Math.round(Number(input.submittedPriceFen || 0)))
const minPriceFen = Math.max(0, Number(input.pricing.minPriceFen || 0))
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) {
if (!input.pricing.allowWorkerPrice) {
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) {
errors.push(`寄售价格不能少于 ¥${(minPriceFen / 100).toFixed(2)}`)
}
if (priceFen > 0) {
priceFen = Math.max(100, priceFen)
}
return {
priceFen,
capFen: computed.capFen,
templatePriceFen: computed.templatePriceFen,
source: computed.source,
errors,
}
}
/**
* 通用规格匹配:精确 / 最低值 / 通配 / 忽略。
* 订单侧缺字段 -> unknown(不能算精确);库存侧缺字段 -> conflict。
*/
export function evaluateConsignmentSpecMatch(input: {
specs: ConsignmentSpecField[]
orderValues: ConsignmentSpecValues
inventoryValues: ConsignmentSpecValues
}): ConsignmentSpecMatchEvaluation {
const details: ConsignmentSpecMatchDetail[] = []
const conflicts: string[] = []
const unknowns: string[] = []
let partial = false
for (const field of input.specs) {
if (field.matchMode === 'ignore') {
details.push({
key: field.key,
label: field.label,
orderValue: String(input.orderValues[field.key] ?? ''),
inventoryValue: String(input.inventoryValues[field.key] ?? ''),
status: 'ignored',
})
continue
}
const orderRaw = String(input.orderValues[field.key] ?? '').trim()
const inventoryRaw = String(input.inventoryValues[field.key] ?? '').trim()
const detailBase = {
key: field.key,
label: field.label,
orderValue: orderRaw,
inventoryValue: inventoryRaw,
}
if (!orderRaw) {
unknowns.push(field.label)
details.push({ ...detailBase, status: 'unknown' })
continue
}
if (!inventoryRaw) {
conflicts.push(`${field.label}(库存未填写)`)
details.push({ ...detailBase, status: 'conflict' })
continue
}
const wildcardHit =
(field.matchMode === 'wildcard' || field.wildcardValues.length > 0) &&
(field.wildcardValues.some(
(item) => normalizeComparable(field, item) === normalizeComparable(field, orderRaw),
) ||
field.wildcardValues.some(
(item) => normalizeComparable(field, item) === normalizeComparable(field, inventoryRaw),
))
if (wildcardHit) {
details.push({ ...detailBase, status: 'match' })
continue
}
if (isNumericSpecField(field)) {
const orderNumber = Number(orderRaw)
const inventoryNumber = Number(inventoryRaw)
if (!Number.isFinite(orderNumber) || !Number.isFinite(inventoryNumber)) {
if (normalizeComparable(field, orderRaw) === normalizeComparable(field, inventoryRaw)) {
details.push({ ...detailBase, status: 'match' })
} else {
conflicts.push(field.label)
details.push({ ...detailBase, status: 'conflict' })
}
continue
}
if (field.matchMode === 'minimum') {
if (inventoryNumber < orderNumber) {
conflicts.push(field.label)
details.push({ ...detailBase, status: 'conflict' })
} else if (inventoryNumber > orderNumber) {
partial = true
details.push({ ...detailBase, status: 'partial' })
} else {
details.push({ ...detailBase, status: 'match' })
}
continue
}
if (inventoryNumber !== orderNumber) {
conflicts.push(field.label)
details.push({ ...detailBase, status: 'conflict' })
} else {
details.push({ ...detailBase, status: 'match' })
}
continue
}
if (normalizeComparable(field, orderRaw) !== normalizeComparable(field, inventoryRaw)) {
conflicts.push(field.label)
details.push({ ...detailBase, status: 'conflict' })
} else {
details.push({ ...detailBase, status: 'match' })
}
}
return {
exact: conflicts.length === 0 && unknowns.length === 0 && !partial,
partial: conflicts.length === 0 && unknowns.length === 0 && partial,
unknown: unknowns.length > 0,
conflicts,
unknowns,
details,
}
}
/** 旧列同步:key 为 system/serverZone/series/diamondLevel/brillianceCount 时取对应值。 */
export function resolveLegacyConsignmentColumns(values: ConsignmentSpecValues): {
system: string
serverZone: string
series: string
diamondLevel: number
brillianceCount: number
} {
return {
system: String(values.system ?? '').trim(),
serverZone: String(values.serverZone ?? '').trim(),
series: String(values.series ?? '').trim(),
diamondLevel: Math.max(1, Math.round(Number(values.diamondLevel || 1))),
brillianceCount: Math.max(0, Math.round(Number(values.brillianceCount || 0))),
}
}
/** 仅用于旧匹配器兼容判断:字段 key 是否属于历史五字段。 */
export function isBuiltInConsignmentSpecField(field: ConsignmentSpecField): boolean {
return isBuiltInSpecKey(field.key)
}
/** 把数据库里的 spec_values_json 解析成可用的规格值对象。 */
export function parseConsignmentSpecValues(value: unknown): ConsignmentSpecValues {
const source = record(value)
const result: ConsignmentSpecValues = {}
for (const [key, raw] of Object.entries(source)) {
if (typeof raw === 'string' || typeof raw === 'number') result[key] = raw
}
return result
}
@@ -26,6 +26,18 @@ import {
resolvePriceDropSuggestion,
} from './consignment-profile-service.js'
import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js'
import {
DEFAULT_CONSIGNMENT_PRICING,
buildConsignmentItemName,
buildConsignmentSpecKey,
buildConsignmentSpecText,
computeConsignmentPrice,
normalizeConsignmentSpecValues,
resolveLegacyConsignmentColumns,
resolveWorkerConsignmentPrice,
type ConsignmentSpecField,
type ConsignmentSpecValues,
} from './consignment-spec-service.js'
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
@@ -64,6 +76,16 @@ function mapConsignmentContactSupporter(source: {
}
}
function parseSpecValues(value: unknown): ConsignmentSpecValues {
const source = toRecord(value)
if (!source) return {}
const result: ConsignmentSpecValues = {}
for (const [key, raw] of Object.entries(source)) {
if (typeof raw === 'string' || typeof raw === 'number') result[key] = raw
}
return result
}
function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | null) {
if (!boundWo) {
return {
@@ -198,7 +220,30 @@ export async function updateWorkerConsignmentSalePrice(
errorCode: 'consignment_inventory_unavailable',
})
}
const profileConfig = inventory.profile_snapshot_json
? normalizeConsignmentProfileConfig(inventory.profile_snapshot_json)
: null
if (profileConfig && !profileConfig.pricing.allowWorkerPrice) {
throw createHttpError('当前寄卖配置不允许打手修改售价,请联系客服', {
statusCode: 403,
errorCode: 'consignment_price_edit_forbidden',
})
}
const priceFen = Math.max(100, Math.round(Number(priceFenInput || 0)))
const capFen = Math.max(0, Math.round(Number(inventory.price_cap_fen || 0)))
if (capFen > 0 && priceFen > capFen) {
throw createHttpError(`寄售价格不能高于系统建议价 ¥${(capFen / 100).toFixed(2)}`, {
statusCode: 409,
errorCode: 'consignment_price_above_cap',
})
}
const minPriceFen = Math.max(0, Number(profileConfig?.pricing.minPriceFen || 0))
if (priceFen < minPriceFen) {
throw createHttpError(`寄售价格不能少于 ¥${(minPriceFen / 100).toFixed(2)}`, {
statusCode: 409,
errorCode: 'consignment_price_below_min',
})
}
const updated = await updateConsignmentInventory(inventory.id, {
salePriceFen: priceFen,
now: nowIso(),
@@ -254,58 +299,33 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
}
}
const system = String(payload.system || '').trim()
const serverZone = String(payload.serverZone || '').trim()
const series = String(payload.series || '').trim()
if (!system || !serverZone || !series) {
throw createHttpError('请完整填写操作系统、区服与枪系', {
const specs = profile?.config.specs ?? []
const specValidation = normalizeConsignmentSpecValues(specs, payload)
if (specValidation.errors.length > 0) {
throw createHttpError(specValidation.errors[0]!, {
statusCode: 400,
errorCode: 'specs_required',
})
}
// 苏尔南寄卖必须区分苹果/安卓(不收「通用」);区服支持 微信区/QQ区/互通 三选
if (system !== '苹果' && system !== '安卓') {
throw createHttpError('苏尔南寄卖的操作系统只能选 苹果 或 安卓', {
statusCode: 400,
errorCode: 'consignment_system_invalid',
})
}
// 互通 = QQ/微信都能收(苏尔南发布端默认且唯一展示项)
if (serverZone !== '微信区' && serverZone !== 'QQ区' && serverZone !== '互通') {
throw createHttpError('游戏区服只能选 微信区 / QQ区 / 互通', {
statusCode: 400,
errorCode: 'consignment_server_zone_invalid',
})
}
if (
payload.diamondLevel === undefined ||
payload.diamondLevel === null ||
payload.diamondLevel === ''
) {
throw createHttpError('请选择钻石等级', {
statusCode: 400,
errorCode: 'diamond_level_required',
})
}
const diamondLevel = Math.max(1, Math.min(7, Number(payload.diamondLevel)))
if (
payload.brillianceCount === undefined ||
payload.brillianceCount === null ||
payload.brillianceCount === ''
) {
throw createHttpError('请选择璀璨数', {
statusCode: 400,
errorCode: 'brilliance_required',
})
}
const brillianceCount = Math.max(0, Math.min(7, Number(payload.brillianceCount)))
if (brillianceCount > diamondLevel) {
throw createHttpError('璀璨数不能超过钻石等级(如1钻最多1璀、2钻最多2璀)', {
statusCode: 400,
errorCode: 'brilliance_exceeds_diamond',
errorCode: 'consignment_specs_invalid',
})
}
// 配置没有字段时仍兼容旧版平铺参数(老客户端不带 profileId/specValues)。
const specValues: ConsignmentSpecValues =
specs.length > 0
? specValidation.values
: {
...(String(payload.system || '').trim() ? { system: String(payload.system).trim() } : {}),
...(String(payload.serverZone || '').trim()
? { serverZone: String(payload.serverZone).trim() }
: {}),
...(String(payload.series || '').trim() ? { series: String(payload.series).trim() } : {}),
...(payload.diamondLevel !== undefined && String(payload.diamondLevel).trim() !== ''
? { diamondLevel: Math.round(Number(payload.diamondLevel)) }
: {}),
...(payload.brillianceCount !== undefined && String(payload.brillianceCount).trim() !== ''
? { brillianceCount: Math.max(0, Math.round(Number(payload.brillianceCount))) }
: {}),
}
const { system, serverZone, series, diamondLevel, brillianceCount } =
resolveLegacyConsignmentColumns(specValues)
const contactInfo = String(payload.sellerContact || payload.contactInfo || '').trim()
if (!contactInfo) {
@@ -338,40 +358,46 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
})
}
// 寄售价:配置开启「模板价」时以接单模板为准;允许打手改价时以打手填写价为准
// (不低于配置的最低价)。未命中配置的存量调用保持旧行为。
// 寄售价:按配置算出系统建议价(模板折扣价 / 固定价),打手只能往下调。
// 没有建议价时按 missingPricePolicy 处理(manual = 允许打手手填,仍受最低价约束)。
const priceSuggestion = await getWorkerConsignmentPriceSuggestion({
system,
series,
diamondLevel,
brillianceCount,
profileId: profile?.profileId,
specs,
values: specValues,
})
const templatePriceFen = Math.max(0, Math.round(Number(priceSuggestion.suggestedPriceFen || 0)))
const pricing = profile?.config.pricing ?? DEFAULT_CONSIGNMENT_PRICING
const submittedPriceFen = Math.max(
0,
Math.round(Number(payload.askingPriceFen ?? payload.salePriceFen ?? 0)),
)
let askingPriceFen = templatePriceFen > 0 ? templatePriceFen : submittedPriceFen
if (profile) {
const minPriceFen = Math.max(0, profile.config.pricing.minPriceFen)
if (profile.config.pricing.allowWorkerPrice && submittedPriceFen > 0) {
askingPriceFen = submittedPriceFen
} else if (!profile.config.pricing.useTemplatePrice && submittedPriceFen > 0) {
askingPriceFen = submittedPriceFen
}
askingPriceFen = Math.max(minPriceFen, askingPriceFen)
const priceResolution = resolveWorkerConsignmentPrice({
pricing,
templatePriceFen: priceSuggestion.templatePriceFen,
submittedPriceFen,
})
if (priceResolution.errors.length > 0) {
throw createHttpError(priceResolution.errors[0]!, {
statusCode: 400,
errorCode: 'consignment_price_invalid',
})
}
askingPriceFen = Math.max(100, askingPriceFen)
const askingPriceFen = priceResolution.priceFen
const priceCapFen = priceResolution.capFen
const depositAmountFen = profile?.config.deposit.enabled
? buildDepositAmountFen(profile.config, quantity)
: getConsignmentPublishDepositFen()
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
const briStr = brillianceCount > 0 ? `${brillianceCount}璀璨` : ''
// 区服是「互通」(通配)时不拼进物品名:对买家无信息量,"安卓互通暗潮…"读着也别扭
const zoneLabel = serverZone === '互通' ? '' : serverZone
const defaultItemName = `${system}${zoneLabel}${series}${diamondLevel}钻${briStr} 苏尔南`
const defaultItemName = buildConsignmentItemName({
template: profile?.config.nameTemplate || '',
specs,
values: specValues,
categoryName: profile?.categoryName || '',
fallbackSuffix: '苏尔南',
})
const itemName = String(payload.itemName || defaultItemName).trim()
const specKey = buildConsignmentSpecKey(specs, specValues)
const specText = buildConsignmentSpecText(specs, specValues)
const submissionNo = generateConsignmentSubmissionNo()
const now = nowIso()
@@ -401,6 +427,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
profileVersionId: profile?.versionId ?? null,
quantity,
salePriceFen: askingPriceFen,
priceCapFen,
specValues,
specText,
},
screenshotsJson: screenshots,
askingPriceFen,
@@ -412,11 +441,16 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
? {
profileId: profile.profileId,
categoryId: profile.categoryId,
categoryName: profile.categoryName,
versionId: profile.versionId,
versionNo: profile.versionNo,
config: profile.config,
}
: null,
specValuesJson: specValues,
specKey,
specText,
priceCapFen,
workerId: Number(worker.id),
workerName: worker.display_name || worker.username,
workerPhone: sellerPhone || worker.phone || '',
@@ -450,6 +484,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
brillianceCount: submission.brilliance_count,
askingPriceFen: submission.asking_price_fen,
salePriceFen: Number(submission.requested_sale_price_fen || submission.asking_price_fen || 0),
priceCapFen: Number(submission.price_cap_fen || 0),
specValues,
specText,
quantity: Number(submission.quantity || 1),
profileId: submission.profile_id ? Number(submission.profile_id) : null,
profileVersionId: submission.profile_version_id
@@ -574,6 +611,9 @@ export async function listWorkerConsignments(
: { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' }
// 现货上的对接客服优先级更高:客服可能在审核入库后改派对接人,卖家端应看到最新归属。
const contactSupporter = inv?.contact_supporter_id ? inv : sub
const profileConfig = inv?.profile_snapshot_json
? normalizeConsignmentProfileConfig(inv.profile_snapshot_json)
: null
return {
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
consignmentNo: sub.submission_no,
@@ -586,18 +626,21 @@ export async function listWorkerConsignments(
series: sub.series,
diamondLevel: sub.diamond_level,
brillianceCount: sub.brilliance_count,
specValues: parseSpecValues(inv?.spec_values_json ?? sub.spec_values_json),
specText: inv?.spec_text || sub.spec_text || '',
askingPriceFen: sub.asking_price_fen,
salePriceFen: Number(
inv?.sale_price_fen ?? sub.requested_sale_price_fen ?? sub.asking_price_fen ?? 0,
),
priceCapFen: Number(inv?.price_cap_fen ?? sub.price_cap_fen ?? 0),
quantity: Math.max(1, Number(sub.quantity || inv?.quantity || 1)),
remainingQuantity: inv ? Math.max(0, Number(inv.remaining_quantity ?? 0)) : 0,
listedAt: inv?.listed_at || null,
canEditPrice: inv?.status === 'in_stock',
priceDropSuggestion: inv?.profile_snapshot_json
? resolvePriceDropSuggestion(normalizeConsignmentProfileConfig(inv.profile_snapshot_json), {
listedAt: inv.listed_at || inv.inbound_at,
salePriceFen: Number(inv.sale_price_fen || 0),
canEditPrice: inv?.status === 'in_stock' && profileConfig?.pricing.allowWorkerPrice !== false,
priceDropSuggestion: profileConfig
? resolvePriceDropSuggestion(profileConfig, {
listedAt: inv?.listed_at || inv?.inbound_at || '',
salePriceFen: Number(inv?.sale_price_fen || 0),
now: nowIso(),
})
: null,
@@ -631,6 +674,9 @@ export async function listWorkerConsignments(
const evidence = showingMatchedBuyer
? extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
: { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' }
const profileConfig = inv.profile_snapshot_json
? normalizeConsignmentProfileConfig(inv.profile_snapshot_json)
: null
return {
consignmentNo: '客服手工入库',
...(stage.canConfirmFriend || stage.canSubmitEvidence
@@ -645,14 +691,17 @@ export async function listWorkerConsignments(
series: inv.series,
diamondLevel: inv.diamond_level,
brillianceCount: inv.brilliance_count,
specValues: parseSpecValues(inv.spec_values_json),
specText: inv.spec_text || '',
askingPriceFen: inv.cost_price_fen,
salePriceFen: Number(inv.sale_price_fen || inv.cost_price_fen || 0),
priceCapFen: Number(inv.price_cap_fen || 0),
quantity: Math.max(1, Number(inv.quantity || 1)),
remainingQuantity: Math.max(0, Number(inv.remaining_quantity ?? 0)),
listedAt: inv.listed_at || inv.inbound_at || null,
canEditPrice: inv.status === 'in_stock',
priceDropSuggestion: inv.profile_snapshot_json
? resolvePriceDropSuggestion(normalizeConsignmentProfileConfig(inv.profile_snapshot_json), {
canEditPrice: inv.status === 'in_stock' && profileConfig?.pricing.allowWorkerPrice !== false,
priceDropSuggestion: profileConfig
? resolvePriceDropSuggestion(profileConfig, {
listedAt: inv.listed_at || inv.inbound_at,
salePriceFen: Number(inv.sale_price_fen || 0),
now: nowIso(),
@@ -774,45 +823,76 @@ export async function submitWorkerConsignmentGiftEvidence(
}
/**
* 发布寄卖的建议价:按 规格(系统/系列/钻级)从「接单模板」里带出价格,
* 例如模板里有「安卓暗潮1钻 ¥120」,卖家选到 安卓+暗潮+1钻 就自动带出 120。
* 找不到匹配的模板(本地没配、或规格没配价)就返回 null,前端保持手填。
* 发布寄卖的建议价:
* - 模板折扣:接单模板价 × 折扣;
* - 固定价格:直接使用固定价,有模板价时校验不得高于模板价;
* - 同时返回改价上限(等于建议价,打手只能往下调)。
*/
export async function getWorkerConsignmentPriceSuggestion(payload: {
profileId?: unknown
/** 保留旧参数,价格只依赖系统/系列/钻级/璀璨 */
specs?: ConsignmentSpecField[]
values?: ConsignmentSpecValues
/** 旧调用兼容:平铺参数 */
system?: unknown
series?: unknown
diamondLevel?: unknown
brillianceCount?: unknown
}): Promise<{
suggestedPriceFen: number | null
capFen: number
templatePriceFen: number
source: 'template' | 'fixed' | 'none'
matchedCount: number
matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[]
}> {
const system = String(payload.system || '').trim()
const series = String(payload.series || '').trim()
const diamondLevel = Math.round(Number(payload.diamondLevel || 0))
const profileIdInput = Number(payload.profileId ?? 0)
const profile =
Number.isInteger(profileIdInput) && profileIdInput > 0
? await getPublishedConsignmentProfileById(profileIdInput)
: null
const pricing = profile?.config.pricing ?? DEFAULT_CONSIGNMENT_PRICING
const values: ConsignmentSpecValues = { ...(payload.values || {}) }
if (values.system === undefined && payload.system !== undefined)
values.system = String(payload.system || '').trim()
if (values.series === undefined && payload.series !== undefined)
values.series = String(payload.series || '').trim()
if (values.diamondLevel === undefined && payload.diamondLevel !== undefined) {
values.diamondLevel = Math.round(Number(payload.diamondLevel || 0))
}
if (values.brillianceCount === undefined && payload.brillianceCount !== undefined) {
values.brillianceCount = Math.max(0, Math.round(Number(payload.brillianceCount || 0)))
}
const system = String(values.system || '').trim()
const series = String(values.series || '').trim()
const diamondLevel = Math.round(Number(values.diamondLevel || 0))
const brillianceCount =
payload.brillianceCount !== undefined &&
payload.brillianceCount !== null &&
String(payload.brillianceCount).trim() !== ''
? Math.max(0, Math.round(Number(payload.brillianceCount || 0)))
values.brillianceCount !== undefined && String(values.brillianceCount).trim() !== ''
? Math.max(0, Math.round(Number(values.brillianceCount || 0)))
: undefined
if (!system || !series || diamondLevel <= 0) {
return { suggestedPriceFen: null, matchedCount: 0, matchedRules: [] }
}
const rules = await listEnabledWorkProductRulesForSuggestion()
const suggestion = suggestConsignmentPriceFromRules(rules, {
system,
series,
diamondLevel,
brillianceCount,
})
if (!suggestion) {
return { suggestedPriceFen: null, matchedCount: 0, matchedRules: [] }
let templatePriceFen = 0
let matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[] = []
if (system && series && diamondLevel > 0) {
const rules = await listEnabledWorkProductRulesForSuggestion()
const suggestion = suggestConsignmentPriceFromRules(rules, {
system,
series,
diamondLevel,
brillianceCount,
})
templatePriceFen = Math.max(0, Math.round(Number(suggestion?.suggestedPriceFen || 0)))
matchedRules = suggestion?.matchedRules || []
}
const computed = computeConsignmentPrice({ pricing, templatePriceFen })
return {
suggestedPriceFen: suggestion.suggestedPriceFen,
matchedCount: suggestion.matchedRules.length,
matchedRules: suggestion.matchedRules,
suggestedPriceFen: computed.priceFen > 0 ? computed.priceFen : null,
capFen: computed.capFen,
templatePriceFen: computed.templatePriceFen,
source: computed.source,
matchedCount: matchedRules.length,
matchedRules,
}
}