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
@@ -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