From 44de11a3a732196c478f7cdea69d227836971068 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 17 Sep 2026 10:57:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=AF=84=E5=8D=96=E5=8D=95=E5=8F=B7=E9=94=9A=E5=AE=9A=E5=85=A8?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=20+=20=E7=BB=9F=E4=B8=80=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9C=BA=20+=20=E5=8D=96=E5=AE=B6=E4=BE=A7=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E5=8A=A0=E5=A5=BD=E5=8F=8B/=E6=8F=90=E4=BA=A4=E8=B5=A0?= =?UTF-8?q?=E9=80=81=E6=88=AA=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:一件货身上挂着三套互不相通的状态(提报单审核态、仓库库存态、工单赠送态), 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);按寄卖单号搜索命中正确。临时数据已清理。 --- .../src/domain/consignment-item-stage.test.ts | 175 +++++++ .../src/domain/consignment-item-stage.ts | 261 +++++++++++ .../worker-platform/consignment-repo.test.ts | 20 + .../worker-platform/consignment-repo.ts | 42 +- .../worker-platform/work-order-query-repo.ts | 17 + apps/backend/src/routes/worker/consignment.ts | 90 ++++ .../consignment-admin-service.ts | 2 + .../consignment-fulfillment-service.ts | 5 + .../consignment-worker-service.test.ts | 37 ++ .../consignment-worker-service.ts | 280 ++++++++++-- .../panels/ConsignmentInventoryPanel.tsx | 55 ++- .../pages/admin/panels/WorkOrdersPanel.tsx | 42 +- .../pages/worker/WorkerConsignmentPage.tsx | 429 ++++++++++++------ apps/frontend/src/services/worker.ts | 112 ++++- apps/frontend/src/styles/worker.css | 13 + apps/frontend/src/types/consignment.ts | 18 + .../src/utils/consignment-cooldown.ts | 72 +++ .../tests/consignment-cooldown.test.ts | 68 +++ docs/寄卖系统优化方案.md | 40 ++ 19 files changed, 1574 insertions(+), 204 deletions(-) create mode 100644 apps/backend/src/domain/consignment-item-stage.test.ts create mode 100644 apps/backend/src/domain/consignment-item-stage.ts create mode 100644 apps/backend/src/repositories/worker-platform/consignment-repo.test.ts create mode 100644 apps/backend/src/services/worker-platform/consignment-worker-service.test.ts create mode 100644 apps/frontend/src/utils/consignment-cooldown.ts create mode 100644 apps/frontend/tests/consignment-cooldown.test.ts diff --git a/apps/backend/src/domain/consignment-item-stage.test.ts b/apps/backend/src/domain/consignment-item-stage.test.ts new file mode 100644 index 00000000..0e9af166 --- /dev/null +++ b/apps/backend/src/domain/consignment-item-stage.test.ts @@ -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 = {}) { + 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) +}) diff --git a/apps/backend/src/domain/consignment-item-stage.ts b/apps/backend/src/domain/consignment-item-stage.ts new file mode 100644 index 00000000..926bca9a --- /dev/null +++ b/apps/backend/src/domain/consignment-item-stage.ts @@ -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 +} diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts new file mode 100644 index 00000000..ee7cefb7 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts @@ -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', + ) +}) diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 5ea138e5..0ebe9d0f 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -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 { + const res = await query( + ` + 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 { diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 0b895387..9a08fc8e 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -678,6 +678,23 @@ export async function getWorkOrderByIdWithClient( return result.rows[0] || null } +/** + * 按 id 批量取工单。 + * + * 「我的寄卖」列表要把库存绑定的工单状态一次性拼上(阶段推导要用到赠送阶段/冷却时间), + * 一件货查一次太浪费往返。 + */ +export async function listWorkOrdersByIds(ids: number[]): Promise { + 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(`${WORK_ORDER_SELECT} WHERE wo.id = ANY($1::bigint[])`, [ + normalized, + ]) + return result.rows +} + export function buildWorkOrderWhere({ statuses = [], workOrderId = 0, diff --git a/apps/backend/src/routes/worker/consignment.ts b/apps/backend/src/routes/worker/consignment.ts index 9bcc2551..be1c2b18 100644 --- a/apps/backend/src/routes/worker/consignment.ts +++ b/apps/backend/src/routes/worker/consignment.ts @@ -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 diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index 03ad2f42..f8043509 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -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 || '', diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index 68dde903..9935bf04 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -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, }) diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts new file mode 100644 index 00000000..76dc1eb7 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts @@ -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'")) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.ts index 802670f2..5a641480 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -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() + 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>((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) +} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index ed081fa6..4a45cb13 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -92,6 +92,14 @@ const BUYER_COLORS: Record = { 客服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 = [ { - title: '物品/库存编号', + title: '物品/寄卖单号', key: 'item', - width: 220, + width: 240, render: (_, record) => (
{record.itemName}
- - - {record.inventoryNo} + + + 寄卖单号 + + {resolveConsignmentNo(record)} + + + {record.sourceSubmissionNo ? ( +
+ + 仓库号 {record.inventoryNo} + +
+ ) : null} +
{record.sourceType === 'worker_portal' ? ( 打手端提报 @@ -365,7 +385,7 @@ export default function ConsignmentInventoryPanel() { 人工入库 )} - +
), }, @@ -1176,7 +1196,7 @@ export default function ConsignmentInventoryPanel() { {/* 查看详情与凭据弹窗 */} {viewingItem.itemName} - - {viewingItem.inventoryNo} + + + {resolveConsignmentNo(viewingItem)} + + {viewingItem.sourceSubmissionNo ? null : ( + + (客服手工入库,无来源提报单号) + + )} + + + + {viewingItem.inventoryNo} + {viewingItem.status === 'in_stock' ? ( @@ -1289,7 +1321,8 @@ export default function ConsignmentInventoryPanel() { 确认将该物品{' '} - [{returnItem?.itemName} - {returnItem?.inventoryNo}] + [{returnItem?.itemName} - 寄卖单号{' '} + {returnItem ? resolveConsignmentNo(returnItem) : ''}] {' '} 退回复库吗? diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index a79a7ca8..18102575 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -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 | 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 (
) : null} - {(row.material as any)?.consignment ? ( + {consignment ? ( - 寄售履约: {(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)}) ) : null}
@@ -1869,6 +1887,13 @@ export default function WorkOrdersPanel() { {detailOrder.categoryName || '-'} + {detailConsignment ? ( + + + {resolveConsignmentNo(detailConsignment) || '-'} + + + ) : null} {formatStatus(detailOrder.status)} @@ -2999,9 +3024,14 @@ export default function WorkOrdersPanel() { ) : ( 【部分匹配 / 高配充低配】 )} - - {inv.inventoryNo} + + 寄卖单号 {inv.sourceSubmissionNo || inv.inventoryNo} + {inv.sourceSubmissionNo ? ( + + 仓库号 {inv.inventoryNo} + + ) : null}
diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index 0f6e92d8..7592ab96 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -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('all') const [screenshots, setScreenshots] = useState([]) const [submitting, setSubmitting] = useState(false) + const [evidenceItem, setEvidenceItem] = useState(null) + const [evidenceFiles, setEvidenceFiles] = useState([]) + 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() + 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: ( +
+

确认后系统立即开始赠送冷却,请逐条确认风险:

+

1. 确认后立即进入赠送冷却期,冷却期间不能赠送;

+

2. 冷却期结束后必须用本人账号完成赠送并上传截图;

+

+ + 3. 冷却期结束后未完成赠送的,履约押金不退。 + +

+
+ ), + 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 (
@@ -254,9 +365,9 @@ export default function WorkerConsignmentPage() {
{ - setRecordFilter('pending') + setRecordFilter('reviewing') setActiveTab('records') }} > @@ -265,24 +376,22 @@ export default function WorkerConsignmentPage() {
- 审核中 / 驳回 + 待审核 - {pendingCount + rejectedCount} + {reviewingCount} 单
- - {pendingCount > 0 ? `${pendingCount} 单待客服审核` : '暂无待审提报'} - + 客服审核中,通过后自动入库待匹配
{ - setRecordFilter('in_stock') + setRecordFilter('active') setActiveTab('records') }} > @@ -291,22 +400,22 @@ export default function WorkerConsignmentPage() {
- 在库待派单 + 履约中 - {inStockCount} + {activeCount} 把
- 已入库,等待匹配买家工单 + 待加好友 / 冷却 / 待赠送 / 待验收
{ - setRecordFilter('done') + setRecordFilter('finished') setActiveTab('records') }} > @@ -315,15 +424,15 @@ export default function WorkerConsignmentPage() {
- 已履约出库 + 已完成 - {doneCount} + {finishedCount} 把
- 押金履约交付完成 + 含已完成、已驳回、已下架作废
@@ -594,10 +703,8 @@ export default function WorkerConsignmentPage() { label: ( 我的寄卖 - {submissions.length + inventories.length > 0 ? ( - - {submissions.length + inventories.length} - + {records.length > 0 ? ( + {records.length} ) : null} ), @@ -606,10 +713,10 @@ export default function WorkerConsignmentPage() {
{( [ - { 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) => ( ))}
@@ -628,19 +736,28 @@ export default function WorkerConsignmentPage() { ) : recordTotal === 0 ? ( - + {recordFilter === 'all' ? ( + + ) : null} ) : (
- {filteredRecords.inventories.map((inv) => ( - - ))} - {filteredRecords.submissions.map((sub) => ( - + {filteredRecords.map((record) => ( + ))}
)} @@ -650,6 +767,43 @@ export default function WorkerConsignmentPage() { ]} /> + + + + + + setEvidenceNote(event.target.value)} + /> + + ) } @@ -678,87 +832,77 @@ function SpecTags({ ) } -function InventoryRecordCard({ inv }: { inv: WorkerInventoryItem }) { - return ( -
-
-
- {inv.itemName} - - {inv.inventoryNo} - - {inv.status === 'in_stock' ? ( - 在库待派 - ) : inv.status === 'consumed' ? ( - 已履约 - ) : ( - 已下架 - )} -
- -
- - 货款 ¥{(inv.costPriceFen / 100).toFixed(2)} - - - 押金 ¥{((inv.depositAmountFen || 0) / 100).toFixed(0)} - - {inv.boundPlatformOrderId ? ( - - 履约单 {inv.boundPlatformOrderId} - - ) : null} -
-
- - {inv.createdAt} - -
- ) -} - -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 (
- {sub.itemName} + {record.itemName} - {sub.submissionNo} + 寄卖单号 {record.consignmentNo} - {sub.status === 'pending_review' ? ( - 审核中 - ) : sub.status === 'approved' ? ( - 已通过 - ) : ( - 已驳回 - )} + {record.label}
- 期望价 ¥{(sub.askingPriceFen / 100).toFixed(2)} + 寄售价 ¥{(record.askingPriceFen / 100).toFixed(2)} - {sub.reviewNote ? ( - 审核说明:{sub.reviewNote} - ) : null} + + 押金 ¥{(Number(record.depositAmountFen || 0) / 100).toFixed(2)} + + {record.workOrderId ? 履约单 #{record.workOrderId} : null}
+ {record.reviewNote ? ( + 审核说明:{record.reviewNote} + ) : null} + {record.hint ? ( + + {record.hint} + + ) : null} + {cooldown?.text ? ( + + {cooldown.text} + {cooldownDeadline ? ( + + 冷却截止 {cooldownDeadline},到点后点右上角「刷新」查看最新状态 + + ) : null} + + ) : null} {shots.length > 0 ? (
@@ -776,9 +920,26 @@ function SubmissionRecordCard({ sub }: { sub: WorkerConsignmentItem }) {
) : null} + {record.canConfirmFriend || record.canSubmitEvidence ? ( +
+ {record.canConfirmFriend ? ( + + ) : null} + {record.canSubmitEvidence ? ( + + ) : null} +
+ ) : null}
- {sub.createdAt} + {formatDateTime(record.createdAt)}
) diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 6f42e6af..c67dca6c 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -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 } 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('/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, + ) } diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index b253aff1..54472d85 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -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; diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index 3f44fa0c..61bb548b 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -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 diff --git a/apps/frontend/src/utils/consignment-cooldown.ts b/apps/frontend/src/utils/consignment-cooldown.ts new file mode 100644 index 00000000..f3f6b3a8 --- /dev/null +++ b/apps/frontend/src/utils/consignment-cooldown.ts @@ -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} 小时` +} diff --git a/apps/frontend/tests/consignment-cooldown.test.ts b/apps/frontend/tests/consignment-cooldown.test.ts new file mode 100644 index 00000000..95b14acb --- /dev/null +++ b/apps/frontend/tests/consignment-cooldown.test.ts @@ -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) +}) diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index d5d63e05..c0267904 100644 --- a/docs/寄卖系统优化方案.md +++ b/docs/寄卖系统优化方案.md @@ -21,6 +21,7 @@ | `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 | | `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` | | `d6f6219f` | 批次 F1:内部现货履约停在「待加好友」,客服确认后才进赠送冷却 + 备注快捷项 | `work-order-gift-repo.ts`、履约服务、`WorkOrdersPanel.tsx`、`utils/gift-phase.ts` | +| 本批提交(批次 I,同一提交内含本文档,故不写自身 hash) | 批次 I:提报单号为唯一寄卖单号(一件货一行)+ 统一状态机 + 卖家确认加好友/提交赠送截图 | `consignment-item-stage.ts`、`consignment-worker-service.ts`、`routes/worker/consignment.ts`、`ConsignmentInventoryPanel.tsx`、`WorkerConsignmentPage.tsx` | | 本批提交(批次 H,同一提交内含本文档,故不写自身 hash) | 批次 H:修掉发布时乱弹内部库存提醒(部分匹配兜底过宽)+ 弹窗赏金分/元换算 | `consignment-matching-service.ts`、履约复检、`WorkOrdersPanel.tsx` | | 本批提交(批次 G,同一提交内含本文档,故不写自身 hash) | 批次 G:确认加好友后现货作废不回池 + 撤销寄售履约押金可选(含修打手押金永久冻结、真实库 42804 500) | `consignment-repo.ts`、`consignment-deposit-policy.ts`、`work-order-deposit-repo.ts`、撤销履约服务与路由、`WorkOrdersPanel.tsx` | | 本批提交(批次 F2,同一提交内含本文档,故不写自身 hash) | 批次 F2:确认加好友后卖家发布押金不退(`deposit_deduction` 落地)+ 二次冻结护栏 | `consignment-deposit-repo.ts`、取消/退单仓库、撤销履约服务 | @@ -278,6 +279,45 @@ 前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。 3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。 +### 批次 I · 一个单号锚定 + 统一状态机(本轮) + +**问题**:一件货在系统里有三层互不相通的状态——提报单(`SUB…`,审核态)、库存(`INV…`,仓库态)、 +工单赠送(`material_required / friend_countdown / pending_acceptance / accepted`)。 +`my-items` 同时返回两个数组、前端分两个列表渲染,于是**同一件货两行、两个号**; +更糟的是卖家端只映射了前两套状态,"已出库"之后流程就断在黑暗里, +卖家不知道已经匹配、不知道该加好友、不知道在冷却、不知道该赠送。 + +**改法**: + +1. ✅ **提报单号 `SUB…` 作为唯一「寄卖单号」**,从发布锚定到结算(零迁移,历史数据直接可用)。 + `listWorkerConsignments` 改成**一个提报单一条记录**:库存、绑定工单的信息挂上去, + 客服手工入库的现货(没有提报单)才用库存号当锚点。真实库验证:打手 3 的 2 件货从 4 行收敛成 2 行。 +2. ✅ **统一状态机** `domain/consignment-item-stage.ts`(纯函数、可单测),把三套底层状态推导成 + 卖家看得懂的一个阶段 + 一句"下一步该做什么": + 待审核 → 已通过(在库待匹配)→ 已匹配-待加好友 → 加好友赠送冷却中 → 待赠送 + → 已赠送待审核 → 已完成;另有 已驳回 / 已下架作废 / 已取消,以及标准验收单的 履约中 / 待验收。 + 列表筛选与 KPI 也改由它驱动(待审核 / 履约中 / 已完成)。 +3. ✅ **卖家可以在「我的寄卖」操作**(以前只有 publish + my-items 两个接口,卖家只能干看): + - `POST /worker/consignment/items/:submissionId/confirm-friend`:确认已加好买家好友 → + 进入赠送冷却期;前端弹窗明确写「确认后进入赠送冷却期、未完成赠送押金不退」。 + - `POST /worker/consignment/items/:submissionId/gift-evidence`:上传赠送截图给客服审核。 + - 客服手工入库的现货没有提报单,另开 `POST /worker/consignment/inventories/:inventoryId/...` + 两个路由走同一套动作(**不做"一个 id 两义"**,避免提报单号与库存号撞号时误操作别人的货)。 + - 两个动作都**复用既有工单链路**(`confirmWorkOrderFriendAdded` / `submitWorkerOrderAcceptance`), + 只加一道归属校验:不是本人履约的订单直接 403,卖家碰不到别人的单。 + 顺带白拿了既有的强校验:**冷却没结束不许提交凭证**、必须带图。 +4. ✅ **客服端同号**:库存列表/详情接口补 `sourceSubmissionNo`(LEFT JOIN 提报单), + 履约/指派写进工单的寄售信息也带上 `sourceSubmissionNo`,客服面板与工单详情显示同一个寄卖单号 + (历史老单回退显示仓库号)。 + +**真实库端到端验证**(提报单线与手工入库线各跑一遍):卖家「确认加好友」→ +`gift_phase=friend_countdown`、`friend_added_at` 落库、`gift_available_at` 正好 +72 小时; +冷却期内提交赠送截图被拒(`work_order_gift_cooldown_active`); +换另一个打手来操作被拒(`consignment_item_not_found`)。临时数据已清理。 + +**遗留**:列表按 `pageSize: 200` 拉全量再聚合,单个卖家寄卖件数超过 200 时需改成游标分页; +`pending_acceptance` 仍在寄卖可撤销白名单内(见 P2-7);客服长时间不点「确认加好友」没有超时策略(按业务确认先不管)。 + ### 批次 H · 修掉"乱弹匹配提醒"与"赏金没换算成元"(本轮) 1. ✅ **发布时不再乱弹「内部寄售库存匹配提醒」**: