feat:拼单明细管理加一个撤份额的功能,指定数量
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
-- 107_work_order_share_cancel_note.sql
|
||||
-- 目标:后台「拼单明细」列表要能直接看到撤销原因。
|
||||
-- 1. cancelled_reason 仍保留枚举口径(admin/timeout/worker_request/problem/settlement),
|
||||
-- 用于状态标签与既有逻辑判断,不塞自由文本;
|
||||
-- 2. cancel_note 存运营在「单人撤单 / 撤份额」弹窗里填写的原因(选填),只做展示;
|
||||
-- 3. 未填写时保持空串,列表显示为「-」;历史数据不做回填(原因只存在于事件流里)。
|
||||
|
||||
ALTER TABLE work_order_shares
|
||||
ADD COLUMN IF NOT EXISTS cancel_note TEXT NOT NULL DEFAULT '';
|
||||
|
||||
COMMENT ON COLUMN work_order_shares.cancel_note IS '后台撤单/撤份额时填写的原因(自由文本,选填),仅用于展示';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 108_work_order_share_cancel_quantity.sql
|
||||
-- 目标:拼单明细的「撤销原因」要带上撤销份数(列表显示为「撤 4 份 · 原因」)。
|
||||
-- 1. cancel_quantity 记录这一份份额被撤掉的份数:
|
||||
-- - 撤份额:每次累加 reduceQuantity(同一份额多次撤则累计);
|
||||
-- - 单人撤单:等于撤销时该份额的全部份数;
|
||||
-- 2. 0 = 从未撤过;超时 / 打手退单 / 问题单处置不写这一列,原因仍由状态标签表达;
|
||||
-- 3. 不做历史回填:改动前的撤单没有份数记录,列表保持「-」。
|
||||
|
||||
ALTER TABLE work_order_shares
|
||||
ADD COLUMN IF NOT EXISTS cancel_quantity INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN work_order_shares.cancel_quantity IS '被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过),仅用于展示';
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolveShareReduceTargetQuantity } from './work-order-share-quantity.js'
|
||||
|
||||
test('按份数撤份额:正常扣减', () => {
|
||||
assert.deepEqual(resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 4 }), {
|
||||
nextQuantity: 5,
|
||||
failureReason: null,
|
||||
})
|
||||
assert.deepEqual(resolveShareReduceTargetQuantity({ currentQuantity: 2, reduceQuantity: 1 }), {
|
||||
nextQuantity: 1,
|
||||
failureReason: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('按份数撤份额:不能撤光、不能撤 0、不能超量', () => {
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: 1, reduceQuantity: 1 }).failureReason,
|
||||
'invalid_quantity',
|
||||
)
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 0 }).failureReason,
|
||||
'invalid_quantity',
|
||||
)
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: -2 }).failureReason,
|
||||
'invalid_quantity',
|
||||
)
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 9 }).failureReason,
|
||||
'invalid_quantity',
|
||||
)
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 10 }).failureReason,
|
||||
'invalid_quantity',
|
||||
)
|
||||
})
|
||||
|
||||
test('按份数撤份额:非法输入(非数字/小数/空)一律拒绝', () => {
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: Number.NaN, reduceQuantity: 4 })
|
||||
.failureReason,
|
||||
'invalid_quantity',
|
||||
)
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 4.7 }).nextQuantity,
|
||||
5,
|
||||
'小数向下取整后按 4 份处理',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 后台按份数撤销拼单份额:校验并算出保留下来的份数。
|
||||
*
|
||||
* 口径(已确认):
|
||||
* - 只能部分撤销,撤到 0 份走「单人撤单」,所以 `1 ≤ 撤掉份数 ≤ 当前份数 − 1`;
|
||||
* - 押金不随份数减少而退还(保持原冻结额),这里只算数量。
|
||||
*/
|
||||
export function resolveShareReduceTargetQuantity(input: {
|
||||
currentQuantity: number
|
||||
reduceQuantity: number
|
||||
}): { nextQuantity: number; failureReason: 'invalid_quantity' | null } {
|
||||
const current = Math.floor(Number(input.currentQuantity) || 0)
|
||||
const reduce = Math.floor(Number(input.reduceQuantity) || 0)
|
||||
if (!Number.isFinite(current) || !Number.isFinite(reduce)) {
|
||||
return { nextQuantity: 0, failureReason: 'invalid_quantity' }
|
||||
}
|
||||
if (current < 2 || reduce < 1 || reduce > current - 1) {
|
||||
return { nextQuantity: 0, failureReason: 'invalid_quantity' }
|
||||
}
|
||||
return { nextQuantity: current - reduce, failureReason: null }
|
||||
}
|
||||
@@ -365,6 +365,10 @@ export type WorkOrderShareRow = {
|
||||
timeout_policy?: string
|
||||
/** 取消来源:timeout/worker_request/admin/problem/settlement */
|
||||
cancelled_reason?: string
|
||||
/** 后台撤单/撤份额时填写的原因(自由文本,选填),仅用于列表展示 */
|
||||
cancel_note?: string
|
||||
/** 被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过) */
|
||||
cancel_quantity?: number
|
||||
/** 份额级问题单:备注 / 标记时间(NULL = 正常)/ 操作人;不影响母单状态 */
|
||||
problem_note?: string
|
||||
problem_marked_at?: string | null
|
||||
|
||||
@@ -817,13 +817,15 @@ export async function cancelWorkOrderShare(input: {
|
||||
`UPDATE work_order_shares
|
||||
SET status = 'cancelled',
|
||||
cancelled_reason = 'admin',
|
||||
cancel_note = $2,
|
||||
cancel_quantity = COALESCE(quantity, 0),
|
||||
acceptance_json = '{}'::jsonb,
|
||||
draft_acceptance_json = '{}'::jsonb,
|
||||
submitted_at = NULL,
|
||||
accepted_at = NULL,
|
||||
updated_at = $1
|
||||
WHERE id = $2`,
|
||||
[input.now, input.shareId],
|
||||
WHERE id = $3`,
|
||||
[input.now, String(input.reason || '').trim(), input.shareId],
|
||||
)
|
||||
// 母单状态保持不变:这里只取消这一份份额,不再按剩余份额重推母单状态。
|
||||
// (旧实现会 recompute:待验收的母单撤掉一份后 allocated < total → 变回 open,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import { resolveShareReduceTargetQuantity } from '../../domain/work-order-share-quantity.js'
|
||||
import {
|
||||
ensureWorkerWalletWithClient,
|
||||
getWorkerWalletWithClient,
|
||||
@@ -11,6 +12,7 @@ import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import {
|
||||
countWorkerActiveOrdersWithClient,
|
||||
getWorkOrderById,
|
||||
getWorkOrderByIdWithClient,
|
||||
isWorkOrderWithinHallCapacityWithClient,
|
||||
} from './work-order-repo.js'
|
||||
import type { WorkOrderRow, WorkOrderShareRow } from './types.js'
|
||||
@@ -414,6 +416,123 @@ export async function joinWorkOrderShare(input: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台按份数撤销某个打手的部分份额。
|
||||
*
|
||||
* 只改这一行的数量与份额报酬:
|
||||
* - 大厅余量 = 总份数 − Σ 有效份额数量,所以减少的份数会自动回到大厅;
|
||||
* - 母单状态不动(与「单人撤单」同口径);
|
||||
* - 押金按要求保持原冻结额,不做退还;
|
||||
* - 验收资料(验收图/视频/备注)留在原份额上,不清空。
|
||||
*/
|
||||
export async function reduceWorkOrderShareQuantity(input: {
|
||||
workOrderId: number
|
||||
shareId: number
|
||||
reduceQuantity: number
|
||||
note?: string
|
||||
actorName?: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
share: WorkOrderShareRow | null
|
||||
failureReason:
|
||||
| 'work_order_not_reducible'
|
||||
| 'share_not_found'
|
||||
| 'share_status_not_reducible'
|
||||
| 'share_quantity_invalid'
|
||||
| null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const orderResult = await client.query<WorkOrderRow>(
|
||||
`SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`,
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = orderResult.rows[0] || null
|
||||
if (
|
||||
!workOrder ||
|
||||
!['open', 'in_progress', 'pending_acceptance'].includes(String(workOrder.status || ''))
|
||||
) {
|
||||
return { order: workOrder, share: null, failureReason: 'work_order_not_reducible' as const }
|
||||
}
|
||||
|
||||
const shareResult = await client.query<WorkOrderShareRow>(
|
||||
`SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`,
|
||||
[input.shareId, input.workOrderId],
|
||||
)
|
||||
const share = shareResult.rows[0] || null
|
||||
if (!share) {
|
||||
return { order: workOrder, share: null, failureReason: 'share_not_found' as const }
|
||||
}
|
||||
if (!['joined', 'submitted'].includes(String(share.status || ''))) {
|
||||
return { order: workOrder, share, failureReason: 'share_status_not_reducible' as const }
|
||||
}
|
||||
|
||||
const target = resolveShareReduceTargetQuantity({
|
||||
currentQuantity: Number(share.quantity || 0),
|
||||
reduceQuantity: input.reduceQuantity,
|
||||
})
|
||||
if (target.failureReason || target.nextQuantity <= 0) {
|
||||
return { order: workOrder, share, failureReason: 'share_quantity_invalid' as const }
|
||||
}
|
||||
|
||||
const unitReward = toPositiveInteger(share.unit_reward, 0)
|
||||
const nextRewardAmount = target.nextQuantity * unitReward
|
||||
// 原因只做展示:本次没填就保留上一次填的,避免后续撤份额把已有说明覆盖成空
|
||||
const nextCancelNote = String(input.note || '').trim() || String(share.cancel_note || '')
|
||||
// 撤销份数累计:同一份额多次撤份额时,列表显示总共撤了多少份
|
||||
const nextCancelQuantity =
|
||||
Math.max(0, Number(share.cancel_quantity || 0)) + input.reduceQuantity
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET quantity = $1, share_reward = $2, cancel_note = $3,
|
||||
cancel_quantity = $4, updated_at = $5
|
||||
WHERE id = $6`,
|
||||
[
|
||||
target.nextQuantity,
|
||||
nextRewardAmount,
|
||||
nextCancelNote,
|
||||
nextCancelQuantity,
|
||||
input.now,
|
||||
input.shareId,
|
||||
],
|
||||
)
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'sharing_share_quantity_reduced',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
shareId: input.shareId,
|
||||
workerId: Number(share.worker_id || 0),
|
||||
fromQuantity: Number(share.quantity || 0),
|
||||
toQuantity: target.nextQuantity,
|
||||
reduceQuantity: input.reduceQuantity,
|
||||
rewardBefore: Number(share.share_reward || 0),
|
||||
rewardAfter: nextRewardAmount,
|
||||
// 押金口径:本次保持原冻结额,不退不补
|
||||
shareDeposit: Number(share.share_deposit || 0),
|
||||
note: String(input.note || '').trim(),
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
|
||||
const updatedShare = await client.query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
|
||||
[input.shareId],
|
||||
)
|
||||
// 必须回读聚合口径的母单(active_share_quantity 等),否则前端拿到的拼单进度会归零
|
||||
const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId)
|
||||
return {
|
||||
order: updatedOrder || workOrder,
|
||||
share: updatedShare.rows[0] || null,
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function submitWorkOrderShareAcceptance(input: {
|
||||
workOrderId: number
|
||||
workerId: number
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
deductAdminWorkOrderPendingDeposit,
|
||||
markAdminWorkOrderProblem,
|
||||
markAdminWorkOrderShareProblem,
|
||||
reduceAdminWorkOrderShareQuantity,
|
||||
resolveAdminProblemWorkOrder,
|
||||
disposeAdminWorkOrderShareProblem,
|
||||
} from '../../../services/worker-platform/index.js'
|
||||
@@ -163,6 +164,32 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
// 按份数撤销部分份额:减少的份数自动回到大厅,押金保持原冻结额。
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/shares/:shareId/reduce-quantity',
|
||||
requireAdminPermission('worker_order.cancel_share'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
reduceAdminWorkOrderShareQuantity(
|
||||
String(req.params.workOrderId || ''),
|
||||
String(req.params.shareId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '已撤销指定份数,剩余份数继续履约',
|
||||
errorMessage: '撤销拼单份额失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/reduce-quantity]',
|
||||
audit: (req, data) => ({
|
||||
action: 'work_order_share_quantity_reduced',
|
||||
targetType: 'work_order_share',
|
||||
targetId: String(req.params.shareId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// 份额级问题单:只影响该份额持有人,母单状态不变。
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/shares/:shareId/problem',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -34,6 +34,18 @@ function formatStatusLabel(status: string) {
|
||||
return STATUS_LABELS[status] || status || '未知'
|
||||
}
|
||||
|
||||
/** 份额级问题单处置动作中文名(与后台弹窗的四个选项一一对应) */
|
||||
const SHARE_PROBLEM_ACTION_LABELS: Record<string, string> = {
|
||||
return_to_worker: '退回打手继续处理',
|
||||
reopen: '取消该份额并退回接单大厅',
|
||||
cancel_release: '取消该份额并退还押金',
|
||||
cancel_deduct: '取消该份额并扣除押金',
|
||||
}
|
||||
|
||||
function formatShareProblemAction(action: string) {
|
||||
return SHARE_PROBLEM_ACTION_LABELS[action] || ''
|
||||
}
|
||||
|
||||
function readPayload(event: WorkOrderEvent, key: string): string {
|
||||
const value = event.payload?.[key]
|
||||
if (value === undefined || value === null) return ''
|
||||
@@ -311,6 +323,88 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
||||
}
|
||||
case 'sharing_config_updated':
|
||||
return { title: '更新了拼单配置', color: 'default' }
|
||||
case 'sharing_joined': {
|
||||
const workerLabel = resolveEventWorkerLabel(event)
|
||||
const quantity = readPayloadAmount(event, 'quantity')
|
||||
const deadlineAt = readPayload(event, 'deadlineAt')
|
||||
const timeoutMinutes = readPayloadAmount(event, 'timeoutMinutes')
|
||||
const deadlineText = deadlineAt
|
||||
? `截止 ${dayjs(deadlineAt).format('MM-DD HH:mm')}`
|
||||
: timeoutMinutes > 0
|
||||
? `限时 ${Math.round(timeoutMinutes)} 分钟`
|
||||
: '不限时'
|
||||
return {
|
||||
title: workerLabel ? `打手参与拼单(${workerLabel})` : '打手参与拼单',
|
||||
description: [quantity > 0 ? `参与 ${quantity} 份` : '', deadlineText]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
color: 'purple',
|
||||
}
|
||||
}
|
||||
case 'sharing_share_quantity_reduced': {
|
||||
const workerLabel = resolveEventWorkerLabel(event)
|
||||
const fromQuantity = readPayloadAmount(event, 'fromQuantity')
|
||||
const toQuantity = readPayloadAmount(event, 'toQuantity')
|
||||
const reduceQuantity = readPayloadAmount(event, 'reduceQuantity')
|
||||
const details = [
|
||||
fromQuantity > 0 && toQuantity > 0 ? `${fromQuantity} 份 → ${toQuantity} 份` : '',
|
||||
reduceQuantity > 0 ? `撤销 ${reduceQuantity} 份` : '',
|
||||
note ? `原因:${note}` : '',
|
||||
'撤掉的份数已退回接单大厅,押金保持冻结',
|
||||
].filter(Boolean)
|
||||
return {
|
||||
title: workerLabel ? `撤销部分拼单份额(${workerLabel})` : '撤销部分拼单份额',
|
||||
description: details.join(';'),
|
||||
color: 'orange',
|
||||
}
|
||||
}
|
||||
case 'sharing_share_marked_problem': {
|
||||
const workerLabel = resolveEventWorkerLabel(event)
|
||||
return {
|
||||
title: workerLabel ? `标记拼单份额问题单(${workerLabel})` : '标记拼单份额问题单',
|
||||
description: note
|
||||
? `原因:${note};仅该份额受影响,母单状态不变`
|
||||
: '未填备注;仅该份额受影响,母单状态不变',
|
||||
color: 'red',
|
||||
}
|
||||
}
|
||||
case 'sharing_share_problem_disposed': {
|
||||
const workerLabel = resolveEventWorkerLabel(event)
|
||||
const actionLabel = formatShareProblemAction(readPayload(event, 'action'))
|
||||
const depositAmount = readPayloadAmount(event, 'depositAmount')
|
||||
const details = [
|
||||
actionLabel ? `处理方式:${actionLabel}` : '',
|
||||
depositAmount > 0 ? `涉及押金 ${formatFenAmount(depositAmount)}` : '',
|
||||
note ? `备注:${note}` : '',
|
||||
].filter(Boolean)
|
||||
return {
|
||||
title: workerLabel ? `处置拼单份额问题单(${workerLabel})` : '处置拼单份额问题单',
|
||||
description: details.length > 0 ? details.join(';') : undefined,
|
||||
color: 'orange',
|
||||
}
|
||||
}
|
||||
case 'sharing_acceptance_draft_saved':
|
||||
case 'sharing_acceptance_updated': {
|
||||
const workerLabel = resolveEventWorkerLabel(event)
|
||||
const imageCount = readPayloadAmount(event, 'imageCount')
|
||||
const hasVideo = Boolean(event.payload?.hasVideo)
|
||||
const details = [
|
||||
imageCount > 0 ? `验收图 ${imageCount} 张` : '',
|
||||
hasVideo ? '含验收视频' : '',
|
||||
].filter(Boolean)
|
||||
return {
|
||||
title:
|
||||
type === 'sharing_acceptance_draft_saved'
|
||||
? workerLabel
|
||||
? `保存了拼单验收草稿(${workerLabel})`
|
||||
: '保存了拼单验收草稿'
|
||||
: workerLabel
|
||||
? `更新了拼单验收资料(${workerLabel})`
|
||||
: '更新了拼单验收资料',
|
||||
description: details.length > 0 ? details.join(' · ') : undefined,
|
||||
color: 'purple',
|
||||
}
|
||||
}
|
||||
case 'price_synced_from_rule': {
|
||||
const rewardAmount = Number(readPayload(event, 'rewardAmount') || 0)
|
||||
const unitReward = Number(readPayload(event, 'sharingUnitReward') || 0)
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
markAdminWorkOrderShareProblem,
|
||||
pinAdminWorkOrder,
|
||||
publishAdminWorkOrder,
|
||||
reduceAdminWorkOrderShareQuantity,
|
||||
reopenAdminCancelledWorkOrder,
|
||||
resetAdminWorkOrderGiftMaterial,
|
||||
resolveAdminProblemWorkOrder,
|
||||
@@ -306,6 +307,7 @@ export default function WorkOrdersPanel() {
|
||||
const [materialPasteText, setMaterialPasteText] = useState('')
|
||||
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
|
||||
const [cancelShare, setCancelShare] = useState<WorkOrderShare | null>(null)
|
||||
const [reduceShare, setReduceShare] = useState<WorkOrderShare | null>(null)
|
||||
const [problemShare, setProblemShare] = useState<WorkOrderShare | null>(null)
|
||||
const [disposeShare, setDisposeShare] = useState<WorkOrderShare | null>(null)
|
||||
const [cancelSharingOrder, setCancelSharingOrder] = useState<WorkOrder | null>(null)
|
||||
@@ -320,6 +322,7 @@ export default function WorkOrdersPanel() {
|
||||
const [materialForm] = Form.useForm<MaterialFormValues>()
|
||||
const [cancelForm] = Form.useForm<CancelOrderFormValues>()
|
||||
const [cancelShareForm] = Form.useForm<CancelShareFormValues>()
|
||||
const [reduceShareForm] = Form.useForm<{ reduceQuantity?: number; note?: string }>()
|
||||
const [problemShareForm] = Form.useForm<{ note?: string }>()
|
||||
const [disposeShareForm] = Form.useForm<{
|
||||
action?: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct'
|
||||
@@ -725,6 +728,32 @@ export default function WorkOrdersPanel() {
|
||||
cancelShareForm.resetFields()
|
||||
}
|
||||
|
||||
/** 按份数撤销部分份额:减少的份数回到大厅,押金保持冻结。 */
|
||||
async function submitReduceShare(values: { reduceQuantity?: number; note?: string }) {
|
||||
if (!sharingOrder || !reduceShare) return
|
||||
const workOrderId = sharingOrder.workOrderId
|
||||
const reduceQuantity = Math.floor(Number(values.reduceQuantity || 0))
|
||||
if (!Number.isFinite(reduceQuantity) || reduceQuantity <= 0) {
|
||||
message.warning('请填写要撤掉的份数')
|
||||
return
|
||||
}
|
||||
const keepQuantity = Math.max(0, reduceShare.quantity - reduceQuantity)
|
||||
const succeeded = await runAction(
|
||||
() =>
|
||||
reduceAdminWorkOrderShareQuantity(workOrderId, reduceShare.shareId, {
|
||||
reduceQuantity,
|
||||
note: String(values.note || '').trim(),
|
||||
}),
|
||||
`已撤掉 ${reduceQuantity} 份,保留 ${keepQuantity} 份继续履约`,
|
||||
)
|
||||
if (!succeeded) return
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-order-sharing', workOrderId],
|
||||
})
|
||||
setReduceShare(null)
|
||||
reduceShareForm.resetFields()
|
||||
}
|
||||
|
||||
/** 按份额标记问题单:只影响该打手,母单状态不变。备注选填。 */
|
||||
async function submitProblemShare(values: { note?: string }) {
|
||||
if (!sharingOrder || !problemShare) return
|
||||
@@ -3083,9 +3112,11 @@ export default function WorkOrdersPanel() {
|
||||
onCancel={() => {
|
||||
setSharingOrder(null)
|
||||
setCancelShare(null)
|
||||
setReduceShare(null)
|
||||
setCancelSharingOrder(null)
|
||||
sharingForm.resetFields()
|
||||
cancelShareForm.resetFields()
|
||||
reduceShareForm.resetFields()
|
||||
cancelSharingForm.resetFields()
|
||||
}}
|
||||
onOk={() => sharingForm.submit()}
|
||||
@@ -3218,7 +3249,7 @@ export default function WorkOrdersPanel() {
|
||||
loading={sharingQuery.isFetching}
|
||||
pagination={false}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1624 }}
|
||||
scroll={{ x: 1794 }}
|
||||
rowClassName={(share) =>
|
||||
share.problemMarked
|
||||
? 'admin-share-row-problem'
|
||||
@@ -3363,6 +3394,27 @@ export default function WorkOrdersPanel() {
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '撤单记录',
|
||||
width: 170,
|
||||
render: (_, share) => {
|
||||
// 份数前缀由展示层拼接:接口只给结构化的份数与原因文本
|
||||
const cancelledQuantity = Math.max(
|
||||
0,
|
||||
Math.floor(Number(share.cancelQuantity || 0)),
|
||||
)
|
||||
const note = String(share.cancelNote || '').trim()
|
||||
if (!cancelledQuantity && !note) return '-'
|
||||
const text = cancelledQuantity
|
||||
? `撤 ${cancelledQuantity} 份${note ? ` · ${note}` : ''}`
|
||||
: note
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 12 }} ellipsis={{ tooltip: text }}>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 300,
|
||||
@@ -3451,6 +3503,24 @@ export default function WorkOrdersPanel() {
|
||||
单人撤单
|
||||
</Button>
|
||||
) : null}
|
||||
{(share.status === 'joined' || share.status === 'submitted') &&
|
||||
share.quantity >= 2 ? (
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
title="按份数撤销部分份额:减少的份数立即回到大厅,押金保持冻结,验收资料保留"
|
||||
onClick={() => {
|
||||
setReduceShare(share)
|
||||
reduceShareForm.setFieldsValue({
|
||||
reduceQuantity: 1,
|
||||
note: '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
撤份额
|
||||
</Button>
|
||||
) : null}
|
||||
{problemAction}
|
||||
</Space>
|
||||
)
|
||||
@@ -3496,6 +3566,75 @@ export default function WorkOrdersPanel() {
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`撤份额 · ${reduceShare?.worker?.displayName || reduceShare?.worker?.username || ''}`}
|
||||
open={Boolean(reduceShare)}
|
||||
okText="确认撤销"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onCancel={() => {
|
||||
setReduceShare(null)
|
||||
reduceShareForm.resetFields()
|
||||
}}
|
||||
onOk={() => reduceShareForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
{reduceShare ? (
|
||||
<Form
|
||||
form={reduceShareForm}
|
||||
layout="vertical"
|
||||
initialValues={{ reduceQuantity: 1 }}
|
||||
onFinish={submitReduceShare}
|
||||
>
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message={`将从该打手的 ${reduceShare.quantity} 份中撤掉指定份数`}
|
||||
description="撤掉的份数会立刻回到抢单大厅;押金保持原冻结额不退还;验收资料保留,之后仍可单独验收剩余份数。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form.Item
|
||||
label={`撤掉份数(1 ~ ${Math.max(1, reduceShare.quantity - 1)} 份)`}
|
||||
name="reduceQuantity"
|
||||
rules={[{ required: true, message: '请填写要撤掉的份数' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={Math.max(1, reduceShare.quantity - 1)}
|
||||
precision={0}
|
||||
addonAfter="份"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() => {
|
||||
const reduceQuantity = Math.floor(
|
||||
Number(reduceShareForm.getFieldValue('reduceQuantity') || 0),
|
||||
)
|
||||
const boundedReduce = Math.max(
|
||||
0,
|
||||
Math.min(Math.max(0, reduceShare.quantity - 1), reduceQuantity),
|
||||
)
|
||||
const keepQuantity = Math.max(0, reduceShare.quantity - boundedReduce)
|
||||
return (
|
||||
<Typography.Text type="secondary">
|
||||
调整后保留 {keepQuantity} 份,回到大厅 {boundedReduce} 份;剩余份额报酬{' '}
|
||||
{formatMoney(keepQuantity * Number(reduceShare.unitReward || 0))}
|
||||
</Typography.Text>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item label="撤销原因(选填)" name="note">
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
placeholder="如:打手账号实际只能赠送 5 份"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`标记份额问题单 · ${
|
||||
problemShare?.worker?.displayName || problemShare?.worker?.username || ''
|
||||
|
||||
@@ -892,6 +892,18 @@ export function cancelAdminWorkOrderShare(
|
||||
}>(`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/cancel`, payload)
|
||||
}
|
||||
|
||||
/** 按份数撤销某个打手的部分份额:减少的份数回到大厅,押金保持原冻结额。 */
|
||||
export function reduceAdminWorkOrderShareQuantity(
|
||||
workOrderId: number,
|
||||
shareId: number,
|
||||
payload: { reduceQuantity: number; note?: string },
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder; share: WorkOrderShare }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/reduce-quantity`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
/** 按份额标记问题单:只影响该份额持有人,母单状态不变。备注选填。 */
|
||||
export function markAdminWorkOrderShareProblem(workOrderId: number, shareId: number, note = '') {
|
||||
return apiPost<{ order: WorkOrder; share: WorkOrderShare }>(
|
||||
|
||||
@@ -572,6 +572,10 @@ export type WorkOrderShare = {
|
||||
timeoutMinutes?: number
|
||||
timeoutPolicy?: string
|
||||
cancelledReason?: string
|
||||
/** 后台撤单/撤份额时填写的原因(自由文本,选填) */
|
||||
cancelNote?: string
|
||||
/** 被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过) */
|
||||
cancelQuantity?: number
|
||||
/** 份额级问题单:只影响该份额持有人,母单状态不变 */
|
||||
problemMarked?: boolean
|
||||
problemNote?: string
|
||||
|
||||
Reference in New Issue
Block a user