feat(consignment): 一个寄卖单号锚定全流程 + 统一状态机 + 卖家侧确认加好友/提交赠送截图

问题:一件货身上挂着三套互不相通的状态(提报单审核态、仓库库存态、工单赠送态),
my-items 同时返回两个数组、卖家端分两个列表渲染,同一件货显示两行两个号;
且卖家端只映射了前两套状态,"已出库"之后流程就断了,卖家不知道该干什么。

- 提报单号 SUB… 作为唯一「寄卖单号」从发布锚定到结算(零迁移);
  listWorkerConsignments 改成一件货一条记录,库存/工单信息挂上去,
  客服手工入库的现货(无提报单)才回退用库存号当锚点
- 新增 domain/consignment-item-stage.ts:把三套底层状态推导成卖家看得懂的一个阶段
  (待审核/已通过待匹配/已匹配-待加好友/冷却中/待赠送/已赠送待审核/已完成/驳回/作废/取消)
  + 一句"下一步做什么" + 能力位(谁能确认加好友、谁该传截图),列表筛选与 KPI 也改由它驱动
- 卖家侧新增动作(以前只有 publish + my-items,卖家只能干看):
  POST /worker/consignment/items/:submissionId/{confirm-friend,gift-evidence}
  POST /worker/consignment/inventories/:inventoryId/{confirm-friend,gift-evidence}(手工入库现货)
  复用既有工单链路 confirmWorkOrderFriendAdded / submitWorkerOrderAcceptance,
  只加归属校验(不是本人履约的订单 403),顺带白拿"冷却没结束不许提交凭证""必须带图"
- 客服端用同一个号:库存列表/详情补 sourceSubmissionNo(LEFT JOIN 提报单),
  履约/指派写进工单的寄售信息也带上;关键字搜索支持按寄卖单号搜(计数 SQL 同步补 join)

