feat(worker): support batch sharing cancellation

This commit is contained in:
yml2213
2026-08-28 19:40:49 +08:00
parent e189811617
commit d91d7180ad
13 changed files with 633 additions and 96 deletions
@@ -61,3 +61,57 @@ export function resolveWorkOrderShareCancellationStatus(
if (!['joined', 'submitted'].includes(shareStatus)) return null if (!['joined', 'submitted'].includes(shareStatus)) return null
return 'open' return 'open'
} }
/**
* 校验母单级拼单撤单。退回大厅只影响尚未验收的份额;取消整单则不能存在已验收份额,
* 否则必须经售后处理已结算的报酬和订单退款。
*/
export function resolveWorkOrderSharingCancellationPlan(
workOrderStatus: string,
shareStatuses: string[],
action: 'return_to_hall' | 'cancel_order',
): {
nextOrderStatus: 'open' | 'cancelled' | null
cancellableShareCount: number
acceptedShareCount: number
failureReason:
| 'work_order_not_cancellable'
| 'no_cancellable_shares'
| 'accepted_shares_present'
| null
} {
if (!['open', 'pending_acceptance', 'in_progress'].includes(workOrderStatus)) {
return {
nextOrderStatus: null,
cancellableShareCount: 0,
acceptedShareCount: 0,
failureReason: 'work_order_not_cancellable',
}
}
const cancellableShareCount = shareStatuses.filter((status) =>
['joined', 'submitted'].includes(status),
).length
const acceptedShareCount = shareStatuses.filter((status) => status === 'accepted').length
if (cancellableShareCount === 0) {
return {
nextOrderStatus: null,
cancellableShareCount,
acceptedShareCount,
failureReason: 'no_cancellable_shares',
}
}
if (action === 'cancel_order' && acceptedShareCount > 0) {
return {
nextOrderStatus: null,
cancellableShareCount,
acceptedShareCount,
failureReason: 'accepted_shares_present',
}
}
return {
nextOrderStatus: action === 'return_to_hall' ? 'open' : 'cancelled',
cancellableShareCount,
acceptedShareCount,
failureReason: null,
}
}
@@ -473,6 +473,7 @@ export type WorkOrderCapabilities = {
canReopen: boolean canReopen: boolean
canPublish: boolean canPublish: boolean
canCancel: boolean canCancel: boolean
canCancelSharing: boolean
canResolveProblem: boolean canResolveProblem: boolean
canDeduct: boolean canDeduct: boolean
canConfirmFriend: boolean canConfirmFriend: boolean
@@ -28,4 +28,5 @@ export {
acceptWorkOrderAndSettle, acceptWorkOrderAndSettle,
acceptWorkOrderShareAndSettle, acceptWorkOrderShareAndSettle,
cancelWorkOrderShare, cancelWorkOrderShare,
cancelWorkOrderSharing,
} from './work-order-settlement-repo.js' } from './work-order-settlement-repo.js'
@@ -5,6 +5,7 @@ import {
getWorkerWalletWithClient, getWorkerWalletWithClient,
WORK_ORDER_SHARE_SELECT, WORK_ORDER_SHARE_SELECT,
resolveWorkOrderShareCancellationStatus, resolveWorkOrderShareCancellationStatus,
resolveWorkOrderSharingCancellationPlan,
} from './shared.js' } from './shared.js'
import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
@@ -740,3 +741,165 @@ export async function cancelWorkOrderShare(input: {
} }
}) })
} }
/**
* 母单级拼单撤单。只撤回尚未验收的 joined/submitted 份额;已验收份额保留,
* 取消整单时则要求不存在已验收份额,避免绕过售后和已结算资金处理。
*/
export async function cancelWorkOrderSharing(input: {
workOrderId: number
action: 'return_to_hall' | 'cancel_order'
now: string
reason?: string
actorName?: string
}): Promise<{
order: WorkOrderRow | null
cancelledShareIds: number[]
cancelledWorkerIds: number[]
cancelledShareCount: number
releasedDepositAmount: number
failureReason:
| 'work_order_not_cancellable'
| 'no_cancellable_shares'
| 'accepted_shares_present'
| 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 || workOrder.sharing_enabled !== true) {
return {
order: workOrder,
cancelledShareIds: [],
cancelledWorkerIds: [],
cancelledShareCount: 0,
releasedDepositAmount: 0,
failureReason: 'work_order_not_cancellable',
}
}
const sharesResult = await client.query<WorkOrderShareRow>(
`SELECT * FROM work_order_shares
WHERE work_order_id = $1 AND status != 'cancelled'
ORDER BY id ASC
FOR UPDATE`,
[input.workOrderId],
)
const shares = sharesResult.rows
const plan = resolveWorkOrderSharingCancellationPlan(
workOrder.status,
shares.map((share) => share.status),
input.action,
)
if (!plan.nextOrderStatus) {
return {
order: workOrder,
cancelledShareIds: [],
cancelledWorkerIds: [],
cancelledShareCount: plan.cancellableShareCount,
releasedDepositAmount: 0,
failureReason: plan.failureReason,
}
}
const cancellableShares = shares.filter((share) => ['joined', 'submitted'].includes(share.status))
let releasedDepositAmount = 0
for (const share of cancellableShares) {
const workerId = Number(share.worker_id || 0)
if (workerId <= 0) continue
await ensureWorkerWalletWithClient(client, workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, workerId)
const releaseAmount = Math.min(
Number(share.share_deposit || 0),
Number(wallet?.frozen_deposit_amount || 0),
)
if (releaseAmount <= 0) continue
const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount)
await client.query(
`UPDATE worker_wallets
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
WHERE worker_id = $4`,
[nextAvailable, nextFrozen, input.now, workerId],
)
await client.query(
`INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '后台批量撤销拼单份额退还押金', $6::jsonb, $7)`,
[
workerId,
releaseAmount,
nextAvailable,
nextFrozen,
input.workOrderId,
JSON.stringify({
workOrderId: input.workOrderId,
shareId: Number(share.id),
action: input.action,
reason: String(input.reason || '').trim(),
}),
input.now,
],
)
releasedDepositAmount += releaseAmount
}
const cancelledShareIds = cancellableShares.map((share) => Number(share.id))
const cancelledWorkerIds = [
...new Set(cancellableShares.map((share) => Number(share.worker_id || 0)).filter(Boolean)),
]
await client.query(
`UPDATE work_order_shares
SET status = 'cancelled', updated_at = $1
WHERE id = ANY($2::bigint[])`,
[input.now, cancelledShareIds],
)
if (input.action === 'return_to_hall') {
await client.query(
`UPDATE work_orders
SET status = 'open', submitted_at = NULL, published_at = COALESCE(published_at, $1),
hall_queued_at = $1, updated_at = $1
WHERE id = $2`,
[input.now, input.workOrderId],
)
} else {
await client.query(
`UPDATE work_orders
SET status = 'cancelled', assigned_worker_id = NULL, assigned_at = NULL, deadline_at = NULL,
submitted_at = NULL, published_at = NULL, pinned_at = NULL, updated_at = $1
WHERE id = $2`,
[input.now, input.workOrderId],
)
}
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
actorType: 'admin',
actorId: input.actorName || '',
eventType:
input.action === 'return_to_hall'
? 'sharing_unaccepted_shares_returned_to_hall_by_admin'
: 'sharing_order_cancelled_by_admin',
fromStatus: workOrder.status,
toStatus: plan.nextOrderStatus,
payloadJson: JSON.stringify({
cancelledShareIds,
cancelledShareCount: cancelledShareIds.length,
acceptedShareCount: plan.acceptedShareCount,
releasedDepositAmount,
reason: String(input.reason || '').trim(),
}),
now: input.now,
})
return {
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
cancelledShareIds,
cancelledWorkerIds,
cancelledShareCount: cancelledShareIds.length,
releasedDepositAmount,
failureReason: null,
}
})
}
@@ -4,11 +4,12 @@ import {
acceptAdminWorkOrder, acceptAdminWorkOrder,
acceptAdminWorkOrderShare, acceptAdminWorkOrderShare,
cancelAdminWorkOrderShare, cancelAdminWorkOrderShare,
cancelAdminWorkOrderSharing,
deductAdminWorkOrderPendingDeposit, deductAdminWorkOrderPendingDeposit,
markAdminWorkOrderProblem, markAdminWorkOrderProblem,
resolveAdminProblemWorkOrder, resolveAdminProblemWorkOrder,
} from '../../../services/worker-platform/index.js' } from '../../../services/worker-platform/index.js'
import { createJsonHandler, requireAdminPermission } from '../session.js' import { createJsonHandler, requireAdminPermission, requireAnyAdminPermission } from '../session.js'
const router = Router() const router = Router()
@@ -104,6 +105,34 @@ router.post(
), ),
) )
router.post(
'/worker-platform/orders/:workOrderId/sharing/cancel',
requireAnyAdminPermission(['worker_order.return_to_hall', 'worker_order.cancel']),
createJsonHandler(
(req) =>
cancelAdminWorkOrderSharing(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.permissions || [],
),
{
successMessage: '拼单撤单处理完成',
errorMessage: '拼单撤单失败',
scope: '[admin/worker-platform/orders/:workOrderId/sharing/cancel]',
audit: (req, data) => ({
action:
String(req.body?.action || '').trim() === 'return_to_hall'
? 'work_order_sharing_returned_to_hall'
: 'work_order_sharing_cancelled',
targetType: 'work_order',
targetId: String(req.params.workOrderId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
router.post( router.post(
'/worker-platform/orders/:workOrderId/shares/:shareId/cancel', '/worker-platform/orders/:workOrderId/shares/:shareId/cancel',
requireAdminPermission('worker_order.cancel_share'), requireAdminPermission('worker_order.cancel_share'),
@@ -3,6 +3,7 @@ import {
acceptWorkOrderAndSettle, acceptWorkOrderAndSettle,
acceptWorkOrderShareAndSettle, acceptWorkOrderShareAndSettle,
cancelWorkOrderShare, cancelWorkOrderShare,
cancelWorkOrderSharing,
countWorkOrderPendingSharingSubmissions, countWorkOrderPendingSharingSubmissions,
updateWorkOrder, updateWorkOrder,
} from '../../repositories/worker-platform/index.js' } from '../../repositories/worker-platform/index.js'
@@ -157,6 +158,81 @@ export async function cancelAdminWorkOrderShare(
} }
} }
export async function cancelAdminWorkOrderSharing(
workOrderId: number | string,
payload: JsonObject = {},
actorName = '',
actorPermissions: string[] = [],
) {
const action = String(payload.action || '').trim()
if (!['return_to_hall', 'cancel_order'].includes(action)) {
throw createHttpError('请选择拼单撤单处理方式', {
statusCode: 400,
errorCode: 'work_order_sharing_cancel_action_required',
})
}
const requiredPermission =
action === 'return_to_hall' ? 'worker_order.return_to_hall' : 'worker_order.cancel'
if (!actorPermissions.includes(requiredPermission)) {
throw createHttpError('当前账号没有此拼单撤单权限', {
statusCode: 403,
errorCode: 'admin_permission_denied',
})
}
const reason = String(payload.reason || '').trim()
if (action === 'cancel_order' && !reason) {
throw createHttpError('取消拼单订单时请填写退款或撤单原因', {
statusCode: 400,
errorCode: 'work_order_sharing_cancel_reason_required',
})
}
const result = await cancelWorkOrderSharing({
workOrderId: Number(workOrderId),
action: action as 'return_to_hall' | 'cancel_order',
reason,
now: nowIso(),
actorName,
})
if (result.failureReason === 'work_order_not_cancellable') {
throw createHttpError('当前订单不是可撤单的拼单', {
statusCode: 409,
errorCode: 'work_order_sharing_cancel_status_invalid',
})
}
if (result.failureReason === 'no_cancellable_shares') {
throw createHttpError('没有可撤销的未验收拼单份额', {
statusCode: 409,
errorCode: 'work_order_sharing_cancel_no_cancellable_shares',
})
}
if (result.failureReason === 'accepted_shares_present') {
throw createHttpError('存在已验收份额,不能取消整单;请通过售后处理退款', {
statusCode: 409,
errorCode: 'work_order_sharing_cancel_accepted_shares_present',
})
}
if (!result.order) {
throw createHttpError('拼单撤单失败,订单状态可能已变化', {
statusCode: 409,
errorCode: 'work_order_sharing_cancel_conflict',
})
}
const activeWorkerIds = await resolveWorkOrderRealtimeWorkerIds(result.order)
const workerIds = [...new Set([...activeWorkerIds, ...result.cancelledWorkerIds])]
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrderId),
workerIds,
hallChanged: action === 'return_to_hall',
})
for (const workerId of result.cancelledWorkerIds) publishWorkerWalletRealtimeChange(workerId)
return {
order: mapWorkOrderAdmin(result.order),
action,
cancelledShareCount: result.cancelledShareCount,
releasedDepositAmount: result.releasedDepositAmount,
}
}
export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') { export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') {
const workOrderIds = [ const workOrderIds = [
...new Set( ...new Set(
@@ -51,6 +51,7 @@ export {
acceptAdminWorkOrderShare, acceptAdminWorkOrderShare,
acceptAdminWorkOrders, acceptAdminWorkOrders,
cancelAdminWorkOrderShare, cancelAdminWorkOrderShare,
cancelAdminWorkOrderSharing,
} from './admin-work-order-acceptance-service.js' } from './admin-work-order-acceptance-service.js'
export * from './worker-product-match-config-service.js' export * from './worker-product-match-config-service.js'
export * from './worker-announcement-config-service.js' export * from './worker-announcement-config-service.js'
@@ -174,6 +174,10 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
canPublish: canPublish:
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED && Number(workOrder.reward_amount || 0) > 0, workOrder.status === WORK_ORDER_STATUS.UNASSIGNED && Number(workOrder.reward_amount || 0) > 0,
canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id), canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id),
canCancelSharing:
workOrder.sharing_enabled === true &&
hasActiveShares &&
['open', 'pending_acceptance', 'in_progress'].includes(workOrder.status),
canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM, canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM,
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED, canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED,
canConfirmFriend: canConfirmFriend:
@@ -13,6 +13,7 @@ import type {
import { import {
buildWorkOrderWhere, buildWorkOrderWhere,
resolveWorkOrderShareCancellationStatus, resolveWorkOrderShareCancellationStatus,
resolveWorkOrderSharingCancellationPlan,
resolveWorkOrderShareJoinQuantity, resolveWorkOrderShareJoinQuantity,
resolveOpenSharingSyncReward, resolveOpenSharingSyncReward,
} from '../../repositories/worker-platform/index.js' } from '../../repositories/worker-platform/index.js'
@@ -991,6 +992,7 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
assert.equal(partial.capabilities.canConfigureSharing, false) assert.equal(partial.capabilities.canConfigureSharing, false)
assert.equal(partial.capabilities.canAssign, false) assert.equal(partial.capabilities.canAssign, false)
assert.equal(partial.capabilities.canUpdateMaterial, true) assert.equal(partial.capabilities.canUpdateMaterial, true)
assert.equal(partial.capabilities.canCancelSharing, true)
const filled = mapWorkOrderAdmin( const filled = mapWorkOrderAdmin(
buildWorkOrderRow({ buildWorkOrderRow({
@@ -1005,6 +1007,7 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
assert.equal(filled.hallAvailable, false) assert.equal(filled.hallAvailable, false)
assert.equal(filled.capabilities.canAccept, true) assert.equal(filled.capabilities.canAccept, true)
assert.equal(filled.capabilities.canUnpublish, false) assert.equal(filled.capabilities.canUnpublish, false)
assert.equal(filled.capabilities.canCancelSharing, true)
}) })
test('已取消工单可重新启用,恢复未分配后可编辑和发布', () => { test('已取消工单可重新启用,恢复未分配后可编辑和发布', () => {
@@ -1051,6 +1054,44 @@ test('拼单份额撤单只允许未验收份额并让母工单回到大厅状
assert.equal(resolveWorkOrderShareCancellationStatus('open', 'cancelled'), null) assert.equal(resolveWorkOrderShareCancellationStatus('open', 'cancelled'), null)
}) })
test('拼单母单撤单区分退回大厅和取消整单的验收边界', () => {
assert.deepEqual(
resolveWorkOrderSharingCancellationPlan(
'pending_acceptance',
['submitted', 'accepted'],
'return_to_hall',
),
{
nextOrderStatus: 'open',
cancellableShareCount: 1,
acceptedShareCount: 1,
failureReason: null,
},
)
assert.equal(
resolveWorkOrderSharingCancellationPlan(
'pending_acceptance',
['submitted', 'accepted'],
'cancel_order',
).failureReason,
'accepted_shares_present',
)
assert.deepEqual(
resolveWorkOrderSharingCancellationPlan('open', ['joined', 'submitted'], 'cancel_order'),
{
nextOrderStatus: 'cancelled',
cancellableShareCount: 2,
acceptedShareCount: 0,
failureReason: null,
},
)
assert.equal(
resolveWorkOrderSharingCancellationPlan('accepted', ['submitted'], 'return_to_hall')
.failureReason,
'work_order_not_cancellable',
)
})
test('问题单退回打手按标记前状态恢复', () => { test('问题单退回打手按标记前状态恢复', () => {
assert.equal(resolveProblemReturnToWorkerStatus('pending_acceptance'), 'pending_acceptance') assert.equal(resolveProblemReturnToWorkerStatus('pending_acceptance'), 'pending_acceptance')
assert.equal(resolveProblemReturnToWorkerStatus('open'), 'open') assert.equal(resolveProblemReturnToWorkerStatus('open'), 'open')
@@ -313,6 +313,22 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
color: 'red', color: 'red',
} }
} }
case 'sharing_unaccepted_shares_returned_to_hall_by_admin': {
const count = readPayload(event, 'cancelledShareCount')
return {
title: `批量撤回未验收拼单份额${count ? `${count} 个)` : ''}`,
description: note ? `原因:${note}` : '对应数量已重新开放到接单大厅',
color: 'orange',
}
}
case 'sharing_order_cancelled_by_admin': {
const count = readPayload(event, 'cancelledShareCount')
return {
title: `取消拼单订单${count ? `(已撤销 ${count} 个参与份额)` : ''}`,
description: note ? `原因:${note}` : undefined,
color: 'red',
}
}
case 'pending_deposit_deducted': case 'pending_deposit_deducted':
return { title: '扣减了待解冻押金', color: 'default' } return { title: '扣减了待解冻押金', color: 'default' }
case 'voucher_backfilled': case 'voucher_backfilled':
@@ -49,6 +49,7 @@ import {
assignAdminWorkOrderToWorker, assignAdminWorkOrderToWorker,
cancelAdminWorkOrder, cancelAdminWorkOrder,
cancelAdminWorkOrderShare, cancelAdminWorkOrderShare,
cancelAdminWorkOrderSharing,
confirmAdminWorkOrderFriend, confirmAdminWorkOrderFriend,
deductAdminWorkOrderPendingDeposit, deductAdminWorkOrderPendingDeposit,
deleteAdminWorkOrder, deleteAdminWorkOrder,
@@ -146,6 +147,11 @@ type CancelShareFormValues = {
reason?: string reason?: string
} }
type CancelSharingFormValues = {
action?: 'return_to_hall' | 'cancel_order'
reason?: string
}
export default function WorkOrdersPanel() { export default function WorkOrdersPanel() {
const { message, modal } = App.useApp() const { message, modal } = App.useApp()
const navigate = useNavigate() const navigate = useNavigate()
@@ -221,6 +227,7 @@ export default function WorkOrdersPanel() {
const [materialPasteText, setMaterialPasteText] = useState('') const [materialPasteText, setMaterialPasteText] = useState('')
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null) const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
const [cancelShare, setCancelShare] = useState<WorkOrderShare | null>(null) const [cancelShare, setCancelShare] = useState<WorkOrderShare | null>(null)
const [cancelSharingOrder, setCancelSharingOrder] = useState<WorkOrder | null>(null)
const [assignOrder, setAssignOrder] = useState<WorkOrder | null>(null) const [assignOrder, setAssignOrder] = useState<WorkOrder | null>(null)
const [assignWorkerId, setAssignWorkerId] = useState<number | undefined>(undefined) const [assignWorkerId, setAssignWorkerId] = useState<number | undefined>(undefined)
const [editOrder, setEditOrder] = useState<WorkOrder | null>(null) const [editOrder, setEditOrder] = useState<WorkOrder | null>(null)
@@ -232,6 +239,7 @@ export default function WorkOrdersPanel() {
const [materialForm] = Form.useForm<MaterialFormValues>() const [materialForm] = Form.useForm<MaterialFormValues>()
const [cancelForm] = Form.useForm<CancelOrderFormValues>() const [cancelForm] = Form.useForm<CancelOrderFormValues>()
const [cancelShareForm] = Form.useForm<CancelShareFormValues>() const [cancelShareForm] = Form.useForm<CancelShareFormValues>()
const [cancelSharingForm] = Form.useForm<CancelSharingFormValues>()
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>() const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
const [editForm] = Form.useForm<{ const [editForm] = Form.useForm<{
productName?: string productName?: string
@@ -335,6 +343,11 @@ export default function WorkOrdersPanel() {
queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)), queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)),
enabled: Boolean(sharingOrder), enabled: Boolean(sharingOrder),
}) })
const sharingShares = sharingQuery.data?.data.shares || []
const hasCancellableSharingShares = sharingShares.some((share) =>
['joined', 'submitted'].includes(share.status),
)
const hasAcceptedSharingShares = sharingShares.some((share) => share.status === 'accepted')
const assignWorkersQuery = useQuery({ const assignWorkersQuery = useQuery({
queryKey: ['admin-worker-platform-workers', 'assign', assignWorkerKeyword], queryKey: ['admin-worker-platform-workers', 'assign', assignWorkerKeyword],
queryFn: () => queryFn: () =>
@@ -498,6 +511,28 @@ export default function WorkOrdersPanel() {
cancelShareForm.resetFields() cancelShareForm.resetFields()
} }
async function submitCancelSharing(values: CancelSharingFormValues) {
if (!cancelSharingOrder || !values.action) return
const action = values.action
const succeeded = await runAction(
() =>
cancelAdminWorkOrderSharing(cancelSharingOrder.workOrderId, {
action,
reason: String(values.reason || '').trim(),
}),
(result) =>
action === 'return_to_hall'
? `已撤回 ${result.data.cancelledShareCount} 个未验收份额并退回大厅`
: `拼单订单已取消,已撤销 ${result.data.cancelledShareCount} 个参与份额`,
)
if (!succeeded) return
await queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-order-sharing', cancelSharingOrder.workOrderId],
})
setCancelSharingOrder(null)
cancelSharingForm.resetFields()
}
async function refreshAll() { async function refreshAll() {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -1097,7 +1132,7 @@ export default function WorkOrdersPanel() {
(row.capabilities?.canConfigureSharing && canConfigureSharing) ? ( (row.capabilities?.canConfigureSharing && canConfigureSharing) ? (
<Button icon={<TeamOutlined />} onClick={() => openSharingConfigModal(row)}> <Button icon={<TeamOutlined />} onClick={() => openSharingConfigModal(row)}>
{row.sharing?.enabled && !row.capabilities?.canConfigureSharing {row.sharing?.enabled && !row.capabilities?.canConfigureSharing
? '拼单明细' ? '拼单管理'
: '拼单配置'} : '拼单配置'}
</Button> </Button>
) : null} ) : null}
@@ -2148,8 +2183,10 @@ export default function WorkOrdersPanel() {
onCancel={() => { onCancel={() => {
setSharingOrder(null) setSharingOrder(null)
setCancelShare(null) setCancelShare(null)
setCancelSharingOrder(null)
sharingForm.resetFields() sharingForm.resetFields()
cancelShareForm.resetFields() cancelShareForm.resetFields()
cancelSharingForm.resetFields()
}} }}
onOk={() => sharingForm.submit()} onOk={() => sharingForm.submit()}
footer={ footer={
@@ -2237,10 +2274,46 @@ export default function WorkOrdersPanel() {
) : (sharingQuery.data?.data.shares || []).length === 0 ? ( ) : (sharingQuery.data?.data.shares || []).length === 0 ? (
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
) : ( ) : (
<>
{sharingOrder.capabilities?.canCancelSharing &&
hasCancellableSharingShares &&
(canReturnToHall || canCancelOrder) ? (
<Space
direction="vertical"
size={8}
className="full-width"
style={{ marginBottom: 12 }}
>
<Alert
showIcon
type="warning"
message="拼单撤单会批量处理所有未验收份额"
description={
hasAcceptedSharingShares
? '已验收份额会保留;如需退款,请从售后处理。'
: '可选择把未验收份额退回大厅,或取消整个拼单订单。'
}
/>
<div>
<Button
danger
onClick={() => {
setCancelSharingOrder(sharingOrder)
cancelSharingForm.setFieldsValue({
action: canReturnToHall ? 'return_to_hall' : 'cancel_order',
reason: '',
})
}}
>
</Button>
</div>
</Space>
) : null}
<Table<WorkOrderShare> <Table<WorkOrderShare>
rowKey="shareId" rowKey="shareId"
size="small" size="small"
dataSource={sharingQuery.data?.data.shares || []} dataSource={sharingShares}
loading={sharingQuery.isFetching} loading={sharingQuery.isFetching}
pagination={false} pagination={false}
columns={[ columns={[
@@ -2331,12 +2404,13 @@ export default function WorkOrdersPanel() {
danger danger
size="small" size="small"
icon={<UndoOutlined />} icon={<UndoOutlined />}
title="只撤销当前参与者的拼单份额,其他参与者不受影响"
onClick={() => { onClick={() => {
setCancelShare(share) setCancelShare(share)
cancelShareForm.setFieldsValue({ reason: '' }) cancelShareForm.setFieldsValue({ reason: '' })
}} }}
> >
</Button> </Button>
) : null} ) : null}
</Space> </Space>
@@ -2345,6 +2419,7 @@ export default function WorkOrdersPanel() {
}, },
]} ]}
/> />
</>
)} )}
</Card> </Card>
</Space> </Space>
@@ -2381,6 +2456,66 @@ export default function WorkOrdersPanel() {
</Form> </Form>
) : null} ) : null}
</Modal> </Modal>
<Modal
title={`拼单撤单处理 · ${getDisplayOrderNo(cancelSharingOrder)}`}
open={Boolean(cancelSharingOrder)}
onCancel={() => {
setCancelSharingOrder(null)
cancelSharingForm.resetFields()
}}
onOk={() => cancelSharingForm.submit()}
destroyOnHidden
>
{cancelSharingOrder ? (
<Form form={cancelSharingForm} layout="vertical" onFinish={submitCancelSharing}>
<Alert
showIcon
type="warning"
message="将批量处理所有未验收的拼单份额"
description="退回大厅会释放未验收份额的押金并重新开放对应数量;取消整单仅在不存在已验收份额时可用。"
style={{ marginBottom: 16 }}
/>
<Form.Item
label="处理方式"
name="action"
rules={[{ required: true, message: '请选择拼单撤单处理方式' }]}
>
<Radio.Group>
<Space direction="vertical">
{canReturnToHall ? (
<Radio value="return_to_hall">
退
</Radio>
) : null}
{canCancelOrder ? (
<Radio value="cancel_order">
</Radio>
) : null}
</Space>
</Radio.Group>
</Form.Item>
<Form.Item
label="处理原因"
name="reason"
dependencies={['action']}
rules={[
({ getFieldValue }) => ({
validator(_, value) {
if (getFieldValue('action') !== 'cancel_order' || String(value || '').trim()) {
return Promise.resolve()
}
return Promise.reject(new Error('取消整单时请填写退款或撤单原因'))
},
}),
]}
>
<Input.TextArea rows={3} placeholder="取消整单时请填写退款原因;退回大厅可选填" />
</Form.Item>
</Form>
) : null}
</Modal>
</section> </section>
) )
} }
@@ -728,6 +728,21 @@ export function cancelAdminWorkOrderShare(
}>(`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/cancel`, payload) }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/cancel`, payload)
} }
export function cancelAdminWorkOrderSharing(
workOrderId: number,
payload: {
action: 'return_to_hall' | 'cancel_order'
reason?: string
},
) {
return apiPost<{
order: WorkOrder
action: 'return_to_hall' | 'cancel_order'
cancelledShareCount: number
releasedDepositAmount: number
}>(`/api/v1/admin/worker-platform/orders/${workOrderId}/sharing/cancel`, payload)
}
export function acceptAdminWorkOrders(workOrderIds: number[]) { export function acceptAdminWorkOrders(workOrderIds: number[]) {
return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', { return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', {
workOrderIds, workOrderIds,
@@ -550,6 +550,7 @@ export type WorkOrder = {
canReopen: boolean canReopen: boolean
canPublish: boolean canPublish: boolean
canCancel: boolean canCancel: boolean
canCancelSharing?: boolean
canResolveProblem: boolean canResolveProblem: boolean
canDeduct: boolean canDeduct: boolean
canConfirmFriend?: boolean canConfirmFriend?: boolean