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,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} 小时`
}