fix(consignment): 下线 H5 收集链接渠道并封闭寄卖履约状态机

下线定向收集链接(H5 填报)整条线,提报统一走接单平台打手端:
- 删除前端 ConsignmentCollectPage、consignment-public 服务与 /consignment-collect/:token 路由
- 删除后端公开路由 routes/consignment.ts、consignment-public-service 与管理端 /consignment/links 三接口
- 迁移 079 删表 consignment_collect_links、删列 consignment_submissions.link_id,
  source_type 的 collection_link 归并为 worker_portal 并替换 CHECK

修复手工入库/编辑的凭据与截图静默丢失:后端统一按 credentialsJson ?? credentials、
screenshotsJson ?? screenshots 读取(入库/编辑/审核入库三个入口)。

按"已验收一律禁止撤销"封闭状态机:
- 撤销寄售履约加状态白名单(in_progress|pending_acceptance|problem)+ 事务内 FOR UPDATE
  锁单 + UPDATE 带 status 条件(0 行 409)+ 原因必填 + 清空指派/时限/验收草稿痕迹
- 库存改状态一律带条件:撤销要求 bound_work_order_id 命中本工单;作废/回库要求未被
  进行中工单占用(被挡时提示具体工单号);079 加部分唯一索引并清理历史重复绑定
- 工单终止统一解绑回库:打手撤单/取消指派/退回大厅/撤单/打手退单申请/超时处置接入
  releaseConsignmentInventoryBindingForWorkOrderWithClient(售后不改工单状态,无需接入)
- 回库路径保留运营的 allow_matching 开关设置(不再强制开启),前端文案同步更正

顺带修掉资金与归属的同源缺陷:
- 押金归属与重冻金额改取不可变的冻结核销行,审核改派不再"退给错人/向错人扣款"
- 新增 assigned_worker_id 拆开"卖家"与"被指派履约人",占库不再覆盖卖家
- PUT /inventories/:id 禁止直接改 status;发布接口补 requireActiveWorker
- 押金冻结/释放后补推钱包实时事件;迁移 079 给发布冻结加台账幂等唯一索引

验证:后端 401 测试/398 通过/3 skip(新增 5 例回归),前端 16 通过;
两侧 typecheck、lint:check、format:check 与 check:sql、build 全绿。
迁移 079 未经真实数据库执行(本环境无 Postgres),上库前请先 dry-run。
This commit is contained in:
yml2213
2026-09-16 20:05:49 +08:00
parent e828e677a5
commit 632dddc4c3
25 changed files with 960 additions and 1363 deletions
@@ -1,21 +1,17 @@
import { withTransaction } from '../../db/client.js'
import {
type ConsignmentCollectLinkRow,
type ConsignmentInventoryRow,
type ConsignmentSubmissionRow,
approveConsignmentSubmissionWithInbound,
createConsignmentCollectLink,
createConsignmentInventory,
getConsignmentInventoryById,
getConsignmentInventoryStats,
getConsignmentSubmissionById,
listConsignmentCollectLinks,
listConsignmentInventories,
listConsignmentSubmissions,
rejectConsignmentSubmissionWithClient,
archiveConsignmentInventoryWithClient,
returnConsignmentInventoryToStockWithClient,
updateConsignmentCollectLink,
updateConsignmentInventory,
} from '../../repositories/worker-platform/consignment-repo.js'
import {
@@ -24,15 +20,15 @@ import {
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { generateConsignmentInventoryNo, randomToken } from '../../utils/random.js'
import { generateConsignmentInventoryNo } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import {
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
getConsignmentDepositErrorCode,
releaseConsignmentPublishDepositBySubmissionWithClient,
releaseConsignmentPublishDepositWithClient,
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js'
function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never {
const code = getConsignmentDepositErrorCode(error)
@@ -53,6 +49,21 @@ function throwConsignmentDepositHttpError(error: unknown, restock: boolean): nev
throw error
}
/**
* 前端历史字段为 credentialsJson / screenshotsJson,早期接口写的是 credentials / screenshots,
* 两个键名都接受,避免客服录入的账号密码被静默丢弃。
*/
function pickCredentialsJson(payload: JsonObject): Record<string, unknown> | undefined {
const value = payload.credentialsJson ?? payload.credentials
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
return value as Record<string, unknown>
}
function pickScreenshotsJson(payload: JsonObject): unknown[] | undefined {
const value = payload.screenshotsJson ?? payload.screenshots
return Array.isArray(value) ? value : undefined
}
export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
return {
id: Number(row.id),
@@ -94,6 +105,7 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
workerId: row.worker_id ? Number(row.worker_id) : null,
workerName: row.worker_name || '',
workerPhone: row.worker_phone || '',
assignedWorkerId: row.assigned_worker_id ? Number(row.assigned_worker_id) : null,
depositAmountFen: Number(row.deposit_amount_fen || 0),
inboundAt: row.inbound_at || row.created_at,
consumedAt: row.consumed_at,
@@ -102,42 +114,10 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
}
}
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,
@@ -252,11 +232,8 @@ export async function createAdminConsignmentInventory(payload: JsonObject, actor
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 : [],
credentialsJson: pickCredentialsJson(payload) ?? {},
screenshotsJson: pickScreenshotsJson(payload) ?? [],
remark: String(payload.remark || '').trim(),
allowMatching: payload.allowMatching !== false,
sourceType: 'manual',
@@ -276,6 +253,14 @@ export async function updateAdminConsignmentInventory(
_actorName = '',
) {
const now = nowIso()
// 状态只能通过「下架作废 / 退回复库 / 匹配开关」这些带守卫的动作变更,
// 编辑接口直接写 status 会绕过「现货被进行中工单占用」保护与押金处理。
if (payload.status !== undefined) {
throw createHttpError('编辑接口不支持直接修改物品状态', {
statusCode: 400,
errorCode: 'consignment_status_update_forbidden',
})
}
const updated = await updateConsignmentInventory(id, {
itemName: payload.itemName !== undefined ? String(payload.itemName).trim() : undefined,
system: payload.system !== undefined ? String(payload.system).trim() : undefined,
@@ -305,14 +290,10 @@ export async function updateAdminConsignmentInventory(
? 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,
credentialsJson: pickCredentialsJson(payload),
screenshotsJson: pickScreenshotsJson(payload),
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,
workerId:
payload.workerId !== undefined
? payload.workerId
@@ -362,17 +343,46 @@ export async function archiveAdminConsignmentInventory(id: number | string) {
return archived
})
if (!updated) {
throw createHttpError('寄卖物品不存在', {
statusCode: 404,
errorCode: 'consignment_not_found',
})
await throwConsignmentInventoryBlockedError(id, '作废')
}
return mapConsignmentInventory(updated)
const mapped = mapConsignmentInventory(updated!)
if (mapped.workerId) {
publishWorkerWalletRealtimeChange(mapped.workerId)
}
return mapped
} catch (error) {
throwConsignmentDepositHttpError(error, false)
}
}
/**
* 人工改状态被「现货正被进行中工单占用」挡住时,给出可操作提示,
* 而不是笼统地报"不存在"。
*/
async function throwConsignmentInventoryBlockedError(
id: number | string,
actionLabel: string,
): Promise<never> {
const current = await getConsignmentInventoryById(id)
if (!current) {
throw createHttpError('寄卖物品不存在', {
statusCode: 404,
errorCode: 'consignment_not_found',
})
}
const boundWorkOrderId = Number(current.bound_work_order_id || 0)
if (boundWorkOrderId > 0 && current.status === 'consumed') {
throw createHttpError(
`该现货正被工单 #${boundWorkOrderId} 使用,无法${actionLabel};请先撤销寄售履约或撤单,再操作`,
{ statusCode: 409, errorCode: 'consignment_inventory_in_use' },
)
}
throw createHttpError(`当前状态(${current.status})不支持${actionLabel}`, {
statusCode: 409,
errorCode: 'consignment_inventory_status_invalid',
})
}
/** 已出库物品退回复库 */
export async function returnAdminConsignmentInventory(
id: number | string,
@@ -395,84 +405,41 @@ export async function returnAdminConsignmentInventory(
return returned
})
if (!updated) {
const current = await getConsignmentInventoryById(id)
if (!current) {
throw createHttpError('寄卖物品不存在', {
statusCode: 404,
errorCode: 'consignment_not_found',
})
}
if (current.status === 'consumed') {
await throwConsignmentInventoryBlockedError(id, '退回复库')
}
throw createHttpError('只有已出库物品可以退回复库', {
statusCode: 409,
errorCode: 'consignment_not_returnable',
})
}
return mapConsignmentInventory(updated)
const mapped = mapConsignmentInventory(updated)
if (mapped.workerId) {
publishWorkerWalletRealtimeChange(mapped.workerId)
}
return mapped
} catch (error) {
throwConsignmentDepositHttpError(error, true)
}
}
// ---------------- 收集链接管理 ----------------
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,
@@ -560,11 +527,8 @@ export async function approveAdminConsignmentSubmission(
sellerGameId: String(payload.sellerGameId || submission.seller_game_id).trim(),
sellerPaymentInfo: String(payload.sellerPaymentInfo || submission.seller_payment_info).trim(),
sellerPhone: phoneToLookup,
credentialsJson:
payload.credentials && typeof payload.credentials === 'object'
? (payload.credentials as Record<string, unknown>)
: undefined,
screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : undefined,
credentialsJson: pickCredentialsJson(payload),
screenshotsJson: pickScreenshotsJson(payload),
reviewNote: String(payload.reviewNote || '').trim(),
workerId,
workerName,
@@ -600,8 +564,8 @@ export async function rejectAdminConsignmentSubmission(
now,
})
if (rejected?.worker_id) {
await releaseConsignmentPublishDepositWithClient(client, {
workerId: Number(rejected.worker_id),
// 释放走"按提报单"版本:押金归属以冻结核销行为准,不受审核改派影响。
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
submissionId: rejected.id,
note: `寄卖提报驳回退还押金 ${rejected.submission_no}`,
now,
@@ -615,5 +579,8 @@ export async function rejectAdminConsignmentSubmission(
errorCode: 'submission_already_reviewed',
})
}
if (updated.worker_id) {
publishWorkerWalletRealtimeChange(Number(updated.worker_id))
}
return mapConsignmentSubmission(updated)
}
@@ -312,12 +312,19 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
}
const now = nowIso()
const orderMaterial = safeParseJson(workOrder.material_json)
const orderSource = safeParseJson(orderMaterial.source)
const assignShopName = String(orderSource.shopName || orderSource.shopTitle || '').trim()
// 1. 先原子占用库存:并发指派时只有一方能把 status 从 in_stock 改成 consumed,
// 避免同一件现货被两张工单同时认领。
// 避免同一件现货被两张工单同时认领。同时写入工单绑定,便于撤单/超时按工单解绑回库。
const claimed = await claimConsignmentInventoryForWorker(inventory.id, {
workerId: Number(targetWorkerId),
now,
boundOrderId: workOrder.order_id ? Number(workOrder.order_id) : null,
boundWorkOrderId: Number(workOrder.id),
boundPlatformOrderId: String(workOrder.platform_order_id || ''),
shopName: assignShopName,
})
if (!claimed) {
throw createHttpError('寄卖库存已被其他操作占用,请刷新重试', {
@@ -334,7 +341,7 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
input.actorName,
)
const material = safeParseJson(workOrder.material_json)
const material = orderMaterial
// 3. 将寄售手枪信息写入工单 material
const updatedMaterial = {
@@ -409,12 +416,36 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
}
}
/**
* 允许「撤销寄售履约」的工单状态白名单。
*
* 已验收(accepted)的工单代表钱已结算、货已交付,撤销会把工单改回未分配、把现货放回
* 在库池并重新冻结已退还的押金 —— 等于同一件现货二次出货,因此一律禁止;
* 买家退款/纠纷请走售后流程。已取消(cancelled)的工单同样禁止撤销。
*/
const CONSIGNMENT_REVOCABLE_WORK_ORDER_STATUSES: readonly string[] = [
WORK_ORDER_STATUS.IN_PROGRESS,
WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
WORK_ORDER_STATUS.PROBLEM,
]
export function assertConsignmentRevocableWorkOrderStatus(status: unknown): void {
const normalized = String(status || '').trim()
if (!CONSIGNMENT_REVOCABLE_WORK_ORDER_STATUSES.includes(normalized)) {
throw createHttpError('该工单已验收或已取消,不能撤销寄售履约;退款与纠纷请走售后流程', {
statusCode: 409,
errorCode: 'consignment_revoke_status_invalid',
})
}
}
/**
* 校验工单上的寄售信息是否可走「撤销寄售履约」流程,并返回关联库存 ID。
*
* 只有“内部现货占库”履约才能走本流程;押金履约模式(worker_deposit)由打手接单并
* 冻结了履约押金,必须走接单平台的「撤单并退回大厅 / 撤单并取消订单」,
* 否则打手押金不会释放,会造成资金挂账。
* 只有"内部现货占库"履约才能走本流程;押金履约模式(worker_deposit)由打手接单并
* 冻结了履约押金,必须走接单平台的「撤单并退回大厅 / 撤单并取消订单」——
* 这两条链路会调用 releaseConsignmentInventoryBindingForWorkOrderWithClient 自动解绑
* 寄卖库存,打手押金由既有逻辑释放,避免资金挂账与库存悬空。
*/
export function resolveRevocableConsignmentInventoryId(
consignment: Record<string, unknown>,
@@ -457,6 +488,15 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
if (!workOrder) {
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
}
assertConsignmentRevocableWorkOrderStatus(workOrder.status)
const reason = String(input.reason || '').trim()
if (!reason) {
throw createHttpError('请填写撤销原因', {
statusCode: 400,
errorCode: 'consignment_revoke_reason_required',
})
}
const material = safeParseJson(workOrder.material_json)
const consignmentInfo = safeParseJson(material.consignment)
@@ -466,70 +506,85 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
try {
const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => {
// 0. 事务内重新加锁读单:避免与「验收 / 并发撤销」竞争时用陈旧快照覆盖状态。
const lockedRes = await client.query<WorkOrderRow>(
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
[workOrder.id],
)
const lockedOrder = lockedRes.rows[0]
if (!lockedOrder) {
throw createHttpError('工单不存在', {
statusCode: 404,
errorCode: 'work_order_not_found',
})
}
assertConsignmentRevocableWorkOrderStatus(lockedOrder.status)
const lockedMaterial = safeParseJson(lockedOrder.material_json)
resolveRevocableConsignmentInventoryId(safeParseJson(lockedMaterial.consignment))
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,
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
}
// 1. 处理关联库存解绑与状态流转。
// 必须带「仍被本工单占用」条件:否则会把别的工单正在使用的现货抢过来解绑,
// 造成同一件现货被多张工单履约。
const invSql =
input.action === 'archive_inventory'
? `
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 AND status = 'consumed' AND bound_work_order_id = $4
RETURNING *
`
: `
UPDATE consignment_inventories
SET
status = 'in_stock',
allocation_status = '待安排',
assigned_worker_id = NULL,
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
consumed_at = NULL,
remark = remark || ' [撤销回库: ' || $1 || ']',
updated_at = $2
WHERE id = $3 AND status = 'consumed' AND bound_work_order_id = $4
RETURNING *
`
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
reason,
now,
inventoryId,
lockedOrder.id,
])
inv = invRes.rows[0] || null
if (!inv) {
throw createHttpError('该现货已被其他操作解绑或占用,请刷新工单后重试', {
statusCode: 409,
errorCode: 'consignment_inventory_binding_changed',
})
}
const submissionId = consignmentInfo.sourceSubmissionId || inv?.source_submission_id
const submissionId = consignmentInfo.sourceSubmissionId || inv.source_submission_id
if (submissionId) {
if (input.action === 'archive_inventory') {
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
submissionId,
note: `寄卖履约作废退还押金 ${inv?.inventory_no || submissionId}`,
note: `寄卖履约作废退还押金 ${inv.inventory_no || submissionId}`,
now,
})
} else {
await ensureConsignmentPublishDepositFrozenBySubmissionWithClient(client, {
submissionId,
note: `寄卖撤销回库重新冻结押金 ${inv?.inventory_no || submissionId}`,
note: `寄卖撤销回库重新冻结押金 ${inv.inventory_no || submissionId}`,
now,
})
}
@@ -541,46 +596,67 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
nextOrderStatus = WORK_ORDER_STATUS.CANCELLED
}
const { consignment: _removed, ...cleanMaterial } = material
const { consignment: _removed, ...cleanMaterial } = lockedMaterial
const archiveHistory = [
...(Array.isArray(material.consignmentHistory) ? material.consignmentHistory : []),
...(Array.isArray(lockedMaterial.consignmentHistory)
? lockedMaterial.consignmentHistory
: []),
{
...consignmentInfo,
revokedAt: now,
revokedBy: input.actorName,
revokeReason: input.reason,
revokeReason: reason,
},
]
// 工单回到「未指派 / 已取消」前,要把上一次交付留下的指派、时限、验收草稿与
// 问题单痕迹一并清干净;否则重新指派时新打手会看到上一手的 deadline 与验收内容。
const woSql = `
UPDATE work_orders
SET
status = $1,
material_json = $2::jsonb,
assigned_worker_id = NULL,
assigned_at = NULL,
deadline_at = NULL,
submitted_at = NULL,
accepted_at = NULL,
acceptance_json = '{}'::jsonb,
draft_acceptance_json = '{}'::jsonb,
problem_note = '',
published_at = NULL,
pinned_at = NULL,
${RESET_GIFT_FLOW_SQL},
updated_at = $3
WHERE id = $4
WHERE id = $4 AND status = $5
RETURNING *
`
const woRes = await client.query<WorkOrderRow>(woSql, [
nextOrderStatus,
JSON.stringify({ ...cleanMaterial, consignmentHistory: archiveHistory }),
now,
workOrder.id,
lockedOrder.id,
lockedOrder.status,
])
const wo = woRes.rows[0]!
const wo = woRes.rows[0]
if (!wo) {
throw createHttpError('工单状态已被其他操作变更,请刷新后重试', {
statusCode: 409,
errorCode: 'work_order_status_changed',
})
}
// 3. 记录事件
await createWorkOrderEventWithClient(client, {
workOrderId: workOrder.id,
workOrderId: lockedOrder.id,
actorType: 'operator',
actorId: input.actorName || 'admin',
eventType: 'consignment_revoked',
fromStatus: workOrder.status,
fromStatus: lockedOrder.status,
toStatus: nextOrderStatus,
payloadJson: JSON.stringify({
action: input.action,
reason: input.reason,
reason,
inventoryId,
}),
now,
@@ -589,7 +665,10 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
return { updatedOrder: wo, updatedInventory: inv }
})
publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true })
publishWorkOrderRealtimeChange({
workOrderId: Number(updatedOrder.id),
hallChanged: updatedOrder.status === WORK_ORDER_STATUS.UNASSIGNED,
})
return {
order: mapWorkOrderAdmin(updatedOrder),
@@ -1,188 +0,0 @@
import {
createConsignmentSubmission,
getConsignmentCollectLinkByToken,
} from '../../repositories/worker-platform/consignment-repo.js'
import { getWorkerUserByPhone } from '../../repositories/worker-platform/index.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'
import { getConsignmentPublishDepositFen } from './worker-finance-config-service.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('请填写微信号或QQ号以便联系与核实', {
statusCode: 400,
errorCode: 'contact_info_required',
})
}
const contentPayload =
payload.contentJson && typeof payload.contentJson === 'object'
? (payload.contentJson as Record<string, unknown>)
: payload.content && typeof payload.content === 'object'
? (payload.content as Record<string, unknown>)
: {}
const characterName = String(contentPayload.characterName || payload.characterName || '').trim()
const characterId = String(contentPayload.characterId || payload.characterId || '').trim()
if (!characterName) {
throw createHttpError('请填写游戏内角色名', {
statusCode: 400,
errorCode: 'character_name_required',
})
}
if (!characterId) {
throw createHttpError('请填写角色数字ID', {
statusCode: 400,
errorCode: 'character_id_required',
})
}
const screenshots = Array.isArray(payload.screenshotsJson)
? payload.screenshotsJson
: Array.isArray(payload.screenshots)
? payload.screenshots
: []
if (screenshots.length === 0) {
throw createHttpError('请上传游戏主页截图以供核验', {
statusCode: 400,
errorCode: 'screenshot_required',
})
}
const now = nowIso()
const submissionNo = generateConsignmentSubmissionNo()
const askingPriceFen = Math.max(
100,
Math.round(Number(payload.askingPriceFen || link.preset_price_fen || 0)),
)
const sellerPhone = String(payload.sellerPhone || '').trim()
if (!sellerPhone) {
throw createHttpError('请填写联系电话', {
statusCode: 400,
errorCode: 'seller_phone_required',
})
}
let workerId: number | null = null
let workerName = ''
let workerPhone = ''
if (sellerPhone) {
const matchedWorker = await getWorkerUserByPhone(sellerPhone)
if (matchedWorker) {
workerId = Number(matchedWorker.id)
workerName = matchedWorker.display_name || matchedWorker.username || ''
workerPhone = matchedWorker.phone || sellerPhone
}
}
const diamondLevel = Math.max(
1,
Math.min(7, Number(payload.diamondLevel || link.diamond_level || 1)),
)
const brillianceCount = Math.max(
0,
Math.min(7, Number(payload.brillianceCount ?? link.brilliance_count ?? 0)),
)
const sellerGameId = String(payload.sellerGameId || `${characterName} (${characterId})`).trim()
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,
brillianceCount,
sellerGameId,
sellerPaymentInfo: String(payload.sellerPaymentInfo || '走接单平台钱包结算').trim(),
sellerPhone,
contactInfo,
contentJson: {
...contentPayload,
characterName,
characterId,
sellerNote: String(contentPayload.sellerNote || payload.sellerNote || '').trim(),
channel: 'collect_link',
},
screenshotsJson: screenshots,
askingPriceFen,
workerId,
workerName,
workerPhone,
depositAmountFen: Math.max(
0,
Math.round(Number(payload.depositAmountFen ?? getConsignmentPublishDepositFen())),
),
now,
})
return {
submissionNo: submission.submission_no,
status: submission.status,
submittedAt: submission.created_at,
}
}
@@ -10,6 +10,7 @@ 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'
import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js'
import { getConsignmentPublishDepositFen as getConfiguredConsignmentPublishDepositFen } from './worker-finance-config-service.js'
export function getConsignmentPublishDepositFen() {
@@ -106,7 +107,6 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
const submission = await withTransaction(async (client) => {
const created = await createConsignmentSubmissionWithClient(client, {
submissionNo,
linkId: null,
gameName: '和平精英',
categoryId: null,
itemName,
@@ -146,6 +146,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
return created
})
// 发布冻结押金后推送钱包变更,避免打手端余额展示滞后。
publishWorkerWalletRealtimeChange(Number(worker.id))
return {
id: submission.id,
submissionNo: submission.submission_no,
@@ -61,11 +61,17 @@ import {
releaseConsignmentPublishDepositWithClient,
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
import {
archiveConsignmentInventoryWithClient,
claimConsignmentInventoryForWorkerWithClient,
releaseConsignmentInventoryBindingForWorkOrderWithClient,
releaseConsignmentInventoryClaimWithClient,
returnConsignmentInventoryToStockWithClient,
} from '../../repositories/worker-platform/consignment-repo.js'
import { ADMIN_PERMISSION_KEYS } from '../admin/admin-rbac-service.js'
import { resolveRevocableConsignmentInventoryId } from './consignment-fulfillment-service.js'
import {
assertConsignmentRevocableWorkOrderStatus,
resolveRevocableConsignmentInventoryId,
} from './consignment-fulfillment-service.js'
test('后台权限白名单覆盖全部迁移登记的权限,避免保存角色权限时被静默清除', () => {
const migrationsDir = fileURLToPath(new URL('../../db/migrations', import.meta.url))
@@ -94,11 +100,11 @@ test('后台权限白名单覆盖全部迁移登记的权限,避免保存角
})
test('寄卖指派先原子占库:只有 in_stock 能被占用,已被占用返回 null', async () => {
const statements: string[] = []
const statements: Array<{ sql: string; values: unknown[] }> = []
let matched = false
const client = {
query: async (sql: string) => {
statements.push(sql)
query: async (sql: string, values: unknown[]) => {
statements.push({ sql, values })
return { rows: matched ? [{ id: 9, inventory_no: 'INV2609150001' }] : [] }
},
} as unknown as PoolClient
@@ -113,11 +119,110 @@ test('寄卖指派先原子占库:只有 in_stock 能被占用,已被占用
const claimed = await claimConsignmentInventoryForWorkerWithClient(client, 9, {
workerId: 3,
now: '2026-09-15T00:00:00.000Z',
boundOrderId: 66,
boundWorkOrderId: 77,
boundPlatformOrderId: 'PT-77',
shopName: '旗舰店',
})
assert.equal(claimed?.inventory_no, 'INV2609150001')
assert.ok(statements[0]!.includes("WHERE id = $4 AND status = 'in_stock'"))
assert.ok(statements[0]!.includes('RETURNING *'))
const claimSql = statements[1]!.sql
assert.ok(claimSql.includes("WHERE id = $8 AND status = 'in_stock'"))
assert.ok(claimSql.includes('RETURNING *'))
// 占库写入工单绑定;被指派打手写 assigned_worker_id,卖家 worker_id 不再被覆盖
assert.ok(claimSql.includes('assigned_worker_id = $2'))
assert.ok(claimSql.includes('bound_work_order_id = $5'))
assert.deepEqual(statements[1]!.values.slice(3, 8), [66, 77, 'PT-77', '旗舰店', 9])
})
test('撤销寄售履约的状态白名单:只允许进行中/待验收/问题单', () => {
for (const status of ['in_progress', 'pending_acceptance', 'problem']) {
assert.doesNotThrow(() => assertConsignmentRevocableWorkOrderStatus(status))
}
for (const status of ['accepted', 'cancelled', 'unassigned', 'open', '']) {
assert.throws(
() => assertConsignmentRevocableWorkOrderStatus(status),
/不能撤销寄售履约/,
`状态 ${status} 必须被拒绝`,
)
}
})
test('工单终止解绑寄卖现货:只认 bound_work_order_id,且不放开匹配开关', async () => {
const statements: string[] = []
const client = {
query: async (sql: string) => {
statements.push(sql)
return { rows: [] }
},
} as unknown as PoolClient
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, 77, {
now: '2026-09-15T00:00:00.000Z',
reason: '后台撤单取消订单',
})
const sql = statements[0]!
assert.ok(sql.includes('WHERE bound_work_order_id = $2'))
assert.ok(sql.includes("status = 'consumed'"))
assert.ok(!sql.includes('allow_matching'))
})
test('人工作废与退回复库在现货仍被进行中工单占用时不改状态', async () => {
const statements: string[] = []
const client = {
query: async (sql: string) => {
statements.push(sql)
return { rows: [] }
},
} as unknown as PoolClient
await archiveConsignmentInventoryWithClient(client, 9, '2026-09-15T00:00:00.000Z')
await returnConsignmentInventoryToStockWithClient(
client,
9,
'买家退货',
'2026-09-15T00:00:00.000Z',
)
for (const sql of statements) {
assert.ok(
sql.includes('FROM work_orders wo') && sql.includes("NOT IN ('accepted', 'cancelled')"),
`缺少进行中工单占用保护:${sql}`,
)
}
// 退回复库不得再强制打开匹配开关(避免覆盖运营手动暂停)
assert.ok(!statements[1]!.includes('allow_matching'))
})
test('工单终态链路全部接上寄卖解绑,避免现货永久 consumed', async () => {
const readFile = await import('node:fs/promises')
const cancelRepo = await readFile.readFile(
new URL('../../repositories/worker-platform/work-order-cancel-repo.ts', import.meta.url),
'utf8',
)
const issueRepo = await readFile.readFile(
new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url),
'utf8',
)
const cancelRequestRepo = await readFile.readFile(
new URL('../../repositories/worker-platform/worker-cancel-request-repo.ts', import.meta.url),
'utf8',
)
for (const [name, source] of [
['work-order-cancel-repo', cancelRepo],
['work-order-issue-repo', issueRepo],
['worker-cancel-request-repo', cancelRequestRepo],
] as const) {
assert.ok(
source.includes('releaseConsignmentInventoryBindingForWorkOrderWithClient('),
`${name} 缺少寄卖解绑调用`,
)
}
// 撤单仓库内 5 条终态链路:打手撤单 / 取消指派 / 退回大厅 / 撤单取消 / 重新启用
assert.equal(
cancelRepo.match(/releaseConsignmentInventoryBindingForWorkOrderWithClient\(/g)?.length,
5,
)
})
test('寄卖指派失败回滚只在物品仍为 consumed 时放回在库', async () => {