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
@@ -0,0 +1,175 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { CONSIGNMENT_ITEM_STAGE, deriveConsignmentItemStage } from './consignment-item-stage.js'
const SELLER = { role: 'seller', workerId: 7 } as const
const NOW = Date.parse('2026-09-17T12:00:00.000Z')
function friendGiftOrder(overrides: Record<string, unknown> = {}) {
return {
status: 'in_progress',
acceptance_mode: 'friend_gift',
gift_phase: 'material_required',
gift_available_at: null,
friend_added_at: null,
assignedWorkerId: 7,
...overrides,
}
}
test('提报审核态:待审核 / 已驳回', () => {
const pending = deriveConsignmentItemStage({ submissionStatus: 'pending_review' })
assert.equal(pending.stage, CONSIGNMENT_ITEM_STAGE.PENDING_REVIEW)
assert.equal(pending.label, '待审核')
assert.equal(pending.group, 'reviewing')
const rejected = deriveConsignmentItemStage({
submissionStatus: 'rejected',
inventoryStatus: null,
})
assert.equal(rejected.stage, CONSIGNMENT_ITEM_STAGE.REJECTED)
assert.equal(rejected.group, 'finished')
})
test('审核通过到在库:已通过(在库待匹配),没人需要操作', () => {
const result = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'in_stock',
})
assert.equal(result.stage, CONSIGNMENT_ITEM_STAGE.IN_STOCK)
assert.equal(result.label, '已通过(在库待匹配)')
assert.equal(result.canConfirmFriend, false)
assert.equal(result.canSubmitEvidence, false)
})
test('内部现货履约:卖家只等客服确认加好友,不给卖家按钮', () => {
const result = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder({ internalStock: true, assignedWorkerId: null }),
viewer: SELLER,
})
assert.equal(result.stage, CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND)
assert.equal(result.canConfirmFriend, false)
assert.ok(result.hint.includes('客服'))
})
test('指派打手线:本人履约时卖家可以点确认加好友,别人不行', () => {
const mine = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder({ gift_phase: 'material_submitted' }),
viewer: SELLER,
})
assert.equal(mine.stage, CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND)
assert.equal(mine.canConfirmFriend, true)
assert.ok(mine.hint.includes('押金不退'), '确认加好友必须提示押金不退')
const others = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder({ gift_phase: 'material_submitted', assignedWorkerId: 99 }),
viewer: SELLER,
})
assert.equal(others.canConfirmFriend, false)
})
test('冷却中:给倒计时阶段,不给提交按钮;冷却到点后变成待赠送', () => {
const cooling = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder({
gift_phase: 'friend_countdown',
friend_added_at: '2026-09-17T00:00:00.000Z',
gift_available_at: '2026-09-20T00:00:00.000Z',
}),
viewer: SELLER,
now: NOW,
})
assert.equal(cooling.stage, CONSIGNMENT_ITEM_STAGE.GIFT_COOLDOWN)
assert.equal(cooling.label, '加好友赠送冷却中')
assert.equal(cooling.canSubmitEvidence, false, '冷却期没走完不允许上传赠送截图')
const ready = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder({
gift_phase: 'friend_countdown',
friend_added_at: '2026-09-14T00:00:00.000Z',
gift_available_at: '2026-09-17T00:00:00.000Z',
}),
viewer: SELLER,
now: NOW,
})
assert.equal(ready.stage, CONSIGNMENT_ITEM_STAGE.GIFT_READY)
assert.equal(ready.label, '待赠送')
assert.equal(ready.canSubmitEvidence, true)
assert.equal(ready.evidenceButtonLabel, '提交赠送截图')
})
test('已赠送与已完成:凭证待审 → 客服验收后完成', () => {
const submitted = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder({
status: 'pending_acceptance',
gift_phase: 'friend_countdown',
friend_added_at: '2026-09-14T00:00:00.000Z',
gift_available_at: '2026-09-17T00:00:00.000Z',
}),
viewer: SELLER,
now: NOW,
})
assert.equal(submitted.stage, CONSIGNMENT_ITEM_STAGE.GIFT_SUBMITTED)
assert.equal(submitted.label, '已赠送待审核')
assert.equal(submitted.canSubmitEvidence, false)
const completed = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder({ status: 'accepted' }),
viewer: SELLER,
})
assert.equal(completed.stage, CONSIGNMENT_ITEM_STAGE.COMPLETED)
assert.equal(completed.group, 'finished')
})
test('终态:已下架作废 / 关联订单取消', () => {
assert.equal(
deriveConsignmentItemStage({ submissionStatus: 'approved', inventoryStatus: 'archived' }).stage,
CONSIGNMENT_ITEM_STAGE.ARCHIVED,
)
assert.equal(
deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder({ status: 'cancelled' }),
}).stage,
CONSIGNMENT_ITEM_STAGE.CANCELLED,
)
})
test('标准验收的寄卖单:履约中 / 待验收,按钮文案是提交验收材料', () => {
const fulfilling = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: {
status: 'in_progress',
acceptance_mode: 'standard',
assignedWorkerId: 7,
},
viewer: SELLER,
})
assert.equal(fulfilling.stage, CONSIGNMENT_ITEM_STAGE.FULFILLING)
assert.equal(fulfilling.canSubmitEvidence, true)
assert.equal(fulfilling.evidenceButtonLabel, '提交验收材料')
const pending = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: { status: 'pending_acceptance', acceptance_mode: 'standard' },
viewer: SELLER,
})
assert.equal(pending.stage, CONSIGNMENT_ITEM_STAGE.PENDING_ACCEPTANCE)
})
@@ -0,0 +1,261 @@
/**
* 寄卖单统一状态机。
*
* 以前一件寄卖货身上挂着三套互不相通的状态(提报单审核态、仓库库存态、工单赠送态),
* 卖家端只能看到前两套,"已出库"之后流程就断在黑暗里。这里把三套状态**推导成一个**
* 卖家看得懂的阶段,作为"我的寄卖"列表与详情页的唯一展示口径。
*
* 阶段顺序(正常流程):
* 待审核 → 已通过(在库待匹配) → 已匹配-待加好友 → 加好友赠送冷却中 → 待赠送
* → 已赠送待审核 → 已完成
* 异常/终态:已驳回、已下架作废、已取消
*/
import {
WORK_ORDER_ACCEPTANCE_MODE,
WORK_ORDER_GIFT_PHASE,
isFriendGiftAcceptanceMode,
} from './work-order-acceptance-mode.js'
export const CONSIGNMENT_ITEM_STAGE = {
PENDING_REVIEW: 'pending_review',
IN_STOCK: 'in_stock',
MATCHED_WAITING_FRIEND: 'matched_waiting_friend',
GIFT_COOLDOWN: 'gift_cooldown',
GIFT_READY: 'gift_ready',
GIFT_SUBMITTED: 'gift_submitted',
COMPLETED: 'completed',
REJECTED: 'rejected',
ARCHIVED: 'archived',
CANCELLED: 'cancelled',
/** 标准验收(非好友赠送)的履约中 / 待验收 */
FULFILLING: 'fulfilling',
PENDING_ACCEPTANCE: 'pending_acceptance',
} as const
export type ConsignmentItemStage =
(typeof CONSIGNMENT_ITEM_STAGE)[keyof typeof CONSIGNMENT_ITEM_STAGE]
/** 列表筛选分组:进行中 / 已结束 */
export type ConsignmentItemStageGroup = 'reviewing' | 'active' | 'finished'
const STAGE_META: Record<
ConsignmentItemStage,
{ label: string; group: ConsignmentItemStageGroup; color: string }
> = {
[CONSIGNMENT_ITEM_STAGE.PENDING_REVIEW]: { label: '待审核', group: 'reviewing', color: 'gold' },
[CONSIGNMENT_ITEM_STAGE.REJECTED]: { label: '已驳回', group: 'finished', color: 'red' },
[CONSIGNMENT_ITEM_STAGE.IN_STOCK]: {
label: '已通过(在库待匹配)',
group: 'active',
color: 'blue',
},
[CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND]: {
label: '已匹配-待加好友',
group: 'active',
color: 'purple',
},
[CONSIGNMENT_ITEM_STAGE.GIFT_COOLDOWN]: {
label: '加好友赠送冷却中',
group: 'active',
color: 'orange',
},
[CONSIGNMENT_ITEM_STAGE.GIFT_READY]: { label: '待赠送', group: 'active', color: 'orange' },
[CONSIGNMENT_ITEM_STAGE.GIFT_SUBMITTED]: {
label: '已赠送待审核',
group: 'active',
color: 'processing',
},
[CONSIGNMENT_ITEM_STAGE.FULFILLING]: { label: '履约中', group: 'active', color: 'purple' },
[CONSIGNMENT_ITEM_STAGE.PENDING_ACCEPTANCE]: {
label: '待验收',
group: 'active',
color: 'processing',
},
[CONSIGNMENT_ITEM_STAGE.COMPLETED]: { label: '已完成', group: 'finished', color: 'green' },
[CONSIGNMENT_ITEM_STAGE.ARCHIVED]: { label: '已下架作废', group: 'finished', color: 'default' },
[CONSIGNMENT_ITEM_STAGE.CANCELLED]: { label: '已取消', group: 'finished', color: 'default' },
}
export function getConsignmentItemStageMeta(stage: ConsignmentItemStage) {
return STAGE_META[stage] || STAGE_META[CONSIGNMENT_ITEM_STAGE.PENDING_REVIEW]
}
export type ConsignmentItemWorkOrderInput = {
status: string
acceptance_mode?: string | null
gift_phase?: string | null
gift_available_at?: string | null
friend_added_at?: string | null
/** 是否走内部现货履约(内部仓账号交付,客服操作) */
internalStock?: boolean
assignedWorkerId?: number | null
}
export type DeriveConsignmentItemStageInput = {
submissionStatus: string
inventoryStatus?: string | null
workOrder?: ConsignmentItemWorkOrderInput | null
/** 当前查看者身份:卖家端要判断"是不是我在履约"才能给出按钮 */
viewer?: { role: 'seller'; workerId: number } | { role: 'admin' }
/** 便于测试注入"现在" */
now?: number
}
export type ConsignmentItemStageResult = {
stage: ConsignmentItemStage
label: string
group: ConsignmentItemStageGroup
color: string
/** 卖家侧下一步该做什么,直接可展示给用户 */
hint: string
/** 卖家能否自己点「确认加好友」 */
canConfirmFriend: boolean
/** 卖家能否上传赠送/验收截图 */
canSubmitEvidence: boolean
evidenceButtonLabel: '提交赠送截图' | '提交验收材料'
}
const ACTIVE_WORK_ORDER_STATUSES = new Set(['in_progress', 'problem', 'pending_acceptance'])
function build(
stage: ConsignmentItemStage,
hint: string,
capabilities?: {
canConfirmFriend?: boolean
canSubmitEvidence?: boolean
evidenceButtonLabel?: '提交赠送截图' | '提交验收材料'
},
): ConsignmentItemStageResult {
const meta = getConsignmentItemStageMeta(stage)
return {
stage,
label: meta.label,
group: meta.group,
color: meta.color,
hint,
canConfirmFriend: Boolean(capabilities?.canConfirmFriend),
canSubmitEvidence: Boolean(capabilities?.canSubmitEvidence),
evidenceButtonLabel: capabilities?.evidenceButtonLabel || '提交赠送截图',
}
}
/**
* 把「提报单 + 库存 + 绑定工单」三套状态推导成卖家端唯一阶段。
*/
export function deriveConsignmentItemStage(
input: DeriveConsignmentItemStageInput,
): ConsignmentItemStageResult {
const submissionStatus = String(input.submissionStatus || '').trim()
const inventoryStatus = String(input.inventoryStatus || '').trim()
if (submissionStatus === 'rejected') {
return build(CONSIGNMENT_ITEM_STAGE.REJECTED, '提报未通过,可修改后重新提报')
}
if (submissionStatus === 'pending_review') {
return build(CONSIGNMENT_ITEM_STAGE.PENDING_REVIEW, '客服审核中,通过后自动进入在库待匹配')
}
if (!inventoryStatus) {
// 审核已通过但库存行还没读到(正常不会发生),按在库展示而不是给卖家一个空白
return build(CONSIGNMENT_ITEM_STAGE.IN_STOCK, '已通过审核,正在入库')
}
if (inventoryStatus === 'archived') {
return build(CONSIGNMENT_ITEM_STAGE.ARCHIVED, '该现货已下架作废,如需继续寄卖请重新提报')
}
if (inventoryStatus !== 'consumed') {
return build(CONSIGNMENT_ITEM_STAGE.IN_STOCK, '已在库待匹配,匹配到订单后会自动占用')
}
const workOrder = input.workOrder || null
if (!workOrder) {
return build(CONSIGNMENT_ITEM_STAGE.FULFILLING, '已占用但未读到关联订单,请联系客服核对')
}
const status = String(workOrder.status || '').trim()
if (status === 'cancelled') {
return build(CONSIGNMENT_ITEM_STAGE.CANCELLED, '关联订单已取消,现货已按规则处理')
}
if (status === 'accepted' || status === 'completed') {
return build(CONSIGNMENT_ITEM_STAGE.COMPLETED, '订单已完成,货款与押金已结算')
}
if (!ACTIVE_WORK_ORDER_STATUSES.has(status)) {
return build(CONSIGNMENT_ITEM_STAGE.FULFILLING, '关联订单状态已变化,请联系客服核对')
}
const isFriendGift = isFriendGiftAcceptanceMode(workOrder.acceptance_mode)
const viewerWorkerId = input.viewer?.role === 'seller' ? Number(input.viewer.workerId) : 0
const selfFulfilling =
viewerWorkerId > 0 && Number(workOrder.assignedWorkerId || 0) === viewerWorkerId
if (isFriendGift) {
const now = input.now ?? Date.now()
const rawPhase = String(workOrder.gift_phase || '').trim()
const phase =
rawPhase === WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN && isGiftAvailable(workOrder, now)
? WORK_ORDER_GIFT_PHASE.GIFT_READY
: rawPhase
if (status === 'pending_acceptance') {
return build(CONSIGNMENT_ITEM_STAGE.GIFT_SUBMITTED, '赠送凭证已提交,等待客服审核')
}
if (
phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED ||
phase === WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED
) {
const internalStock = Boolean(workOrder.internalStock)
if (internalStock) {
// 内部仓账号由客服操作,卖家只等
return build(
CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND,
'等待客服确认加好友,确认后进入赠送冷却期',
)
}
if (phase === WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED) {
return build(
CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND,
selfFulfilling
? '请先在订单页提交赠送资料,再确认加好友'
: '等待打手提交赠送资料后确认加好友',
)
}
return build(
CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND,
selfFulfilling
? '好友加好后点「确认加好友」:确认即进入赠送冷却期,未完成赠送押金不退'
: '等待接单打手确认加好友,确认后进入赠送冷却期',
{ canConfirmFriend: selfFulfilling },
)
}
if (phase === WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN) {
return build(CONSIGNMENT_ITEM_STAGE.GIFT_COOLDOWN, '冷却期结束后才能赠送,请耐心等待')
}
if (phase === WORK_ORDER_GIFT_PHASE.GIFT_READY) {
return build(
CONSIGNMENT_ITEM_STAGE.GIFT_READY,
selfFulfilling
? '冷却已结束,请用账号赠送并上传赠送截图给客服审核(未完成赠送押金不退)'
: '冷却已结束,等待履约方赠送并上传截图',
{ canSubmitEvidence: selfFulfilling, evidenceButtonLabel: '提交赠送截图' },
)
}
return build(CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND, '等待确认加好友')
}
// 标准验收(非好友赠送)的寄卖单
if (status === 'pending_acceptance') {
return build(CONSIGNMENT_ITEM_STAGE.PENDING_ACCEPTANCE, '验收材料已提交,等待客服验收')
}
return build(
CONSIGNMENT_ITEM_STAGE.FULFILLING,
selfFulfilling ? '请在订单页提交验收材料' : '履约中,等待打手提交验收材料',
{ canSubmitEvidence: selfFulfilling, evidenceButtonLabel: '提交验收材料' },
)
}
function isGiftAvailable(workOrder: ConsignmentItemWorkOrderInput, now: number) {
if (String(workOrder.acceptance_mode || '') !== WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT) {
return false
}
const availableAt = Date.parse(String(workOrder.gift_available_at || ''))
return Number.isFinite(availableAt) && availableAt <= now
}
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
test('寄卖仓库按关键字搜索必须能搜到"寄卖单号"(SUB…),且计数查询带上同一个 join', async () => {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
// 卖家端与客服端现在只把提报单号(SUB…)当主标识展示,搜不到它客服等于瞎了
assert.ok(source.includes('OR cs.submission_no ILIKE $${values.length}'))
// 条件里引用了 cs.submission_no,计数 SQL 就必须同样 join,否则 COUNT 直接 500
const countSqlLine = source
.split('\n')
.find((line) =>
line.includes('SELECT COUNT(*)::integer AS total FROM consignment_inventories ci'),
)
assert.ok(countSqlLine, '未找到计数 SQL')
assert.ok(
countSqlLine!.includes('LEFT JOIN consignment_submissions cs'),
'计数 SQL 缺少提报单 join,会导致按寄卖单号搜索时报 500',
)
})
@@ -33,6 +33,8 @@ export type ConsignmentInventoryRow = {
shop_name: string
source_type: 'manual' | 'worker_portal' | 'recycle'
source_submission_id: string | number | null
/** 唯一锚点:来源提报单号(客服手工入库的现货没有) */
source_submission_no?: string | null
worker_id?: string | number | null
worker_name?: string
worker_phone?: string
@@ -159,12 +161,13 @@ export async function listConsignmentInventories(filters: {
if (filters.keyword) {
values.push(`%${filters.keyword.trim()}%`)
conditions.push(
`(ci.item_name ILIKE $${values.length} OR ci.inventory_no ILIKE $${values.length} OR ci.bound_platform_order_id ILIKE $${values.length} OR ci.seller_contact ILIKE $${values.length} OR ci.seller_game_id ILIKE $${values.length} OR ci.seller_payment_info ILIKE $${values.length} OR ci.remark ILIKE $${values.length})`,
`(ci.item_name ILIKE $${values.length} OR ci.inventory_no ILIKE $${values.length} OR ci.bound_platform_order_id ILIKE $${values.length} OR ci.seller_contact ILIKE $${values.length} OR ci.seller_game_id ILIKE $${values.length} OR ci.seller_payment_info ILIKE $${values.length} OR ci.remark ILIKE $${values.length} OR cs.submission_no ILIKE $${values.length})`,
)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_inventories ci ${whereClause}`
// 计数也要带上提报单 join:关键字现在包含 cs.submission_no(卖家端主标识就是它)
const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_inventories ci LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id ${whereClause}`
const countRes = await query<{ total: number }>(countSql, values)
const total = countRes.rows[0]?.total || 0
@@ -176,9 +179,11 @@ export async function listConsignmentInventories(filters: {
const listSql = `
SELECT
ci.*,
wc.name AS category_name
wc.name AS category_name,
cs.submission_no AS source_submission_no
FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
${whereClause}
ORDER BY ci.id DESC
LIMIT $${values.length - 1} OFFSET $${values.length}
@@ -284,9 +289,11 @@ export async function getConsignmentInventoryById(
const sql = `
SELECT
ci.*,
wc.name AS category_name
wc.name AS category_name,
cs.submission_no AS source_submission_no
FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
WHERE ci.id = $1
LIMIT 1
`
@@ -948,6 +955,33 @@ export async function listConsignmentSubmissions(filters: {
return { items: listRes.rows, total }
}
/**
* 按提报单精确取现货。
*
* 卖家「确认加好友 / 提交赠送截图」必须精确落到自己那件货上,
* 不能用"列表前 N 条里找"——提报件数一多就会漏,操作类接口不能有这个隐患。
*/
export async function getConsignmentInventoryBySubmissionId(
submissionId: number | string,
): Promise<ConsignmentInventoryRow | null> {
const res = await query<ConsignmentInventoryRow>(
`
SELECT
ci.*,
wc.name AS category_name,
cs.submission_no AS source_submission_no
FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
WHERE ci.source_submission_id = $1
ORDER BY ci.id DESC
LIMIT 1
`,
[Number(submissionId)],
)
return res.rows[0] || null
}
export async function getConsignmentSubmissionById(
id: number | string,
): Promise<ConsignmentSubmissionRow | null> {
@@ -678,6 +678,23 @@ export async function getWorkOrderByIdWithClient(
return result.rows[0] || null
}
/**
* 按 id 批量取工单。
*
* 「我的寄卖」列表要把库存绑定的工单状态一次性拼上(阶段推导要用到赠送阶段/冷却时间),
* 一件货查一次太浪费往返。
*/
export async function listWorkOrdersByIds(ids: number[]): Promise<WorkOrderRow[]> {
const normalized = Array.from(
new Set(ids.map((id) => Number(id)).filter((id) => Number.isInteger(id) && id > 0)),
)
if (normalized.length === 0) return []
const result = await query<WorkOrderRow>(`${WORK_ORDER_SELECT} WHERE wo.id = ANY($1::bigint[])`, [
normalized,
])
return result.rows
}
export function buildWorkOrderWhere({
statuses = [],
workOrderId = 0,
@@ -1,8 +1,12 @@
import { Router } from 'express'
import {
confirmWorkerConsignmentFriendAdded,
confirmWorkerConsignmentInventoryFriendAdded,
listWorkerConsignments,
publishWorkerConsignment,
submitWorkerConsignmentGiftEvidence,
submitWorkerConsignmentInventoryGiftEvidence,
} from '../../services/worker-platform/consignment-worker-service.js'
import { sendRouteError } from '../../utils/http.js'
import { getRequiredWorkerSession, requireActiveWorker } from './session.js'
@@ -37,4 +41,90 @@ router.get('/consignment/my-items', async (req, res) => {
}
})
/**
* POST /api/v1/worker/consignment/items/:submissionId/confirm-friend
* 卖家确认已加好买家好友 → 进入赠送冷却期(确认后未完成赠送押金不退)
*/
router.post(
'/consignment/items/:submissionId/confirm-friend',
requireActiveWorker,
async (req, res) => {
try {
const session = getRequiredWorkerSession(req)
const result = await confirmWorkerConsignmentFriendAdded(
session.workerId,
String(req.params.submissionId || ''),
session.username || '',
)
res.json({ ok: true, data: result })
} catch (error) {
sendRouteError(res, error, '确认加好友失败', '[worker/consignment/confirm-friend]')
}
},
)
/**
* POST /api/v1/worker/consignment/items/:submissionId/gift-evidence
* 卖家上传赠送完成截图,工单转待验收交给客服审核(冷却未结束会被拒绝)
*/
router.post(
'/consignment/items/:submissionId/gift-evidence',
requireActiveWorker,
async (req, res) => {
try {
const session = getRequiredWorkerSession(req)
const result = await submitWorkerConsignmentGiftEvidence(
session.workerId,
String(req.params.submissionId || ''),
req.body || {},
session,
)
res.json({ ok: true, data: result })
} catch (error) {
sendRouteError(res, error, '提交赠送截图失败', '[worker/consignment/gift-evidence]')
}
},
)
/**
* 客服手工入库的现货没有提报单,按库存号走同一套卖家动作。
* 单独开路由而不是"一个 id 两义",避免提报单 id 与库存 id 撞号时误操作别人的货。
*/
router.post(
'/consignment/inventories/:inventoryId/confirm-friend',
requireActiveWorker,
async (req, res) => {
try {
const session = getRequiredWorkerSession(req)
const result = await confirmWorkerConsignmentInventoryFriendAdded(
session.workerId,
String(req.params.inventoryId || ''),
session.username || '',
)
res.json({ ok: true, data: result })
} catch (error) {
sendRouteError(res, error, '确认加好友失败', '[worker/consignment/inventory-confirm-friend]')
}
},
)
router.post(
'/consignment/inventories/:inventoryId/gift-evidence',
requireActiveWorker,
async (req, res) => {
try {
const session = getRequiredWorkerSession(req)
const result = await submitWorkerConsignmentInventoryGiftEvidence(
session.workerId,
String(req.params.inventoryId || ''),
req.body || {},
session,
)
res.json({ ok: true, data: result })
} catch (error) {
sendRouteError(res, error, '提交赠送截图失败', '[worker/consignment/inventory-gift-evidence]')
}
},
)
export default router
@@ -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)
}