feat(consignment): 寄卖配置、内部匹配与打手端升级
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user