fix(consignment): 复核修复——占库撞 080 CHECK 必 500、指派非卖家双付货款、手动匹配权限门控
- 占库默认 allocation_status 从自由文本改为「已安排」,不再撞 080 的
CHECK ('待安排','已安排');加静态守卫测试(剥注释断言枚举内默认值)
- resolveConsignmentAssignRewardFen 加 assigneeIsSupplier:指派供货卖家本人
→ 报酬=结算价(货款走报酬渠道);指派别的打手 → 报酬保持原值(货款走
供货货款渠道),两条渠道互斥,封住平台双付;补行为级测试 + 调用点钉住
- 手动匹配两处入口(库存面板行内按钮、拦截弹窗 footer)统一按
consignment.fulfill 门控,编辑/作废仍为 manage,与后端权限一一对应
- docs/寄卖系统优化方案.md 记录批次 U
后端 473(+3)/ 前端 49(+1)测试全绿,typecheck / lint / format / check:sql / build 通过
This commit is contained in:
@@ -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`,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)比对',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
{record.status === 'in_stock' && canFulfillConsignment ? (
|
||||
// 手动匹配是履约动作:提交打到要求 consignment.fulfill 的 /fulfill、/assign-worker。
|
||||
// 以前挂在 manage 门下,只有 manage 的人能点进去但提交必 403(本面板注释自己要求避免的口径)
|
||||
<Tooltip
|
||||
title={
|
||||
record.allowMatching
|
||||
? '从待匹配订单里人工挑一单(支持规格冲突强制匹配,需填原因)'
|
||||
: '该物品已暂停匹配,先打开上方「允许匹配」开关'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={!record.allowMatching}
|
||||
onClick={() => setManualMatchItem(record)}
|
||||
>
|
||||
手动匹配订单
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{record.status === 'in_stock' && canManageConsignment ? (
|
||||
<>
|
||||
<Tooltip
|
||||
title={
|
||||
record.allowMatching
|
||||
? '从待匹配订单里人工挑一单(支持规格冲突强制匹配,需填原因)'
|
||||
: '该物品已暂停匹配,先打开上方「允许匹配」开关'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={!record.allowMatching}
|
||||
onClick={() => setManualMatchItem(record)}
|
||||
>
|
||||
手动匹配订单
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
|
||||
@@ -2987,23 +2987,27 @@ export default function WorkOrdersPanel() {
|
||||
})
|
||||
}}
|
||||
footer={[
|
||||
<Button
|
||||
key="manual-match"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
const currentOrder = consignmentInterceptModal.order
|
||||
if (!currentOrder) return
|
||||
setConsignmentInterceptModal({
|
||||
open: false,
|
||||
order: null,
|
||||
matchData: null,
|
||||
submitting: false,
|
||||
})
|
||||
setManualMatchOrder(currentOrder)
|
||||
}}
|
||||
>
|
||||
手动指定现货匹配
|
||||
</Button>,
|
||||
// 手动指定现货会直接提交履约/指派(要求 consignment.fulfill),
|
||||
// 与行内入口(canFulfillConsignment 门控)保持一致,无权限不显示
|
||||
canFulfillConsignment ? (
|
||||
<Button
|
||||
key="manual-match"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
const currentOrder = consignmentInterceptModal.order
|
||||
if (!currentOrder) return
|
||||
setConsignmentInterceptModal({
|
||||
open: false,
|
||||
order: null,
|
||||
matchData: null,
|
||||
submitting: false,
|
||||
})
|
||||
setManualMatchOrder(currentOrder)
|
||||
}}
|
||||
>
|
||||
手动指定现货匹配
|
||||
</Button>
|
||||
) : null,
|
||||
<Button
|
||||
key="ignore-and-publish"
|
||||
onClick={() => {
|
||||
|
||||
@@ -44,3 +44,36 @@ test('按钮点击要真的把状态设上,弹窗的 open 由该状态驱动',
|
||||
assert.ok(source.includes('setManualMatchItem(record)'), '行内按钮必须设置待匹配的现货')
|
||||
assert.ok(source.includes('open={Boolean(manualMatchItem)}'), '弹窗 open 必须由该状态驱动')
|
||||
})
|
||||
|
||||
test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履约动作,不能只挂 manage)', () => {
|
||||
const inventoryPanel = readFileSync(
|
||||
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
|
||||
'utf8',
|
||||
)
|
||||
// 库存面板:手动匹配订单按钮要与编辑/作废拆开 —— 前者 fulfill(提交打到
|
||||
// /fulfill、/assign-worker,后端要求 consignment.fulfill),后者 manage
|
||||
assert.ok(
|
||||
inventoryPanel.includes("resolveAdminPermission(sessionPermissions, 'consignment.fulfill')"),
|
||||
'库存面板必须解析 consignment.fulfill 权限',
|
||||
)
|
||||
const buttonIndex = inventoryPanel.indexOf('手动匹配订单')
|
||||
assert.ok(buttonIndex > 0, '找不到「手动匹配订单」按钮')
|
||||
assert.ok(
|
||||
inventoryPanel.slice(0, buttonIndex).lastIndexOf('canFulfillConsignment ?') > 0,
|
||||
'「手动匹配订单」必须挂在 canFulfillConsignment 门下',
|
||||
)
|
||||
|
||||
const ordersPanel = readFileSync(
|
||||
new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url).pathname,
|
||||
'utf8',
|
||||
)
|
||||
// 拦截弹窗 footer 的入口同样要门控,无 fulfill 权限的人不该看到能直达 403 的按钮
|
||||
const footerButtonIndex = ordersPanel.indexOf('手动指定现货匹配')
|
||||
assert.ok(footerButtonIndex > 0, '找不到「手动指定现货匹配」按钮')
|
||||
const beforeFooterButton = ordersPanel.slice(0, footerButtonIndex)
|
||||
assert.ok(
|
||||
beforeFooterButton.lastIndexOf('canFulfillConsignment ?') >
|
||||
beforeFooterButton.lastIndexOf('footer={['),
|
||||
'拦截弹窗 footer 的「手动指定现货匹配」必须按 canFulfillConsignment 门控',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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` |
|
||||
| 本批提交(批次 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` |
|
||||
| 本批提交(批次 Q,同一提交内含本文档,故不写自身 hash) | 批次 Q:验收结算补寄卖货款给供货卖家;修指派打手线报酬 100 倍单位错 | `consignment-deposit-repo.ts`、`work-order-settlement-repo.ts`、`consignment-fulfillment-service.ts` |
|
||||
@@ -288,6 +289,34 @@
|
||||
规格冲突 → 409 `consignment_spec_mismatch`;非 exact 且未显式传 `allowPartial` → 409 `consignment_spec_partial_requires_confirm`。
|
||||
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
|
||||
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
|
||||
### 批次 U · 复核修复:占库 500 / 指派双付 / 手动匹配权限门控(本轮)
|
||||
|
||||
对 `94328ac6..0476339e` 做了一轮对抗复核(两个并行审查 + 人工验证),修三处:
|
||||
|
||||
1. ✅ **P0 指派打手占库在真实库必 500**:`claimConsignmentInventoryForWorker` 的
|
||||
`allocation_status` 默认值是自由文本「已指派打手(押金履约中)」,撞上迁移 `080` 的
|
||||
`CHECK ('待安排','已安排')`。假 client 单测不跑真 SQL 一直没暴露(与当年 42804 同病)。
|
||||
默认值改为「已安排」,并加静态守卫测试(剥掉注释后断言默认值必须在枚举内,
|
||||
防止注释里引用历史值误伤)。
|
||||
2. ✅ **P1 指派非供货卖家时货款双付**:`resolveConsignmentAssignRewardFen` 以前无条件把
|
||||
工单报酬改写成结算价。指派了别的打手时(API 层允许,界面不会主动构造):打手按结算价
|
||||
拿一份 + 卖家再拿一份供货货款(`settleConsignmentSupplierPayout` 只在"接单人===卖家"时
|
||||
跳过)= 平台双付。现在纯函数加 `assigneeIsSupplier` 参数:卖家本人履约 → 报酬=结算价
|
||||
(货款走工单报酬渠道);别的打手 → 报酬保持原值(货款走供货货款渠道),两条渠道互斥。
|
||||
补行为级测试 + 调用点静态钉住(判定基准必须是 `inventory.worker_id`)。
|
||||
3. ✅ **P1 手动匹配入口权限错位**:库存面板「手动匹配订单」挂在 `consignment.manage` 门下、
|
||||
拦截弹窗 footer「手动指定现货匹配」没门控,而两个动作提交打到要求 `consignment.fulfill`
|
||||
的 `/fulfill`、`/assign-worker` —— 只有 manage 的运营能点进去、选完单、点确认后必 403。
|
||||
两处入口统一按 `canFulfillConsignment` 门控(编辑/作废仍为 manage,与后端权限一一对应),
|
||||
mount 测试静态钉住。
|
||||
|
||||
**验证**:后端 473 测试(+3)全绿、前端 49(+1)全绿;两侧 `typecheck` / `lint:check` /
|
||||
`format:check` / `check:sql` / `build` 通过。
|
||||
|
||||
**复核中确认无需改动**:手动指定现货的服务端复检与留痕、售后回落卖家、卖家操作归属校验、
|
||||
删除类提交的残留引用 —— 均已核对无发现。遗留待办(拼单份额验收补寄卖钩子、文件接口登录态、
|
||||
弹窗 409 后刷新)见复核报告,未在本批处理。
|
||||
|
||||
|
||||
### 批次 T · 手动匹配的入口修复(本轮)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user