diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index 519adb9a..ddb7cf48 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -647,6 +647,42 @@ export async function acceptWorkOrderShareAndSettle(input: { `UPDATE work_orders SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`, [input.now, input.workOrderId], ) + + // 最后一个份额把母单转成已验收时,必须与统一验收(acceptWorkOrderAndSettle)同口径补寄卖钩子, + // 否则这条路径下发布押金永久挂死、供货卖家拿不到货款。 + const shareMaterial = + typeof workOrder.material_json === 'string' + ? (() => { + try { + return JSON.parse(workOrder.material_json) as Record + } catch { + return {} + } + })() + : workOrder.material_json && typeof workOrder.material_json === 'object' + ? (workOrder.material_json as Record) + : {} + const shareConsignment = + shareMaterial.consignment && typeof shareMaterial.consignment === 'object' + ? (shareMaterial.consignment as Record) + : null + const shareSourceSubmissionId = shareConsignment?.sourceSubmissionId + if (shareSourceSubmissionId) { + await releaseConsignmentPublishDepositBySubmissionWithClient(client, { + submissionId: String(shareSourceSubmissionId), + note: `寄卖拼单份额全部验收完成释放发布押金 #${input.workOrderId}`, + now: input.now, + }) + } + // 拼单母单没有单一接单人(份额打手已在上面各自按 share_reward 结算), + // 传 0 让供货货款必走本渠道,不会与份额报酬重复。 + await settleConsignmentSupplierPayoutByWorkOrderWithClient(client, { + workOrderId: input.workOrderId, + assignedWorkerId: 0, + rewardUnfreezeEnabled: input.rewardUnfreezeEnabled, + unfreezeDays: input.unfreezeDays, + now: input.now, + }) } await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, diff --git a/apps/backend/src/services/worker-platform/consignment-payout.test.ts b/apps/backend/src/services/worker-platform/consignment-payout.test.ts index 327e880d..8c443a80 100644 --- a/apps/backend/src/services/worker-platform/consignment-payout.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-payout.test.ts @@ -107,3 +107,24 @@ test('指派链路必须按“指派对象===供货卖家”判定报酬渠道 '指派报酬必须传 assigneeIsSupplier,且与供货卖家(inventory.worker_id)比对', ) }) + +test('拼单份额全部验收(母单转已验收)也必须补寄卖钩子,否则货款漏付、押金挂死', async () => { + const source = await readFile(SETTLEMENT_REPO, 'utf8') + const start = source.indexOf('export async function acceptWorkOrderShareAndSettle') + assert.ok(start > 0, '未找到份额单独验收函数') + const body = source.slice(start) + // 只看"最后一个份额把母单转 accepted"的分支,别被统一验收函数里的同名调用误判 + const acceptedBranch = body.slice( + body.indexOf("nextOrderStatus === 'accepted'"), + body.indexOf('sharing_share_accepted'), + ) + assert.ok(acceptedBranch.length > 0, '未找到母单转已验收的分支') + assert.ok( + acceptedBranch.includes('releaseConsignmentPublishDepositBySubmissionWithClient('), + '份额验收收官时必须释放卖家发布押金', + ) + assert.ok( + acceptedBranch.includes('settleConsignmentSupplierPayoutByWorkOrderWithClient('), + '份额验收收官时必须结算供货货款(拼单母单无接单人,传 0 走供货渠道)', + ) +}) diff --git a/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx index fbbf6f5b..95b3e832 100644 --- a/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Alert, Button, @@ -30,6 +30,7 @@ import { resolveManualMatchRequirement, type ManualMatchType, } from '@/utils/consignment-manual-match' +import { notifyVoucherConsume } from '@/utils/voucher-consume' const { Text, Paragraph } = Typography @@ -74,6 +75,10 @@ export default function ConsignmentManualMatchModal({ const [options, setOptions] = useState([]) const [orders, setOrders] = useState([]) const [inventoryLabel, setInventoryLabel] = useState('') + const [inventoryWorker, setInventoryWorker] = useState<{ + workerId: number | null + workerName: string + } | null>(null) const [selected, setSelected] = useState(null) const [deliveryKind, setDeliveryKind] = useState<'internal_stock' | 'assign_worker'>( 'internal_stock', @@ -98,13 +103,22 @@ export default function ConsignmentManualMatchModal({ setLoading(true) try { if (inventory) { - const res = await fetchInventoryMatchableOrders(inventory.inventoryId) + // 关键字与分页透传后端:库存侧默认只拉 50 条时,第 51 单以后永远搜不到 + const res = await fetchInventoryMatchableOrders(inventory.inventoryId, { + keyword: keyword.trim() || undefined, + pageSize: 100, + }) setOrders(res.data?.orders || []) setInventoryLabel( res.data?.inventory ? `${res.data.inventory.itemName}(${res.data.inventory.sourceSubmissionNo || res.data.inventory.inventoryNo})` : inventory.label, ) + // 指派线需要现货的供货打手:库存侧入口从响应里记下来,选中订单时带上 + setInventoryWorker({ + workerId: res.data?.inventory?.workerId ?? null, + workerName: res.data?.inventory?.workerName || '', + }) } else if (workOrder) { const res = await fetchWorkOrderConsignmentMatchOptions(workOrder.workOrderId) setOptions(res.data?.options || []) @@ -116,6 +130,16 @@ export default function ConsignmentManualMatchModal({ } } + // 库存侧:关键字防抖透传后端搜索(本地过滤只作用在已拉取的 100 条内) + useEffect(() => { + if (!open || !inventory) return + const timer = setTimeout(() => { + void load() + }, 300) + return () => clearTimeout(timer) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [keyword]) + const filteredOptions = useMemo( () => filterManualMatchOptions(options, keyword, (item) => [ @@ -152,6 +176,10 @@ export default function ConsignmentManualMatchModal({ async function handleSubmit() { if (!selected) return + if (deliveryKind === 'assign_worker' && !selected.workerId) { + message.error('该现货未关联供货打手,不能走指派履约;请改用内部现货履约') + return + } setSubmitting(true) try { const payload = { @@ -169,15 +197,10 @@ export default function ConsignmentManualMatchModal({ }) : await fulfillWorkOrderWithConsignment(payload) + // 核销失败时共享工具会弹警告(状态已变更但凭证没消耗),此时不能再弹"成功"掩盖 const voucher = (res.data as any)?.voucherConsume - if (voucher && voucher.ok === false) { - message.warning({ - content: `状态已变更,但电子凭证核销失败:${ - voucher.errorMessage || '未知错误' - }。请在快手后台人工核销`, - duration: 8, - }) - } else { + notifyVoucherConsume(voucher) + if (!voucher || voucher.ok) { message.success( deliveryKind === 'assign_worker' ? '已手动匹配:指派供货打手履约,押金已冻结' @@ -188,6 +211,27 @@ export default function ConsignmentManualMatchModal({ resetState() onClose() } catch (err: any) { + // 列表数据过期(并发占用 / 打开弹窗后订单规格被改):刷新列表按最新判定重来, + // 否则运营会对着已失效的候选反复重试、循环撞同一个错 + const code = String(err?.errorCode || '') + if ( + code === 'consignment_inventory_race_condition' || + code === 'consignment_inventory_unavailable' + ) { + message.error('该现货刚被其他操作占用或已下架,已刷新列表,请重新选择') + setSelected(null) + void load() + return + } + if ( + code === 'consignment_spec_mismatch' || + code === 'consignment_spec_partial_requires_confirm' + ) { + message.error('订单或现货的规格已变化(列表数据过期),已刷新,请按最新判定重新确认') + setSelected(null) + void load() + return + } message.error(err.message || '手动匹配失败') } finally { setSubmitting(false) @@ -312,6 +356,10 @@ export default function ConsignmentManualMatchModal({ setConfirmedPartial(false) setConfirmedMismatch(false) setMatchNote('') + // 选了无打手的现货时指派不可用,兜底切回内部履约 + if (!option.inventory.workerId && deliveryKind === 'assign_worker') { + setDeliveryKind('internal_stock') + } }} > {isSelected ? '已选' : '选它'} @@ -377,10 +425,17 @@ export default function ConsignmentManualMatchModal({ inventoryLabel, matchType: order.matchType, conflicts: order.conflicts, + // 指派线要用现货的供货打手:库存侧入口从接口响应的 inventory 里带过来 + workerId: inventoryWorker?.workerId ?? null, + workerName: inventoryWorker?.workerName || '', }) setConfirmedPartial(false) setConfirmedMismatch(false) setMatchNote('') + // 选了无打手的现货时指派不可用,兜底切回内部履约 + if (!inventoryWorker?.workerId && deliveryKind === 'assign_worker') { + setDeliveryKind('internal_stock') + } }} > {isSelected ? '已选' : '选它'} @@ -421,8 +476,12 @@ export default function ConsignmentManualMatchModal({ }, { value: 'assign_worker', + // 没关联打手的现货走指派必 400 consignment_worker_required,直接禁用并说明,别给死路 + disabled: !selected.workerId, label: `指派供货打手履约(冻结押金、工单进行中)${ - selected.workerName ? ` · 打手:${selected.workerName}` : '' + selected.workerName + ? ` · 打手:${selected.workerName}` + : ' · 该现货未关联打手,不可指派' }`, }, ]} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index a5ea0e59..091eed75 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -79,8 +79,8 @@ import { checkWorkOrderConsignmentMatches, fulfillWorkOrderWithConsignment, revokeWorkOrderConsignmentFulfillment, - type WorkOrderVoucherConsumeResult, } from '@/services/admin/consignment' +import { notifyVoucherConsume } from '@/utils/voucher-consume' import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment' import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform' import { isManuallyMatchableOrderStatus } from '@/utils/consignment-manual-match' @@ -764,24 +764,7 @@ export default function WorkOrdersPanel() { confirmPublishWorkOrder(row) } - /** - * 电子凭证核销结果必须如实反馈:核销失败时后端已经改完状态,但凭证没消耗, - * 之前这里无条件弹"成功",运营会以为凭证已核销而漏掉人工处理。 - */ - function notifyVoucherConsume(voucherConsume?: WorkOrderVoucherConsumeResult) { - if (!voucherConsume || voucherConsume.ok) { - if (voucherConsume?.alreadyConsumedCount) { - message.info(`电子凭证已提前核销 ${voucherConsume.alreadyConsumedCount} 张,本次未重复核销`) - } - return - } - message.warning({ - content: `状态已变更,但电子凭证核销失败:${ - voucherConsume.errorMessage || '未知错误' - }。请在快手后台人工核销后再发货`, - duration: 8, - }) - } + // 电子凭证核销结果的如实反馈抽到了 utils/voucher-consume(与手动匹配弹窗共用,口径不再漂移) /** 非精确匹配(订单规格信息不全 / 高配充低配)必须人工确认,后端也会二次拦截 */ /** diff --git a/apps/frontend/src/services/admin/consignment.ts b/apps/frontend/src/services/admin/consignment.ts index 698f7b2b..58e0c26e 100644 --- a/apps/frontend/src/services/admin/consignment.ts +++ b/apps/frontend/src/services/admin/consignment.ts @@ -112,7 +112,7 @@ export function fetchWorkOrderConsignmentMatchOptions(workOrderId: number | stri /** 手动匹配:从现货出发,列出可以匹配的待指派/待补资料订单(逐单带规格判定) */ export function fetchInventoryMatchableOrders( inventoryId: number | string, - params?: { keyword?: string }, + params?: { keyword?: string; pageSize?: number }, ) { return apiGet( `/api/v1/admin/worker-platform/consignment/inventories/${inventoryId}/match-orders`, diff --git a/apps/frontend/src/utils/voucher-consume.ts b/apps/frontend/src/utils/voucher-consume.ts new file mode 100644 index 00000000..7cde5255 --- /dev/null +++ b/apps/frontend/src/utils/voucher-consume.ts @@ -0,0 +1,23 @@ +import { message } from 'antd' + +import type { WorkOrderVoucherConsumeResult } from '@/services/admin/consignment' + +/** + * 电子凭证核销结果必须如实反馈:核销失败时后端已经改完状态,但凭证没消耗, + * 之前有调用方无条件弹"成功",运营会以为凭证已核销而漏掉人工处理。 + * 统一出口:所有履约/指派/手动匹配的调用方都用这一个,避免文案与口径各自漂移。 + */ +export function notifyVoucherConsume(voucherConsume?: WorkOrderVoucherConsumeResult) { + if (!voucherConsume || voucherConsume.ok) { + if (voucherConsume?.alreadyConsumedCount) { + message.info(`电子凭证已提前核销 ${voucherConsume.alreadyConsumedCount} 张,本次未重复核销`) + } + return + } + message.warning({ + content: `状态已变更,但电子凭证核销失败:${ + voucherConsume.errorMessage || '未知错误' + }。请在快手后台人工核销后再发货`, + duration: 8, + }) +} diff --git a/apps/frontend/tests/consignment-manual-match-mount.test.ts b/apps/frontend/tests/consignment-manual-match-mount.test.ts index 1245b83d..25df48cb 100644 --- a/apps/frontend/tests/consignment-manual-match-mount.test.ts +++ b/apps/frontend/tests/consignment-manual-match-mount.test.ts @@ -77,3 +77,23 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履 '拦截弹窗 footer 的「手动指定现货匹配」必须按 canFulfillConsignment 门控', ) }) + +test('手动匹配弹窗:数据过期的 409 要清选择并刷新,无打手的现货禁止走指派', () => { + const source = readFileSync( + new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url).pathname, + 'utf8', + ) + // 并发占用/规格在打开弹窗后被改 → 清空选择并重新拉列表,不能让运营循环撞同一个错 + assert.ok(source.includes('consignment_inventory_race_condition'), '要处理并发占用错误码') + assert.ok(source.includes('consignment_spec_mismatch'), '要处理规格变化错误码') + assert.ok(source.includes('setSelected(null)'), '数据过期时要清空已选') + // 没关联打手的现货走指派必 400 consignment_worker_required:选项禁用 + 提交前再拦一次 + assert.ok(source.includes('disabled: !selected.workerId'), '无打手时指派选项必须禁用') + assert.ok( + source.includes("deliveryKind === 'assign_worker' && !selected.workerId"), + '提交前要对无打手的指派再拦一道', + ) + // 凭证核销反馈与关键字搜索:用共享出口 / 透传后端,不内联复制 + assert.ok(source.includes("from '@/utils/voucher-consume'"), '核销反馈必须用共享出口') + assert.ok(source.includes('keyword: keyword.trim() || undefined'), '库存侧关键字要透传后端') +}) diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index 55f291e9..48ca61a1 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` | +| 本批提交(批次 V,同一提交内含本文档,故不写自身 hash) | 批次 V:复核遗留——拼单份额验收收官补寄卖钩子(押金释放+供货货款)、手动匹配弹窗 409 刷新/关键字透传/无打手禁指派、核销反馈抽共享出口 | `work-order-settlement-repo.ts`、`ConsignmentManualMatchModal.tsx`、`utils/voucher-consume.ts` | | 本批提交(批次 U,同一提交内含本文档,故不写自身 hash) | 批次 U:复核修复——占库 allocation_status 撞 080 CHECK(真实库必 500)、指派非供货卖家时货款双付、手动匹配入口按 fulfill 权限门控 | `consignment-repo.ts`、`consignment-fulfillment-service.ts`、两个面板、三个测试文件 | | 本批提交(批次 S,同一提交内含本文档,故不写自身 hash) | 批次 S:手动指定现货匹配(判定/差异/强制留痕 + 双向入口 + 共享弹窗) | `consignment-matching-service.ts`、`consignment-fulfillment-service.ts`、`ConsignmentManualMatchModal.tsx` | | 本批提交(批次 R,同一提交内含本文档,故不写自身 hash) | 批次 R:售后立案回落到供货卖家;抽共享"从工单找供货卖家"查询 | `consignment-supplier-repo.ts`、`after-sales-repo.ts`、`consignment-deposit-repo.ts` | @@ -289,6 +290,15 @@ 规格冲突 → 409 `consignment_spec_mismatch`;非 exact 且未显式传 `allowPartial` → 409 `consignment_spec_partial_requires_confirm`。 前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。 3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。 +### 批次 V · 复核遗留:份额验收钩子 + 手动匹配弹窗三处(本轮) + +1. ✅ **拼单份额全部验收(母单转已验收)补寄卖钩子**:`acceptWorkOrderShareAndSettle` 里最后一个份额把母单转 `accepted` 的分支,此前既不释放发布押金也不结算供货货款(两个钩子只在统一验收里)。现在与 `acceptWorkOrderAndSettle` 同口径:有 `material.consignment.sourceSubmissionId` 就释放发布押金;供货货款固定传 `assignedWorkerId: 0`(拼单母单没有单一接单人,份额打手已各自按 share_reward 结算,不会重复)。 +2. ✅ **手动匹配弹窗 · 数据过期的 409 不再死循环**:`consignment_inventory_race_condition / consignment_inventory_unavailable / consignment_spec_mismatch / consignment_spec_partial_requires_confirm` 四个错误码清空已选并自动重拉列表,按最新判定重来,不再对着失效候选反复撞错。 +3. ✅ **手动匹配弹窗 · 库存侧搜索与指派死路**:关键字 + `pageSize:100` 透传后端(原来只拉默认 50 条、keyword 是从未传出去的死参数,第 51 单以后搜不到);关键字 300ms 防抖触发后端搜索;无供货打手的现货禁用"指派供货打手履约"选项(原来选了必 400 `consignment_worker_required` 且弹窗里没有打手选择器,是死路),提交前再拦一道;库存侧选中订单时从响应 inventory 带出 `workerId/workerName` 回显。 +4. ✅ **凭证核销反馈抽共享出口**:`utils/voucher-consume.ts` 统一 `notifyVoucherConsume`(含"已提前核销 N 张"提示),`WorkOrdersPanel` 本地实现删除、手动匹配弹窗弃用内联子集版,口径不再漂移。 + +**验证**:后端 477 测试(+4)全绿、前端 50(+1)全绿;两侧 `typecheck` / `lint:check` / `format:check` / `check:sql` 通过。 + ### 批次 U · 复核修复:占库 500 / 指派双付 / 手动匹配权限门控(本轮) 对 `94328ac6..0476339e` 做了一轮对抗复核(两个并行审查 + 人工验证),修三处: