feat(consignment): 新增和平精英寄卖库存管理、定向收集审核与工单智能匹配履约

This commit is contained in:
yml2213
2026-09-14 11:39:15 +08:00
parent 38c0cf1223
commit 1aa6dba931
30 changed files with 6350 additions and 44 deletions
@@ -0,0 +1,494 @@
import {
type ConsignmentCollectLinkRow,
type ConsignmentInventoryRow,
type ConsignmentSubmissionRow,
approveConsignmentSubmissionWithInbound,
createConsignmentCollectLink,
createConsignmentInventory,
getConsignmentInventoryById,
getConsignmentInventoryStats,
getConsignmentSubmissionById,
listConsignmentCollectLinks,
listConsignmentInventories,
listConsignmentSubmissions,
rejectConsignmentSubmission,
returnConsignmentInventoryToStock,
updateConsignmentCollectLink,
updateConsignmentInventory,
} from '../../repositories/worker-platform/consignment-repo.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { generateConsignmentInventoryNo, randomToken } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
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,
itemName: row.item_name,
system: row.system,
serverZone: row.server_zone,
series: row.series,
diamondLevel: Number(row.diamond_level || 1),
brillianceCount: Number(row.brilliance_count || 0),
costPriceFen: Number(row.cost_price_fen || 0),
buyerName: row.buyer_name || '',
shopName: row.shop_name || '',
inboundBatch: row.inbound_batch || '',
sellerContact: row.seller_contact || '',
sellerGameId: row.seller_game_id || '',
sellerPaymentInfo: row.seller_payment_info || '',
sellerPhone: row.seller_phone || '',
credentialsJson:
row.credentials_json && typeof row.credentials_json === 'object'
? (row.credentials_json as Record<string, unknown>)
: safeParseJson(row.credentials_json),
screenshotsJson: Array.isArray(row.screenshots_json)
? row.screenshots_json
: Array.isArray(safeParseJson(row.screenshots_json))
? (safeParseJson(row.screenshots_json) as unknown[])
: [],
remark: row.remark || '',
allowMatching: Boolean(row.allow_matching),
status: row.status,
allocationStatus: row.allocation_status || '待安排',
boundWorkOrderId: row.bound_work_order_id ? Number(row.bound_work_order_id) : null,
boundPlatformOrderId: row.bound_platform_order_id || '',
sourceType: row.source_type,
sourceSubmissionId: row.source_submission_id ? Number(row.source_submission_id) : null,
inboundAt: row.inbound_at || row.created_at,
consumedAt: row.consumed_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export function mapConsignmentCollectLink(row: ConsignmentCollectLinkRow) {
return {
id: Number(row.id),
token: row.token,
title: row.title,
gameName: row.game_name,
categoryId: row.category_id ? Number(row.category_id) : null,
categoryName: row.category_name,
system: row.system,
serverZone: row.server_zone,
series: row.series,
diamondLevel: Number(row.diamond_level || 1),
brillianceCount: Number(row.brilliance_count || 0),
presetPriceFen: Number(row.preset_price_fen || 0),
inboundBatch: row.inbound_batch || '',
buyerName: row.buyer_name || '',
templateJson:
row.template_json && typeof row.template_json === 'object'
? (row.template_json as Record<string, unknown>)
: safeParseJson(row.template_json),
isMultiUse: Boolean(row.is_multi_use),
expiresAt: row.expires_at,
enabled: Boolean(row.enabled),
createdBy: row.created_by || '',
createdAt: row.created_at,
updatedAt: row.updated_at,
submissionCount: Number(row.submission_count || 0),
}
}
export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
return {
id: Number(row.id),
submissionNo: row.submission_no,
linkId: row.link_id ? Number(row.link_id) : null,
linkTitle: row.link_title,
gameName: row.game_name,
categoryId: row.category_id ? Number(row.category_id) : null,
categoryName: row.category_name,
itemName: row.item_name,
system: row.system,
serverZone: row.server_zone,
series: row.series,
diamondLevel: Number(row.diamond_level || 1),
brillianceCount: Number(row.brilliance_count || 0),
sellerGameId: row.seller_game_id || '',
sellerPaymentInfo: row.seller_payment_info || '',
sellerPhone: row.seller_phone || '',
contactInfo: row.contact_info || '',
contentJson:
row.content_json && typeof row.content_json === 'object'
? (row.content_json as Record<string, unknown>)
: safeParseJson(row.content_json),
screenshotsJson: Array.isArray(row.screenshots_json)
? row.screenshots_json
: Array.isArray(safeParseJson(row.screenshots_json))
? (safeParseJson(row.screenshots_json) as unknown[])
: [],
askingPriceFen: Number(row.asking_price_fen || 0),
status: row.status,
reviewNote: row.review_note || '',
reviewerName: row.reviewer_name || '',
reviewedAt: row.reviewed_at,
resultInventoryId: row.result_inventory_id ? Number(row.result_inventory_id) : null,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export async function fetchAdminConsignmentInventories(query: JsonObject = {}) {
const status = query.status ? String(query.status).trim() : undefined
const buyerName = query.buyerName ? String(query.buyerName).trim() : undefined
const shopName = query.shopName ? String(query.shopName).trim() : undefined
const inboundBatch = query.inboundBatch ? String(query.inboundBatch).trim() : undefined
const categoryId = query.categoryId ? Number(query.categoryId) : undefined
const system = query.system ? String(query.system).trim() : undefined
const serverZone = query.serverZone ? String(query.serverZone).trim() : undefined
const series = query.series ? String(query.series).trim() : undefined
const diamondLevel = query.diamondLevel !== undefined ? Number(query.diamondLevel) : undefined
const allowMatching =
query.allowMatching !== undefined ? String(query.allowMatching) === 'true' : undefined
const keyword = query.keyword ? String(query.keyword).trim() : undefined
const page = query.page ? Number(query.page) : 1
const pageSize = query.pageSize ? Number(query.pageSize) : 20
const res = await listConsignmentInventories({
status,
buyerName,
shopName,
inboundBatch,
categoryId,
system,
serverZone,
series,
diamondLevel,
allowMatching,
keyword,
page,
pageSize,
})
return {
items: res.items.map(mapConsignmentInventory),
total: res.total,
}
}
export async function fetchAdminConsignmentStats() {
return getConsignmentInventoryStats()
}
export async function getAdminConsignmentInventory(id: number | string) {
const item = await getConsignmentInventoryById(id)
if (!item) {
throw createHttpError('寄卖物品不存在', { statusCode: 404, errorCode: 'consignment_not_found' })
}
return mapConsignmentInventory(item)
}
export async function createAdminConsignmentInventory(payload: JsonObject, actorName = '') {
const itemName = String(payload.itemName || '').trim()
if (!itemName) {
throw createHttpError('物品名称不能为空', { statusCode: 400, errorCode: 'invalid_item_name' })
}
const now = nowIso()
const inventoryNo = generateConsignmentInventoryNo()
const costPriceFen = Math.max(0, Math.round(Number(payload.costPriceFen || 0)))
const row = await createConsignmentInventory({
inventoryNo,
gameName: String(payload.gameName || '和平精英').trim(),
categoryId: payload.categoryId ? Number(payload.categoryId) : null,
itemName,
system: String(payload.system || '').trim(),
serverZone: String(payload.serverZone || '').trim(),
series: String(payload.series || '').trim(),
diamondLevel: Math.max(1, Math.min(6, Number(payload.diamondLevel || 1))),
brillianceCount: Math.max(0, Math.min(6, Number(payload.brillianceCount || 0))),
costPriceFen,
buyerName: String(payload.buyerName || actorName || '').trim(),
inboundBatch: String(payload.inboundBatch || '').trim(),
sellerContact: String(payload.sellerContact || '').trim(),
sellerGameId: String(payload.sellerGameId || '').trim(),
sellerPaymentInfo: String(payload.sellerPaymentInfo || '').trim(),
sellerPhone: String(payload.sellerPhone || '').trim(),
credentialsJson:
payload.credentials && typeof payload.credentials === 'object'
? (payload.credentials as Record<string, unknown>)
: {},
screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : [],
remark: String(payload.remark || '').trim(),
allowMatching: payload.allowMatching !== false,
sourceType: 'manual',
now,
})
return mapConsignmentInventory(row)
}
export async function updateAdminConsignmentInventory(
id: number | string,
payload: JsonObject,
_actorName = '',
) {
const now = nowIso()
const updated = await updateConsignmentInventory(id, {
itemName: payload.itemName !== undefined ? String(payload.itemName).trim() : undefined,
system: payload.system !== undefined ? String(payload.system).trim() : undefined,
serverZone: payload.serverZone !== undefined ? String(payload.serverZone).trim() : undefined,
series: payload.series !== undefined ? String(payload.series).trim() : undefined,
diamondLevel: payload.diamondLevel !== undefined ? Number(payload.diamondLevel) : undefined,
brillianceCount:
payload.brillianceCount !== undefined ? Number(payload.brillianceCount) : undefined,
costPriceFen:
payload.costPriceFen !== undefined
? Math.max(0, Math.round(Number(payload.costPriceFen)))
: undefined,
buyerName: payload.buyerName !== undefined ? String(payload.buyerName).trim() : undefined,
inboundBatch:
payload.inboundBatch !== undefined ? String(payload.inboundBatch).trim() : undefined,
sellerContact:
payload.sellerContact !== undefined ? String(payload.sellerContact).trim() : undefined,
sellerGameId:
payload.sellerGameId !== undefined ? String(payload.sellerGameId).trim() : undefined,
sellerPaymentInfo:
payload.sellerPaymentInfo !== undefined
? String(payload.sellerPaymentInfo).trim()
: undefined,
sellerPhone: payload.sellerPhone !== undefined ? String(payload.sellerPhone).trim() : undefined,
credentialsJson:
payload.credentials && typeof payload.credentials === 'object'
? (payload.credentials as Record<string, unknown>)
: undefined,
screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : undefined,
remark: payload.remark !== undefined ? String(payload.remark).trim() : undefined,
allowMatching: payload.allowMatching !== undefined ? Boolean(payload.allowMatching) : undefined,
status: payload.status !== undefined ? (payload.status as any) : undefined,
now,
})
if (!updated) {
throw createHttpError('寄卖物品不存在', { statusCode: 404, errorCode: 'consignment_not_found' })
}
return mapConsignmentInventory(updated)
}
/** 快捷切换匹配开关 */
export async function toggleAdminConsignmentMatching(id: number | string, allowMatching: boolean) {
const now = nowIso()
const updated = await updateConsignmentInventory(id, {
allowMatching: Boolean(allowMatching),
now,
})
if (!updated) {
throw createHttpError('寄卖物品不存在', { statusCode: 404, errorCode: 'consignment_not_found' })
}
return mapConsignmentInventory(updated)
}
/** 物品下架作废 */
export async function archiveAdminConsignmentInventory(id: number | string) {
const now = nowIso()
const updated = await updateConsignmentInventory(id, {
status: 'archived',
allowMatching: false,
now,
})
if (!updated) {
throw createHttpError('寄卖物品不存在', { statusCode: 404, errorCode: 'consignment_not_found' })
}
return mapConsignmentInventory(updated)
}
/** 已出库物品退回复库 */
export async function returnAdminConsignmentInventory(
id: number | string,
payload: JsonObject,
_actorName = '',
) {
const now = nowIso()
const reason = String(payload.reason || '').trim()
const updated = await returnConsignmentInventoryToStock(id, reason, now)
if (!updated) {
throw createHttpError('只有已出库物品可以退回复库', {
statusCode: 409,
errorCode: 'consignment_not_returnable',
})
}
return mapConsignmentInventory(updated)
}
// ---------------- 收集链接管理 ----------------
export async function fetchAdminConsignmentLinks() {
const items = await listConsignmentCollectLinks()
return { items: items.map(mapConsignmentCollectLink) }
}
export async function createAdminConsignmentLink(payload: JsonObject, actorName = '') {
const title = String(payload.title || '').trim()
if (!title) {
throw createHttpError('链接标题不能为空', { statusCode: 400, errorCode: 'invalid_link_title' })
}
const now = nowIso()
const token = randomToken(16)
const row = await createConsignmentCollectLink({
token,
title,
gameName: String(payload.gameName || '和平精英').trim(),
categoryId: payload.categoryId ? Number(payload.categoryId) : null,
system: String(payload.system || '').trim(),
serverZone: String(payload.serverZone || '').trim(),
series: String(payload.series || '').trim(),
diamondLevel: Math.max(1, Math.min(6, Number(payload.diamondLevel || 1))),
brillianceCount: Math.max(0, Math.min(6, Number(payload.brillianceCount || 0))),
presetPriceFen: Math.max(0, Math.round(Number(payload.presetPriceFen || 0))),
inboundBatch: String(payload.inboundBatch || '').trim(),
buyerName: String(payload.buyerName || actorName || '').trim(),
templateJson:
payload.template && typeof payload.template === 'object'
? (payload.template as Record<string, unknown>)
: {},
isMultiUse: payload.isMultiUse !== false,
expiresAt: payload.expiresAt ? String(payload.expiresAt) : null,
createdBy: actorName,
now,
})
return mapConsignmentCollectLink(row)
}
export async function updateAdminConsignmentLink(id: number | string, payload: JsonObject) {
const now = nowIso()
const updated = await updateConsignmentCollectLink(id, {
title: payload.title !== undefined ? String(payload.title).trim() : undefined,
enabled: payload.enabled !== undefined ? Boolean(payload.enabled) : undefined,
now,
})
if (!updated) {
throw createHttpError('收集链接不存在', { statusCode: 404, errorCode: 'link_not_found' })
}
return mapConsignmentCollectLink(updated)
}
// ---------------- 提报单审核 ----------------
export async function fetchAdminConsignmentSubmissions(query: JsonObject = {}) {
const status = query.status ? String(query.status).trim() : undefined
const linkId = query.linkId ? Number(query.linkId) : undefined
const keyword = query.keyword ? String(query.keyword).trim() : undefined
const page = query.page ? Number(query.page) : 1
const pageSize = query.pageSize ? Number(query.pageSize) : 20
const res = await listConsignmentSubmissions({
status,
linkId,
keyword,
page,
pageSize,
})
return {
items: res.items.map(mapConsignmentSubmission),
total: res.total,
}
}
export async function getAdminConsignmentSubmission(id: number | string) {
const item = await getConsignmentSubmissionById(id)
if (!item) {
throw createHttpError('提报单不存在', { statusCode: 404, errorCode: 'submission_not_found' })
}
return mapConsignmentSubmission(item)
}
export async function approveAdminConsignmentSubmission(
id: number | string,
payload: JsonObject,
actorName = '',
) {
const submission = await getConsignmentSubmissionById(id)
if (!submission) {
throw createHttpError('提报单不存在', { statusCode: 404, errorCode: 'submission_not_found' })
}
if (submission.status !== 'pending_review') {
throw createHttpError('该提报单已被处理', {
statusCode: 409,
errorCode: 'submission_already_reviewed',
})
}
const now = nowIso()
const inventoryNo = generateConsignmentInventoryNo()
const costPriceFen = Math.max(
0,
Math.round(Number(payload.costPriceFen ?? submission.asking_price_fen ?? 0)),
)
const res = await approveConsignmentSubmissionWithInbound({
submissionId: id,
inventoryNo,
buyerName: String(payload.buyerName || actorName || '').trim(),
inboundBatch: String(payload.inboundBatch || '').trim(),
itemName: String(payload.itemName || submission.item_name).trim(),
system: String(payload.system || submission.system).trim(),
serverZone: String(payload.serverZone || submission.server_zone).trim(),
series: String(payload.series || submission.series).trim(),
diamondLevel: Math.max(
1,
Math.min(6, Number(payload.diamondLevel ?? submission.diamond_level ?? 1)),
),
brillianceCount: Math.max(
0,
Math.min(6, Number(payload.brillianceCount ?? submission.brilliance_count ?? 0)),
),
costPriceFen,
sellerContact: String(payload.sellerContact || submission.contact_info).trim(),
sellerGameId: String(payload.sellerGameId || submission.seller_game_id).trim(),
sellerPaymentInfo: String(payload.sellerPaymentInfo || submission.seller_payment_info).trim(),
sellerPhone: String(payload.sellerPhone || submission.seller_phone).trim(),
credentialsJson:
payload.credentials && typeof payload.credentials === 'object'
? (payload.credentials as Record<string, unknown>)
: undefined,
screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : undefined,
reviewNote: String(payload.reviewNote || '').trim(),
now,
})
return {
submission: mapConsignmentSubmission(res.submission),
inventory: mapConsignmentInventory(res.inventory),
}
}
export async function rejectAdminConsignmentSubmission(
id: number | string,
payload: JsonObject,
actorName = '',
) {
const note = String(payload.reviewNote || '').trim()
if (!note) {
throw createHttpError('请填写驳回原因', {
statusCode: 400,
errorCode: 'reject_reason_required',
})
}
const now = nowIso()
const updated = await rejectConsignmentSubmission({
submissionId: id,
reviewNote: note,
reviewerName: actorName,
now,
})
if (!updated) {
throw createHttpError('提报单不存在或已被处理', {
statusCode: 409,
errorCode: 'submission_already_reviewed',
})
}
return mapConsignmentSubmission(updated)
}
@@ -0,0 +1,385 @@
import type { PoolClient } from 'pg'
import { withTransaction } from '../../db/client.js'
import { WORK_ORDER_STATUS, type WorkOrderStatus } from '../../domain/work-order-status.js'
import {
type ConsignmentInventoryRow,
getConsignmentInventoryById,
listActiveConsignmentInventoriesForMatching,
} from '../../repositories/worker-platform/consignment-repo.js'
import {
createWorkOrderEvent,
getWorkOrderById,
type WorkOrderRow,
} from '../../repositories/worker-platform/index.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
import { mapWorkOrderAdmin } from './mappers.js'
import {
consumeIndustryVouchersBeforeWorkOrderPublish,
type WorkOrderVoucherConsumeResult,
} from './publish-voucher-service.js'
import {
type ConsignmentMatchResult,
findMatchingPeacekeeperInventories,
} from './consignment-matching-service.js'
import { mapConsignmentInventory } from './consignment-admin-service.js'
export type ConsignmentMatchResultView = Omit<ConsignmentMatchResult, 'inventory'> & {
inventory: ReturnType<typeof mapConsignmentInventory>
}
/**
* 检查工单对应的内部寄售库存匹配情况
*/
export async function checkWorkOrderInventoryMatches(workOrderId: number | string): Promise<{
hasMatch: boolean
exactCount: number
partialCount: number
matches: ConsignmentMatchResultView[]
}> {
const workOrder = await getWorkOrderById(workOrderId)
if (!workOrder) {
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
}
const material = safeParseJson(workOrder.material_json)
const collect = safeParseJson(material.collect)
const fields = safeParseJson(collect.fields)
const system = String(fields.system || '').trim()
const serverZone = String(fields.serverZone || '').trim()
const inventories = await listActiveConsignmentInventoriesForMatching()
const matches = findMatchingPeacekeeperInventories(workOrder.product_name, inventories, {
system,
serverZone,
})
const exactCount = matches.filter((m) => m.matchType === 'exact').length
const partialCount = matches.filter((m) => m.matchType === 'partial').length
return {
hasMatch: matches.length > 0,
exactCount,
partialCount,
matches: matches.map((m) => ({
...m,
inventory: mapConsignmentInventory(m.inventory),
})),
}
}
/**
* 选用内部寄售库存直接履约工单
*/
export async function fulfillWorkOrderWithConsignment(input: {
workOrderId: number | string
inventoryId: number | string
actorName: string
}): Promise<{
order: ReturnType<typeof mapWorkOrderAdmin>
inventory: ReturnType<typeof mapConsignmentInventory>
voucherConsume: WorkOrderVoucherConsumeResult
}> {
const workOrder = await getWorkOrderById(input.workOrderId)
if (!workOrder) {
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
}
// 仅未分配或待补资料工单支持选用库存履约
if (
![WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.PENDING_MATERIAL].includes(
workOrder.status as any,
)
) {
throw createHttpError('当前工单状态不允许直接使用内部库存履约', {
statusCode: 409,
errorCode: 'work_order_status_not_fulfillable',
})
}
const inventory = await getConsignmentInventoryById(input.inventoryId)
if (!inventory) {
throw createHttpError('寄卖物品不存在', {
statusCode: 404,
errorCode: 'consignment_inventory_not_found',
})
}
if (inventory.status !== 'in_stock' || !inventory.allow_matching) {
throw createHttpError('该寄卖物品不可用或已被暂停匹配', {
statusCode: 409,
errorCode: 'consignment_inventory_unavailable',
})
}
const material = safeParseJson(workOrder.material_json)
const source = safeParseJson(material.source)
const shopName = String(source.shopName || source.shopTitle || '').trim()
const now = nowIso()
// 开启原子事务扣减库存并流转工单
const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => {
const invSql = `
UPDATE consignment_inventories
SET
status = 'consumed',
allocation_status = '已安排',
bound_order_id = $1,
bound_work_order_id = $2,
bound_platform_order_id = $3,
shop_name = $4,
consumed_at = $5,
updated_at = $5
WHERE id = $6 AND status = 'in_stock'
RETURNING *
`
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
workOrder.order_id || null,
workOrder.id,
workOrder.platform_order_id || '',
shopName,
now,
inventory.id,
])
const inv = invRes.rows[0]
if (!inv) {
throw createHttpError('寄卖库存已被其他操作占用,请刷新重试', {
statusCode: 409,
errorCode: 'consignment_inventory_race_condition',
})
}
const updatedMaterial = {
...material,
consignment: {
inventoryId: Number(inv.id),
inventoryNo: inv.inventory_no,
itemName: inv.item_name,
system: inv.system,
serverZone: inv.server_zone,
series: inv.series,
diamondLevel: inv.diamond_level,
brillianceCount: inv.brilliance_count,
buyerName: inv.buyer_name,
inboundBatch: inv.inbound_batch,
credentials: safeParseJson(inv.credentials_json),
costPriceFen: inv.cost_price_fen,
fulfilledAt: now,
fulfilledBy: input.actorName,
},
}
const woSql = `
UPDATE work_orders
SET
status = 'completed',
material_json = $1::jsonb,
accepted_at = $2,
updated_at = $2
WHERE id = $3 AND status = $4
RETURNING *
`
const woRes = await client.query<WorkOrderRow>(woSql, [
JSON.stringify(updatedMaterial),
now,
workOrder.id,
workOrder.status,
])
const wo = woRes.rows[0]
if (!wo) {
throw createHttpError('工单状态已发生变化,履约失败', {
statusCode: 409,
errorCode: 'work_order_conflict',
})
}
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'operator',
actorId: input.actorName || 'admin',
eventType: 'consignment_fulfilled',
fromStatus: workOrder.status,
toStatus: 'completed',
payloadJson: JSON.stringify({
inventoryId: inv.id,
inventoryNo: inv.inventory_no,
itemName: inv.item_name,
costPriceFen: inv.cost_price_fen,
buyerName: inv.buyer_name,
shopName,
}),
now,
})
return { updatedOrder: wo, updatedInventory: inv }
})
// 顺带核销快手电子凭证
let voucherConsume: WorkOrderVoucherConsumeResult
try {
voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(updatedOrder)
} catch (error) {
voucherConsume = {
ok: false,
voucherCount: 0,
consumedCount: 0,
alreadyConsumedCount: 0,
failedCount: 1,
voucherCodes: [],
errorMessage: error instanceof Error ? error.message : '电子凭证核销失败',
}
}
publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true })
return {
order: mapWorkOrderAdmin(updatedOrder),
inventory: mapConsignmentInventory(updatedInventory),
voucherConsume,
}
}
/**
* 撤销寄售履约(用于选错重选、买家退款或物品异常)
*/
export async function revokeWorkOrderConsignmentFulfillment(input: {
workOrderId: number | string
action: 'reopen' | 'archive_inventory' | 'cancel_order'
reason: string
actorName: string
}): Promise<{
order: ReturnType<typeof mapWorkOrderAdmin>
inventory: ReturnType<typeof mapConsignmentInventory> | null
}> {
const workOrder = await getWorkOrderById(input.workOrderId)
if (!workOrder) {
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
}
const material = safeParseJson(workOrder.material_json)
const consignmentInfo = safeParseJson(material.consignment)
const inventoryId = consignmentInfo.inventoryId ? Number(consignmentInfo.inventoryId) : null
const now = nowIso()
const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => {
let inv: ConsignmentInventoryRow | null = null
// 1. 处理关联库存解绑与状态流转
if (inventoryId) {
if (input.action === 'archive_inventory') {
// 物品异常作废
const invSql = `
UPDATE consignment_inventories
SET
status = 'archived',
allow_matching = FALSE,
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
remark = remark || ' [交付异常作废: ' || $1 || ']',
updated_at = $2
WHERE id = $3
RETURNING *
`
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
input.reason || '异常作废',
now,
inventoryId,
])
inv = invRes.rows[0] || null
} else {
// 恢复在库 (in_stock)
const invSql = `
UPDATE consignment_inventories
SET
status = 'in_stock',
allocation_status = '待安排',
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
consumed_at = NULL,
allow_matching = TRUE,
remark = remark || ' [撤销回库: ' || $1 || ']',
updated_at = $2
WHERE id = $3
RETURNING *
`
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
input.reason || '撤单回库',
now,
inventoryId,
])
inv = invRes.rows[0] || null
}
}
// 2. 处理工单状态流转
let nextOrderStatus: WorkOrderStatus = WORK_ORDER_STATUS.UNASSIGNED
if (input.action === 'cancel_order') {
nextOrderStatus = WORK_ORDER_STATUS.CANCELLED
}
const { consignment: _removed, ...cleanMaterial } = material
const archiveHistory = [
...(Array.isArray(material.consignmentHistory) ? material.consignmentHistory : []),
{
...consignmentInfo,
revokedAt: now,
revokedBy: input.actorName,
revokeReason: input.reason,
},
]
const woSql = `
UPDATE work_orders
SET
status = $1,
material_json = $2::jsonb,
updated_at = $3
WHERE id = $4
RETURNING *
`
const woRes = await client.query<WorkOrderRow>(woSql, [
nextOrderStatus,
JSON.stringify({ ...cleanMaterial, consignmentHistory: archiveHistory }),
now,
workOrder.id,
])
const wo = woRes.rows[0]!
// 3. 记录事件
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'operator',
actorId: input.actorName || 'admin',
eventType: 'consignment_revoked',
fromStatus: workOrder.status,
toStatus: nextOrderStatus,
payloadJson: JSON.stringify({
action: input.action,
reason: input.reason,
inventoryId,
}),
now,
})
return { updatedOrder: wo, updatedInventory: inv }
})
publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true })
return {
order: mapWorkOrderAdmin(updatedOrder),
inventory: updatedInventory ? mapConsignmentInventory(updatedInventory) : null,
}
}
@@ -0,0 +1,172 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js'
import {
extractPeacekeeperSpecs,
findMatchingPeacekeeperInventories,
matchPeacekeeperOrderWithInventory,
} from './consignment-matching-service.js'
function buildInventory(overrides: Partial<ConsignmentInventoryRow> = {}): ConsignmentInventoryRow {
return {
id: 1,
inventory_no: 'INV001',
game_name: '和平精英',
category_id: 1,
item_name: '安卓微信区 暗潮 1钻',
system: '安卓',
server_zone: '微信区',
series: '暗潮',
diamond_level: 1,
brilliance_count: 0,
cost_price_fen: 10500,
buyer_name: '周杰雄',
inbound_batch: '8.30-1',
seller_contact: 'wx_123',
seller_game_id: 'UID8888',
seller_payment_info: '王大锤商行 331682...',
seller_phone: '13800000000',
credentials_json: {},
screenshots_json: [],
remark: '',
allow_matching: true,
status: 'in_stock',
allocation_status: '待安排',
bound_order_id: null,
bound_work_order_id: null,
bound_platform_order_id: '',
shop_name: '',
source_type: 'manual',
source_submission_id: null,
inbound_at: new Date().toISOString(),
consumed_at: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
}
}
test('extractPeacekeeperSpecs extracts specs accurately', () => {
const spec1 = extractPeacekeeperSpecs('安卓微信区 苏尔南手枪 暗潮 1钻')
assert.equal(spec1.system, '安卓')
assert.equal(spec1.serverZone, '微信区')
assert.equal(spec1.series, '暗潮')
assert.equal(spec1.diamondLevel, 1)
assert.equal(spec1.brillianceCount, 0)
const spec2 = extractPeacekeeperSpecs('苹果QQ区 怒嚎 2钻 1璀璨')
assert.equal(spec2.system, '苹果')
assert.equal(spec2.serverZone, 'QQ区')
assert.equal(spec2.series, '怒嚎')
assert.equal(spec2.diamondLevel, 2)
assert.equal(spec2.brillianceCount, 1)
})
test('matchPeacekeeperOrderWithInventory matches exact spec', () => {
const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
const inv = buildInventory({
system: '安卓',
server_zone: '微信区',
series: '暗潮',
diamond_level: 1,
brilliance_count: 0,
})
const matched = matchPeacekeeperOrderWithInventory(order, inv)
assert.ok(matched)
assert.equal(matched.matchType, 'exact')
assert.equal(matched.score, 100)
})
test('matchPeacekeeperOrderWithInventory allows partial match for higher brilliance (高配充低配)', () => {
const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
const inv = buildInventory({
system: '安卓',
server_zone: '微信区',
series: '暗潮',
diamond_level: 1,
brilliance_count: 1,
})
const matched = matchPeacekeeperOrderWithInventory(order, inv)
assert.ok(matched)
assert.equal(matched.matchType, 'partial')
assert.equal(matched.score, 80)
assert.ok(matched.reason.includes('多出【1璀璨】'))
})
test('matchPeacekeeperOrderWithInventory rejects series mismatch', () => {
const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
const inv = buildInventory({
system: '安卓',
server_zone: '微信区',
series: '怒嚎',
diamond_level: 1,
})
const matched = matchPeacekeeperOrderWithInventory(order, inv)
assert.equal(matched, null)
})
test('matchPeacekeeperOrderWithInventory rejects when allow_matching is false', () => {
const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
const inv = buildInventory({
system: '安卓',
server_zone: '微信区',
series: '暗潮',
diamond_level: 1,
allow_matching: false,
})
const matched = matchPeacekeeperOrderWithInventory(order, inv)
assert.equal(matched, null)
})
test('matchPeacekeeperOrderWithInventory rejects lower tier for higher tier order', () => {
const order = extractPeacekeeperSpecs('安卓微信区 暗潮 2钻')
const inv = buildInventory({
system: '安卓',
server_zone: '微信区',
series: '暗潮',
diamond_level: 1,
})
const matched = matchPeacekeeperOrderWithInventory(order, inv)
assert.equal(matched, null)
})
test('findMatchingPeacekeeperInventories ranks exact before partial and lower cost first', () => {
const invExactHigh = buildInventory({
id: 1,
series: '暗潮',
diamond_level: 1,
brilliance_count: 0,
cost_price_fen: 12000,
})
const invExactLow = buildInventory({
id: 2,
series: '暗潮',
diamond_level: 1,
brilliance_count: 0,
cost_price_fen: 9500,
})
const invPartial = buildInventory({
id: 3,
series: '暗潮',
diamond_level: 1,
brilliance_count: 1,
cost_price_fen: 8000,
})
const matches = findMatchingPeacekeeperInventories('安卓微信区 暗潮 1钻', [
invExactHigh,
invPartial,
invExactLow,
])
assert.equal(matches.length, 3)
assert.equal(matches[0]!.inventory.id, 2) // exact low cost
assert.equal(matches[1]!.inventory.id, 1) // exact high cost
assert.equal(matches[2]!.inventory.id, 3) // partial
})
@@ -0,0 +1,194 @@
import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js'
export type ConsignmentMatchResult = {
inventory: ConsignmentInventoryRow
matchType: 'exact' | 'partial'
score: number
matchedSeries: string
matchedDiamond: number
matchedBrilliance: number
reason: string
}
export type ParsedOrderSpecs = {
system: string
serverZone: string
series: string
diamondLevel: number
brillianceCount: number
rawText: string
}
/**
* 提取订单或文本中的《和平精英》规格(系统、区服、暗潮/怒嚎/血宴、钻级、璀璨)
*/
export function extractPeacekeeperSpecs(
rawText: string,
extraMeta?: { system?: string; serverZone?: string; series?: string; diamondLevel?: number },
): ParsedOrderSpecs {
const text = String(rawText || '')
.trim()
.toLowerCase()
// 1. 系统识别
let system = (extraMeta?.system || '').trim()
if (!system) {
if (text.includes('苹果') || text.includes('ios') || text.includes('iphone')) {
system = '苹果'
} else if (text.includes('安卓') || text.includes('android')) {
system = '安卓'
}
}
// 2. 大区识别
let serverZone = (extraMeta?.serverZone || '').trim()
if (!serverZone) {
if (text.includes('微信') || text.includes('wx')) {
serverZone = '微信区'
} else if (text.includes('qq') || text.includes('手q')) {
serverZone = 'QQ区'
}
}
// 3. 词条系列识别 (暗潮 / 怒嚎 / 血宴)
let series = (extraMeta?.series || '').trim()
if (!series) {
if (text.includes('暗潮')) {
series = '暗潮'
} else if (text.includes('怒嚎') || text.includes('怒号')) {
series = '怒嚎'
} else if (text.includes('血宴')) {
series = '血宴'
}
}
// 4. 钻级识别 (1~6钻)
let diamondLevel = extraMeta?.diamondLevel || 1
const diamondMatch = text.match(/([1-6])\s*钻/)
if (diamondMatch?.[1]) {
diamondLevel = Number(diamondMatch[1])
}
// 5. 璀璨数量识别 (0~6璀璨)
let brillianceCount = 0
const brillianceMatch = text.match(/([1-6])\s*璀璨/)
if (brillianceMatch?.[1]) {
brillianceCount = Number(brillianceMatch[1])
} else if (text.includes('璀璨')) {
brillianceCount = 1
}
return {
system,
serverZone,
series,
diamondLevel,
brillianceCount,
rawText,
}
}
/**
* 单品匹配度比对
*/
export function matchPeacekeeperOrderWithInventory(
order: ParsedOrderSpecs,
inventory: ConsignmentInventoryRow,
): ConsignmentMatchResult | null {
// 必须开启匹配,且处于在库状态
if (!inventory.allow_matching || inventory.status !== 'in_stock') {
return null
}
// 硬约束:系统必须一致(若指定)
if (order.system && inventory.system && inventory.system !== '通用') {
if (order.system !== inventory.system) {
return null
}
}
// 硬约束:区服必须一致(若指定)
if (order.serverZone && inventory.server_zone && inventory.server_zone !== '通用') {
if (order.serverZone !== inventory.server_zone) {
return null
}
}
// 硬约束:系列必须完全一致(暗潮 只能配 暗潮,不能拿 怒嚎 充数)
if (order.series && inventory.series) {
if (order.series !== inventory.series) {
return null
}
}
const invDiamond = Number(inventory.diamond_level || 1)
const invBrilliance = Number(inventory.brilliance_count || 0)
// 低配不能充高配:若库存钻级低于订单要求,或库存璀璨低于订单要求,直接不匹配
if (invDiamond < order.diamondLevel || invBrilliance < order.brillianceCount) {
return null
}
// 1. 全部匹配(Exact Match)
if (invDiamond === order.diamondLevel && invBrilliance === order.brillianceCount) {
return {
inventory,
matchType: 'exact',
score: 100,
matchedSeries: inventory.series || order.series,
matchedDiamond: invDiamond,
matchedBrilliance: invBrilliance,
reason: '规格完全一致',
}
}
// 2. 部分匹配(Partial / Superset Match):高配充低配
const reasons: string[] = []
if (invDiamond > order.diamondLevel) {
reasons.push(`钻级更高(${invDiamond}钻充${order.diamondLevel}钻)`)
}
if (invBrilliance > order.brillianceCount) {
reasons.push(`多出【${invBrilliance - order.brillianceCount}璀璨】`)
}
return {
inventory,
matchType: 'partial',
score: invDiamond > order.diamondLevel ? 70 : 80,
matchedSeries: inventory.series || order.series,
matchedDiamond: invDiamond,
matchedBrilliance: invBrilliance,
reason: `属于高配充低配:${reasons.join(',')}`,
}
}
/**
* 检索并排序可匹配的库存候选
*/
export function findMatchingPeacekeeperInventories(
rawRequirementText: string,
inventories: ConsignmentInventoryRow[],
extraMeta?: { system?: string; serverZone?: string; series?: string; diamondLevel?: number },
): ConsignmentMatchResult[] {
const orderSpecs = extractPeacekeeperSpecs(rawRequirementText, extraMeta)
const results: ConsignmentMatchResult[] = []
for (const inv of inventories) {
const matched = matchPeacekeeperOrderWithInventory(orderSpecs, inv)
if (matched) {
results.push(matched)
}
}
// 排序:
// 1. 全部匹配优先 (score DESC)
// 2. 收货成本从低到高 (cost_price_fen ASC)
// 3. 先入库优先 (id ASC)
return results.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
if (a.inventory.cost_price_fen !== b.inventory.cost_price_fen) {
return a.inventory.cost_price_fen - b.inventory.cost_price_fen
}
return Number(a.inventory.id) - Number(b.inventory.id)
})
}
@@ -0,0 +1,110 @@
import {
createConsignmentSubmission,
getConsignmentCollectLinkByToken,
} from '../../repositories/worker-platform/consignment-repo.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { generateConsignmentSubmissionNo } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
export async function getPublicConsignmentLink(token: string) {
const cleanToken = String(token || '').trim()
if (!cleanToken) {
throw createHttpError('链接无效', { statusCode: 400, errorCode: 'invalid_token' })
}
const link = await getConsignmentCollectLinkByToken(cleanToken)
if (!link) {
throw createHttpError('收集链接不存在或已被关闭', {
statusCode: 404,
errorCode: 'link_not_found',
})
}
if (link.expires_at && new Date(link.expires_at).getTime() < Date.now()) {
throw createHttpError('该收集链接已过期', { statusCode: 410, errorCode: 'link_expired' })
}
return {
title: link.title,
gameName: link.game_name,
categoryId: link.category_id,
categoryName: link.category_name,
system: link.system,
serverZone: link.server_zone,
series: link.series,
diamondLevel: link.diamond_level,
brillianceCount: link.brilliance_count,
presetPriceFen: link.preset_price_fen,
template: link.template_json,
}
}
export async function submitPublicConsignment(token: string, payload: JsonObject) {
const cleanToken = String(token || '').trim()
if (!cleanToken) {
throw createHttpError('链接无效', { statusCode: 400, errorCode: 'invalid_token' })
}
const link = await getConsignmentCollectLinkByToken(cleanToken)
if (!link) {
throw createHttpError('收集链接不存在或已被关闭', {
statusCode: 404,
errorCode: 'link_not_found',
})
}
if (link.expires_at && new Date(link.expires_at).getTime() < Date.now()) {
throw createHttpError('该收集链接已过期', { statusCode: 410, errorCode: 'link_expired' })
}
const itemName = String(
payload.itemName ||
link.title ||
`${link.system}${link.server_zone}${link.series}${link.diamond_level}钻`,
).trim()
const contactInfo = String(payload.contactInfo || payload.sellerContact || '').trim()
if (!contactInfo) {
throw createHttpError('请填写联系方式(微信号或手机号)', {
statusCode: 400,
errorCode: 'contact_info_required',
})
}
const now = nowIso()
const submissionNo = generateConsignmentSubmissionNo()
const askingPriceFen = Math.max(
0,
Math.round(Number(payload.askingPriceFen || link.preset_price_fen || 0)),
)
const submission = await createConsignmentSubmission({
submissionNo,
linkId: Number(link.id),
gameName: link.game_name,
categoryId: link.category_id ? Number(link.category_id) : null,
itemName,
system: String(payload.system || link.system || '').trim(),
serverZone: String(payload.serverZone || link.server_zone || '').trim(),
series: String(payload.series || link.series || '').trim(),
diamondLevel: Number(payload.diamondLevel || link.diamond_level || 1),
brillianceCount: Number(payload.brillianceCount ?? link.brilliance_count ?? 0),
sellerGameId: String(payload.sellerGameId || '').trim(),
sellerPaymentInfo: String(payload.sellerPaymentInfo || '').trim(),
sellerPhone: String(payload.sellerPhone || '').trim(),
contactInfo,
contentJson:
payload.content && typeof payload.content === 'object'
? (payload.content as Record<string, unknown>)
: (payload as Record<string, unknown>),
screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : [],
askingPriceFen,
now,
})
return {
submissionNo: submission.submission_no,
status: submission.status,
submittedAt: submission.created_at,
}
}