feat(consignment): 寄卖配置、内部匹配与打手端升级

This commit is contained in:
2026-11-05 15:39:40 +08:00
parent f94b914ab1
commit 2ef16ddcc7
34 changed files with 3705 additions and 350 deletions
@@ -37,6 +37,7 @@ import {
enqueueOrderServiceAutoPublish,
processDueOrderServiceAutoPublishJobs,
} from './order-service-auto-publish-service.js'
import { enterConsignmentInternalMatching } from './consignment-match-engine-service.js'
import { ensureWorkerPlatformDefaults } from './worker-platform-defaults.js'
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
@@ -201,6 +202,9 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
void processDueOrderServiceAutoPublishJobs(1).catch(() => undefined)
}
}
if (materialComplete && !autoPublishQueued) {
await enterConsignmentInternalMatching(Number(workOrder.id)).catch(() => false)
}
return { order: mapWorkOrderAdmin(workOrder), autoPublishQueued }
}
@@ -24,6 +24,10 @@ import { generateConsignmentInventoryNo } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import {
normalizeConsignmentProfileConfig,
resolvePriceDropSuggestion,
} from './consignment-profile-service.js'
import {
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
getConsignmentDepositErrorCode,
@@ -160,12 +164,33 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
// 只保留卖家自己的提报信息,不再把上一单买家角色、主页截图、赠送凭证带出来。
const showingMatchedBuyer = row.status === 'consumed'
const evidence = mapConsignmentEvidence(row)
const quantity = Math.max(1, Number(row.quantity || 1))
const remainingQuantity = Math.max(0, Number(row.remaining_quantity ?? quantity))
const salePriceFen = Math.max(0, Number(row.sale_price_fen || 0))
const profileConfig = row.profile_snapshot_json
? normalizeConsignmentProfileConfig(row.profile_snapshot_json)
: null
const priceDropSuggestion = profileConfig
? resolvePriceDropSuggestion(profileConfig, {
listedAt: row.listed_at || row.inbound_at,
salePriceFen,
now: nowIso(),
})
: null
return {
id: Number(row.id),
inventoryNo: row.inventory_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,
quantity,
remainingQuantity,
salePriceFen,
listedAt: row.listed_at || row.inbound_at,
priceUpdatedAt: row.price_updated_at || null,
priceDropSuggestion,
itemName: row.item_name,
system: row.system,
serverZone: row.server_zone,
@@ -385,6 +410,10 @@ export async function updateAdminConsignmentInventory(
payload.costPriceFen !== undefined
? Math.max(0, Math.round(Number(payload.costPriceFen)))
: undefined,
salePriceFen:
payload.salePriceFen !== undefined
? Math.max(0, Math.round(Number(payload.salePriceFen)))
: undefined,
buyerName: payload.buyerName !== undefined ? String(payload.buyerName).trim() : undefined,
inboundBatch:
payload.inboundBatch !== undefined ? String(payload.inboundBatch).trim() : undefined,
@@ -665,6 +694,13 @@ export async function approveAdminConsignmentSubmission(
workerName,
workerPhone,
depositAmountFen,
quantity: Math.max(1, Math.round(Number(submission.quantity || 1))),
salePriceFen: Math.max(
0,
Math.round(
Number(submission.requested_sale_price_fen || submission.asking_price_fen || costPriceFen),
),
),
contactSupporterId:
payload.contactSupporterId !== undefined
? payload.contactSupporterId
@@ -0,0 +1,678 @@
import type { PoolClient } from 'pg'
import { withTransaction, query } from '../../db/client.js'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
createWorkOrderEventWithClient,
getWorkOrderById,
type WorkOrderRow,
} from '../../repositories/worker-platform/index.js'
import {
listActiveConsignmentInventoriesForMatching,
type ConsignmentInventoryRow,
} from '../../repositories/worker-platform/consignment-repo.js'
import {
reserveConsignmentInventoryWithClient,
releaseConsignmentAllocationsForWorkOrderWithClient,
} from '../../repositories/worker-platform/consignment-allocation-repo.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import {
buildServiceFeeFen,
resolveConsignmentProfileForCategory,
type ConsignmentProfileSnapshot,
} from './consignment-profile-service.js'
import {
classifyConsignmentMatch,
extractPeacekeeperSpecs,
} from './consignment-matching-service.js'
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
type MatchCandidate = {
workOrder: WorkOrderRow
profile: ConsignmentProfileSnapshot | null
}
function parseMaterial(value: unknown): Record<string, unknown> {
const parsed = safeParseJson(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {}
}
function addMinutesIso(minutes: number, now: string): string | null {
const normalized = Math.max(0, Math.round(Number(minutes || 0)))
if (normalized <= 0) return null
return new Date(new Date(now).getTime() + normalized * 60 * 1000).toISOString()
}
function inventorySalePriceFen(inventory: ConsignmentInventoryRow): number {
return Math.max(0, Math.round(Number(inventory.sale_price_fen || inventory.cost_price_fen || 0)))
}
function inventoryMatchesProfile(
inventory: ConsignmentInventoryRow,
profile: ConsignmentProfileSnapshot | null,
): boolean {
if (!profile) return true
const inventoryProfileId = inventory.profile_id ? Number(inventory.profile_id) : null
if (inventoryProfileId) return inventoryProfileId === profile.profileId
// 存量库存没有 profile_id:按分类兜底(配置挂一级分类时同样命中)。
return Number(inventory.category_id || 0) === profile.categoryId
}
function inventoryMatchesWorkOrder(
workOrder: WorkOrderRow,
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
const fields = resolveCollectSubmittedFieldValues(workOrder)
const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, {
system: fields.system,
serverZone: fields.serverZone,
series: fields.series,
})
return classifyConsignmentMatch(orderSpecs, inventory).matchType === 'exact'
}
async function resolveProfileForWorkOrder(
workOrder: WorkOrderRow,
): Promise<ConsignmentProfileSnapshot | null> {
if (workOrder.consignment_profile_id) {
// 内部匹配阶段写入的 profile 快照优先;仍以发布配置为准。
const result = await query<{ category_id: number }>(
'SELECT category_id FROM consignment_profiles WHERE id = $1 LIMIT 1',
[workOrder.consignment_profile_id],
)
const categoryId = result.rows[0]?.category_id
if (categoryId) {
const resolved = await resolveConsignmentProfileForCategory(Number(categoryId))
if (resolved) return resolved
}
}
return resolveConsignmentProfileForCategory(workOrder.category_id)
}
/** 进入「内部匹配中」:命中分类寄卖配置时由资料提交/自动发布链路调用。 */
export async function enterConsignmentInternalMatching(
workOrderId: number,
now = nowIso(),
): Promise<boolean> {
const workOrder = await getWorkOrderById(workOrderId)
if (!workOrder || workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) return false
const profile = await resolveProfileForWorkOrder(workOrder)
if (!profile) 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)
const result = await query<WorkOrderRow>(
`
UPDATE work_orders
SET status = 'internal_matching',
consignment_profile_id = $1,
internal_match_deadline_at = $2,
hall_visible_at = $2,
updated_at = $3
WHERE id = $4 AND status = $5
RETURNING *
`,
[profile.profileId, deadline, now, workOrderId, WORK_ORDER_STATUS.UNASSIGNED],
)
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_internal_matching_entered', $2, 'internal_matching', $3::jsonb, $4)
`,
[
workOrderId,
WORK_ORDER_STATUS.UNASSIGNED,
JSON.stringify({ profileId: profile.profileId, delayMinutes, deadline }),
now,
],
)
return true
}
export async function reenterConsignmentInternalMatching(
workOrderId: number,
now = nowIso(),
): Promise<boolean> {
const workOrder = await getWorkOrderById(workOrderId)
if (
!workOrder ||
![WORK_ORDER_STATUS.OPEN, WORK_ORDER_STATUS.UNASSIGNED].includes(workOrder.status as never)
) {
return false
}
const profile = await resolveProfileForWorkOrder(workOrder)
if (!profile) return false
const delayMinutes = Math.max(0, profile.config.internalMatch.delayMinutes)
if (delayMinutes <= 0) return false
const deadline = addMinutesIso(delayMinutes, now)
const result = await query<WorkOrderRow>(
`
UPDATE work_orders
SET status = 'internal_matching',
consignment_profile_id = $1,
internal_match_deadline_at = $2,
hall_visible_at = $2,
internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1,
updated_at = $3
WHERE id = $4 AND status IN ('open', 'unassigned')
RETURNING *
`,
[profile.profileId, deadline, 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_internal_matching_reentered', $2, 'internal_matching', $3::jsonb, $4)
`,
[
workOrderId,
workOrder.status,
JSON.stringify({ profileId: profile.profileId, delayMinutes, deadline }),
now,
],
)
return true
}
function pickCandidateInventories(
workOrder: WorkOrderRow,
profile: ConsignmentProfileSnapshot | null,
inventories: ConsignmentInventoryRow[],
): ConsignmentInventoryRow[] {
return inventories
.filter(
(inventory) =>
inventoryMatchesProfile(inventory, profile) &&
inventoryMatchesWorkOrder(workOrder, inventory),
)
.sort((a, b) => {
const priceDiff = inventorySalePriceFen(a) - inventorySalePriceFen(b)
if (priceDiff !== 0) return priceDiff
const listedA = Date.parse(String(a.listed_at || a.inbound_at || '')) || 0
const listedB = Date.parse(String(b.listed_at || b.inbound_at || '')) || 0
if (listedA !== listedB) return listedA - listedB
return Number(a.id) - Number(b.id)
})
}
async function applyWholeOrderMatch(
input: MatchCandidate,
inventory: ConsignmentInventoryRow,
): Promise<boolean> {
const { workOrder, profile } = input
if (!workOrder.id || !inventory.worker_id) return false
const now = nowIso()
return withTransaction(async (client: PoolClient) => {
const locked = await client.query<WorkOrderRow>(
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
[workOrder.id],
)
const current = locked.rows[0]
if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) return false
if (current.assigned_worker_id) return false
const allocation = await reserveConsignmentInventoryWithClient(client, {
inventoryId: Number(inventory.id),
workOrderId: Number(current.id),
quantity: 1,
unitPriceFen: inventorySalePriceFen(inventory),
now,
})
if (!allocation) return false
const config = profile?.config
const grossRewardFen = inventorySalePriceFen(inventory) || Number(current.reward_amount || 0)
const serviceFeeFen = config ? buildServiceFeeFen(config, grossRewardFen) : 0
const netRewardFen = Math.max(0, grossRewardFen - serviceFeeFen)
const timeoutMinutes = config?.timeout.minutes ?? 30
const timeoutPolicy = config?.timeout.policy ?? 'reopen'
const material = parseMaterial(current.material_json)
const nextMaterial = {
...material,
consignment: {
inventoryId: Number(inventory.id),
inventoryNo: inventory.inventory_no,
itemName: inventory.item_name,
system: inventory.system,
serverZone: inventory.server_zone,
series: inventory.series,
diamondLevel: inventory.diamond_level,
brillianceCount: inventory.brilliance_count,
profileId: profile?.profileId ?? null,
profileVersionId: profile?.versionId ?? null,
salePriceFen: inventorySalePriceFen(inventory),
grossRewardFen,
serviceFeeFen,
netRewardFen,
previousRewardAmountFen: Number(current.reward_amount || 0),
workerId: Number(inventory.worker_id),
workerName: inventory.worker_name || '',
matchedAt: now,
matchType: 'auto',
},
}
const updated = await client.query<WorkOrderRow>(
`
UPDATE work_orders
SET status = $1,
assigned_worker_id = $2,
assigned_at = $3,
deadline_at = $4,
timeout_minutes = $5,
timeout_policy = $6,
reward_amount = $7,
material_json = $8::jsonb,
hall_visible_at = NULL,
internal_match_deadline_at = NULL,
internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1,
updated_at = $3
WHERE id = $9 AND status = 'internal_matching'
RETURNING *
`,
[
WORK_ORDER_STATUS.IN_PROGRESS,
Number(inventory.worker_id),
now,
addMinutesIso(timeoutMinutes, now),
timeoutMinutes,
timeoutPolicy,
netRewardFen,
JSON.stringify(nextMaterial),
current.id,
],
)
if (!updated.rows[0]) return false
await createWorkOrderEventWithClient(client, {
workOrderId: Number(current.id),
actorType: 'system',
actorId: 'consignment_match',
eventType: 'consignment_auto_matched',
fromStatus: WORK_ORDER_STATUS.INTERNAL_MATCHING,
toStatus: WORK_ORDER_STATUS.IN_PROGRESS,
payloadJson: JSON.stringify({
inventoryId: Number(inventory.id),
inventoryNo: inventory.inventory_no,
workerId: Number(inventory.worker_id),
profileId: profile?.profileId ?? null,
salePriceFen: inventorySalePriceFen(inventory),
grossRewardFen,
serviceFeeFen,
netRewardFen,
allocationId: allocation.id,
}),
now,
})
return true
})
}
async function applySharingMatch(
input: MatchCandidate,
inventories: ConsignmentInventoryRow[],
): Promise<number> {
const { workOrder, profile } = input
const now = nowIso()
const totalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
if (!workOrder.id || totalQuantity <= 1) return 0
return withTransaction(async (client: PoolClient) => {
const locked = await client.query<WorkOrderRow>(
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
[workOrder.id],
)
const current = locked.rows[0]
if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) return 0
const activeResult = await client.query<{ active: number | string }>(
`SELECT COALESCE(SUM(quantity), 0) AS active FROM work_order_shares WHERE work_order_id = $1 AND status IN ('joined', 'submitted', 'accepted')`,
[current.id],
)
let active = Number(activeResult.rows[0]?.active || 0)
let remaining = Math.max(0, totalQuantity - active)
if (remaining <= 0) return 0
const config = profile?.config
const timeoutMinutes = config?.timeout.minutes ?? 30
const timeoutPolicy = config?.timeout.policy ?? 'reopen'
const deadline = addMinutesIso(timeoutMinutes, now)
const unitRewardFen =
Math.max(0, Number(current.sharing_unit_reward || 0)) ||
Math.max(0, Number(current.reward_amount || 0)) / totalQuantity
const shareGrossFen = Math.max(0, Math.round(unitRewardFen))
const shareFeeFen = config ? buildServiceFeeFen(config, shareGrossFen) : 0
const shareNetFen = Math.max(0, shareGrossFen - shareFeeFen)
let createdCount = 0
let firstInventoryId: number | null = null
for (const inventory of inventories) {
if (remaining <= 0) break
if (!inventory.worker_id) continue
if (!inventoryMatchesProfile(inventory, profile)) continue
if (!inventoryMatchesWorkOrder(current, inventory)) continue
const allocation = await reserveConsignmentInventoryWithClient(client, {
inventoryId: Number(inventory.id),
workOrderId: Number(current.id),
quantity: 1,
unitPriceFen: inventorySalePriceFen(inventory),
now,
})
if (!allocation) continue
const share = await client.query<{ id: number }>(
`
INSERT INTO work_order_shares (
work_order_id, worker_id, quantity, unit_reward, share_reward, share_deposit,
status, acceptance_json, draft_acceptance_json, deadline_at, timeout_minutes,
timeout_policy, request_id, consignment_inventory_id, created_at, updated_at
) VALUES (
$1, $2, 1, $3, $4, 0,
'joined', '{}'::jsonb, '{}'::jsonb, $5, $6,
$7, '', $8, $9, $9
)
RETURNING id
`,
[
current.id,
Number(inventory.worker_id),
shareGrossFen,
shareNetFen,
deadline,
timeoutMinutes,
timeoutPolicy,
Number(inventory.id),
now,
],
)
const shareId = Number(share.rows[0]?.id || 0)
if (shareId <= 0) continue
await client.query(
'UPDATE consignment_inventory_allocations SET work_order_share_id = $1, updated_at = $2 WHERE id = $3',
[shareId, now, allocation.id],
)
createdCount += 1
if (!firstInventoryId) firstInventoryId = Number(inventory.id)
remaining -= 1
}
if (createdCount > 0) {
active += createdCount
const material = parseMaterial(current.material_json)
const nextStatus =
active >= totalQuantity
? WORK_ORDER_STATUS.IN_PROGRESS
: WORK_ORDER_STATUS.INTERNAL_MATCHING
await client.query(
`
UPDATE work_orders
SET status = $1,
material_json = $2::jsonb,
assigned_at = CASE WHEN $1 = 'in_progress' THEN $3 ELSE assigned_at END,
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,
updated_at = $3
WHERE id = $4
`,
[
nextStatus,
JSON.stringify({
...material,
...(firstInventoryId
? {
consignment: {
inventoryId: firstInventoryId,
profileId: profile?.profileId ?? null,
matchedAt: now,
matchType: 'auto_sharing',
},
}
: {}),
consignmentSharing: {
profileId: profile?.profileId ?? null,
totalQuantity,
matchedQuantity: active,
matchedAt: now,
},
}),
now,
current.id,
],
)
await createWorkOrderEventWithClient(client, {
workOrderId: Number(current.id),
actorType: 'system',
actorId: 'consignment_match',
eventType: 'consignment_sharing_matched',
fromStatus: WORK_ORDER_STATUS.INTERNAL_MATCHING,
toStatus: nextStatus,
payloadJson: JSON.stringify({
createdCount,
activeQuantity: active,
totalQuantity,
shareRewardFen: shareNetFen,
serviceFeeFenPerShare: shareFeeFen,
}),
now,
})
}
return createdCount
})
}
/** 自动匹配一批内部匹配工单:低价订单优先,精确规格,支持整单/拼单。 */
export async function autoMatchConsignmentWorkOrders(options: { limit?: number } = {}) {
const limit = Math.min(Math.max(Number(options.limit || 50), 1), 200)
const orders = await query<WorkOrderRow>(
`
SELECT * FROM work_orders
WHERE status = 'internal_matching'
ORDER BY reward_amount ASC, created_at ASC
LIMIT $1
`,
[limit],
)
let matched = 0
for (const workOrder of orders.rows) {
try {
const profile = await resolveProfileForWorkOrder(workOrder)
if (!profile) continue
if (profile.config.matching.mode !== 'auto' || !profile.config.matching.autoMatchEnabled) {
continue
}
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
(inventory) => inventoryMatchesProfile(inventory, profile),
)
const candidates = pickCandidateInventories(workOrder, profile, inventories)
if (candidates.length === 0) continue
if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) {
const created = await applySharingMatch({ workOrder, profile }, candidates)
if (created > 0) matched += 1
} else {
const applied = await applyWholeOrderMatch({ workOrder, profile }, candidates[0]!)
if (applied) matched += 1
}
} catch {
// 单笔失败不影响同批其它工单;下一次调度会重试。
}
}
return { scanned: orders.rows.length, matched }
}
/** 内部匹配超时:发回大厅(保留已占库存的拼单份额,剩余份数继续抢单)。 */
export async function expireConsignmentInternalMatching(options: { now?: string } = {}) {
const now = options.now || nowIso()
const expired = await query<WorkOrderRow>(
`
UPDATE work_orders
SET status = 'open',
published_at = COALESCE(published_at, $1),
hall_visible_at = NULL,
internal_match_deadline_at = NULL,
updated_at = $1
WHERE status = 'internal_matching'
AND internal_match_deadline_at IS NOT NULL
AND internal_match_deadline_at <= $1
RETURNING *
`,
[now],
)
for (const workOrder of expired.rows) {
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_internal_matching_expired', 'internal_matching', 'open', $2::jsonb, $3)
`,
[workOrder.id, JSON.stringify({ expiredAt: now }), now],
)
}
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 options = pickCandidateInventories(workOrder, profile, inventories).map((inventory) => ({
inventoryId: Number(inventory.id),
inventoryNo: inventory.inventory_no,
itemName: inventory.item_name,
system: inventory.system,
serverZone: inventory.server_zone,
series: inventory.series,
diamondLevel: Number(inventory.diamond_level || 1),
brillianceCount: Number(inventory.brilliance_count || 0),
quantity: Number(inventory.quantity || 1),
remainingQuantity: Number(inventory.remaining_quantity ?? 1),
salePriceFen: inventorySalePriceFen(inventory),
listedAt: inventory.listed_at || inventory.inbound_at,
workerId: inventory.worker_id ? Number(inventory.worker_id) : null,
workerName: inventory.worker_name || '',
}))
const active = await query<{ active: number | string }>(
`SELECT COALESCE(SUM(quantity), 0) AS active FROM work_order_shares WHERE work_order_id = $1 AND status IN ('joined', 'submitted', 'accepted')`,
[workOrder.id],
)
return {
workOrderId: Number(workOrder.id),
profileId: profile?.profileId ?? null,
sharingEnabled: Boolean(workOrder.sharing_enabled),
totalQuantity: Math.max(1, Number(workOrder.sharing_total_quantity || 1)),
activeQuantity: Number(active.rows[0]?.active || 0),
options,
}
}
/** 后台手动匹配:选中一条库存走整单;拼单工单可多选库存,每份一条份额。 */
export async function matchConsignmentWorkOrderManually(input: {
workOrderId: number
inventoryIds: number[]
actorName?: string
}): Promise<{ matched: boolean; matchedCount: number }> {
const workOrder = await getWorkOrderById(input.workOrderId)
if (!workOrder) {
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
}
if (
![
WORK_ORDER_STATUS.INTERNAL_MATCHING,
WORK_ORDER_STATUS.UNASSIGNED,
WORK_ORDER_STATUS.OPEN,
].includes(workOrder.status as never)
) {
throw createHttpError('当前工单状态不允许内部库存匹配', {
statusCode: 409,
errorCode: 'work_order_not_matchable',
})
}
const profile = await resolveProfileForWorkOrder(workOrder)
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter((inventory) =>
inventoryMatchesProfile(inventory, profile),
)
const selected = input.inventoryIds
.map((id) => inventories.find((inventory) => Number(inventory.id) === Number(id)))
.filter((inventory): inventory is ConsignmentInventoryRow => Boolean(inventory))
if (selected.length === 0) {
throw createHttpError('请选择可用的寄卖库存', {
statusCode: 400,
errorCode: 'consignment_inventory_required',
})
}
const appliedWorkOrder = { ...workOrder, status: WORK_ORDER_STATUS.INTERNAL_MATCHING }
if (!workOrder.sharing_enabled) {
await query(
`UPDATE work_orders SET status = 'internal_matching', updated_at = $1 WHERE id = $2 AND status = ANY($3::text[])`,
[nowIso(), workOrder.id, [WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN]],
)
}
if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) {
// 手动匹配前先把工单置为内部匹配中,复用同一套份额事务。
await query(
`UPDATE work_orders SET status = 'internal_matching', updated_at = $1 WHERE id = $2 AND status = ANY($3::text[])`,
[nowIso(), workOrder.id, [WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN]],
)
const created = await applySharingMatch({ workOrder: appliedWorkOrder, profile }, selected)
return { matched: created > 0, matchedCount: created }
}
const applied = await applyWholeOrderMatch({ workOrder: appliedWorkOrder, profile }, selected[0]!)
return { matched: applied, matchedCount: applied ? 1 : 0 }
}
/** 工单取消/退回大厅时释放所有未消耗的寄卖库存。 */
export async function releaseConsignmentForWorkOrder(workOrderId: number): Promise<number> {
const now = nowIso()
return withTransaction((client) =>
releaseConsignmentAllocationsForWorkOrderWithClient(client, workOrderId, now),
)
}
let matchTimer: NodeJS.Timeout | null = null
/** 轻量调度:每 30 秒扫描一次内部匹配与超时;unref 不阻塞进程退出。 */
export function startConsignmentMatchScheduler(intervalMs = 30_000) {
if (matchTimer) return
matchTimer = setInterval(
() => {
void autoMatchConsignmentWorkOrders().catch(() => undefined)
void expireConsignmentInternalMatching().catch(() => undefined)
},
Math.max(5_000, intervalMs),
)
matchTimer.unref?.()
}
export function stopConsignmentMatchScheduler() {
if (matchTimer) {
clearInterval(matchTimer)
matchTimer = null
}
}
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import {
buildDepositAmountFen,
buildServiceFeeFen,
normalizeConsignmentProfileConfig,
resolvePriceDropSuggestion,
} from './consignment-profile-service.js'
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.timeout.minutes, 30)
assert.equal(config.internalMatch.delayMinutes, 30)
assert.equal(config.fields.quantity, true)
})
test('寄卖配置:非法枚举与越界数字回落到安全值', () => {
const config = normalizeConsignmentProfileConfig({
deposit: { mode: 'bad', fixedAmountFen: -1, unitAmountFen: 'x' },
serviceFee: { mode: 'bad', percent: 500 },
matching: { mode: 'bad' },
timeout: { policy: 'bad', minutes: -10 },
priceDrop: { hours: 0, mode: 'bad', value: -5 },
})
assert.equal(config.deposit.mode, 'fixed')
assert.equal(config.deposit.fixedAmountFen, 0)
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.timeout.policy, 'reopen')
assert.equal(config.timeout.minutes, 0)
assert.equal(config.priceDrop.hours, 1)
assert.equal(config.priceDrop.value, 0)
})
test('保证金:固定金额或按数量计算', () => {
const fixed = normalizeConsignmentProfileConfig({
deposit: { enabled: true, mode: 'fixed', fixedAmountFen: 2000 },
})
assert.equal(buildDepositAmountFen(fixed, 3), 2000)
const perQuantity = normalizeConsignmentProfileConfig({
deposit: { enabled: true, mode: 'per_quantity', unitAmountFen: 500 },
})
assert.equal(buildDepositAmountFen(perQuantity, 3), 1500)
assert.equal(buildDepositAmountFen(perQuantity, 1), 500)
})
test('服务费:按比例或固定金额,且不超过成交金额', () => {
const percent = normalizeConsignmentProfileConfig({
serviceFee: { enabled: true, mode: 'percent', percent: 10 },
})
assert.equal(buildServiceFeeFen(percent, 12_800), 1_280)
const fixed = normalizeConsignmentProfileConfig({
serviceFee: { enabled: true, mode: 'fixed', amountFen: 500 },
})
assert.equal(buildServiceFeeFen(fixed, 1_200), 500)
assert.equal(buildServiceFeeFen(fixed, 300), 300)
})
test('降价提示:未到时长不提示,到点后按比例给出建议售价', () => {
const config = normalizeConsignmentProfileConfig({
priceDrop: { enabled: true, hours: 24, mode: 'percent', value: 10 },
})
const listedAt = '2026-01-01T00:00:00.000Z'
assert.equal(
resolvePriceDropSuggestion(config, {
listedAt,
salePriceFen: 10_000,
now: '2026-01-01T12:00:00.000Z',
}),
null,
)
const suggestion = resolvePriceDropSuggestion(config, {
listedAt,
salePriceFen: 10_000,
now: '2026-01-03T00:00:00.000Z',
})
assert.equal(suggestion?.suggestedPriceFen, 9_000)
assert.equal(suggestion?.dropHours, 24)
})
test('迁移 129 必须包含寄卖配置、库存数量/售价与分配表', async () => {
const source = await readFile(
new URL('../../db/migrations/129_consignment_matching.sql', import.meta.url),
'utf8',
)
for (const fragment of [
'CREATE TABLE IF NOT EXISTS consignment_profiles',
'CREATE TABLE IF NOT EXISTS consignment_profile_versions',
'remaining_quantity',
'sale_price_fen',
'consignment_inventory_allocations',
'internal_match_deadline_at',
'consignment_inventory_id',
]) {
assert.ok(source.includes(fragment), `迁移 129 缺少 ${fragment}`)
}
})
@@ -0,0 +1,624 @@
import { query, withTransaction } from '../../db/client.js'
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'
export type ConsignmentPricingConfig = {
useTemplatePrice: boolean
allowWorkerPrice: boolean
minPriceFen: number
}
export type ConsignmentDepositConfig = {
enabled: boolean
mode: 'fixed' | 'per_quantity'
fixedAmountFen: number
unitAmountFen: number
}
export type ConsignmentServiceFeeConfig = {
enabled: boolean
mode: 'percent' | 'fixed'
percent: number
amountFen: number
}
export type ConsignmentInventoryLimitConfig = {
enabled: boolean
maxActiveQuantity: number
}
export type ConsignmentMatchingConfig = {
/** manual = 只允许人工匹配;auto = 调度器自动匹配 */
mode: 'manual' | 'auto'
autoMatchEnabled: boolean
exactOnly: boolean
preferLowReward: boolean
}
export type ConsignmentInternalMatchConfig = {
/** 资料提交后等待内部库存匹配的分钟数;0 = 不等待,直接发大厅 */
delayMinutes: number
fallbackToHall: boolean
}
export type ConsignmentTimeoutConfig = {
/** 匹配成功进入代练中后的时限(分钟),默认 30 */
minutes: number
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
}
export type ConsignmentPriceDropConfig = {
enabled: boolean
hours: number
mode: 'percent' | 'fixed'
value: number
}
export type ConsignmentPublishFields = {
system: boolean
serverZone: boolean
series: boolean
diamondLevel: boolean
brillianceCount: boolean
quantity: boolean
}
export type ConsignmentProfileConfig = {
version: 1
tabTitle: string
tabDescription: string
fields: ConsignmentPublishFields
pricing: ConsignmentPricingConfig
deposit: ConsignmentDepositConfig
serviceFee: ConsignmentServiceFeeConfig
inventoryLimit: ConsignmentInventoryLimitConfig
matching: ConsignmentMatchingConfig
internalMatch: ConsignmentInternalMatchConfig
timeout: ConsignmentTimeoutConfig
priceDrop: ConsignmentPriceDropConfig
}
export type ConsignmentProfileRow = {
id: number
category_id: number
name: string
title: string
description: string
tab_title: string
config_json: unknown
status: 'draft' | 'published' | 'archived'
current_version_id: number | null
created_by_admin_user_id: number | null
created_at: string
updated_at: string
category_name?: string
category_parent_id?: number | null
current_version_no?: number | null
}
export type ConsignmentProfileSnapshot = {
profileId: number
categoryId: number
versionId: number
versionNo: number
config: ConsignmentProfileConfig
}
export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
version: 1,
tabTitle: '',
tabDescription: '',
fields: {
system: true,
serverZone: true,
series: true,
diamondLevel: true,
brillianceCount: true,
quantity: true,
},
pricing: {
useTemplatePrice: true,
allowWorkerPrice: false,
minPriceFen: 0,
},
deposit: {
enabled: false,
mode: 'fixed',
fixedAmountFen: 0,
unitAmountFen: 0,
},
serviceFee: {
enabled: false,
mode: 'percent',
percent: 0,
amountFen: 0,
},
inventoryLimit: {
enabled: false,
maxActiveQuantity: 0,
},
matching: {
mode: 'manual',
autoMatchEnabled: false,
exactOnly: true,
preferLowReward: true,
},
internalMatch: {
delayMinutes: 30,
fallbackToHall: true,
},
timeout: {
minutes: 30,
policy: 'reopen',
},
priceDrop: {
enabled: false,
hours: 72,
mode: 'percent',
value: 10,
},
}
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)))
}
export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentProfileConfig {
const source = record(value)
const fields = record(source.fields)
const pricing = record(source.pricing)
const deposit = record(source.deposit)
const serviceFee = record(source.serviceFee)
const inventoryLimit = record(source.inventoryLimit)
const matching = record(source.matching)
const internalMatch = record(source.internalMatch)
const timeout = record(source.timeout)
const priceDrop = record(source.priceDrop)
const matchingMode = matching.mode === 'auto' ? 'auto' : 'manual'
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'
? timeout.policy
: 'reopen'
return {
version: 1,
tabTitle: text(source.tabTitle, 30),
tabDescription: text(source.tabDescription, 200),
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,
),
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),
},
deposit: {
enabled: bool(deposit.enabled, false),
mode: depositMode,
fixedAmountFen: integer(deposit.fixedAmountFen, 0, 0, 100_000_000),
unitAmountFen: integer(deposit.unitAmountFen, 0, 0, 100_000_000),
},
serviceFee: {
enabled: bool(serviceFee.enabled, false),
mode: serviceFeeMode,
percent: integer(serviceFee.percent, 0, 0, 100),
amountFen: integer(serviceFee.amountFen, 0, 0, 100_000_000),
},
inventoryLimit: {
enabled: bool(inventoryLimit.enabled, false),
maxActiveQuantity: integer(inventoryLimit.maxActiveQuantity, 0, 0, 100_000),
},
matching: {
mode: matchingMode,
autoMatchEnabled: matchingMode === 'auto' ? bool(matching.autoMatchEnabled, true) : false,
exactOnly: bool(matching.exactOnly, true),
preferLowReward: bool(matching.preferLowReward, true),
},
internalMatch: {
delayMinutes: integer(internalMatch.delayMinutes, 30, 0, 7 * 24 * 60),
fallbackToHall: bool(internalMatch.fallbackToHall, true),
},
timeout: {
minutes: integer(timeout.minutes, 30, 0, 7 * 24 * 60),
policy: timeoutPolicy,
},
priceDrop: {
enabled: bool(priceDrop.enabled, false),
hours: integer(priceDrop.hours, 72, 1, 24 * 365),
mode: priceDropMode,
value: integer(priceDrop.value, 10, 0, 100_000_000),
},
}
}
function mapProfile(row: ConsignmentProfileRow) {
return {
profileId: Number(row.id),
categoryId: Number(row.category_id),
categoryName: row.category_name || '',
categoryParentId: row.category_parent_id ? Number(row.category_parent_id) : null,
name: row.name,
title: row.title || '',
description: row.description || '',
tabTitle: row.tab_title || '',
status: row.status,
currentVersionId: row.current_version_id ? Number(row.current_version_id) : null,
currentVersionNo: row.current_version_no ? Number(row.current_version_no) : null,
config: normalizeConsignmentProfileConfig(row.config_json),
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
const PROFILE_SELECT = `
SELECT
p.*,
c.name AS category_name,
c.parent_id AS category_parent_id,
v.version_no AS current_version_no
FROM consignment_profiles p
INNER JOIN work_categories c ON c.id = p.category_id
LEFT JOIN consignment_profile_versions v ON v.id = p.current_version_id
`
export async function listAdminConsignmentProfiles() {
const [categories, profiles] = await Promise.all([
listAllWorkCategories(),
query<ConsignmentProfileRow>(`${PROFILE_SELECT} ORDER BY c.sort_order ASC, p.id ASC`),
])
return {
categories: categories.map((category) => ({
categoryId: Number(category.id),
categoryKey: category.category_key,
name: category.name,
parentId: category.parent_id ? Number(category.parent_id) : null,
status: category.status,
})),
profiles: profiles.rows.map(mapProfile),
}
}
export async function saveAdminConsignmentProfile(payload: JsonObject = {}) {
const categoryId = Number(payload.categoryId ?? payload.category_id ?? 0)
if (!Number.isInteger(categoryId) || categoryId <= 0) {
throw createHttpError('请选择寄卖分类', {
statusCode: 400,
errorCode: 'consignment_category_required',
})
}
const categoryResult = await query<{ id: number }>(
'SELECT id FROM work_categories WHERE id = $1 LIMIT 1',
[categoryId],
)
if (!categoryResult.rows[0]) {
throw createHttpError('分类不存在', {
statusCode: 404,
errorCode: 'work_category_not_found',
})
}
const config = normalizeConsignmentProfileConfig(payload.config ?? payload.config_json)
const now = nowIso()
const adminUserId = Number(payload.adminUserId ?? payload.admin_user_id ?? 0) || null
const name = text(payload.name, 80) || '寄卖配置'
const title = text(payload.title, 120)
const description = text(payload.description, 500)
const tabTitle = text(payload.tabTitle, 30) || config.tabTitle || name
const result = await query<ConsignmentProfileRow>(
`
INSERT INTO consignment_profiles (
category_id, name, title, description, tab_title, config_json,
status, created_by_admin_user_id, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'draft', $7, $8, $8)
ON CONFLICT (category_id) DO UPDATE SET
name = EXCLUDED.name,
title = EXCLUDED.title,
description = EXCLUDED.description,
tab_title = EXCLUDED.tab_title,
config_json = EXCLUDED.config_json,
updated_at = EXCLUDED.updated_at
RETURNING *
`,
[
categoryId,
name,
title,
description,
tabTitle,
JSON.stringify({ ...config, tabTitle }),
adminUserId,
now,
],
)
const profiles = await query<ConsignmentProfileRow>(`${PROFILE_SELECT} WHERE p.id = $1`, [
result.rows[0]!.id,
])
return { profile: mapProfile(profiles.rows[0]!) }
}
export async function publishAdminConsignmentProfile(
profileIdInput: number | string,
adminUserIdInput: unknown = null,
) {
const profileId = Number(profileIdInput)
if (!Number.isInteger(profileId) || profileId <= 0) {
throw createHttpError('寄卖配置不存在', {
statusCode: 400,
errorCode: 'consignment_profile_invalid',
})
}
const adminUserId = Number(adminUserIdInput ?? 0) || null
return withTransaction(async (client) => {
const locked = await client.query<ConsignmentProfileRow>(
'SELECT * FROM consignment_profiles WHERE id = $1 FOR UPDATE',
[profileId],
)
const profile = locked.rows[0]
if (!profile) {
throw createHttpError('寄卖配置不存在', {
statusCode: 404,
errorCode: 'consignment_profile_not_found',
})
}
const config = normalizeConsignmentProfileConfig(profile.config_json)
const maxVersion = await client.query<{ max_version: number | null }>(
'SELECT MAX(version_no) AS max_version FROM consignment_profile_versions WHERE profile_id = $1',
[profileId],
)
const versionNo = Number(maxVersion.rows[0]?.max_version || 0) + 1
const now = nowIso()
const version = await client.query<{ id: number }>(
`
INSERT INTO consignment_profile_versions (
profile_id, version_no, config_json, published_by_admin_user_id, published_at, created_at
) VALUES ($1, $2, $3::jsonb, $4, $5, $5)
RETURNING id
`,
[profileId, versionNo, JSON.stringify(config), adminUserId, now],
)
await client.query(
`
UPDATE consignment_profiles
SET status = 'published', current_version_id = $1, updated_at = $2
WHERE id = $3
`,
[version.rows[0]!.id, now, profileId],
)
const profiles = await client.query<ConsignmentProfileRow>(
`${PROFILE_SELECT} WHERE p.id = $1`,
[profileId],
)
return { profile: mapProfile(profiles.rows[0]!) }
})
}
export async function archiveAdminConsignmentProfile(profileIdInput: number | string) {
const profileId = Number(profileIdInput)
if (!Number.isInteger(profileId) || profileId <= 0) {
throw createHttpError('寄卖配置不存在', {
statusCode: 400,
errorCode: 'consignment_profile_invalid',
})
}
const result = await query<ConsignmentProfileRow>(
`UPDATE consignment_profiles SET status = 'archived', updated_at = $1 WHERE id = $2 RETURNING *`,
[nowIso(), profileId],
)
if (!result.rows[0]) {
throw createHttpError('寄卖配置不存在', {
statusCode: 404,
errorCode: 'consignment_profile_not_found',
})
}
return { profile: mapProfile(result.rows[0]) }
}
/** 打手端:只返回已发布且分类启用的寄卖配置,按分类排序生成 tab。 */
export async function listWorkerConsignmentProfiles() {
const result = await query<ConsignmentProfileRow>(
`
${PROFILE_SELECT}
WHERE p.status = 'published' AND c.status = 'active'
ORDER BY c.sort_order ASC, p.id ASC
`,
)
return result.rows.map((row) => {
const config = normalizeConsignmentProfileConfig(row.config_json)
return {
profileId: Number(row.id),
categoryId: Number(row.category_id),
categoryName: row.category_name || '',
name: row.name,
title: row.title || row.name,
description: row.description || '',
tabTitle: row.tab_title || config.tabTitle || row.name,
tabDescription: config.tabDescription,
config,
}
})
}
/**
* 工单分类解析寄卖配置:优先二级分类自身,没有则回退一级分类。
* 只认已发布版本,返回可直接固化的快照。
*/
export async function resolveConsignmentProfileForCategory(
categoryIdInput: number | null | undefined,
): Promise<ConsignmentProfileSnapshot | null> {
const categoryId = Number(categoryIdInput || 0)
if (!Number.isInteger(categoryId) || categoryId <= 0) return null
const categoryResult = await query<{ id: number; parent_id: number | null }>(
'SELECT id, parent_id FROM work_categories WHERE id = $1 LIMIT 1',
[categoryId],
)
const category = categoryResult.rows[0]
if (!category) return null
const candidates = [Number(category.id)]
if (category.parent_id) candidates.push(Number(category.parent_id))
for (const candidate of candidates) {
const result = await query<ConsignmentProfileRow>(
`${PROFILE_SELECT} WHERE p.category_id = $1 AND p.status = 'published' LIMIT 1`,
[candidate],
)
const row = result.rows[0]
if (!row || !row.current_version_id) continue
return {
profileId: Number(row.id),
categoryId: Number(row.category_id),
versionId: Number(row.current_version_id),
versionNo: Number(row.current_version_no || 1),
config: normalizeConsignmentProfileConfig(row.config_json),
}
}
return null
}
export async function getPublishedConsignmentProfileById(
profileIdInput: number | string,
): Promise<ConsignmentProfileSnapshot | null> {
const profileId = Number(profileIdInput)
if (!Number.isInteger(profileId) || profileId <= 0) return null
const result = await query<ConsignmentProfileRow>(
`${PROFILE_SELECT} WHERE p.id = $1 AND p.status = 'published' LIMIT 1`,
[profileId],
)
const row = result.rows[0]
if (!row || !row.current_version_id) return null
return {
profileId: Number(row.id),
categoryId: Number(row.category_id),
versionId: Number(row.current_version_id),
versionNo: Number(row.current_version_no || 1),
config: normalizeConsignmentProfileConfig(row.config_json),
}
}
/** 打手当前在售 + 待审核的寄卖数量(按件数,供单人寄售上限校验)。 */
export async function countWorkerActiveConsignmentQuantity(workerId: number): Promise<number> {
const result = await query<{ total: number | string }>(
`
SELECT
COALESCE((
SELECT SUM(GREATEST(remaining_quantity, 0))
FROM consignment_inventories
WHERE worker_id = $1 AND status = 'in_stock'
), 0)
+ COALESCE((
SELECT SUM(GREATEST(quantity, 1))
FROM consignment_submissions
WHERE worker_id = $1 AND status = 'pending_review'
), 0) AS total
`,
[workerId],
)
return Math.max(0, Math.round(Number(result.rows[0]?.total || 0)))
}
export function buildDepositAmountFen(config: ConsignmentProfileConfig, quantity: number): number {
if (!config.deposit.enabled) return 0
if (config.deposit.mode === 'per_quantity') {
return Math.max(0, Math.round(config.deposit.unitAmountFen)) * Math.max(1, quantity)
}
return Math.max(0, Math.round(config.deposit.fixedAmountFen))
}
export function buildServiceFeeFen(
config: ConsignmentProfileConfig,
grossAmountFen: number,
): number {
if (!config.serviceFee.enabled) return 0
const gross = Math.max(0, Math.round(grossAmountFen))
if (config.serviceFee.mode === 'fixed') {
return Math.min(gross, Math.max(0, Math.round(config.serviceFee.amountFen)))
}
return Math.min(gross, Math.round((gross * config.serviceFee.percent) / 100))
}
/** 降价提示:未售出超过配置时长时给出建议售价(仅提示,不自动改价)。 */
export function resolvePriceDropSuggestion(
config: ConsignmentProfileConfig,
input: { listedAt: string | null | undefined; salePriceFen: number; now: string },
): {
suggestedPriceFen: number
dropHours: number
dropMode: 'percent' | 'fixed'
dropValue: number
} | null {
if (!config.priceDrop.enabled) return null
const listedAtMs = Date.parse(String(input.listedAt || ''))
const nowMs = Date.parse(input.now)
if (!Number.isFinite(listedAtMs) || !Number.isFinite(nowMs)) return null
if (nowMs - listedAtMs < config.priceDrop.hours * 60 * 60 * 1000) return null
const price = Math.max(0, Math.round(input.salePriceFen))
const suggested =
config.priceDrop.mode === 'fixed'
? Math.max(0, price - config.priceDrop.value)
: Math.max(0, Math.round((price * (100 - config.priceDrop.value)) / 100))
if (suggested >= price) return null
return {
suggestedPriceFen: suggested,
dropHours: config.priceDrop.hours,
dropMode: config.priceDrop.mode,
dropValue: config.priceDrop.value,
}
}
@@ -2,11 +2,13 @@ import { withTransaction } from '../../db/client.js'
import { freezeConsignmentPublishDepositWithClient } from '../../repositories/worker-platform/consignment-deposit-repo.js'
import {
createConsignmentSubmissionWithClient,
getConsignmentInventoryById,
getConsignmentInventoryBySubmissionId,
getConsignmentSubmissionById,
listConsignmentInventoriesByIds,
listConsignmentSubmissionsByIds,
listWorkerConsignmentPage,
updateConsignmentInventory,
type WorkerConsignmentGroup,
} from '../../repositories/worker-platform/consignment-repo.js'
import { getWorkerUserById } from '../../repositories/worker-platform/index.js'
@@ -16,6 +18,13 @@ import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js'
import { canWorkerPublishConsignment } from '../../domain/consignment-whitelist.js'
import { resolveEnabledConsignmentContact } from './consignment-contact-service.js'
import {
buildDepositAmountFen,
countWorkerActiveConsignmentQuantity,
getPublishedConsignmentProfileById,
normalizeConsignmentProfileConfig,
resolvePriceDropSuggestion,
} from './consignment-profile-service.js'
import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js'
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js'
import type { JsonObject } from '../../types/json.js'
@@ -157,6 +166,51 @@ export function getConsignmentPublishDepositFen() {
return getConfiguredConsignmentPublishDepositFen()
}
/** 打手修改在售寄卖售价(仅限本人、未售出的库存)。 */
export async function updateWorkerConsignmentSalePrice(
workerId: number,
submissionIdInput: number | string,
priceFenInput: unknown,
) {
const submission = await getConsignmentSubmissionById(submissionIdInput)
if (!submission || Number(submission.worker_id || 0) !== Number(workerId)) {
throw createHttpError('寄卖单不存在', {
statusCode: 404,
errorCode: 'consignment_submission_not_found',
})
}
if (submission.status !== 'approved' || !submission.result_inventory_id) {
throw createHttpError('该寄卖单尚未入库,不能改价', {
statusCode: 409,
errorCode: 'consignment_not_in_stock',
})
}
const inventory = await getConsignmentInventoryById(Number(submission.result_inventory_id))
if (!inventory || Number(inventory.worker_id || 0) !== Number(workerId)) {
throw createHttpError('寄卖库存不存在', {
statusCode: 404,
errorCode: 'consignment_inventory_not_found',
})
}
if (inventory.status !== 'in_stock') {
throw createHttpError('该寄卖库存已售出或已下架,不能改价', {
statusCode: 409,
errorCode: 'consignment_inventory_unavailable',
})
}
const priceFen = Math.max(100, Math.round(Number(priceFenInput || 0)))
const updated = await updateConsignmentInventory(inventory.id, {
salePriceFen: priceFen,
now: nowIso(),
})
return {
submissionId: Number(submission.id),
inventoryId: Number(inventory.id),
salePriceFen: Number(updated?.sale_price_fen || priceFen),
updatedAt: updated?.updated_at || nowIso(),
}
}
export async function publishWorkerConsignment(workerId: number, payload: JsonObject) {
const worker = await getWorkerUserById(workerId)
if (!worker) {
@@ -177,6 +231,29 @@ 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 =
Number.isInteger(profileIdInput) && profileIdInput > 0
? await getPublishedConsignmentProfileById(profileIdInput)
: null
if (Number.isInteger(profileIdInput) && profileIdInput > 0 && !profile) {
throw createHttpError('寄卖配置不存在或未发布', {
statusCode: 400,
errorCode: 'consignment_profile_unavailable',
})
}
const quantity = Math.max(1, Math.min(1000, Math.round(Number(payload.quantity || 1))))
if (profile?.config.inventoryLimit.enabled) {
const activeQuantity = await countWorkerActiveConsignmentQuantity(workerId)
if (activeQuantity + quantity > profile.config.inventoryLimit.maxActiveQuantity) {
throw createHttpError(
`超出单人寄售数量上限(最多 ${profile.config.inventoryLimit.maxActiveQuantity} 件,当前在售/待审核 ${activeQuantity} 件)`,
{ statusCode: 409, errorCode: 'consignment_quantity_limit_exceeded' },
)
}
}
const system = String(payload.system || '').trim()
const serverZone = String(payload.serverZone || '').trim()
const series = String(payload.series || '').trim()
@@ -261,9 +338,8 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
})
}
// 寄售价以「接单模板」为准:服务端按同一套规则复算,不信任前端传来的金额
// (卖家端已把该字段改成只读展示,这里再兜一道,避免直连接口随意定价)。
// 只有该规格在模板里完全没配过价时才回落到调用方传来的值。
// 寄售价:配置开启「模板价」时以接单模板为准;允许打手改价时以打手填写价为准
// (不低于配置的最低价)。未命中配置的存量调用保持旧行为。
const priceSuggestion = await getWorkerConsignmentPriceSuggestion({
system,
series,
@@ -271,10 +347,25 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
brillianceCount,
})
const templatePriceFen = Math.max(0, Math.round(Number(priceSuggestion.suggestedPriceFen || 0)))
const submittedPriceFen = Math.max(100, Math.round(Number(payload.askingPriceFen || 0)))
const askingPriceFen = templatePriceFen > 0 ? templatePriceFen : submittedPriceFen
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)
}
askingPriceFen = Math.max(100, askingPriceFen)
const depositAmountFen = getConsignmentPublishDepositFen()
const depositAmountFen = profile?.config.deposit.enabled
? buildDepositAmountFen(profile.config, quantity)
: getConsignmentPublishDepositFen()
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
const briStr = brillianceCount > 0 ? `${brillianceCount}璀璨` : ''
// 区服是「互通」(通配)时不拼进物品名:对买家无信息量,"安卓互通暗潮…"读着也别扭
@@ -289,7 +380,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
const created = await createConsignmentSubmissionWithClient(client, {
submissionNo,
gameName: '和平精英',
categoryId: null,
categoryId: profile?.categoryId ?? null,
itemName,
system,
serverZone,
@@ -306,9 +397,26 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
sellerNote: String(payload.sellerNote || '').trim(),
channel: 'worker_portal',
publishDepositFen: depositAmountFen,
profileId: profile?.profileId ?? null,
profileVersionId: profile?.versionId ?? null,
quantity,
salePriceFen: askingPriceFen,
},
screenshotsJson: screenshots,
askingPriceFen,
requestedSalePriceFen: askingPriceFen,
quantity,
profileId: profile?.profileId ?? null,
profileVersionId: profile?.versionId ?? null,
profileSnapshotJson: profile
? {
profileId: profile.profileId,
categoryId: profile.categoryId,
versionId: profile.versionId,
versionNo: profile.versionNo,
config: profile.config,
}
: null,
workerId: Number(worker.id),
workerName: worker.display_name || worker.username,
workerPhone: sellerPhone || worker.phone || '',
@@ -341,6 +449,12 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
diamondLevel: submission.diamond_level,
brillianceCount: submission.brilliance_count,
askingPriceFen: submission.asking_price_fen,
salePriceFen: Number(submission.requested_sale_price_fen || submission.asking_price_fen || 0),
quantity: Number(submission.quantity || 1),
profileId: submission.profile_id ? Number(submission.profile_id) : null,
profileVersionId: submission.profile_version_id
? Number(submission.profile_version_id)
: null,
status: submission.status,
workerId: submission.worker_id,
workerName: submission.worker_name,
@@ -473,6 +587,20 @@ export async function listWorkerConsignments(
diamondLevel: sub.diamond_level,
brillianceCount: sub.brilliance_count,
askingPriceFen: sub.asking_price_fen,
salePriceFen: Number(
inv?.sale_price_fen ?? sub.requested_sale_price_fen ?? sub.asking_price_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),
now: nowIso(),
})
: null,
depositAmountFen: sub.deposit_amount_fen,
...mapConsignmentContactSupporter(contactSupporter),
reviewNote: sub.review_note,
@@ -518,6 +646,18 @@ export async function listWorkerConsignments(
diamondLevel: inv.diamond_level,
brillianceCount: inv.brilliance_count,
askingPriceFen: inv.cost_price_fen,
salePriceFen: Number(inv.sale_price_fen || inv.cost_price_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), {
listedAt: inv.listed_at || inv.inbound_at,
salePriceFen: Number(inv.sale_price_fen || 0),
now: nowIso(),
})
: null,
depositAmountFen: inv.deposit_amount_fen,
...mapConsignmentContactSupporter(inv),
reviewNote: '',
@@ -8,6 +8,7 @@ import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { publishAdminWorkOrder } from './admin-work-order-management-service.js'
import { enterConsignmentInternalMatching } from './consignment-match-engine-service.js'
type Queryable = Pick<PoolClient, 'query'>
@@ -141,6 +142,13 @@ async function processAutoPublishJob(
return 'failed'
}
// 分类命中寄卖配置:先进入内部匹配,不发大厅;超时由调度任务发回大厅。
const enteredInternalMatching = await enterConsignmentInternalMatching(workOrderId)
if (enteredInternalMatching) {
await finishJob(job, 'skipped', '已进入寄卖内部匹配', { internalMatching: true })
return 'skipped'
}
const snapshot = safeParseJson(order.service_profile_snapshot_json)
const autoPublish =
snapshot.autoPublish && typeof snapshot.autoPublish === 'object'
@@ -17,6 +17,7 @@ import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
import { enterConsignmentInternalMatching } from './consignment-match-engine-service.js'
import { normalizeSubmittedFields, normalizeUploadedFiles } from './worker-input-normalizers.js'
import {
ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES,
@@ -386,6 +387,9 @@ export async function submitOrderServiceMaterial(
}
publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) })
// 命中分类寄卖配置时先进入内部匹配,不发大厅;超时由调度任务发回大厅。
await enterConsignmentInternalMatching(Number(result.order.id)).catch(() => false)
if (result.autoPublishEnabled) {
void processDueOrderServiceAutoPublishJobs(1).catch(() => undefined)
}
@@ -5,6 +5,7 @@ import { createHttpError } from '../../utils/http.js'
const PUBLIC_MATERIAL_STATUS_LABELS: Record<string, string> = {
[WORK_ORDER_STATUS.PENDING_MATERIAL]: '待完善',
[WORK_ORDER_STATUS.UNASSIGNED]: '待分配',
[WORK_ORDER_STATUS.INTERNAL_MATCHING]: '内部匹配中',
[WORK_ORDER_STATUS.OPEN]: '待抢单',
[WORK_ORDER_STATUS.IN_PROGRESS]: '代练中',
[WORK_ORDER_STATUS.PENDING_ACCEPTANCE]: '待验收',
@@ -11,6 +11,7 @@ import {
publishWorkOrderRealtimeChange,
} from '../realtime/realtime-event-service.js'
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
import { reenterConsignmentInternalMatching } from './consignment-match-engine-service.js'
export async function settleOverdueWorkOrders(options: { limit?: number; workerId?: number } = {}) {
const limit = Math.max(1, Number(options.limit || 50))
@@ -20,13 +21,18 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
let processedCount = 0
let skippedCount = 0
for (const workOrder of overdue) {
const policy = normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy)
const result = await settleOverdueWorkOrder({
workOrderId: workOrder.id,
policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy),
policy,
now: nowIso(),
})
if (result.order) {
processedCount += 1
// 退回大厅的寄卖单先尝试重新进入内部匹配;匹配不到再由内部匹配超时任务发回大厅。
if (policy === 'reopen') {
await reenterConsignmentInternalMatching(Number(workOrder.id)).catch(() => false)
}
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id),
workerIds: Number(workOrder.assigned_worker_id)