修复拼单明细并支持参与者撤单

This commit is contained in:
yml2213
2026-08-19 14:35:23 +08:00
parent 4b1dcd47ea
commit ab6387759e
9 changed files with 409 additions and 43 deletions
@@ -199,6 +199,16 @@ export function resolveWorkOrderShareJoinQuantity(input: {
return input.takeRemaining ? Math.max(0, input.remaining) : input.quantity
}
/** 校验单个拼单份额能否撤单,并返回母工单应回退到的状态。 */
export function resolveWorkOrderShareCancellationStatus(
workOrderStatus: string,
shareStatus: string,
): 'open' | null {
if (!['open', 'pending_acceptance'].includes(workOrderStatus)) return null
if (!['joined', 'submitted'].includes(shareStatus)) return null
return 'open'
}
export async function joinWorkOrderShare(input: {
workOrderId: number
workerId: number
@@ -2038,7 +2048,9 @@ export async function acceptWorkOrderAndSettle(input: {
)
if (shares.length > 0) {
for (const share of shares) {
// 单独验收过的份额已经完成资金结算,统一验收时只处理尚未结算的份额。
const unsettledShares = shares.filter((share) => share.status !== 'accepted')
for (const share of unsettledShares) {
const shareWorkerId = Number(share.worker_id || 0)
if (!shareWorkerId) continue
await ensureWorkerWalletWithClient(client, shareWorkerId, input.now)
@@ -2263,7 +2275,10 @@ export async function acceptWorkOrderAndSettle(input: {
eventType: 'accepted',
fromStatus: 'pending_acceptance',
toStatus: 'accepted',
payloadJson: JSON.stringify({ shareCount: shares.length }),
payloadJson: JSON.stringify({
shareCount: shares.length,
settledShareCount: shares.filter((share) => share.status !== 'accepted').length,
}),
now: input.now,
})
@@ -2437,6 +2452,149 @@ export async function acceptWorkOrderShareAndSettle(input: {
})
}
/** 后台撤销单个拼单份额,释放该打手押金并把份数重新放回大厅。 */
export async function cancelWorkOrderShare(input: {
workOrderId: number
shareId: number
now: string
reason?: string
actorName?: string
}): Promise<{
order: WorkOrderRow | null
share: WorkOrderShareRow | null
releasedDepositAmount: number
failureReason:
| 'work_order_not_cancellable'
| 'share_not_found'
| 'share_status_not_cancellable'
| 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', 'pending_acceptance'].includes(workOrder.status)) {
return {
order: workOrder,
share: null,
releasedDepositAmount: 0,
failureReason: 'work_order_not_cancellable',
}
}
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,
releasedDepositAmount: 0,
failureReason: 'share_not_found',
}
}
const nextOrderStatus = resolveWorkOrderShareCancellationStatus(workOrder.status, share.status)
if (!nextOrderStatus) {
return {
order: workOrder,
share,
releasedDepositAmount: 0,
failureReason: 'share_status_not_cancellable',
}
}
const workerId = Number(share.worker_id || 0)
let releasedDepositAmount = 0
if (workerId > 0) {
await ensureWorkerWalletWithClient(client, workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, workerId)
releasedDepositAmount = Math.min(
Number(share.share_deposit || 0),
Number(wallet?.frozen_deposit_amount || 0),
)
if (releasedDepositAmount > 0) {
const nextAvailable = Number(wallet?.available_amount || 0) + releasedDepositAmount
const nextFrozen = Math.max(
0,
Number(wallet?.frozen_deposit_amount || 0) - releasedDepositAmount,
)
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,
releasedDepositAmount,
nextAvailable,
nextFrozen,
input.workOrderId,
JSON.stringify({
workOrderId: input.workOrderId,
shareId: input.shareId,
reason: String(input.reason || '').trim(),
}),
input.now,
],
)
}
}
await client.query(
`UPDATE work_order_shares
SET status = 'cancelled', updated_at = $1
WHERE id = $2`,
[input.now, input.shareId],
)
await client.query(
`UPDATE work_orders
SET status = $1,
submitted_at = NULL,
published_at = COALESCE(published_at, $2),
updated_at = $2
WHERE id = $3`,
[nextOrderStatus, input.now, input.workOrderId],
)
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
actorType: 'admin',
actorId: input.actorName || '',
eventType: 'sharing_share_cancelled_by_admin',
fromStatus: workOrder.status,
toStatus: nextOrderStatus,
payloadJson: JSON.stringify({
shareId: input.shareId,
workerId,
quantity: Number(share.quantity || 0),
releasedDepositAmount,
reason: String(input.reason || '').trim(),
}),
now: input.now,
})
const updatedShareResult = await client.query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
[input.shareId],
)
return {
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
share: updatedShareResult.rows[0] || null,
releasedDepositAmount,
failureReason: null,
}
})
}
export async function resolveProblemWorkOrder(input: {
workOrderId: number
action: ProblemWorkOrderResolutionAction
@@ -3230,7 +3388,16 @@ function buildWorkOrderWhere({
}
if (workerId) {
params.push(workerId)
filters.push(`wo.assigned_worker_id = $${params.length}`)
filters.push(`(
wo.assigned_worker_id = $${params.length}
OR EXISTS (
SELECT 1
FROM work_order_shares wos
WHERE wos.work_order_id = wo.id
AND wos.worker_id = $${params.length}
AND wos.status != 'cancelled'
)
)`)
}
if (categoryId) {
params.push(categoryId)
@@ -6,6 +6,7 @@ import {
acceptAdminWorkOrders,
assignAdminWorkOrderToWorker,
cancelAdminWorkOrder,
cancelAdminWorkOrderShare,
unassignAdminWorkOrder,
creditAdminWorkerWallet,
createAdminMockWorkOrder,
@@ -854,6 +855,31 @@ router.post(
),
)
router.post(
'/worker-platform/orders/:workOrderId/shares/:shareId/cancel',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler(
(req) =>
cancelAdminWorkOrderShare(
String(req.params.workOrderId || ''),
String(req.params.shareId || ''),
req.body || {},
req.adminSession?.username || '',
),
{
successMessage: '拼单参与者已撤单,份数已退回大厅',
errorMessage: '撤销拼单参与者失败',
scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/cancel]',
audit: (req, data) => ({
action: 'work_order_share_cancelled',
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/deduct-pending-deposit',
requireAdminRoles(['admin', 'operator']),
@@ -310,11 +310,13 @@ async function publishWorkOrderSnapshots(input: {
const viewedAt =
views.find((item) => Number(item.work_order_id) === workOrderId)?.viewed_at || null
const share = shares.find((item) => Number(item.worker_id) === workerId)
const isAssignedWorker = Number(workOrder.assigned_worker_id || 0) === workerId
const workerOrderVisible = isAssignedWorker || Boolean(share && share.status !== 'cancelled')
publishRealtimeEvent({
type: 'work_order.changed',
entityId: workOrderId,
scopes: ['worker_my_orders'],
operation: 'upsert',
operation: workerOrderVisible ? 'upsert' : 'remove',
data: mapWorkOrderForWorker(
workOrder,
resolveWorkerPermissions(worker),
@@ -6,6 +6,7 @@ import {
addWorkerWalletCredit,
assignWorkOrderToWorker,
cancelAssignedWorkOrder,
cancelWorkOrderShare,
returnAssignedWorkOrderToHall,
unassignWorkOrder,
countWorkerActiveOrders,
@@ -2040,6 +2041,52 @@ export async function acceptAdminWorkOrderShare(
}
}
export async function cancelAdminWorkOrderShare(
workOrderId: number | string,
shareId: number | string,
payload: JsonObject = {},
actorName = '',
) {
const result = await cancelWorkOrderShare({
workOrderId: Number(workOrderId),
shareId: Number(shareId),
now: nowIso(),
reason: String(payload.reason || '').trim(),
actorName,
})
if (result.failureReason === 'work_order_not_cancellable') {
throw createHttpError('当前拼单状态不能撤销参与者', {
statusCode: 409,
errorCode: 'work_order_sharing_cancel_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_cancellable' || !result.share || !result.order) {
throw createHttpError('只有已参与或已提交验收的拼单份额可以撤单', {
statusCode: 409,
errorCode: 'work_order_sharing_share_cancel_status_invalid',
})
}
const workerId = Number(result.share.worker_id || 0)
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrderId),
workerIds: await resolveWorkOrderRealtimeWorkerIds(result.order),
hallChanged: true,
})
if (workerId > 0) publishWorkerWalletRealtimeChange(workerId)
return {
order: mapWorkOrderAdmin(result.order),
share: mapWorkOrderShare(result.share),
releasedDepositAmount: result.releasedDepositAmount,
}
}
export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') {
const workOrderIds = [
...new Set(
@@ -8,7 +8,10 @@ import type {
WorkProductRuleRow,
WorkerUserRow,
} from '../../repositories/worker-platform/index.js'
import { resolveWorkOrderShareJoinQuantity } from '../../repositories/worker-platform/index.js'
import {
resolveWorkOrderShareCancellationStatus,
resolveWorkOrderShareJoinQuantity,
} from '../../repositories/worker-platform/index.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import {
assertWithdrawChannelAllowedForAmount,
@@ -729,6 +732,15 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
assert.equal(filled.capabilities.canUnpublish, false)
})
test('拼单份额撤单只允许未验收份额并让母工单回到大厅状态', () => {
assert.equal(resolveWorkOrderShareCancellationStatus('open', 'joined'), 'open')
assert.equal(resolveWorkOrderShareCancellationStatus('open', 'submitted'), 'open')
assert.equal(resolveWorkOrderShareCancellationStatus('pending_acceptance', 'submitted'), 'open')
assert.equal(resolveWorkOrderShareCancellationStatus('pending_acceptance', 'accepted'), null)
assert.equal(resolveWorkOrderShareCancellationStatus('accepted', 'submitted'), null)
assert.equal(resolveWorkOrderShareCancellationStatus('open', 'cancelled'), null)
})
test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => {
const workOrder = buildWorkOrderRow()
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '')
@@ -182,6 +182,15 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
return { title: '更改了订单信息', color: 'default' }
case 'sharing_config_updated':
return { title: '更新了拼单配置', color: 'default' }
case 'sharing_share_cancelled_by_admin': {
const workerId = readPayload(event, 'workerId')
const quantity = readPayload(event, 'quantity')
return {
title: `撤销拼单参与者${workerId ? `(打手 #${workerId}` : ''}`,
description: `${quantity ? `撤销 ${quantity} 份;` : ''}${note ? `原因:${note}` : '份数已退回接单大厅'}`,
color: 'red',
}
}
case 'pending_deposit_deducted':
return { title: '扣减了待解冻押金', color: 'default' }
case 'voucher_backfilled':
+11 -7
View File
@@ -349,13 +349,17 @@ function applyAdminRealtimeEvent(
})
}
if (
event.type === 'work_order.changed' &&
event.scopes.includes('admin_work_orders') &&
isRecord(event.data)
) {
const data = event.data
patchAdminWorkOrderCaches(queryClient, { ...event, data })
if (event.type === 'work_order.changed' && event.scopes.includes('admin_work_orders')) {
// 拼单明细是独立查询,工单列表快照更新时也要同步刷新当前打开的明细。
void queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-order-sharing', event.entityId],
})
if (isRecord(event.data)) {
const data = event.data
patchAdminWorkOrderCaches(queryClient, { ...event, data })
} else {
void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] })
}
}
if (
@@ -49,6 +49,7 @@ import {
acceptAdminWorkOrders,
assignAdminWorkOrderToWorker,
cancelAdminWorkOrder,
cancelAdminWorkOrderShare,
createAdminMockWorkOrder,
deductAdminWorkOrderPendingDeposit,
deleteAdminWorkOrder,
@@ -120,6 +121,10 @@ type CancelOrderFormValues = {
reason?: string
}
type CancelShareFormValues = {
reason?: string
}
export default function WorkOrdersPanel() {
const { message, modal } = App.useApp()
const navigate = useNavigate()
@@ -142,6 +147,7 @@ export default function WorkOrdersPanel() {
const [materialScreenshots, setMaterialScreenshots] = useState<UploadedFile[]>([])
const [materialPasteText, setMaterialPasteText] = useState('')
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
const [cancelShare, setCancelShare] = useState<WorkOrderShare | null>(null)
const [assignOrder, setAssignOrder] = useState<WorkOrder | null>(null)
const [assignWorkerId, setAssignWorkerId] = useState<number | undefined>(undefined)
const [editOrder, setEditOrder] = useState<WorkOrder | null>(null)
@@ -151,6 +157,7 @@ export default function WorkOrdersPanel() {
const [resolutionForm] = Form.useForm()
const [materialForm] = Form.useForm<MaterialFormValues>()
const [cancelForm] = Form.useForm<CancelOrderFormValues>()
const [cancelShareForm] = Form.useForm<CancelShareFormValues>()
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
const [editForm] = Form.useForm<{
productName?: string
@@ -349,6 +356,24 @@ export default function WorkOrdersPanel() {
sharingForm.resetFields()
}
async function submitCancelShare(values: CancelShareFormValues) {
if (!sharingOrder || !cancelShare) return
const workOrderId = sharingOrder.workOrderId
const succeeded = await runAction(
() =>
cancelAdminWorkOrderShare(workOrderId, cancelShare.shareId, {
reason: String(values.reason || '').trim(),
}),
'该参与者已撤单,押金和拼单份数已恢复',
)
if (!succeeded) return
await queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-order-sharing', workOrderId],
})
setCancelShare(null)
cancelShareForm.resetFields()
}
async function refreshAll() {
await Promise.all([
queryClient.invalidateQueries({
@@ -816,7 +841,9 @@ export default function WorkOrdersPanel() {
) : null}
{row.sharing?.enabled || row.capabilities?.canConfigureSharing ? (
<Button icon={<TeamOutlined />} onClick={() => openSharingConfigModal(row)}>
{row.sharing?.enabled && !row.capabilities?.canConfigureSharing ? '拼单明细' : '拼单配置'}
{row.sharing?.enabled && !row.capabilities?.canConfigureSharing
? '拼单明细'
: '拼单配置'}
</Button>
) : null}
{row.capabilities?.canAssign ? (
@@ -1589,7 +1616,9 @@ export default function WorkOrdersPanel() {
open={Boolean(sharingOrder)}
onCancel={() => {
setSharingOrder(null)
setCancelShare(null)
sharingForm.resetFields()
cancelShareForm.resetFields()
}}
onOk={() => sharingForm.submit()}
footer={sharingOrder?.capabilities?.canConfigureSharing ? undefined : null}
@@ -1679,6 +1708,7 @@ export default function WorkOrdersPanel() {
rowKey="shareId"
size="small"
dataSource={sharingQuery.data?.data.shares || []}
loading={sharingQuery.isFetching}
pagination={false}
columns={[
{
@@ -1709,8 +1739,14 @@ export default function WorkOrdersPanel() {
width: 150,
render: (_, share) => (
<ImagePreviewList
files={Array.isArray(share.acceptance?.files) ? share.acceptance.files : []}
imageUrls={Array.isArray(share.acceptance?.imageUrls) ? share.acceptance.imageUrls : []}
files={
Array.isArray(share.acceptance?.files) ? share.acceptance.files : []
}
imageUrls={
Array.isArray(share.acceptance?.imageUrls)
? share.acceptance.imageUrls
: []
}
size={44}
maxVisible={2}
/>
@@ -1722,35 +1758,55 @@ export default function WorkOrdersPanel() {
},
{
title: '操作',
width: 110,
render: (_, share) =>
share.status === 'submitted' ? (
<Button
type="primary"
size="small"
onClick={() => {
void runAction(
() => acceptAdminWorkOrderShare(sharingOrder.workOrderId, share.shareId),
'该拼单参与者已验收',
).then((succeeded) => {
if (succeeded) {
void queryClient.invalidateQueries({
queryKey: [
'admin-worker-platform-order-sharing',
sharingOrder.workOrderId,
],
width: 190,
render: (_, share) => {
if (share.status === 'accepted') return <Tag color="green"></Tag>
if (share.status === 'cancelled') return <Tag></Tag>
return (
<Space size={6}>
{share.status === 'submitted' ? (
<Button
type="primary"
size="small"
onClick={() => {
void runAction(
() =>
acceptAdminWorkOrderShare(
sharingOrder.workOrderId,
share.shareId,
),
'该拼单参与者已验收',
).then((succeeded) => {
if (succeeded) {
void queryClient.invalidateQueries({
queryKey: [
'admin-worker-platform-order-sharing',
sharingOrder.workOrderId,
],
})
}
})
}
})
}}
>
</Button>
) : share.status === 'accepted' ? (
<Tag color="green"></Tag>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
}}
>
</Button>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
<Button
danger
size="small"
icon={<UndoOutlined />}
onClick={() => {
setCancelShare(share)
cancelShareForm.setFieldsValue({ reason: '' })
}}
>
</Button>
</Space>
)
},
},
]}
/>
@@ -1759,6 +1815,37 @@ export default function WorkOrdersPanel() {
</Space>
) : null}
</Modal>
<Modal
title={`撤销拼单参与者 · ${
cancelShare?.worker?.displayName || cancelShare?.worker?.username || ''
}`}
open={Boolean(cancelShare)}
okText="确认撤单"
cancelText="取消"
okButtonProps={{ danger: true }}
onCancel={() => {
setCancelShare(null)
cancelShareForm.resetFields()
}}
onOk={() => cancelShareForm.submit()}
destroyOnHidden
>
{cancelShare ? (
<Form form={cancelShareForm} layout="vertical" onFinish={submitCancelShare}>
<Alert
showIcon
type="warning"
message={`将撤销该打手承接的 ${cancelShare.quantity} 份拼单`}
description="撤单后会退还该份额冻结的押金,空出的份数重新回到接单大厅;已提交的验收资料会保留在后台记录中。"
style={{ marginBottom: 16 }}
/>
<Form.Item label="撤单原因" name="reason">
<Input.TextArea rows={3} placeholder="选填,用于后台流转记录" />
</Form.Item>
</Form>
) : null}
</Modal>
</section>
)
}
@@ -470,6 +470,18 @@ export function acceptAdminWorkOrderShare(workOrderId: number, shareId: number)
)
}
export function cancelAdminWorkOrderShare(
workOrderId: number,
shareId: number,
payload: { reason?: string },
) {
return apiPost<{
order: WorkOrder
share: WorkOrderShare
releasedDepositAmount: number
}>(`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/cancel`, payload)
}
export function acceptAdminWorkOrders(workOrderIds: number[]) {
return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', {
workOrderIds,