fix(consignment): 撤销寄售履约回库分支必 500 —— SQL 参数比占位符多一个
线上客服 kf_13 撤销 WO26092609475746 时返回 500 internal_error, 真实库报:bind message supplies 5 parameters, but prepared statement "" requires 4。 根因:解绑现货的两条分支共用一个 5 元参数数组,但回库/取消分支的 SQL 只用到 $1..$4(第 5 个「备注前缀」只有作废分支才用)。于是运营只要选 「退单回库并恢复工单为未指派」或「退单回库并取消工单」就必定 500, 而假 client 的单测与类型检查都看不到这类错。 修复: - 解绑 SQL 与参数抽到 consignment-revoke-inventory-sql.ts 一起构造, 回库/取消分支只传 4 个参数,作废分支才多带备注前缀($5); - 单测锁定「占位符个数 == 参数个数」,覆盖 reopen / archive_inventory / 已确认加好友自动作废三条分支; - db 层加参数个数自检(db/sql-arity.ts + db/client.ts):参数比占位符多时 提前抛出带 SQL 片段的错误,不再让 pg 的 bind 报错裸奔, 并跳过字符串/注释/dollar-quote 里的 $n,避免误杀。 验证:本地影子库端到端跑通三条分支(工单 100140 + 现货 INV26091917439822), 并复现修复前的 bind 报错;全量单测 588 通过,typecheck/lint/build 均通过。
This commit is contained in:
+67
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { buildConsignmentRevokeInventoryUpdate } from './consignment-revoke-inventory-sql.js'
|
||||
|
||||
const base = {
|
||||
reason: '买家申请退款',
|
||||
now: '2026-10-03T07:50:00.000Z',
|
||||
inventoryId: 23,
|
||||
workOrderId: 100140,
|
||||
}
|
||||
|
||||
function maxPlaceholder(sql: string) {
|
||||
return Math.max(0, ...[...sql.matchAll(/\$(\d+)/g)].map((match) => Number(match[1])))
|
||||
}
|
||||
|
||||
test('回库分支只带 4 个参数:占位符个数必须与参数个数一致', () => {
|
||||
// 线上 500 复盘:两条分支共用一个 5 元参数数组,回库分支 SQL 只用到 $4,
|
||||
// PostgreSQL 报 "bind message supplies 5 parameters, but prepared statement "" requires 4",
|
||||
// 运营点「退单回库并恢复工单为未指派 / 退单回库并取消工单」必炸。
|
||||
const { sql, params } = buildConsignmentRevokeInventoryUpdate({
|
||||
...base,
|
||||
archiveOnly: false,
|
||||
autoForfeit: false,
|
||||
})
|
||||
|
||||
assert.equal(maxPlaceholder(sql), 4)
|
||||
assert.equal(params.length, 4)
|
||||
assert.ok(sql.includes("status = 'in_stock'"))
|
||||
assert.ok(sql.includes('consumed_at = NULL'))
|
||||
assert.ok(sql.includes("status = 'consumed' AND bound_work_order_id = $4"))
|
||||
// 回库分支不允许出现第 5 个占位符,否则参数个数又会和作废分支串味
|
||||
assert.ok(!sql.includes('$5'))
|
||||
})
|
||||
|
||||
test('作废分支带 5 个参数(多一个备注前缀),占位符个数同样要对齐', () => {
|
||||
for (const autoForfeit of [true, false]) {
|
||||
const { sql, params } = buildConsignmentRevokeInventoryUpdate({
|
||||
...base,
|
||||
archiveOnly: true,
|
||||
autoForfeit,
|
||||
})
|
||||
|
||||
assert.equal(maxPlaceholder(sql), 5)
|
||||
assert.equal(params.length, 5)
|
||||
assert.ok(sql.includes("status = 'archived'"))
|
||||
assert.ok(sql.includes('allow_matching = FALSE'))
|
||||
assert.ok(sql.includes("status = 'consumed' AND bound_work_order_id = $4"))
|
||||
}
|
||||
})
|
||||
|
||||
test('作废备注前缀区分「已确认加好友」与「交付异常」', () => {
|
||||
const gift = buildConsignmentRevokeInventoryUpdate({
|
||||
...base,
|
||||
archiveOnly: true,
|
||||
autoForfeit: true,
|
||||
})
|
||||
const manual = buildConsignmentRevokeInventoryUpdate({
|
||||
...base,
|
||||
archiveOnly: true,
|
||||
autoForfeit: false,
|
||||
})
|
||||
|
||||
assert.ok(gift.params.includes(' [已确认加好友作废下架: '))
|
||||
assert.ok(manual.params.includes(' [交付异常作废: '))
|
||||
assert.ok(!manual.params.includes(' [已确认加好友作废下架: '))
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 「撤销寄售履约」解绑现货的 SQL 构造。
|
||||
*
|
||||
* 两条分支的参数个数天然不同:作废分支多一个备注前缀($5),回库/取消分支只用 $1..$4。
|
||||
* 之前两条分支共用一个 5 元参数数组,于是回库时 PostgreSQL 直接报
|
||||
* `bind message supplies 5 parameters, but prepared statement "" requires 4`——
|
||||
* 运营只要选「退单回库并恢复工单为未指派 / 退单回库并取消工单」就必定 500。
|
||||
* 现在 SQL 与参数在一起构造,并由单测锁定"占位符个数 == 参数个数"。
|
||||
*/
|
||||
|
||||
export interface ConsignmentRevokeInventoryUpdate {
|
||||
sql: string
|
||||
params: unknown[]
|
||||
}
|
||||
|
||||
export function buildConsignmentRevokeInventoryUpdate(input: {
|
||||
/** 只作废下架(已确认加好友的自动作废,或运营显式选择「报废该现货」) */
|
||||
archiveOnly: boolean
|
||||
/** 已确认加好友导致的自动作废:备注前缀与运营手动作废分开,便于对账 */
|
||||
autoForfeit: boolean
|
||||
reason: string
|
||||
now: string
|
||||
inventoryId: number
|
||||
workOrderId: number | string
|
||||
/** 回库备注文案,默认「撤销回库」 */
|
||||
restockLabel?: string
|
||||
}): ConsignmentRevokeInventoryUpdate {
|
||||
// 必须带「仍被本工单占用」条件:否则会把别的工单正在使用的现货抢过来解绑,
|
||||
// 造成同一件现货被多张工单履约。
|
||||
const bindingGuard = `WHERE id = $3 AND status = 'consumed' AND bound_work_order_id = $4`
|
||||
const baseParams: unknown[] = [input.reason, input.now, input.inventoryId, input.workOrderId]
|
||||
|
||||
if (input.archiveOnly) {
|
||||
return {
|
||||
sql: `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'archived',
|
||||
allow_matching = FALSE,
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
remark = remark || ($5::text || $1 || ']'),
|
||||
updated_at = $2
|
||||
${bindingGuard}
|
||||
RETURNING *
|
||||
`,
|
||||
params: [...baseParams, input.autoForfeit ? ' [已确认加好友作废下架: ' : ' [交付异常作废: '],
|
||||
}
|
||||
}
|
||||
|
||||
const restockLabel = input.restockLabel || '撤销回库'
|
||||
return {
|
||||
sql: `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'in_stock',
|
||||
allocation_status = '待安排',
|
||||
assigned_worker_id = NULL,
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
consumed_at = NULL,
|
||||
remark = remark || ' [${restockLabel}: ' || $1 || ']',
|
||||
updated_at = $2
|
||||
${bindingGuard}
|
||||
RETURNING *
|
||||
`,
|
||||
params: baseParams,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user