feat(consignment): 新增和平精英寄卖库存管理、定向收集审核与工单智能匹配履约
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user