feat:拼单明细管理加一个撤份额的功能,指定数量

This commit is contained in:
2026-10-25 13:44:34 +08:00
parent a7a8634a96
commit 9c620d39cb
16 changed files with 627 additions and 3 deletions
@@ -5,6 +5,7 @@ import {
cancelWorkOrderShare,
cancelWorkOrderSharing,
countWorkOrderPendingSharingSubmissions,
reduceWorkOrderShareQuantity,
updateWorkOrder,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
@@ -189,6 +190,70 @@ export async function cancelAdminWorkOrderShare(
}
}
/**
* 后台按份数撤销某个打手的部分份额。
*
* 减少的份数会随「大厅余量 = 总份数 − Σ有效份额数量」自动回到大厅;押金保持原冻结额,
* 母单状态与验收资料都不动(之后仍可对该份额「单独验收」)。
*/
export async function reduceAdminWorkOrderShareQuantity(
workOrderId: number | string,
shareId: number | string,
payload: JsonObject = {},
actorName = '',
) {
const reduceQuantity = Math.floor(Number(payload.reduceQuantity ?? payload.quantity ?? 0))
if (!Number.isFinite(reduceQuantity) || reduceQuantity <= 0) {
throw createHttpError('请填写要撤掉的份数', {
statusCode: 400,
errorCode: 'work_order_sharing_share_reduce_quantity_required',
})
}
const result = await reduceWorkOrderShareQuantity({
workOrderId: Number(workOrderId),
shareId: Number(shareId),
reduceQuantity,
note: String(payload.note || payload.reason || '').trim(),
actorName,
now: nowIso(),
})
if (result.failureReason === 'work_order_not_reducible') {
throw createHttpError('当前拼单状态不能撤销份额', {
statusCode: 409,
errorCode: 'work_order_sharing_share_reduce_status_invalid',
})
}
if (result.failureReason === 'share_not_found') {
throw createHttpError('未找到该拼单参与记录', {
statusCode: 404,
errorCode: 'work_order_sharing_share_not_found',
})
}
if (result.failureReason === 'share_status_not_reducible' || !result.share || !result.order) {
throw createHttpError('只有已参与或已提交验收的拼单份额可以撤销份数', {
statusCode: 409,
errorCode: 'work_order_sharing_share_reduce_share_status_invalid',
})
}
if (result.failureReason === 'share_quantity_invalid') {
throw createHttpError('撤掉的份数必须在 1 到「当前份数 − 1」之间;要全部撤销请用「单人撤单」', {
statusCode: 409,
errorCode: 'work_order_sharing_share_reduce_quantity_invalid',
})
}
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrderId),
workerIds: await resolveWorkOrderRealtimeWorkerIds(result.order),
hallChanged: true,
})
return {
order: mapWorkOrderAdmin(result.order),
share: mapWorkOrderShare(result.share),
}
}
export async function cancelAdminWorkOrderSharing(
workOrderId: number | string,
payload: JsonObject = {},
@@ -54,6 +54,7 @@ export {
acceptAdminWorkOrders,
cancelAdminWorkOrderShare,
cancelAdminWorkOrderSharing,
reduceAdminWorkOrderShareQuantity,
} from './admin-work-order-acceptance-service.js'
export * from './worker-product-match-config-service.js'
export * from './worker-announcement-config-service.js'
@@ -788,6 +788,10 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) {
timeoutMinutes: Number(share.timeout_minutes || 0),
timeoutPolicy: share.timeout_policy || '',
cancelledReason: share.cancelled_reason || '',
/** 后台撤单/撤份额时填写的原因(自由文本,选填) */
cancelNote: share.cancel_note || '',
/** 被撤掉的份数:撤份额累计,单人撤单为整份份数;0 = 未撤过 */
cancelQuantity: Math.max(0, Number(share.cancel_quantity || 0)),
worker: share.worker_id
? {
workerId: Number(share.worker_id),
@@ -3364,6 +3364,64 @@ test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收
)
})
test('撤份额与单人撤单都把原因和份数写进份额行,列表才能显示「撤 N 份 · 原因」', () => {
const settlementSrc = fs.readFileSync(
new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url),
'utf8',
)
const cancelBlock = settlementSrc.slice(
settlementSrc.indexOf('export async function cancelWorkOrderShare('),
settlementSrc.indexOf('export async function settleOverdueWorkOrderShare('),
)
assert.ok(cancelBlock.length > 0, 'cancelWorkOrderShare 片段必须存在')
assert.ok(cancelBlock.includes('cancel_note = $'), '单人撤单必须把原因写进 cancel_note')
assert.ok(
cancelBlock.includes("String(input.reason || '').trim()"),
'单人撤单写入 cancel_note 的必须是运营填写的自由文本原因',
)
assert.ok(
cancelBlock.includes("cancelled_reason = 'admin'"),
'cancelled_reason 仍保留枚举口径,自由文本不得塞进去',
)
assert.ok(
cancelBlock.includes('cancel_quantity = COALESCE(quantity, 0)'),
'单人撤单要按整份数量记录撤销份数,列表才能显示「撤 N 份」',
)
const shareSrc = fs.readFileSync(
new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url),
'utf8',
)
const reduceBlock = shareSrc.slice(
shareSrc.indexOf('export async function reduceWorkOrderShareQuantity('),
shareSrc.indexOf('export async function submitWorkOrderShareAcceptance('),
)
assert.ok(reduceBlock.length > 0, 'reduceWorkOrderShareQuantity 片段必须存在')
assert.ok(reduceBlock.includes('cancel_note = $'), '撤份额必须把原因写进 cancel_note')
assert.ok(
reduceBlock.includes('String(share.cancel_note'),
'后续撤份额没填原因时必须保留上一次填的说明',
)
assert.ok(
reduceBlock.includes('cancel_quantity = $') &&
reduceBlock.includes('Number(share.cancel_quantity'),
'撤份额必须按「已有撤销份数 + 本次份数」累计写入 cancel_quantity',
)
const mapperSrc = fs.readFileSync(
new URL('./work-order-display-mappers.ts', import.meta.url),
'utf8',
)
assert.ok(
mapperSrc.includes('cancelNote: share.cancel_note'),
'拼单明细接口必须下发 cancelNote,否则列表显示不出来',
)
assert.ok(
mapperSrc.includes('cancelQuantity: Math.max(0, Number(share.cancel_quantity'),
'拼单明细接口必须下发 cancelQuantity,列表才能拼「撤 N 份」前缀',
)
})
test('份额级问题单标记/处置只写份额字段,不得改母单状态或触发整单重算', () => {
const src = fs.readFileSync(
new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url),