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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -92,6 +92,14 @@ const BUYER_COLORS: Record<string, string> = {
|
||||
客服B: 'magenta',
|
||||
}
|
||||
|
||||
/**
|
||||
* 寄卖单号 = 来源提报单号(卖家提报时生成的提报单号就是唯一寄卖单号)。
|
||||
* 客服手工入库的现货没有来源提报,回退显示内部仓库号,保证客服对单口径一致。
|
||||
*/
|
||||
function resolveConsignmentNo(record: ConsignmentInventory) {
|
||||
return record.sourceSubmissionNo || record.inventoryNo
|
||||
}
|
||||
|
||||
export default function ConsignmentInventoryPanel() {
|
||||
const { message } = App.useApp()
|
||||
|
||||
@@ -297,7 +305,7 @@ export default function ConsignmentInventoryPanel() {
|
||||
async function handleArchive(record: ConsignmentInventory) {
|
||||
Modal.confirm({
|
||||
title: '确认下架作废该物品?',
|
||||
content: `下架后该物品 [${record.itemName} - ${record.inventoryNo}] 将不再参与匹配。`,
|
||||
content: `下架后该物品 [${record.itemName} - 寄卖单号 ${resolveConsignmentNo(record)}] 将不再参与匹配。`,
|
||||
okText: '确认作废',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
@@ -342,16 +350,28 @@ export default function ConsignmentInventoryPanel() {
|
||||
|
||||
const columns: TableColumnsType<ConsignmentInventory> = [
|
||||
{
|
||||
title: '物品/库存编号',
|
||||
title: '物品/寄卖单号',
|
||||
key: 'item',
|
||||
width: 220,
|
||||
width: 240,
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{record.itemName}</div>
|
||||
<Space size={4}>
|
||||
<Text type="secondary" copyable style={{ fontSize: 12 }}>
|
||||
{record.inventoryNo}
|
||||
<Space size={4} align="center">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
寄卖单号
|
||||
</Text>
|
||||
<Text copyable style={{ fontSize: 12, fontWeight: 600 }}>
|
||||
{resolveConsignmentNo(record)}
|
||||
</Text>
|
||||
</Space>
|
||||
{record.sourceSubmissionNo ? (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 11, color: '#bfbfbf' }}>
|
||||
仓库号 {record.inventoryNo}
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: 2 }}>
|
||||
{record.sourceType === 'worker_portal' ? (
|
||||
<Tag color="purple" style={{ fontSize: 11, padding: '0 4px' }}>
|
||||
打手端提报
|
||||
@@ -365,7 +385,7 @@ export default function ConsignmentInventoryPanel() {
|
||||
人工入库
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -1176,7 +1196,7 @@ export default function ConsignmentInventoryPanel() {
|
||||
|
||||
{/* 查看详情与凭据弹窗 */}
|
||||
<Modal
|
||||
title={`物品详情 [${viewingItem?.inventoryNo}]`}
|
||||
title={`物品详情 [${viewingItem ? resolveConsignmentNo(viewingItem) : ''}]`}
|
||||
open={Boolean(viewingItem)}
|
||||
width={680}
|
||||
footer={[
|
||||
@@ -1191,8 +1211,20 @@ export default function ConsignmentInventoryPanel() {
|
||||
<Descriptions.Item label="物品名称" span={2}>
|
||||
<strong>{viewingItem.itemName}</strong>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="库存编号">
|
||||
<Text copyable>{viewingItem.inventoryNo}</Text>
|
||||
<Descriptions.Item label="寄卖单号">
|
||||
<Text copyable strong>
|
||||
{resolveConsignmentNo(viewingItem)}
|
||||
</Text>
|
||||
{viewingItem.sourceSubmissionNo ? null : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
(客服手工入库,无来源提报单号)
|
||||
</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="仓库号">
|
||||
<Text type="secondary" copyable>
|
||||
{viewingItem.inventoryNo}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="当前状态">
|
||||
{viewingItem.status === 'in_stock' ? (
|
||||
@@ -1289,7 +1321,8 @@ export default function ConsignmentInventoryPanel() {
|
||||
<Text>
|
||||
确认将该物品{' '}
|
||||
<strong>
|
||||
[{returnItem?.itemName} - {returnItem?.inventoryNo}]
|
||||
[{returnItem?.itemName} - 寄卖单号{' '}
|
||||
{returnItem ? resolveConsignmentNo(returnItem) : ''}]
|
||||
</strong>{' '}
|
||||
退回复库吗?
|
||||
</Text>
|
||||
|
||||
@@ -80,7 +80,7 @@ import {
|
||||
revokeWorkOrderConsignmentFulfillment,
|
||||
type WorkOrderVoucherConsumeResult,
|
||||
} from '@/services/admin/consignment'
|
||||
import type { WorkOrderInventoryMatchCheck } from '@/types/consignment'
|
||||
import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment'
|
||||
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
|
||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
|
||||
@@ -174,6 +174,20 @@ function isInternalConsignmentOrder(row: WorkOrder) {
|
||||
return Boolean(consignment.inventoryId) && !row.worker
|
||||
}
|
||||
|
||||
/** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */
|
||||
function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null {
|
||||
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
|
||||
return Object.keys(consignment).length > 0 ? (consignment as ConsignmentFulfillmentInfo) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 寄卖单号 = 来源提报单号(提报单号即寄卖单号)。
|
||||
* 历史老单的 consignment 没有 sourceSubmissionNo,回退到内部仓库号 inventoryNo。
|
||||
*/
|
||||
function resolveConsignmentNo(info: ConsignmentFulfillmentInfo) {
|
||||
return info.sourceSubmissionNo || info.inventoryNo || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 只有「未验收且未取消」的工单可以撤销寄售履约。
|
||||
* 已验收工单撤销会造成同一件现货二次出货、并重新冻结已退还的押金,后端一律拒绝。
|
||||
@@ -330,6 +344,9 @@ export default function WorkOrdersPanel() {
|
||||
submitting: false,
|
||||
})
|
||||
|
||||
// 详情抽屉里的寄售履约信息(有寄售履约才展示寄卖单号)
|
||||
const detailConsignment = detailOrder ? getConsignmentFulfillment(detailOrder) : null
|
||||
|
||||
// 仅单独筛选「待验收」时提供批量验收入口(多选状态下不启用勾选,避免误操作)
|
||||
const isPendingAcceptanceOnly = statuses.length === 1 && statuses[0] === 'pending_acceptance'
|
||||
const selectedWorkOrderStatuses = statuses.filter(
|
||||
@@ -1210,6 +1227,7 @@ export default function WorkOrdersPanel() {
|
||||
const source = asRecord(row.material?.source)
|
||||
const shopName = String(source.shopName || '').trim()
|
||||
const provider = String(source.provider || '').trim()
|
||||
const consignment = getConsignmentFulfillment(row)
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<ProductNameEditableCell
|
||||
@@ -1255,10 +1273,10 @@ export default function WorkOrdersPanel() {
|
||||
{shopName ? `店铺:${shopName}` : ''}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{(row.material as any)?.consignment ? (
|
||||
{consignment ? (
|
||||
<Tag color="purple" style={{ marginTop: 2, width: 'fit-content' }}>
|
||||
寄售履约: {(row.material as any).consignment.inventoryNo} (成本: ¥
|
||||
{(((row.material as any).consignment.costPriceFen || 0) / 100).toFixed(2)})
|
||||
寄卖单号: {resolveConsignmentNo(consignment) || '-'} (成本: ¥
|
||||
{((Number(consignment.costPriceFen) || 0) / 100).toFixed(2)})
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1869,6 +1887,13 @@ export default function WorkOrdersPanel() {
|
||||
<Descriptions.Item label="分类">
|
||||
{detailOrder.categoryName || '-'}
|
||||
</Descriptions.Item>
|
||||
{detailConsignment ? (
|
||||
<Descriptions.Item label="寄卖单号">
|
||||
<Typography.Text copyable={{ text: resolveConsignmentNo(detailConsignment) }}>
|
||||
{resolveConsignmentNo(detailConsignment) || '-'}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={resolveStatusColor(detailOrder.status)}>
|
||||
{formatStatus(detailOrder.status)}
|
||||
@@ -2999,9 +3024,14 @@ export default function WorkOrdersPanel() {
|
||||
) : (
|
||||
<Tag color="gold">【部分匹配 / 高配充低配】</Tag>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{inv.inventoryNo}
|
||||
<Typography.Text style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
寄卖单号 {inv.sourceSubmissionNo || inv.inventoryNo}
|
||||
</Typography.Text>
|
||||
{inv.sourceSubmissionNo ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
仓库号 {inv.inventoryNo}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
<div style={{ marginTop: 4 }}>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Row,
|
||||
Space,
|
||||
@@ -33,12 +34,18 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import {
|
||||
confirmWorkerConsignmentFriendAdded,
|
||||
confirmWorkerConsignmentInventoryFriendAdded,
|
||||
fetchWorkerConsignments,
|
||||
fetchWorkerProfile,
|
||||
publishWorkerConsignment,
|
||||
submitWorkerConsignmentGiftEvidence,
|
||||
submitWorkerConsignmentInventoryGiftEvidence,
|
||||
} from '@/services/worker'
|
||||
import type { WorkerConsignmentItem, WorkerInventoryItem } from '@/services/worker'
|
||||
import type { WorkerConsignmentRecord } from '@/services/worker'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { useIsMobile } from '@/utils/use-is-mobile'
|
||||
|
||||
const SERIES_OPTIONS = [
|
||||
@@ -57,10 +64,13 @@ const BRILLIANCE_OPTIONS = [
|
||||
...[1, 2, 3, 4, 5, 6, 7].map((v) => ({ label: `${v} 璀璨`, value: v })),
|
||||
]
|
||||
|
||||
type RecordFilter = 'all' | 'pending' | 'in_stock' | 'done'
|
||||
type RecordFilter = 'all' | 'reviewing' | 'active' | 'finished'
|
||||
|
||||
const DEPOSIT_TIP_KEY = 'worker-consignment-deposit-tip-seen'
|
||||
|
||||
/** 赠送冷却倒计时刷新间隔:30 秒足够展示,不需要秒级跳动 */
|
||||
const COOLDOWN_TICK_MS = 30_000
|
||||
|
||||
export default function WorkerConsignmentPage() {
|
||||
const isMobile = useIsMobile()
|
||||
const { message, modal } = App.useApp()
|
||||
@@ -68,6 +78,11 @@ export default function WorkerConsignmentPage() {
|
||||
const [recordFilter, setRecordFilter] = useState<RecordFilter>('all')
|
||||
const [screenshots, setScreenshots] = useState<UploadedFile[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [evidenceItem, setEvidenceItem] = useState<WorkerConsignmentRecord | null>(null)
|
||||
const [evidenceFiles, setEvidenceFiles] = useState<UploadedFile[]>([])
|
||||
const [evidenceNote, setEvidenceNote] = useState('')
|
||||
const [evidenceSubmitting, setEvidenceSubmitting] = useState(false)
|
||||
const [cooldownNow, setCooldownNow] = useState(() => Date.now())
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const profileQuery = useQuery({
|
||||
@@ -83,8 +98,9 @@ export default function WorkerConsignmentPage() {
|
||||
queryFn: () => fetchWorkerConsignments(),
|
||||
})
|
||||
|
||||
const submissions = myConsignmentsQuery.data?.data.submissions || []
|
||||
const inventories = myConsignmentsQuery.data?.data.inventories || []
|
||||
// 后端已按「一件货一行」聚合,前端不再合并提报单与库存
|
||||
const records = myConsignmentsQuery.data?.data.items || []
|
||||
const stageCounts = myConsignmentsQuery.data?.data.stageCounts || {}
|
||||
const publishDepositFen = Number(
|
||||
myConsignmentsQuery.data?.data.publishDepositFen ??
|
||||
financeConfig?.consignmentPublishDepositFen ??
|
||||
@@ -94,10 +110,43 @@ export default function WorkerConsignmentPage() {
|
||||
const availableAmount = Number(worker?.wallet?.availableAmount || 0)
|
||||
const canPublish = availableAmount >= publishDepositFen
|
||||
|
||||
const pendingCount = submissions.filter((s) => s.status === 'pending_review').length
|
||||
const inStockCount = inventories.filter((i) => i.status === 'in_stock').length
|
||||
const doneCount = inventories.filter((i) => i.status === 'consumed').length
|
||||
const rejectedCount = submissions.filter((s) => s.status === 'rejected').length
|
||||
const reviewingCount = records.filter((item) => item.group === 'reviewing').length
|
||||
const activeCount = records.filter((item) => item.group === 'active').length
|
||||
const finishedCount = records.filter((item) => item.group === 'finished').length
|
||||
|
||||
// 阶段 → 分组由后端下发(item.group),这里只把它映射到筛选角标计数上
|
||||
const groupCounts = useMemo(() => {
|
||||
const stageGroups = new Map<string, string>()
|
||||
records.forEach((item) => stageGroups.set(item.stage, item.group))
|
||||
const counts: Record<'reviewing' | 'active' | 'finished', number> = {
|
||||
reviewing: 0,
|
||||
active: 0,
|
||||
finished: 0,
|
||||
}
|
||||
Object.entries(stageCounts).forEach(([stage, count]) => {
|
||||
const group = stageGroups.get(stage)
|
||||
if (group === 'reviewing' || group === 'active' || group === 'finished') {
|
||||
counts[group] += Number(count) || 0
|
||||
}
|
||||
})
|
||||
return counts
|
||||
}, [records, stageCounts])
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
if (recordFilter === 'all') return records
|
||||
return records.filter((item) => item.group === recordFilter)
|
||||
}, [records, recordFilter])
|
||||
|
||||
const hasCooldownItem = records.some(
|
||||
(item) => item.stage === 'gift_cooldown' && Boolean(item.giftAvailableAt),
|
||||
)
|
||||
|
||||
// 只有存在冷却中的寄卖单时才起定时器,避免无意义的重渲染
|
||||
useEffect(() => {
|
||||
if (!hasCooldownItem) return
|
||||
const timer = window.setInterval(() => setCooldownNow(Date.now()), COOLDOWN_TICK_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [hasCooldownItem])
|
||||
|
||||
useEffect(() => {
|
||||
if (worker?.phone) {
|
||||
@@ -193,7 +242,7 @@ export default function WorkerConsignmentPage() {
|
||||
form.resetFields()
|
||||
setScreenshots([])
|
||||
await Promise.all([myConsignmentsQuery.refetch(), profileQuery.refetch()])
|
||||
setRecordFilter('pending')
|
||||
setRecordFilter('reviewing')
|
||||
setActiveTab('records')
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '发布寄卖失败,请重试')
|
||||
@@ -202,32 +251,94 @@ export default function WorkerConsignmentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
const pendingSubs = submissions.filter((s) => s.status === 'pending_review')
|
||||
const rejectedSubs = submissions.filter((s) => s.status === 'rejected')
|
||||
const approvedSubs = submissions.filter((s) => s.status === 'approved')
|
||||
const inStock = inventories.filter((i) => i.status === 'in_stock')
|
||||
const done = inventories.filter((i) => i.status === 'consumed' || i.status === 'archived')
|
||||
/**
|
||||
* 定位要操作的寄卖单:有提报单号(寄卖单号)就用它,客服手工入库的现货回退用库存号。
|
||||
* 两条路由在后端是分开的,这里用 submissionId/inventoryId 是否为 null 决定,
|
||||
* 前端不会把两个号混用。
|
||||
*/
|
||||
function resolveActionAnchor(item: WorkerConsignmentRecord) {
|
||||
if (item.submissionId != null) return { kind: 'submission' as const, id: item.submissionId }
|
||||
if (item.inventoryId != null) return { kind: 'inventory' as const, id: item.inventoryId }
|
||||
message.warning('该寄卖单缺少可操作的编号,请联系客服处理')
|
||||
return null
|
||||
}
|
||||
|
||||
if (recordFilter === 'pending') {
|
||||
return {
|
||||
submissions: [...pendingSubs, ...rejectedSubs],
|
||||
inventories: [] as WorkerInventoryItem[],
|
||||
}
|
||||
}
|
||||
if (recordFilter === 'in_stock') {
|
||||
return { submissions: [] as WorkerConsignmentItem[], inventories: inStock }
|
||||
}
|
||||
if (recordFilter === 'done') {
|
||||
return { submissions: [] as WorkerConsignmentItem[], inventories: done }
|
||||
}
|
||||
return {
|
||||
submissions: [...pendingSubs, ...rejectedSubs, ...approvedSubs],
|
||||
inventories,
|
||||
}
|
||||
}, [submissions, inventories, recordFilter])
|
||||
function handleConfirmFriend(item: WorkerConsignmentRecord) {
|
||||
const anchor = resolveActionAnchor(item)
|
||||
if (!anchor) return
|
||||
|
||||
const recordTotal = filteredRecords.submissions.length + filteredRecords.inventories.length
|
||||
modal.confirm({
|
||||
title: '确认已在游戏内加好买家好友?',
|
||||
content: (
|
||||
<div>
|
||||
<p>确认后系统立即开始赠送冷却,请逐条确认风险:</p>
|
||||
<p style={{ marginBottom: 4 }}>1. 确认后立即进入赠送冷却期,冷却期间不能赠送;</p>
|
||||
<p style={{ marginBottom: 4 }}>2. 冷却期结束后必须用本人账号完成赠送并上传截图;</p>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
<Typography.Text type="danger" strong>
|
||||
3. 冷却期结束后未完成赠送的,履约押金不退。
|
||||
</Typography.Text>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '确认加好友,开始冷却',
|
||||
okType: 'danger',
|
||||
cancelText: '暂不确认',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await (anchor.kind === 'submission'
|
||||
? confirmWorkerConsignmentFriendAdded(anchor.id)
|
||||
: confirmWorkerConsignmentInventoryFriendAdded(anchor.id))
|
||||
message.success('已确认加好友,赠送冷却开始')
|
||||
await myConsignmentsQuery.refetch()
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '确认加好友失败,请刷新后重试')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openEvidenceModal(item: WorkerConsignmentRecord) {
|
||||
if (!resolveActionAnchor(item)) return
|
||||
setEvidenceItem(item)
|
||||
setEvidenceFiles([])
|
||||
setEvidenceNote('')
|
||||
}
|
||||
|
||||
function closeEvidenceModal() {
|
||||
setEvidenceItem(null)
|
||||
setEvidenceFiles([])
|
||||
setEvidenceNote('')
|
||||
}
|
||||
|
||||
async function handleEvidenceSubmit() {
|
||||
const anchor = evidenceItem ? resolveActionAnchor(evidenceItem) : null
|
||||
if (!anchor) return
|
||||
const urls = evidenceFiles.map((file) => file.url).filter(Boolean)
|
||||
if (urls.length === 0) {
|
||||
message.warning('请先上传赠送完成截图,客服需要凭截图验收')
|
||||
return
|
||||
}
|
||||
|
||||
setEvidenceSubmitting(true)
|
||||
try {
|
||||
const note = evidenceNote.trim()
|
||||
const payload = note ? { imageUrls: urls, note } : { imageUrls: urls }
|
||||
await (anchor.kind === 'submission'
|
||||
? submitWorkerConsignmentGiftEvidence(anchor.id, payload)
|
||||
: submitWorkerConsignmentInventoryGiftEvidence(anchor.id, payload))
|
||||
message.success('赠送截图已提交,等待客服验收')
|
||||
closeEvidenceModal()
|
||||
await myConsignmentsQuery.refetch()
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '提交赠送截图失败,请重试')
|
||||
} finally {
|
||||
setEvidenceSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const recordTotal = filteredRecords.length
|
||||
|
||||
return (
|
||||
<section className="page-stack worker-consignment-page">
|
||||
@@ -254,9 +365,9 @@ export default function WorkerConsignmentPage() {
|
||||
|
||||
<div className="worker-orders-kpi-grid">
|
||||
<div
|
||||
className={`worker-orders-kpi-card ${recordFilter === 'pending' && activeTab === 'records' ? 'active' : ''}`}
|
||||
className={`worker-orders-kpi-card ${recordFilter === 'reviewing' && activeTab === 'records' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setRecordFilter('pending')
|
||||
setRecordFilter('reviewing')
|
||||
setActiveTab('records')
|
||||
}}
|
||||
>
|
||||
@@ -265,24 +376,22 @@ export default function WorkerConsignmentPage() {
|
||||
<ClockCircleOutlined />
|
||||
</div>
|
||||
<div className="worker-orders-kpi-info">
|
||||
<span className="worker-orders-kpi-title">审核中 / 驳回</span>
|
||||
<span className="worker-orders-kpi-title">待审核</span>
|
||||
<strong className="worker-orders-kpi-val">
|
||||
{pendingCount + rejectedCount}
|
||||
{reviewingCount}
|
||||
<span className="worker-orders-kpi-unit"> 单</span>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="worker-orders-kpi-bottom">
|
||||
<span className="worker-orders-kpi-sub-label">
|
||||
{pendingCount > 0 ? `${pendingCount} 单待客服审核` : '暂无待审提报'}
|
||||
</span>
|
||||
<span className="worker-orders-kpi-sub-label">客服审核中,通过后自动入库待匹配</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`worker-orders-kpi-card ${recordFilter === 'in_stock' && activeTab === 'records' ? 'active' : ''}`}
|
||||
className={`worker-orders-kpi-card ${recordFilter === 'active' && activeTab === 'records' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setRecordFilter('in_stock')
|
||||
setRecordFilter('active')
|
||||
setActiveTab('records')
|
||||
}}
|
||||
>
|
||||
@@ -291,22 +400,22 @@ export default function WorkerConsignmentPage() {
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<div className="worker-orders-kpi-info">
|
||||
<span className="worker-orders-kpi-title">在库待派单</span>
|
||||
<span className="worker-orders-kpi-title">履约中</span>
|
||||
<strong className="worker-orders-kpi-val">
|
||||
{inStockCount}
|
||||
{activeCount}
|
||||
<span className="worker-orders-kpi-unit"> 把</span>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="worker-orders-kpi-bottom">
|
||||
<span className="worker-orders-kpi-sub-label">已入库,等待匹配买家工单</span>
|
||||
<span className="worker-orders-kpi-sub-label">待加好友 / 冷却 / 待赠送 / 待验收</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`worker-orders-kpi-card ${recordFilter === 'done' && activeTab === 'records' ? 'active' : ''}`}
|
||||
className={`worker-orders-kpi-card ${recordFilter === 'finished' && activeTab === 'records' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setRecordFilter('done')
|
||||
setRecordFilter('finished')
|
||||
setActiveTab('records')
|
||||
}}
|
||||
>
|
||||
@@ -315,15 +424,15 @@ export default function WorkerConsignmentPage() {
|
||||
<CheckCircleOutlined />
|
||||
</div>
|
||||
<div className="worker-orders-kpi-info">
|
||||
<span className="worker-orders-kpi-title">已履约出库</span>
|
||||
<span className="worker-orders-kpi-title">已完成</span>
|
||||
<strong className="worker-orders-kpi-val">
|
||||
{doneCount}
|
||||
{finishedCount}
|
||||
<span className="worker-orders-kpi-unit"> 把</span>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="worker-orders-kpi-bottom">
|
||||
<span className="worker-orders-kpi-sub-label">押金履约交付完成</span>
|
||||
<span className="worker-orders-kpi-sub-label">含已完成、已驳回、已下架作废</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -594,10 +703,8 @@ export default function WorkerConsignmentPage() {
|
||||
label: (
|
||||
<span>
|
||||
<HistoryOutlined /> 我的寄卖
|
||||
{submissions.length + inventories.length > 0 ? (
|
||||
<span className="worker-order-tab-count">
|
||||
{submissions.length + inventories.length}
|
||||
</span>
|
||||
{records.length > 0 ? (
|
||||
<span className="worker-order-tab-count">{records.length}</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
@@ -606,10 +713,10 @@ export default function WorkerConsignmentPage() {
|
||||
<div className="worker-consignment-filter-row">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'pending', label: '审核中' },
|
||||
{ key: 'in_stock', label: '在库' },
|
||||
{ key: 'done', label: '已出库' },
|
||||
{ key: 'all', label: '全部', count: records.length },
|
||||
{ key: 'reviewing', label: '待审核', count: groupCounts.reviewing },
|
||||
{ key: 'active', label: '履约中', count: groupCounts.active },
|
||||
{ key: 'finished', label: '已完成', count: groupCounts.finished },
|
||||
] as const
|
||||
).map((item) => (
|
||||
<Button
|
||||
@@ -619,6 +726,7 @@ export default function WorkerConsignmentPage() {
|
||||
onClick={() => setRecordFilter(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
{item.count > 0 ? ` (${item.count})` : ''}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -628,19 +736,28 @@ export default function WorkerConsignmentPage() {
|
||||
) : recordTotal === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无寄卖记录,去发布一单苏尔南吧"
|
||||
description={
|
||||
recordFilter === 'all'
|
||||
? '暂无寄卖记录,去发布一单苏尔南吧'
|
||||
: '当前筛选下没有寄卖记录'
|
||||
}
|
||||
>
|
||||
<Button type="primary" onClick={() => setActiveTab('publish')}>
|
||||
发布寄卖
|
||||
</Button>
|
||||
{recordFilter === 'all' ? (
|
||||
<Button type="primary" onClick={() => setActiveTab('publish')}>
|
||||
发布寄卖
|
||||
</Button>
|
||||
) : null}
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="worker-consignment-record-list">
|
||||
{filteredRecords.inventories.map((inv) => (
|
||||
<InventoryRecordCard key={`inv-${inv.id}`} inv={inv} />
|
||||
))}
|
||||
{filteredRecords.submissions.map((sub) => (
|
||||
<SubmissionRecordCard key={`sub-${sub.id}`} sub={sub} />
|
||||
{filteredRecords.map((record) => (
|
||||
<ConsignmentRecordCard
|
||||
key={record.consignmentNo}
|
||||
record={record}
|
||||
now={cooldownNow}
|
||||
onConfirmFriend={handleConfirmFriend}
|
||||
onSubmitEvidence={openEvidenceModal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -650,6 +767,43 @@ export default function WorkerConsignmentPage() {
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={evidenceItem != null}
|
||||
title={`${evidenceItem?.evidenceButtonLabel || '提交赠送截图'} · ${evidenceItem?.consignmentNo || ''}`}
|
||||
okText="提交给客服审核"
|
||||
cancelText="取消"
|
||||
confirmLoading={evidenceSubmitting}
|
||||
onOk={handleEvidenceSubmit}
|
||||
onCancel={closeEvidenceModal}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="请上传游戏内赠送完成的截图(含买家角色名/ID、赠送记录)"
|
||||
description="客服核对后才算履约完成;冷却未结束时提交会被系统拒绝。"
|
||||
/>
|
||||
<ImageUpload
|
||||
value={evidenceFiles}
|
||||
onChange={setEvidenceFiles}
|
||||
scene="worker-acceptance"
|
||||
scope="worker"
|
||||
maxCount={6}
|
||||
showPasteHint={!isMobile}
|
||||
/>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
maxLength={200}
|
||||
showCount
|
||||
placeholder="备注(选填,如赠送时间、角色名)"
|
||||
value={evidenceNote}
|
||||
onChange={(event) => setEvidenceNote(event.target.value)}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -678,87 +832,77 @@ function SpecTags({
|
||||
)
|
||||
}
|
||||
|
||||
function InventoryRecordCard({ inv }: { inv: WorkerInventoryItem }) {
|
||||
return (
|
||||
<article className="worker-consignment-record-card">
|
||||
<div className="worker-consignment-record-main">
|
||||
<div className="worker-consignment-record-title-row">
|
||||
<Typography.Text strong>{inv.itemName}</Typography.Text>
|
||||
<Typography.Text type="secondary" className="worker-consignment-record-no">
|
||||
{inv.inventoryNo}
|
||||
</Typography.Text>
|
||||
{inv.status === 'in_stock' ? (
|
||||
<Tag color="green">在库待派</Tag>
|
||||
) : inv.status === 'consumed' ? (
|
||||
<Tag color="blue">已履约</Tag>
|
||||
) : (
|
||||
<Tag>已下架</Tag>
|
||||
)}
|
||||
</div>
|
||||
<SpecTags
|
||||
system={inv.system}
|
||||
serverZone={inv.serverZone}
|
||||
series={inv.series}
|
||||
diamondLevel={inv.diamondLevel}
|
||||
brillianceCount={inv.brillianceCount}
|
||||
/>
|
||||
<div className="worker-consignment-record-meta">
|
||||
<span>
|
||||
货款 <strong>¥{(inv.costPriceFen / 100).toFixed(2)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
押金 <strong>¥{((inv.depositAmountFen || 0) / 100).toFixed(0)}</strong>
|
||||
</span>
|
||||
{inv.boundPlatformOrderId ? (
|
||||
<Typography.Text copyable={{ text: inv.boundPlatformOrderId }}>
|
||||
履约单 {inv.boundPlatformOrderId}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Typography.Text type="secondary" className="worker-consignment-record-time">
|
||||
{inv.createdAt}
|
||||
</Typography.Text>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function SubmissionRecordCard({ sub }: { sub: WorkerConsignmentItem }) {
|
||||
const shots = Array.isArray(sub.screenshotsJson)
|
||||
? sub.screenshotsJson.filter((s): s is string => typeof s === 'string')
|
||||
/**
|
||||
* 「我的寄卖」记录卡片:一件货一张卡。
|
||||
*
|
||||
* 号段只显示后端下发的 `consignmentNo`(提报单号,客服手工入库时是库存号),
|
||||
* 阶段文案 / 配色 / 可执行动作全部用后端下发字段,前端只渲染 + 发请求。
|
||||
*/
|
||||
function ConsignmentRecordCard({
|
||||
record,
|
||||
now,
|
||||
onConfirmFriend,
|
||||
onSubmitEvidence,
|
||||
}: {
|
||||
record: WorkerConsignmentRecord
|
||||
now: number
|
||||
onConfirmFriend: (record: WorkerConsignmentRecord) => void
|
||||
onSubmitEvidence: (record: WorkerConsignmentRecord) => void
|
||||
}) {
|
||||
const shots = Array.isArray(record.screenshotsJson)
|
||||
? record.screenshotsJson.filter((s): s is string => typeof s === 'string')
|
||||
: []
|
||||
// 只有后端把阶段标成 gift_cooldown 时才显示倒计时,前端不自己推阶段
|
||||
const cooldown =
|
||||
record.stage === 'gift_cooldown' && record.giftAvailableAt
|
||||
? formatGiftCooldownRemaining(record.giftAvailableAt, now)
|
||||
: null
|
||||
const cooldownDeadline = record.giftAvailableAt ? formatDateTime(record.giftAvailableAt) : ''
|
||||
|
||||
return (
|
||||
<article className="worker-consignment-record-card">
|
||||
<div className="worker-consignment-record-main">
|
||||
<div className="worker-consignment-record-title-row">
|
||||
<Typography.Text strong>{sub.itemName}</Typography.Text>
|
||||
<Typography.Text strong>{record.itemName}</Typography.Text>
|
||||
<Typography.Text type="secondary" className="worker-consignment-record-no">
|
||||
{sub.submissionNo}
|
||||
寄卖单号 {record.consignmentNo}
|
||||
</Typography.Text>
|
||||
{sub.status === 'pending_review' ? (
|
||||
<Tag color="gold">审核中</Tag>
|
||||
) : sub.status === 'approved' ? (
|
||||
<Tag color="green">已通过</Tag>
|
||||
) : (
|
||||
<Tag color="red">已驳回</Tag>
|
||||
)}
|
||||
<Tag color={record.color}>{record.label}</Tag>
|
||||
</div>
|
||||
<SpecTags
|
||||
system={sub.system}
|
||||
serverZone={sub.serverZone}
|
||||
series={sub.series}
|
||||
diamondLevel={sub.diamondLevel}
|
||||
brillianceCount={sub.brillianceCount}
|
||||
system={record.system}
|
||||
serverZone={record.serverZone}
|
||||
series={record.series}
|
||||
diamondLevel={record.diamondLevel}
|
||||
brillianceCount={record.brillianceCount}
|
||||
/>
|
||||
<div className="worker-consignment-record-meta">
|
||||
<span>
|
||||
期望价 <strong>¥{(sub.askingPriceFen / 100).toFixed(2)}</strong>
|
||||
寄售价 <strong>¥{(record.askingPriceFen / 100).toFixed(2)}</strong>
|
||||
</span>
|
||||
{sub.reviewNote ? (
|
||||
<Typography.Text type="danger">审核说明:{sub.reviewNote}</Typography.Text>
|
||||
) : null}
|
||||
<span>
|
||||
押金 <strong>¥{(Number(record.depositAmountFen || 0) / 100).toFixed(2)}</strong>
|
||||
</span>
|
||||
{record.workOrderId ? <span>履约单 #{record.workOrderId}</span> : null}
|
||||
</div>
|
||||
{record.reviewNote ? (
|
||||
<Typography.Text type="danger">审核说明:{record.reviewNote}</Typography.Text>
|
||||
) : null}
|
||||
{record.hint ? (
|
||||
<Typography.Text type="secondary" className="worker-consignment-record-hint">
|
||||
{record.hint}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{cooldown?.text ? (
|
||||
<Space size={6} wrap>
|
||||
<Tag color={cooldown.finished ? 'green' : 'gold'}>{cooldown.text}</Tag>
|
||||
{cooldownDeadline ? (
|
||||
<Typography.Text type="secondary" className="worker-consignment-record-hint">
|
||||
冷却截止 {cooldownDeadline},到点后点右上角「刷新」查看最新状态
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null}
|
||||
{shots.length > 0 ? (
|
||||
<div className="worker-consignment-record-shots">
|
||||
<Image.PreviewGroup>
|
||||
@@ -776,9 +920,26 @@ function SubmissionRecordCard({ sub }: { sub: WorkerConsignmentItem }) {
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
) : null}
|
||||
{record.canConfirmFriend || record.canSubmitEvidence ? (
|
||||
<div className="worker-consignment-record-actions">
|
||||
{record.canConfirmFriend ? (
|
||||
<Button type="primary" onClick={() => onConfirmFriend(record)}>
|
||||
确认加好友
|
||||
</Button>
|
||||
) : null}
|
||||
{record.canSubmitEvidence ? (
|
||||
<Button
|
||||
type={record.canConfirmFriend ? 'default' : 'primary'}
|
||||
onClick={() => onSubmitEvidence(record)}
|
||||
>
|
||||
{record.evidenceButtonLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Typography.Text type="secondary" className="worker-consignment-record-time">
|
||||
{sub.createdAt}
|
||||
{formatDateTime(record.createdAt)}
|
||||
</Typography.Text>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -390,21 +390,68 @@ export type WorkerConsignmentItem = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type WorkerInventoryItem = {
|
||||
id: number | string
|
||||
inventoryNo: string
|
||||
/** 寄卖单统一状态机阶段(后端推导,前端只透传展示) */
|
||||
export type WorkerConsignmentStage =
|
||||
| 'pending_review'
|
||||
| 'in_stock'
|
||||
| 'matched_waiting_friend'
|
||||
| 'gift_cooldown'
|
||||
| 'gift_ready'
|
||||
| 'gift_submitted'
|
||||
| 'fulfilling'
|
||||
| 'pending_acceptance'
|
||||
| 'completed'
|
||||
| 'rejected'
|
||||
| 'archived'
|
||||
| 'cancelled'
|
||||
|
||||
export type WorkerConsignmentGroup = 'reviewing' | 'active' | 'finished'
|
||||
|
||||
/**
|
||||
* 「我的寄卖」列表行:**一件货一行**。
|
||||
*
|
||||
* 后端已把提报单(SUB…)/ 库存(INV…)/ 绑定工单三套状态聚合成一条记录,
|
||||
* 以 `consignmentNo` 为唯一锚点;阶段文案、配色与可执行动作也由后端下发,
|
||||
* 前端不再自己推导状态,避免和客服侧口径不一致。
|
||||
*/
|
||||
export type WorkerConsignmentRecord = {
|
||||
/** 唯一锚点:提报单号(客服手工入库时是库存号),界面只显示它 */
|
||||
consignmentNo: string
|
||||
submissionId: number | null
|
||||
inventoryId: number | null
|
||||
workOrderId: number | null
|
||||
itemName: string
|
||||
system: string
|
||||
serverZone: string
|
||||
series: string
|
||||
diamondLevel: number
|
||||
brillianceCount: number
|
||||
costPriceFen: number
|
||||
status: 'in_stock' | 'consumed' | 'archived'
|
||||
allocationStatus: string
|
||||
boundPlatformOrderId: string
|
||||
depositAmountFen?: number
|
||||
askingPriceFen: number
|
||||
depositAmountFen: number
|
||||
reviewNote: string
|
||||
screenshotsJson: string[]
|
||||
createdAt: string
|
||||
stage: WorkerConsignmentStage
|
||||
/** 中文阶段名,如「已匹配-待加好友」 */
|
||||
label: string
|
||||
group: WorkerConsignmentGroup
|
||||
/** antd Tag 颜色名 */
|
||||
color: string
|
||||
/** 给卖家看的下一步提示,直接展示 */
|
||||
hint: string
|
||||
canConfirmFriend: boolean
|
||||
canSubmitEvidence: boolean
|
||||
evidenceButtonLabel: '提交赠送截图' | '提交验收材料'
|
||||
/** 赠送冷却截止时间,前端拿它做倒计时 */
|
||||
giftAvailableAt: string | null
|
||||
friendAddedAt: string | null
|
||||
}
|
||||
|
||||
export type WorkerConsignmentListResponse = {
|
||||
publishDepositFen: number
|
||||
items: WorkerConsignmentRecord[]
|
||||
/** 各阶段件数,用于筛选 tab 角标 */
|
||||
stageCounts: Record<string, number>
|
||||
}
|
||||
|
||||
export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayload) {
|
||||
@@ -412,9 +459,48 @@ export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayloa
|
||||
}
|
||||
|
||||
export function fetchWorkerConsignments() {
|
||||
return apiGet<{
|
||||
publishDepositFen: number
|
||||
submissions: WorkerConsignmentItem[]
|
||||
inventories: WorkerInventoryItem[]
|
||||
}>('/api/v1/worker/consignment/my-items')
|
||||
return apiGet<WorkerConsignmentListResponse>('/api/v1/worker/consignment/my-items')
|
||||
}
|
||||
|
||||
/**
|
||||
* 卖家确认已在游戏内加好买家好友 → 进入赠送冷却期。
|
||||
* 注意:确认后若未完成赠送,履约押金不退。
|
||||
*/
|
||||
export function confirmWorkerConsignmentFriendAdded(submissionId: number | string) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/worker/consignment/items/${submissionId}/confirm-friend`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
/** 卖家提交赠送完成截图(后端复用打手验收链路,工单转「待验收」交客服审核)。 */
|
||||
export function submitWorkerConsignmentGiftEvidence(
|
||||
submissionId: number | string,
|
||||
payload: { imageUrls: string[]; note?: string },
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/worker/consignment/items/${submissionId}/gift-evidence`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 客服手工入库的现货没有提报单号,按库存号走同一套动作
|
||||
* (后端单独开路由,不用"一个 id 两义",避免提报单号与库存号撞号时误操作)。
|
||||
*/
|
||||
export function confirmWorkerConsignmentInventoryFriendAdded(inventoryId: number | string) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/worker/consignment/inventories/${inventoryId}/confirm-friend`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export function submitWorkerConsignmentInventoryGiftEvidence(
|
||||
inventoryId: number | string,
|
||||
payload: { imageUrls: string[]; note?: string },
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/worker/consignment/inventories/${inventoryId}/gift-evidence`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4429,6 +4429,19 @@ body {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 「下一步提示」与倒计时说明:比主信息弱一档的次要文字 */
|
||||
.worker-consignment-record-hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.worker-consignment-record-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.worker-consignment-record-card {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -28,6 +28,8 @@ export type ConsignmentInventory = {
|
||||
boundPlatformOrderId: string
|
||||
sourceType: 'manual' | 'worker_portal' | 'recycle'
|
||||
sourceSubmissionId: number | null
|
||||
/** 来源提报单号(提报单号即寄卖单号);客服手工入库的现货为空字符串。 */
|
||||
sourceSubmissionNo?: string
|
||||
workerId?: number | null
|
||||
workerName?: string
|
||||
workerPhone?: string
|
||||
@@ -40,6 +42,22 @@ export type ConsignmentInventory = {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单 `material.consignment` 里的寄售履约信息(履约/指派时后端写入)。
|
||||
* 历史老单没有 sourceSubmissionNo,展示时必须回退到 inventoryNo。
|
||||
*/
|
||||
export type ConsignmentFulfillmentInfo = {
|
||||
inventoryId?: number | null
|
||||
inventoryNo?: string
|
||||
sourceSubmissionId?: number | null
|
||||
/** 来源提报单号(提报单号即寄卖单号);历史老单没有该字段。 */
|
||||
sourceSubmissionNo?: string
|
||||
costPriceFen?: number
|
||||
fulfillmentType?: string
|
||||
workerId?: number | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ConsignmentSubmission = {
|
||||
id: number
|
||||
submissionNo: string
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 寄卖「加好友赠送冷却」倒计时文案。
|
||||
*
|
||||
* 后端只给一个冷却截止时间(giftAvailableAt),卖家端负责把它变成看得懂的剩余时间。
|
||||
* 这里刻意写成纯函数:不依赖 React / antd,也不使用 `@/` 别名,方便被
|
||||
* `node --experimental-strip-types --test` 直接跑单测。
|
||||
*/
|
||||
|
||||
export type GiftCooldownRemaining = {
|
||||
/** 展示文案;无有效截止时间时为空字符串(调用方不渲染倒计时) */
|
||||
text: string
|
||||
/** 冷却是否已经结束 */
|
||||
finished: boolean
|
||||
}
|
||||
|
||||
const MINUTE_MS = 60_000
|
||||
const HOUR_MS = 3_600_000
|
||||
|
||||
/** 冷却已结束的统一文案 */
|
||||
const FINISHED_TEXT = '冷却已结束,可赠送'
|
||||
|
||||
/**
|
||||
* 计算冷却剩余时间文案。
|
||||
*
|
||||
* - 截止时间早于(或等于)now:`冷却已结束,可赠送`,`finished: true`
|
||||
* - 剩余不足 1 小时:`冷却中,剩余 X 分`
|
||||
* - 剩余不足 1 天:`冷却中,剩余 X 小时 Y 分`(整小时时省略「0 分」)
|
||||
* - 剩余 1 天以上:`冷却中,剩余 X 天 Y 小时`(整小时时省略「0 小时」)
|
||||
*
|
||||
* 空值 / 非法时间返回 `{ text: '', finished: false }`:既不展示倒计时,也不误判为已结束。
|
||||
*/
|
||||
export function formatGiftCooldownRemaining(
|
||||
giftAvailableAt: string | null | undefined,
|
||||
now: number = Date.now(),
|
||||
): GiftCooldownRemaining {
|
||||
const raw = String(giftAvailableAt ?? '').trim()
|
||||
if (!raw) {
|
||||
return { text: '', finished: false }
|
||||
}
|
||||
|
||||
const targetMs = Date.parse(raw)
|
||||
if (!Number.isFinite(targetMs)) {
|
||||
return { text: '', finished: false }
|
||||
}
|
||||
|
||||
const nowMs = Number.isFinite(now) ? Number(now) : Date.now()
|
||||
const remainingMs = targetMs - nowMs
|
||||
if (remainingMs <= 0) {
|
||||
return { text: FINISHED_TEXT, finished: true }
|
||||
}
|
||||
|
||||
return { text: `冷却中,剩余 ${formatRemaining(remainingMs)}`, finished: false }
|
||||
}
|
||||
|
||||
function formatRemaining(remainingMs: number) {
|
||||
// 不足 1 小时只按分钟展示,向上取整避免出现「剩余 0 分」
|
||||
if (remainingMs < HOUR_MS) {
|
||||
return `${Math.max(1, Math.ceil(remainingMs / MINUTE_MS))} 分`
|
||||
}
|
||||
|
||||
const totalHours = Math.ceil(remainingMs / HOUR_MS)
|
||||
if (totalHours < 24) {
|
||||
const totalMinutes = Math.ceil(remainingMs / MINUTE_MS)
|
||||
const hours = Math.floor(totalMinutes / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
return minutes === 0 ? `${hours} 小时` : `${hours} 小时 ${minutes} 分`
|
||||
}
|
||||
|
||||
const days = Math.floor(totalHours / 24)
|
||||
const hours = totalHours % 24
|
||||
return hours === 0 ? `${days} 天` : `${days} 天 ${hours} 小时`
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { formatGiftCooldownRemaining } from '../src/utils/consignment-cooldown.ts'
|
||||
|
||||
const MINUTE_MS = 60_000
|
||||
const HOUR_MS = 3_600_000
|
||||
const DAY_MS = 24 * HOUR_MS
|
||||
|
||||
const BASE_MS = Date.parse('2026-03-01T00:00:00.000Z')
|
||||
|
||||
function iso(offsetMs: number) {
|
||||
return new Date(BASE_MS + offsetMs).toISOString()
|
||||
}
|
||||
|
||||
test('未来时间:按「X 天 Y 小时」展示剩余冷却', () => {
|
||||
const result = formatGiftCooldownRemaining(iso(2 * DAY_MS + 3 * HOUR_MS), BASE_MS)
|
||||
assert.deepEqual(result, { text: '冷却中,剩余 2 天 3 小时', finished: false })
|
||||
})
|
||||
|
||||
test('跨天的整小时剩余省略「0 小时」', () => {
|
||||
assert.deepEqual(formatGiftCooldownRemaining(iso(3 * DAY_MS), BASE_MS), {
|
||||
text: '冷却中,剩余 3 天',
|
||||
finished: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('不足一天:按「X 小时 Y 分」展示,整小时省略「0 分」', () => {
|
||||
assert.deepEqual(formatGiftCooldownRemaining(iso(2 * HOUR_MS + 30 * MINUTE_MS), BASE_MS), {
|
||||
text: '冷却中,剩余 2 小时 30 分',
|
||||
finished: false,
|
||||
})
|
||||
assert.deepEqual(formatGiftCooldownRemaining(iso(2 * HOUR_MS), BASE_MS), {
|
||||
text: '冷却中,剩余 2 小时',
|
||||
finished: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('小于 1 小时:按分钟向上取整,不会出现「剩余 0 分」', () => {
|
||||
assert.deepEqual(formatGiftCooldownRemaining(iso(25 * MINUTE_MS), BASE_MS), {
|
||||
text: '冷却中,剩余 25 分',
|
||||
finished: false,
|
||||
})
|
||||
assert.deepEqual(formatGiftCooldownRemaining(iso(30_000), BASE_MS), {
|
||||
text: '冷却中,剩余 1 分',
|
||||
finished: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('已过期(或刚好到点)判定为冷却结束', () => {
|
||||
assert.deepEqual(formatGiftCooldownRemaining(iso(-1), BASE_MS), {
|
||||
text: '冷却已结束,可赠送',
|
||||
finished: true,
|
||||
})
|
||||
assert.deepEqual(formatGiftCooldownRemaining(iso(0), BASE_MS), {
|
||||
text: '冷却已结束,可赠送',
|
||||
finished: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('非法与空输入返回空文案且不误判为已结束', () => {
|
||||
const empty = { text: '', finished: false }
|
||||
assert.deepEqual(formatGiftCooldownRemaining(null, BASE_MS), empty)
|
||||
assert.deepEqual(formatGiftCooldownRemaining(undefined, BASE_MS), empty)
|
||||
assert.deepEqual(formatGiftCooldownRemaining('', BASE_MS), empty)
|
||||
assert.deepEqual(formatGiftCooldownRemaining(' ', BASE_MS), empty)
|
||||
assert.deepEqual(formatGiftCooldownRemaining('2026-03-01 不是时间', BASE_MS), empty)
|
||||
})
|
||||
Reference in New Issue
Block a user