From 63837e473b43dfb777dd689f0498d55b4db21196 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 17 Sep 2026 12:36:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(consignment):=20=E5=A4=8D=E6=A0=B8=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E2=80=94=E2=80=94=E5=8D=A0=E5=BA=93=E6=92=9E=20080=20?= =?UTF-8?q?CHECK=20=E5=BF=85=20500=E3=80=81=E6=8C=87=E6=B4=BE=E9=9D=9E?= =?UTF-8?q?=E5=8D=96=E5=AE=B6=E5=8F=8C=E4=BB=98=E8=B4=A7=E6=AC=BE=E3=80=81?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E5=8C=B9=E9=85=8D=E6=9D=83=E9=99=90=E9=97=A8?= =?UTF-8?q?=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 占库默认 allocation_status 从自由文本改为「已安排」,不再撞 080 的 CHECK ('待安排','已安排');加静态守卫测试(剥注释断言枚举内默认值) - resolveConsignmentAssignRewardFen 加 assigneeIsSupplier:指派供货卖家本人 → 报酬=结算价(货款走报酬渠道);指派别的打手 → 报酬保持原值(货款走 供货货款渠道),两条渠道互斥,封住平台双付;补行为级测试 + 调用点钉住 - 手动匹配两处入口(库存面板行内按钮、拦截弹窗 footer)统一按 consignment.fulfill 门控,编辑/作废仍为 manage,与后端权限一一对应 - docs/寄卖系统优化方案.md 记录批次 U 后端 473(+3)/ 前端 49(+1)测试全绿,typecheck / lint / format / check:sql / build 通过 --- .../worker-platform/consignment-repo.test.ts | 18 +++++++++ .../worker-platform/consignment-repo.ts | 4 +- .../consignment-fulfillment-service.ts | 17 ++++++++- .../consignment-payout.test.ts | 36 ++++++++++++++++++ .../panels/ConsignmentInventoryPanel.tsx | 37 ++++++++++-------- .../pages/admin/panels/WorkOrdersPanel.tsx | 38 ++++++++++--------- .../consignment-manual-match-mount.test.ts | 33 ++++++++++++++++ docs/寄卖系统优化方案.md | 29 ++++++++++++++ 8 files changed, 176 insertions(+), 36 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts index 179fcc8e..9d86256f 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts @@ -37,3 +37,21 @@ test('匹配候选要带出寄卖单号,并且 join 之后所有列名都必 assert.ok(body.includes('ci.allow_matching = TRUE')) assert.ok(!body.includes('SELECT *'), 'SELECT * 会把两表同名列搞乱,必须显式列出') }) + +test('占库写入的 allocation_status 默认值必须在 080 CHECK 枚举(待安排/已安排)内', async () => { + const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') + const start = source.indexOf('export async function claimConsignmentInventoryForWorkerWithClient') + assert.ok(start > 0, '未找到打手指派占库函数') + const body = source.slice(start, source.indexOf('\n}\n', start)) + + // 080 给 allocation_status 加了 CHECK ('待安排','已安排')。默认值以前是一段自由文本描述, + // 真实库一占库就撞约束 500 —— 假 client 单测跑不出真 SQL,只能静态钉住。 + // 先剥掉注释再断言,防止注释里引用旧值(历史说明)误伤。 + const code = body.replace(/^\s*\/\/.*$/gm, '') + const match = code.match(/input\.allocationStatus \|\| '([^']+)'/) + assert.ok(match, '占库必须给 allocation_status 一个枚举内的默认值') + assert.ok( + match![1] === '待安排' || match![1] === '已安排', + `默认值「${match![1]}」违反 080 的 CHECK 约束,真实库必 500`, + ) +}) diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 9ce5c252..39ca9a4a 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -495,7 +495,9 @@ export async function claimConsignmentInventoryForWorkerWithClient( RETURNING * `, [ - input.allocationStatus || '已指派打手(押金履约中)', + // 080 给 allocation_status 加了 CHECK ('待安排','已安排'):默认值必须是枚举内的值, + // 以前默认写「已指派打手(押金履约中)」会在真实库直接撞约束 500(假 client 单测跑不出来)。 + input.allocationStatus || '已安排', input.workerId, input.now, input.boundOrderId ?? null, 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 a9aa4ac6..8f5485d4 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -553,7 +553,13 @@ export async function fulfillWorkOrderWithConsignment(input: { * 选用内部寄售库存并走接单平台指派打手(冻结押金担保履约) */ /** - * 指派打手线里工单报酬要改成"货的结算价":卖家拿结算价、平台赚差价。 + * 指派打手线里工单报酬的口径: + * + * - 指派对象 = 供货卖家本人(常规玩法):报酬改成"货的结算价",卖家通过工单报酬渠道拿货款; + * 验收结算会因"接单人===卖家"跳过供货货款(consignment-deposit-repo),不会双付。 + * - 指派对象 ≠ 供货卖家(API 层允许传任意打手,界面不会主动构造):报酬保持工单原值给打手, + * 卖家的货款由验收结算的 settleConsignmentSupplierPayout 单独支付 —— 两条渠道互斥。 + * 以前这里无条件改写成结算价:打手按结算价拿一份 + 卖家再拿一份供货货款 = 平台双付。 * * 单位必须是**分**(与钱包、流水、其它工单报酬一致)。这里以前多除了 100, * 会把 ¥124 写成 ¥1.24 —— 卖家只拿到结算价的百分之一。 @@ -561,10 +567,14 @@ export async function fulfillWorkOrderWithConsignment(input: { export function resolveConsignmentAssignRewardFen(input: { costPriceFen: unknown orderRewardFen: unknown + /** 指派对象是否供货卖家本人;缺省视为 true(保持旧行为,调用方应显式传) */ + assigneeIsSupplier?: boolean }): number { + const orderRewardFen = Math.round(Number(input.orderRewardFen || 0)) + if (input.assigneeIsSupplier === false) return orderRewardFen const costPriceFen = Math.round(Number(input.costPriceFen || 0)) if (costPriceFen > 0) return costPriceFen - return Math.round(Number(input.orderRewardFen || 0)) + return orderRewardFen } export async function assignWorkOrderWithConsignmentWorker(input: { @@ -687,6 +697,9 @@ export async function assignWorkOrderWithConsignmentWorker(input: { const rewardAmountFen = resolveConsignmentAssignRewardFen({ costPriceFen: claimed.cost_price_fen, orderRewardFen: workOrder.reward_amount, + // 指派对象是供货卖家本人 → 报酬改写为结算价(货款走工单报酬渠道); + // 指派了别的打手 → 报酬保持原值(货款走验收结算的供货货款渠道),两条渠道互斥防双付 + assigneeIsSupplier: Number(targetWorkerId) === Number(inventory.worker_id || 0), }) await updateWorkOrder(workOrder.id, { 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 07158cf9..327e880d 100644 --- a/apps/backend/src/services/worker-platform/consignment-payout.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-payout.test.ts @@ -71,3 +71,39 @@ test('"从工单找供货卖家"只有一处实现,货款与售后共用', asy // 取卖家那段 SQL(含 UNION ALL 兜底)只应存在于共享查询里,别处不要再抄一份 assert.equal(depositSource.includes('UNION ALL'), false) }) + +test('指派对象不是供货卖家时,工单报酬必须保持原值(货款走供货货款渠道,防止双付)', () => { + // 成本价 12400 分、工单原报酬 14500 分:指派了别的打手时不能把报酬改写成 12400 —— + // 否则打手按 12400 拿一份 + 卖家再拿 12400 供货货款(settleConsignmentSupplierPayout + // 只在“接单人===卖家”时跳过)= 平台双付 + assert.equal( + resolveConsignmentAssignRewardFen({ + costPriceFen: 12400, + orderRewardFen: 14500, + assigneeIsSupplier: false, + }), + 14500, + ) + // 卖家本人履约:报酬仍是结算价(供货货款渠道会因“接单人===卖家”跳过,不双付) + assert.equal( + resolveConsignmentAssignRewardFen({ + costPriceFen: 12400, + orderRewardFen: 14500, + assigneeIsSupplier: true, + }), + 12400, + ) +}) + +test('指派链路必须按“指派对象===供货卖家”判定报酬渠道,不能静默丢掉判定', async () => { + const source = await readFile( + new URL('./consignment-fulfillment-service.ts', import.meta.url), + 'utf8', + ) + assert.ok( + source.includes( + 'assigneeIsSupplier: Number(targetWorkerId) === Number(inventory.worker_id || 0)', + ), + '指派报酬必须传 assigneeIsSupplier,且与供货卖家(inventory.worker_id)比对', + ) +}) diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index aea4b269..4e2f557c 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -158,6 +158,7 @@ export default function ConsignmentInventoryPanel() { }) const sessionPermissions = adminSessionQuery.data?.data.user.permissions const canManageConsignment = resolveAdminPermission(sessionPermissions, 'consignment.manage') + const canFulfillConsignment = resolveAdminPermission(sessionPermissions, 'consignment.fulfill') // 查询活跃打手列表用于指派与关联 const workersQuery = useQuery({ @@ -552,24 +553,28 @@ export default function ConsignmentInventoryPanel() { > 详情 + {record.status === 'in_stock' && canFulfillConsignment ? ( + // 手动匹配是履约动作:提交打到要求 consignment.fulfill 的 /fulfill、/assign-worker。 + // 以前挂在 manage 门下,只有 manage 的人能点进去但提交必 403(本面板注释自己要求避免的口径) + + + + ) : null} {record.status === 'in_stock' && canManageConsignment ? ( <> - - - , + // 手动指定现货会直接提交履约/指派(要求 consignment.fulfill), + // 与行内入口(canFulfillConsignment 门控)保持一致,无权限不显示 + canFulfillConsignment ? ( + + ) : null,