真实库端到端验证:确认加好友 → gift_phase=friend_countdown / friend_added_at 落库 /
gift_available_at 正好 +72h;冷却期内提交截图被拒(work_order_gift_cooldown_active);
越权操作被拒(consignment_item_not_found);按寄卖单号搜索命中正确。临时数据已清理。
This commit is contained in:
yml2213
2026-09-17 10:57:57 +08:00
parent 99752dce9e
commit 44de11a3a7
19 changed files with 1574 additions and 204 deletions
@@ -108,6 +108,8 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
boundPlatformOrderId: row.bound_platform_order_id || '',
sourceType: row.source_type,
sourceSubmissionId: row.source_submission_id ? Number(row.source_submission_id) : null,
/** 唯一锚点:卖家端看到的「寄卖单号」就是它,客服端也用同一个号 */
sourceSubmissionNo: row.source_submission_no || '',
workerId: row.worker_id ? Number(row.worker_id) : null,
workerName: row.worker_name || '',
workerPhone: row.worker_phone || '',
@@ -283,6 +283,7 @@ export async function fulfillWorkOrderWithConsignment(input: {
credentials: safeParseJson(inv.credentials_json),
costPriceFen: inv.cost_price_fen,
sourceSubmissionId: inv.source_submission_id ? Number(inv.source_submission_id) : null,
sourceSubmissionNo: inv.source_submission_no || '',
fulfillmentType: CONSIGNMENT_FULFILLMENT_TYPE.INTERNAL_STOCK,
fulfilledAt: now,
fulfilledBy: input.actorName,
@@ -482,6 +483,10 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
inventoryNo: claimed.inventory_no,
workerId: targetWorkerId,
costPriceFen: claimed.cost_price_fen,
sourceSubmissionId: claimed.source_submission_id
? Number(claimed.source_submission_id)
: null,
sourceSubmissionNo: claimed.source_submission_no || '',
}),
now,
})
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
async function readServiceSource() {
return readFile(new URL('./consignment-worker-service.ts', import.meta.url), 'utf8')
}
test('我的寄卖以提报单号为唯一锚点聚合,不再返回 submissions/inventories 两个数组', async () => {
const serviceSource = await readServiceSource()
// 以前接口返回两个数组、前端分两个列表渲染 → 同一件货两行两个号
assert.ok(serviceSource.includes('consignmentNo: sub.submission_no'))
assert.ok(serviceSource.includes('consignmentNo: inv.inventory_no'))
assert.ok(!serviceSource.includes('submissions: submissionsRes.items.map'))
assert.ok(!serviceSource.includes('inventories: inventoriesRes.items.map'))
})
test('列表阶段由统一状态机推导,工单信息批量取而不是逐件查', async () => {
const serviceSource = await readServiceSource()
assert.ok(serviceSource.includes('deriveConsignmentItemStage('))
assert.ok(serviceSource.includes('listWorkOrdersByIds(boundWorkOrderIds)'))
assert.ok(serviceSource.includes('hasInternalConsignmentFulfillment(workOrder)'))
})
test('卖家确认加好友复用既有链路,并且只能动自己的订单', async () => {
const serviceSource = await readServiceSource()
assert.ok(serviceSource.includes('confirmAdminWorkOrderFriendAdded('))
// 归属校验:不是本人履约的直接 403,避免卖家操作别人的单
assert.ok(serviceSource.includes("errorCode: 'consignment_fulfillment_not_owned'"))
assert.ok(serviceSource.includes('workOrder.assigned_worker_id'))
})
test('赠送截图复用打手验收提交链路(冷却校验、必须带图都在那边)', async () => {
const serviceSource = await readServiceSource()
assert.ok(serviceSource.includes('submitWorkerOrderAcceptance('))
assert.ok(serviceSource.includes("errorCode: 'consignment_item_not_found'"))
})
@@ -2,16 +2,26 @@ import { withTransaction } from '../../db/client.js'
import { freezeConsignmentPublishDepositWithClient } from '../../repositories/worker-platform/consignment-deposit-repo.js'
import {
createConsignmentSubmissionWithClient,
getConsignmentInventoryById,
getConsignmentInventoryBySubmissionId,
getConsignmentSubmissionById,
listConsignmentInventories,
listConsignmentSubmissions,
} from '../../repositories/worker-platform/consignment-repo.js'
import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js'
import { getWorkerUserById } from '../../repositories/worker-platform/index.js'
import { listWorkOrdersByIds } from '../../repositories/worker-platform/work-order-query-repo.js'
import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js'
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-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'
import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js'
import { getConsignmentPublishDepositFen as getConfiguredConsignmentPublishDepositFen } from './worker-finance-config-service.js'
import { confirmAdminWorkOrderFriendAdded } from './admin-work-order-gift-service.js'
import { submitWorkerOrderAcceptance } from './worker-acceptance-service.js'
import type { WorkerSession } from './worker-session-context-service.js'
export function getConsignmentPublishDepositFen() {
return getConfiguredConsignmentPublishDepositFen()
@@ -182,48 +192,246 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
}
}
/**
* 「我的寄卖」列表。
*
* 一件货以前会返回两条记录(提报单 SUB… + 库存 INV…),前端也只能分两个列表渲染,
* 卖家看到同一件货两行、两个号。现在**以提报单号为唯一锚点**聚合:一个提报单一条记录,
* 库存与绑定工单的信息挂上去,阶段由统一状态机推导(三套底层状态 → 一个卖家看得懂的阶段)。
*/
export async function listWorkerConsignments(workerId: number) {
const [submissionsRes, inventoriesRes] = await Promise.all([
listConsignmentSubmissions({ workerId, pageSize: 100 }),
listConsignmentInventories({ workerId, pageSize: 100 }),
listConsignmentSubmissions({ workerId, pageSize: 200 }),
listConsignmentInventories({ workerId, pageSize: 200 }),
])
const publishDepositFen = getConsignmentPublishDepositFen()
const inventoryBySubmission = new Map<number, (typeof inventoriesRes.items)[number]>()
const orphanInventories: typeof inventoriesRes.items = []
for (const inv of inventoriesRes.items) {
const submissionId = Number(inv.source_submission_id || 0)
if (submissionId > 0 && !inventoryBySubmission.has(submissionId)) {
inventoryBySubmission.set(submissionId, inv)
} else {
// 客服手工入库的现货没有提报单,用库存号自己当锚点
orphanInventories.push(inv)
}
}
const boundWorkOrderIds = [...inventoryBySubmission.values(), ...orphanInventories]
.map((inv) => Number(inv.bound_work_order_id || 0))
.filter((id) => id > 0)
const workOrders = await listWorkOrdersByIds(boundWorkOrderIds)
const workOrderById = new Map(workOrders.map((wo) => [Number(wo.id), wo]))
const viewer = { role: 'seller', workerId: Number(workerId) } as const
const resolveStage = (
submissionStatus: string,
inv: (typeof inventoriesRes.items)[number] | undefined,
) => {
const workOrder = inv?.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
: undefined
return deriveConsignmentItemStage({
submissionStatus,
inventoryStatus: inv?.status ?? null,
viewer,
workOrder: workOrder
? {
status: String(workOrder.status || ''),
acceptance_mode: workOrder.acceptance_mode,
gift_phase: workOrder.gift_phase,
gift_available_at: workOrder.gift_available_at,
friend_added_at: workOrder.friend_added_at,
internalStock: hasInternalConsignmentFulfillment(workOrder),
assignedWorkerId: Number(workOrder.assigned_worker_id || 0) || null,
}
: null,
})
}
const items = [
...submissionsRes.items.map((sub) => {
const inv = inventoryBySubmission.get(Number(sub.id))
const stage = resolveStage(String(sub.status || ''), inv)
return {
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
consignmentNo: sub.submission_no,
submissionId: sub.id,
inventoryId: inv?.id ?? null,
workOrderId: inv?.bound_work_order_id ?? null,
itemName: sub.item_name,
system: sub.system,
serverZone: sub.server_zone,
series: sub.series,
diamondLevel: sub.diamond_level,
brillianceCount: sub.brilliance_count,
askingPriceFen: sub.asking_price_fen,
depositAmountFen: sub.deposit_amount_fen,
reviewNote: sub.review_note,
screenshotsJson: sub.screenshots_json,
createdAt: sub.created_at,
...stage,
/** 冷却截止时间,前端拿它显示倒计时 */
giftAvailableAt: inv?.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.gift_available_at ?? null)
: null,
friendAddedAt: inv?.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.friend_added_at ?? null)
: null,
}
}),
...orphanInventories.map((inv) => {
const stage = resolveStage('approved', inv)
return {
consignmentNo: inv.inventory_no,
submissionId: null,
inventoryId: inv.id,
workOrderId: inv.bound_work_order_id ?? null,
itemName: inv.item_name,
system: inv.system,
serverZone: inv.server_zone,
series: inv.series,
diamondLevel: inv.diamond_level,
brillianceCount: inv.brilliance_count,
askingPriceFen: inv.cost_price_fen,
depositAmountFen: inv.deposit_amount_fen,
reviewNote: '',
screenshotsJson: [],
createdAt: inv.created_at,
...stage,
giftAvailableAt: inv.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.gift_available_at ?? null)
: null,
friendAddedAt: inv.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.friend_added_at ?? null)
: null,
}
}),
].sort((a, b) => Date.parse(String(b.createdAt || '')) - Date.parse(String(a.createdAt || '')))
return {
publishDepositFen,
submissions: submissionsRes.items.map((sub) => ({
id: sub.id,
submissionNo: sub.submission_no,
itemName: sub.item_name,
system: sub.system,
serverZone: sub.server_zone,
series: sub.series,
diamondLevel: sub.diamond_level,
brillianceCount: sub.brilliance_count,
askingPriceFen: sub.asking_price_fen,
status: sub.status,
reviewNote: sub.review_note,
resultInventoryId: sub.result_inventory_id,
screenshotsJson: sub.screenshots_json,
contentJson: sub.content_json,
depositAmountFen: sub.deposit_amount_fen,
createdAt: sub.created_at,
})),
inventories: inventoriesRes.items.map((inv) => ({
id: 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,
costPriceFen: inv.cost_price_fen,
status: inv.status,
allocationStatus: inv.allocation_status,
boundPlatformOrderId: inv.bound_platform_order_id,
depositAmountFen: inv.deposit_amount_fen,
createdAt: inv.created_at,
})),
items,
stageCounts: items.reduce<Record<string, number>>((acc, item) => {
acc[item.stage] = (acc[item.stage] || 0) + 1
return acc
}, {}),
}
}
/**
* 卖家侧「确认加好友」与「提交赠送截图」的公共入口。
*
* 两个动作本质上就是工单赠送流程里的既有环节,这里只做**归属校验**后复用同一套仓库/服务:
* 谁在履约谁才能操作,避免卖家去动别人的订单。
*/
async function resolveSellerConsignmentWorkOrder(
workerId: number,
anchor: { submissionId: number | string } | { inventoryId: number | string },
): Promise<{ workOrderId: number; inventoryId: number }> {
let inventory: ConsignmentInventoryRow | null = null
if ('submissionId' in anchor) {
const submission = await getConsignmentSubmissionById(anchor.submissionId)
if (!submission || Number(submission.worker_id) !== Number(workerId)) {
throw createHttpError('寄卖单不存在或不属于当前账号', {
statusCode: 404,
errorCode: 'consignment_item_not_found',
})
}
inventory = await getConsignmentInventoryBySubmissionId(submission.id)
} else {
// 客服手工入库的现货没有提报单,只能按库存号定位(提报单线不走这里,避免 id 撞号误操作)
inventory = await getConsignmentInventoryById(anchor.inventoryId)
if (!inventory || Number(inventory.worker_id || 0) !== Number(workerId)) {
throw createHttpError('寄卖单不存在或不属于当前账号', {
statusCode: 404,
errorCode: 'consignment_item_not_found',
})
}
}
if (!inventory) {
throw createHttpError('该寄卖单还没有生成库存,暂无可执行的操作', {
statusCode: 409,
errorCode: 'consignment_inventory_missing',
})
}
const workOrderId = Number(inventory.bound_work_order_id || 0)
if (!workOrderId) {
throw createHttpError('该寄卖单当前没有关联订单,无需操作', {
statusCode: 409,
errorCode: 'consignment_work_order_missing',
})
}
const [workOrder] = await listWorkOrdersByIds([workOrderId])
if (!workOrder) {
throw createHttpError('关联订单不存在', {
statusCode: 404,
errorCode: 'work_order_not_found',
})
}
if (Number(workOrder.assigned_worker_id || 0) !== Number(workerId)) {
throw createHttpError('该订单不是由你履约,无法在此操作', {
statusCode: 403,
errorCode: 'consignment_fulfillment_not_owned',
})
}
return { workOrderId, inventoryId: Number(inventory.id) }
}
/**
* 卖家在「我的寄卖」确认已在游戏内加好买家好友。
*
* 确认即进入赠送冷却期,冷却结束前不能赠送;且按寄卖规则,
* **确认加好友之后未完成赠送的押金不退**。
*/
export async function confirmWorkerConsignmentFriendAdded(
workerId: number,
submissionId: number | string,
workerName = '',
) {
const { workOrderId } = await resolveSellerConsignmentWorkOrder(workerId, { submissionId })
return confirmAdminWorkOrderFriendAdded(workOrderId, workerName || `worker:${workerId}`)
}
/** 客服手工入库现货(没有提报单)的同一动作:按库存号定位 */
export async function confirmWorkerConsignmentInventoryFriendAdded(
workerId: number,
inventoryId: number | string,
workerName = '',
) {
const { workOrderId } = await resolveSellerConsignmentWorkOrder(workerId, { inventoryId })
return confirmAdminWorkOrderFriendAdded(workOrderId, workerName || `worker:${workerId}`)
}
/**
* 卖家提交赠送完成后的截图给客服审核。
*
* 直接复用打手验收提交链路:它会校验「必须是本人订单」「冷却必须已结束」「必须带图」,
* 提交成功后工单进入待验收,客服在工单面板验收。
*/
export async function submitWorkerConsignmentGiftEvidence(
workerId: number,
submissionId: number | string,
payload: JsonObject,
session: WorkerSession,
) {
const { workOrderId } = await resolveSellerConsignmentWorkOrder(workerId, { submissionId })
return submitWorkerOrderAcceptance(workOrderId, payload, session)
}
/** 客服手工入库现货(没有提报单)的同一动作:按库存号定位 */
export async function submitWorkerConsignmentInventoryGiftEvidence(
workerId: number,
inventoryId: number | string,
payload: JsonObject,
session: WorkerSession,
) {
const { workOrderId } = await resolveSellerConsignmentWorkOrder(workerId, { inventoryId })
return submitWorkerOrderAcceptance(workOrderId, payload, session)
}