fix(consignment): 确认加好友与上传赠送截图恢复为卖家操作,客服兜底

问题:卖家「我的寄卖」里"已匹配-待加好友"的货点不了确认加好友,提示还写着"等待客服确认加好友"。

原因:批次 I 把能力位绑在"订单接单人 = 当前卖家"(selfFulfilling)上,而内部现货履约的订单
根本没有接单打手(assigned_worker_id 为空),于是状态机关掉按钮、后端归属校验还会 403。

业务定调:卖家可以确认加好友,冷却到期后也能自己上传赠送成功截图,客服只是兜底。

- 能力位改成按阶段给:material_submitted 可确认;内部现货履约在 material_required 也可确认
  (与客服侧一致);冷却结束可提交赠送截图;提示改成卖家视角
- 后端归属只认"这张提报单就是当前卖家的"(别人的单仍 404),删除"订单接单人必须是卖家"的 403
- 卖家提交凭证走 asConsignmentSupplier 选项:跳过接单人校验(归属由寄卖单保证),
  冷却是否结束、必须带图等校验全部保留
- 客服侧按钮不变,谁先点谁生效,后点者拿到明确 409

真实库端到端验证:卖家列表按钮=true → 确认加好友 gift_phase=friend_countdown(+72h) →
冷却中被拒 work_order_gift_cooldown_active → 冷却到期后卖家上传赠送截图成功(工单转 pending_acceptance)。
This commit is contained in:
yml2213
2026-09-17 11:57:33 +08:00
parent 0fd09976a5
commit 40882aeebb
6 changed files with 116 additions and 39 deletions
@@ -43,7 +43,7 @@ test('审核通过到在库:已通过(在库待匹配),没人需要操
assert.equal(result.canSubmitEvidence, false)
})
test('内部现货履约:卖家只等客服确认加好友,不给卖家按钮', () => {
test('内部现货履约(订单没有接单打手):卖家照样能点确认加好友,和客服能力一致', () => {
const result = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
@@ -51,11 +51,13 @@ test('内部现货履约:卖家只等客服确认加好友,不给卖家按
viewer: SELLER,
})
assert.equal(result.stage, CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND)
assert.equal(result.canConfirmFriend, false)
assert.ok(result.hint.includes('客服'))
// 货是卖家的,好友由卖家加、赠送也由卖家做;客服只是兜底,不能把卖家自己的按钮关掉
assert.equal(result.canConfirmFriend, true)
assert.ok(result.hint.includes('押金不退'), '确认加好友必须提示押金不退')
assert.ok(result.hint.includes('客服'), '要说明客服也能代为确认')
})
test('指派打手线:本人履约时卖家可以点确认加好友,别人不行', () => {
test('赠送资料已提交:卖家可以点确认加好友(接单人是谁都不影响)', () => {
const mine = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
@@ -66,13 +68,43 @@ test('指派打手线:本人履约时卖家可以点确认加好友,别人
assert.equal(mine.canConfirmFriend, true)
assert.ok(mine.hint.includes('押金不退'), '确认加好友必须提示押金不退')
const others = deriveConsignmentItemStage({
const assignedToOther = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder({ gift_phase: 'material_submitted', assignedWorkerId: 99 }),
viewer: SELLER,
})
assert.equal(others.canConfirmFriend, false)
assert.equal(assignedToOther.canConfirmFriend, true)
})
test('赠送资料还没提交:打手线要先交资料,卖家暂时看不到确认按钮', () => {
const result = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder({ gift_phase: 'material_required' }),
viewer: SELLER,
})
assert.equal(result.canConfirmFriend, false)
assert.ok(result.hint.includes('赠送资料'))
})
test('冷却结束:卖家可以上传赠送成功截图给客服审核', () => {
const result = 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-19T00:00:00.000Z',
internalStock: true,
assignedWorkerId: null,
}),
viewer: SELLER,
now: Date.parse('2026-09-20T00:00:00.000Z'),
})
assert.equal(result.stage, CONSIGNMENT_ITEM_STAGE.GIFT_READY)
assert.equal(result.canSubmitEvidence, true)
assert.equal(result.evidenceButtonLabel, '提交赠送截图')
})
test('冷却中:给倒计时阶段,不给提交按钮;冷却到点后变成待赠送', () => {
@@ -202,28 +202,25 @@ export function deriveConsignmentItemStage(
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) {
const canConfirmNow = phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED || internalStock
if (!canConfirmNow) {
return build(
CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND,
selfFulfilling
? '请先在订单页提交赠送资料,再确认加好友'
: '等待打手提交赠送资料后确认加好友',
: '等待履约打手提交赠送资料后确认加好友',
)
}
return build(
CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND,
selfFulfilling
? '好友加好后点「确认加好友」:确认即进入赠送冷却期,未完成赠送押金不退'
: '等待接单打手确认加好友,确认后进入赠送冷却期',
{ canConfirmFriend: selfFulfilling },
phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED && !internalStock
? '好友加好后点「确认加好友」:确认即进入赠送冷却期,未完成赠送押金不退(客服也可代为确认)'
: '请先加上买家好友,加好后点「确认加好友」:确认即进入赠送冷却期,未完成赠送押金不退(客服也可代为确认)',
{ canConfirmFriend: true },
)
}
if (phase === WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN) {
@@ -232,10 +229,8 @@ export function deriveConsignmentItemStage(
if (phase === WORK_ORDER_GIFT_PHASE.GIFT_READY) {
return build(
CONSIGNMENT_ITEM_STAGE.GIFT_READY,
selfFulfilling
? '冷却已结束,请用账号赠送并上传赠送截图给客服审核(未完成赠送押金不退)'
: '冷却已结束,等待履约方赠送并上传截图',
{ canSubmitEvidence: selfFulfilling, evidenceButtonLabel: '提交赠送截图' },
'冷却已结束,请用账号赠送并上传赠送截图给客服审核(未完成赠送押金不退)',
{ canSubmitEvidence: true, evidenceButtonLabel: '提交赠送截图' },
)
}
return build(CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND, '等待确认加好友')
@@ -23,17 +23,21 @@ test('列表阶段由统一状态机推导,工单信息批量取而不是逐
assert.ok(serviceSource.includes('hasInternalConsignmentFulfillment(workOrder)'))
})
test('卖家确认加好友复用既有链路,并且只能动自己的订单', async () => {
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'))
// 归属:别人的提报单必须 404,不能拿别人单号来操作
assert.ok(serviceSource.includes("errorCode: 'consignment_item_not_found'"))
// 但不能再按"订单接单人"卡:内部现货履约的单子没有接单打手,按接单人卡会把卖家自己的货挡在外面
// (assigned_worker_id 仍会读,但只用于推导阶段文案,不再作为归属校验)
assert.ok(!serviceSource.includes("errorCode: 'consignment_fulfillment_not_owned'"))
assert.ok(!serviceSource.includes('该订单不是由你履约'))
})
test('赠送截图复用打手验收提交链路(冷却校验、必须带图都在那边)', async () => {
const serviceSource = await readServiceSource()
assert.ok(serviceSource.includes('submitWorkerOrderAcceptance('))
assert.ok(serviceSource.includes('asConsignmentSupplier: true'))
assert.ok(serviceSource.includes("errorCode: 'consignment_item_not_found'"))
})
@@ -368,13 +368,9 @@ async function resolveSellerConsignmentWorkOrder(
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) }
}
@@ -406,5 +402,5 @@ export async function submitWorkerConsignmentGiftEvidence(
session: WorkerSession,
) {
const { workOrderId } = await resolveSellerConsignmentWorkOrder(workerId, submissionId)
return submitWorkerOrderAcceptance(workOrderId, payload, session)
return submitWorkerOrderAcceptance(workOrderId, payload, session, { asConsignmentSupplier: true })
}
@@ -57,14 +57,26 @@ export async function submitWorkerOrderAcceptance(
workOrderId: number | string,
payload: JsonObject = {},
session: WorkerSession,
options: {
/**
* 寄卖的**供货卖家**提交赠送凭证时用。
*
* 寄卖的货是卖家自己的,赠送由卖家完成,所以不要求 `assigned_worker_id` 等于卖家
* (内部现货履约时订单根本没有接单打手)。归属由寄卖单本身保证:
* 调用方必须先校验"这张提报单就是这个卖家的",这里就不再按接单人来卡。
*/
asConsignmentSupplier?: boolean
} = {},
) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const workerId = Number(session.workerId)
const worker = await getRequiredWorker(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
if (sharingShare) {
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
if (!options.asConsignmentSupplier) {
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
if (sharingShare) {
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
}
}
const canAutoAcceptWithoutEvidence = isFriendGiftAcceptanceMode(workOrder.acceptance_mode)
? false
@@ -93,7 +105,7 @@ export async function submitWorkerOrderAcceptance(
errorCode: 'work_order_timeout_expired',
})
}
if (Number(workOrder.assigned_worker_id || 0) !== workerId) {
if (!options.asConsignmentSupplier && Number(workOrder.assigned_worker_id || 0) !== workerId) {
throw createHttpError('只能提交自己的订单', {
statusCode: 403,
errorCode: 'work_order_owner_required',
+38
View File
@@ -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` |
| 本批提交(批次 P,同一提交内含本文档,故不写自身 hash) | 批次 P:确认加好友/上传赠送截图恢复为卖家操作,客服兜底 | `consignment-item-stage.ts`、`consignment-worker-service.ts`、`worker-acceptance-service.ts` |
| 本批提交(批次 O,同一提交内含本文档,故不写自身 hash) | 批次 O:文件链接去掉签名与有效期(修客服端凭证裂图),寄卖出参统一归一化 | `file-storage-service.ts`、`routes/files.ts`、`consignment-admin-service.ts` |
| 本批提交(批次 N,同一提交内含本文档,故不写自身 hash) | 批次 N:删掉按库存号的卖家入口,卖家动作只按寄卖单号定位 | `consignment-worker-service.ts`、`routes/worker/consignment.ts`、`WorkerConsignmentPage.tsx` |
| 本批提交(批次 M,同一提交内含本文档,故不写自身 hash) | 批次 M:停用客服手动入库(前端入口删除 + 后端 409 守卫),寄卖货源只走平台提报 | `consignment-admin-service.ts`、`consignment-repo.ts`、`ConsignmentInventoryPanel.tsx` |
@@ -285,6 +286,43 @@
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
### 批次 P · 「确认加好友 / 上传赠送截图」恢复为卖家操作(本轮)
**问题(用户截图:卖家端的货点不了确认加好友)**:卖家「我的寄卖」里那件"已匹配-待加好友"的货,
提示写着「等待客服确认加好友」,**没有按钮**。
**原因**:批次 I 把这两个动作的能力位绑在"订单接单人 = 当前卖家"上(`selfFulfilling`),
而内部现货履约的订单**根本没有接单打手**(`assigned_worker_id` 为空),于是:
状态机把按钮关掉 + 提示写成"等客服";后端归属校验也会 403(`consignment_fulfillment_not_owned`)。
**业务定调**(用户):**卖家可以确认加好友,冷却到期后也能自己上传赠送成功截图**,客服只是兜底。
**改法**:
1. ✅ 状态机能力位改成按阶段给(不再看接单人):
- `material_submitted`(赠送资料已交)→ 卖家可点「确认加好友」;
- 内部现货履约没有"先交赠送资料"这一步 → `material_required` 阶段卖家也可直接确认(与客服侧能力一致);
- 冷却结束(`gift_ready`)→ 卖家可「提交赠送截图」。
- 提示统一改成卖家视角:「请先加上买家好友,加好后点『确认加好友』:确认即进入赠送冷却期,
未完成赠送押金不退(客服也可代为确认)」。
2. ✅ 后端归属只认「这张提报单就是当前卖家的」(别人的单仍 404 `consignment_item_not_found`),
删掉"订单接单人必须是卖家"的 403 —— 内部现货单没有接单人,按接单人卡会把卖家自己的货挡在外面。
3. ✅ 卖家提交赠送凭证走 `submitWorkerOrderAcceptance(..., { asConsignmentSupplier: true })`:
跳过"必须是接单打手"的校验(归属已由寄卖单保证),**冷却是否结束、必须带图**等校验全部保留。
4. ✅ 客服侧按钮不变,谁先点谁生效,后点的会拿到明确的 409 提示。
**真实库端到端验证**(造一件内部现货履约的货,订单无接单打手):
| 步骤 | 结果 |
| --- | --- |
| 卖家列表 | 阶段「已匹配-待加好友」,`确认加好友按钮=true`,提示为卖家操作口径 |
| 卖家点确认加好友 | `gift_phase=friend_countdown`,冷却到 +72h |
| 冷却中传截图 | 被拒 `work_order_gift_cooldown_active` |
| 冷却到期 | 阶段「待赠送」,`上传截图按钮=true` |
| 卖家上传赠送截图 | 成功 → 工单 `pending_acceptance`(交客服验收) |
临时数据已清理(残留 0)。
### 批次 O · 去掉文件链接的签名/有效期(本轮)
**问题(用户截图:客服看到的凭证是裂图)**:寄卖凭证的图片地址是**带签名的临时链接**: