From c1dd2ee632da658415ca3576507687e3930c6750 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 14:14:23 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(work-order):=20=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BF=AE=E6=94=B9=E7=94=A8=E6=88=B7=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E9=87=91=E9=A2=9D=E5=B9=B6=E7=95=99=E7=97=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../work-order-management-repo.ts | 45 ++++++ .../routes/admin/worker-platform/orders.ts | 34 +++++ .../admin-work-order-management-service.ts | 103 +++++++++++++- .../work-order-payment-amount.test.ts | 89 ++++++++++++ .../work-order-payment-amount.ts | 80 +++++++++++ .../worker-my-order-service.ts | 5 +- .../src/components/WorkOrderEventTimeline.tsx | 17 +++ .../admin/panels/WorkOrderTableCells.tsx | 129 +++++++++++++++++- .../pages/admin/panels/WorkOrdersPanel.tsx | 9 +- .../admin/panels/work-order-panel-utils.tsx | 8 -- .../pages/admin/panels/work-order-payment.ts | 19 ++- .../src/services/admin/worker-platform.ts | 18 +++ apps/frontend/src/utils/admin-display.ts | 1 + apps/frontend/src/utils/admin-options.ts | 1 + .../tests/work-order-payment-loss.test.ts | 29 +++- 15 files changed, 568 insertions(+), 19 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/work-order-payment-amount.test.ts create mode 100644 apps/backend/src/services/worker-platform/work-order-payment-amount.ts diff --git a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts index d3fa60d1..3a6a0083 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts @@ -305,6 +305,51 @@ export async function updateWorkOrderBasic( return getWorkOrderById(workOrderId) } +/** + * 后台修正「用户付款金额」:同时更新 083 派生列与 material_json 来源快照。 + * + * WHERE 里再守一次状态与接单人,避免读完到写入之间被打手接走; + * 待抢单(open)还要求没有有效拼单份额,与 capabilities.canEdit 口径一致。 + */ +export async function updateWorkOrderPaymentAmount(input: { + workOrderId: number | string + amountFen: number + materialJson: unknown + allowedStatuses: string[] + now: string +}): Promise { + const result = await query<{ id: number }>( + ` + UPDATE work_orders + SET source_order_amount_fen = $1, + material_json = $2::jsonb, + updated_at = $3 + WHERE id = $4 + AND assigned_worker_id IS NULL + AND status = ANY($5::text[]) + AND ( + status <> 'open' + OR NOT EXISTS ( + SELECT 1 + FROM work_order_shares wos + WHERE wos.work_order_id = work_orders.id + AND wos.status <> 'cancelled' + ) + ) + RETURNING id + `, + [ + input.amountFen, + toJsonString(input.materialJson), + input.now, + Number(input.workOrderId), + input.allowedStatuses, + ], + ) + if (!result.rows[0]) return null + return getWorkOrderById(result.rows[0].id) +} + export async function updateWorkOrderAdminNote( workOrderId: number | string, note: string, diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index e21036c0..1fc67840 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -28,6 +28,7 @@ import { unpublishAdminWorkOrder, updateAdminWorkOrder, updateAdminWorkOrderNote, + updateAdminWorkOrderPaymentAmount, updateAdminWorkOrderSharing, } from '../../../services/worker-platform/index.js' import { @@ -237,6 +238,39 @@ router.put( ), ) +router.put( + '/worker-platform/orders/:workOrderId/payment-amount', + requireAdminPermission('worker_order.edit'), + createJsonHandler( + (req) => + updateAdminWorkOrderPaymentAmount(String(req.params.workOrderId || ''), req.body || {}, { + actorName: req.adminSession?.displayName || req.adminSession?.username || '', + }), + { + successMessage: '用户付款金额已更新', + errorMessage: '修改用户付款金额失败', + scope: '[admin/worker-platform/orders/:workOrderId/payment-amount]', + audit: (req, data) => { + const result = (data || {}) as { + previousAmountFen?: number | null + amountFen?: number + reason?: string + } + return { + action: 'work_order_payment_amount_updated', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: { + previousAmountFen: result.previousAmountFen ?? null, + amountFen: result.amountFen ?? null, + reason: result.reason || '', + }, + } + }, + }, + ), +) + router.put( '/worker-platform/orders/:workOrderId/note', requireAdminPermission('worker_order.edit'), diff --git a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts index ed516f58..aeb64468 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts @@ -10,8 +10,9 @@ import { reopenCancelledWorkOrder, resolveProblemWorkOrder, updateWorkOrder, - updateWorkOrderBasic, updateWorkOrderAdminNote, + updateWorkOrderBasic, + updateWorkOrderPaymentAmount, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' @@ -34,6 +35,12 @@ import { } from './mappers.js' import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js' import { getWorkerHallConfig } from './worker-hall-config-service.js' +import { + USER_PAYMENT_AMOUNT_REASON_MAX_LENGTH, + mergeUserPaymentAmountIntoMaterial, + normalizeUserPaymentAmountFen, + resolveDisplayedUserPaymentAmountFen, +} from './work-order-payment-amount.js' import { invalidateWorkOrderShopOptionsCache } from './work-order-shop-options-cache.js' import { getRequiredWorkOrder } from './worker-session-context-service.js' import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js' @@ -120,6 +127,100 @@ export async function updateAdminWorkOrder( return { order: mapWorkOrderAdmin(updated || workOrder) } } +/** + * 后台修正「用户付款金额」。 + * + * 只允许和普通编辑相同的状态(待完善/未分配/待抢单且未指派), + * 金额按元提交、按分落库;修改会写入 work_order_events + 后台审计。 + * 同步 material_json.source 快照,避免列表展示与 083 派生列分叉。 + */ +export async function updateAdminWorkOrderPaymentAmount( + workOrderId: number | string, + payload: JsonObject = {}, + options: { actorName?: string } = {}, +) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (!mapWorkOrderAdmin(workOrder).capabilities.canEdit) { + throw createHttpError('只有待完善、未分配或待抢单的工单可以编辑', { + statusCode: 409, + errorCode: 'work_order_edit_status_invalid', + }) + } + if (workOrder.assigned_worker_id) { + throw createHttpError('工单已被打手接走,不能编辑', { + statusCode: 409, + errorCode: 'work_order_edit_assigned_invalid', + }) + } + + const amountFen = normalizeUserPaymentAmountFen(payload.paymentAmount) + if (amountFen === null) { + throw createHttpError('用户付款金额需在 0.01 元到 1000000 元之间', { + statusCode: 400, + errorCode: 'work_order_payment_amount_invalid', + }) + } + const reason = String(payload.reason ?? '').trim() + if (reason.length > USER_PAYMENT_AMOUNT_REASON_MAX_LENGTH) { + throw createHttpError(`修改原因不能超过 ${USER_PAYMENT_AMOUNT_REASON_MAX_LENGTH} 个字符`, { + statusCode: 400, + errorCode: 'work_order_payment_amount_reason_too_long', + }) + } + + const previousAmountFen = resolveDisplayedUserPaymentAmountFen(workOrder) + if (previousAmountFen === amountFen) { + return { + order: mapWorkOrderAdmin(workOrder), + previousAmountFen, + amountFen, + reason, + unchanged: true, + } + } + + const now = nowIso() + const updated = await updateWorkOrderPaymentAmount({ + workOrderId: workOrder.id, + amountFen, + materialJson: mergeUserPaymentAmountIntoMaterial(workOrder.material_json, amountFen), + allowedStatuses: [ + WORK_ORDER_STATUS.PENDING_MATERIAL, + WORK_ORDER_STATUS.UNASSIGNED, + WORK_ORDER_STATUS.OPEN, + ], + now, + }) + if (!updated) { + throw createHttpError('订单状态已变化,无法修改用户付款金额,请刷新后重试', { + statusCode: 409, + errorCode: 'work_order_payment_amount_conflict', + }) + } + + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: options.actorName || '', + eventType: 'payment_amount_updated', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ previousAmountFen, amountFen, reason }), + now, + }) + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrder.id), + hallChanged: workOrder.status === WORK_ORDER_STATUS.OPEN, + }) + return { + order: mapWorkOrderAdmin(updated), + previousAmountFen, + amountFen, + reason, + unchanged: false, + } +} + export async function updateAdminWorkOrderNote( workOrderId: number | string, note: unknown, diff --git a/apps/backend/src/services/worker-platform/work-order-payment-amount.test.ts b/apps/backend/src/services/worker-platform/work-order-payment-amount.test.ts new file mode 100644 index 00000000..00495eea --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-payment-amount.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + mergeUserPaymentAmountIntoMaterial, + normalizeUserPaymentAmountFen, + resolveDisplayedUserPaymentAmountFen, +} from './work-order-payment-amount.js' + +test('normalizeUserPaymentAmountFen:元转分并保留两位小数', () => { + assert.equal(normalizeUserPaymentAmountFen(0.01), 1) + assert.equal(normalizeUserPaymentAmountFen(12.34), 1234) + assert.equal(normalizeUserPaymentAmountFen('88.8'), 8880) + assert.equal(normalizeUserPaymentAmountFen(1_000_000), 100_000_000) +}) + +test('normalizeUserPaymentAmountFen:0、负数、超上限与非数字都判非法', () => { + for (const value of [0, -0.01, 1_000_000.01, '', 'abc', null, undefined, NaN, Infinity]) { + assert.equal(normalizeUserPaymentAmountFen(value), null, `${String(value)} 应判非法`) + } +}) + +test('resolveDisplayedUserPaymentAmountFen:优先送码实付,其次来源金额,最后派生列', () => { + assert.equal( + resolveDisplayedUserPaymentAmountFen({ + material_json: { + source: { orderAmountFen: 1000, kuaishouSendCode: { paymentFen: 900 } }, + }, + source_order_amount_fen: 800, + }), + 900, + ) + assert.equal( + resolveDisplayedUserPaymentAmountFen({ + material_json: { source: { orderAmountFen: 1000 } }, + source_order_amount_fen: 800, + }), + 1000, + ) + assert.equal( + resolveDisplayedUserPaymentAmountFen({ + material_json: { source: {} }, + source_order_amount_fen: 800, + }), + 800, + ) + assert.equal(resolveDisplayedUserPaymentAmountFen({ material_json: '{}' }), null) +}) + +test('mergeUserPaymentAmountIntoMaterial:同步两处金额并保留其它资料字段', () => { + const merged = mergeUserPaymentAmountIntoMaterial( + { + source: { + orderId: 1, + orderAmountFen: 1000, + kuaishouSendCode: { paymentFen: 1000, code: 'ABC' }, + }, + collect: { fields: { gameId: '123' } }, + }, + 1234, + ) + + assert.deepEqual(merged, { + source: { + orderId: 1, + orderAmountFen: 1234, + kuaishouSendCode: { paymentFen: 1234, code: 'ABC' }, + }, + collect: { fields: { gameId: '123' } }, + }) +}) + +test('mergeUserPaymentAmountIntoMaterial:没有送码字段时不凭空创建', () => { + const merged = mergeUserPaymentAmountIntoMaterial({ source: { orderAmountFen: 1000 } }, 1234) + assert.deepEqual(merged, { source: { orderAmountFen: 1234 } }) +}) + +test('mergeUserPaymentAmountIntoMaterial:兼容 JSON 字符串与异常结构', () => { + assert.deepEqual( + mergeUserPaymentAmountIntoMaterial( + JSON.stringify({ source: { orderAmountFen: '1000' } }), + 1234, + ), + { source: { orderAmountFen: 1234 } }, + ) + assert.deepEqual(mergeUserPaymentAmountIntoMaterial('not-json', 1234), { + source: { orderAmountFen: 1234 }, + }) +}) diff --git a/apps/backend/src/services/worker-platform/work-order-payment-amount.ts b/apps/backend/src/services/worker-platform/work-order-payment-amount.ts new file mode 100644 index 00000000..f8d7dbc6 --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-payment-amount.ts @@ -0,0 +1,80 @@ +import type { JsonObject } from '../../types/json.js' +import { safeParseJson } from '../admin/admin-query-utils.js' + +/** 用户付款金额允许范围:0.01 元 ~ 100 万元(与后台其它金额编辑口径一致,统一存分)。 */ +export const USER_PAYMENT_AMOUNT_MIN_FEN = 1 +export const USER_PAYMENT_AMOUNT_MAX_FEN = 100_000_000 +export const USER_PAYMENT_AMOUNT_REASON_MAX_LENGTH = 200 + +type PaymentAmountWorkOrder = { + material_json?: unknown + source_order_amount_fen?: unknown +} + +function asObject(value: unknown): JsonObject { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} +} + +/** + * 后台表单以「元」提交,落库统一为分。 + * 非法或超出范围返回 null,由调用方返回 400。 + */ +export function normalizeUserPaymentAmountFen(value: unknown): number | null { + const text = String(value ?? '').trim() + if (!text) return null + const yuan = Number(text) + if (!Number.isFinite(yuan)) return null + const fen = Math.round(yuan * 100) + if (fen < USER_PAYMENT_AMOUNT_MIN_FEN || fen > USER_PAYMENT_AMOUNT_MAX_FEN) return null + return fen +} + +/** + * 读取工单当前展示的「用户付款金额」(分)。 + * + * 与前端列表口径一致:优先 material.source.kuaishouSendCode.paymentFen, + * 其次 material.source.orderAmountFen,最后回退 083 派生列。 + * 都没有时返回 null(列表显示 `-`)。 + */ +export function resolveDisplayedUserPaymentAmountFen( + workOrder: PaymentAmountWorkOrder, +): number | null { + const material = asObject(safeParseJson(workOrder.material_json)) + const source = asObject(material.source) + const sendCode = asObject(source.kuaishouSendCode) + for (const raw of [sendCode.paymentFen, source.orderAmountFen]) { + const text = String(raw ?? '').trim() + if (!text) continue + const parsed = Number(text) + if (Number.isFinite(parsed)) return parsed + } + + if ( + workOrder.source_order_amount_fen !== null && + workOrder.source_order_amount_fen !== undefined + ) { + const columnAmount = Number(workOrder.source_order_amount_fen) + if (Number.isFinite(columnAmount)) return columnAmount + } + return null +} + +/** + * 把后台修正后的付款金额写回 material_json,保持列表、详情与后续资料更新同源。 + * + * 只改 source.orderAmountFen;如果原来就有送码实付字段,也同步 paymentFen。 + * 其它来源字段原样保留。 + */ +export function mergeUserPaymentAmountIntoMaterial( + materialJson: unknown, + amountFen: number, +): JsonObject { + const material = asObject(safeParseJson(materialJson)) + const source = asObject(material.source) + const sendCode = asObject(source.kuaishouSendCode) + const nextSource: JsonObject = { ...source, orderAmountFen: amountFen } + if (Object.keys(sendCode).length > 0) { + nextSource.kuaishouSendCode = { ...sendCode, paymentFen: amountFen } + } + return { ...material, source: nextSource } +} diff --git a/apps/backend/src/services/worker-platform/worker-my-order-service.ts b/apps/backend/src/services/worker-platform/worker-my-order-service.ts index 11802692..d21b5dbe 100644 --- a/apps/backend/src/services/worker-platform/worker-my-order-service.ts +++ b/apps/backend/src/services/worker-platform/worker-my-order-service.ts @@ -308,7 +308,10 @@ export async function getWorkerMyOrderEvents(workOrderId: number | string, sessi }) } } - const events = await listWorkOrderEventsByOrderId(workOrderId) + // 用户付款金额属于后台财务口径,打手端时间线不展示该事件。 + const events = (await listWorkOrderEventsByOrderId(workOrderId)).filter( + (event) => event.event_type !== 'payment_amount_updated', + ) return { items: await mapWorkOrderEvents(events) } } diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index a06ad096..ff9fcf0d 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -304,6 +304,23 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { } // —— 其他 —— + case 'payment_amount_updated': { + const previousAmountFen = readPayloadAmount(event, 'previousAmountFen') + const amountFen = readPayloadAmount(event, 'amountFen') + const reason = readPayload(event, 'reason') + return { + title: '修改了用户付款金额', + description: [ + previousAmountFen > 0 + ? `${formatFenAmount(previousAmountFen)} → ${formatFenAmount(amountFen)}` + : `改为 ${formatFenAmount(amountFen)}`, + reason ? `原因:${reason}` : '', + ] + .filter(Boolean) + .join(';'), + color: 'gold', + } + } case 'edited': { const productName = readPayload(event, 'productName') const rewardAmount = readPayloadAmount(event, 'rewardAmount') diff --git a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx index 3aa5bd6c..4faa85a1 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx @@ -1,11 +1,12 @@ -import { App, Input, InputNumber, Space, Tag, Typography } from 'antd' +import { EditOutlined } from '@ant-design/icons' +import { App, Input, InputNumber, Modal, Space, Tag, Typography } from 'antd' import { useRef, useState } from 'react' -import { updateAdminWorkOrder } from '@/services/admin' +import { updateAdminWorkOrder, updateAdminWorkOrderPaymentAmount } from '@/services/admin' import type { WorkOrder } from '@/types/worker-platform' import { formatMoney } from './shared' -import { getRewardAbovePayment } from './work-order-panel-utils' +import { getRewardAbovePayment, getUserPaymentFen } from './work-order-panel-utils' /** 商品名:单击看详情,双击原地修改(仅未接走工单) */ export function ProductNameEditableCell({ @@ -91,6 +92,128 @@ export function ProductNameEditableCell({ ) } +/** 用户付款金额:双击弹窗修改,保留修改原因并写入工单流转记录 */ +export function UserPaymentAmountEditableCell({ + order, + onSaved, +}: { + order: WorkOrder + onSaved: () => Promise | void +}) { + const { message } = App.useApp() + const editable = order.capabilities?.canEdit === true + const currentFen = getUserPaymentFen(order) + const [open, setOpen] = useState(false) + const [draft, setDraft] = useState(null) + const [reason, setReason] = useState('') + const [saving, setSaving] = useState(false) + const savingRef = useRef(false) + + function openEditor() { + if (!editable || savingRef.current) return + setDraft(currentFen === null ? null : Number((currentFen / 100).toFixed(2))) + setReason('') + setOpen(true) + } + + async function save() { + if (savingRef.current) return + if (draft === null || !Number.isFinite(Number(draft))) { + message.error('请输入用户付款金额') + return + } + const nextYuan = Number(draft) + if (nextYuan < 0.01 || nextYuan > 1000000) { + message.error('用户付款金额需在 0.01 元到 1000000 元之间') + return + } + if (currentFen !== null && Math.abs(nextYuan * 100 - currentFen) < 0.5) { + message.info('用户付款金额未变化') + setOpen(false) + return + } + + savingRef.current = true + setSaving(true) + try { + await updateAdminWorkOrderPaymentAmount(order.workOrderId, { + paymentAmount: nextYuan, + reason: reason.trim(), + }) + message.success('用户付款金额已更新') + setOpen(false) + await onSaved() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败') + } finally { + savingRef.current = false + setSaving(false) + } + } + + return ( + <> +
+ + + {currentFen === null ? '-' : formatMoney(currentFen)} + + {editable ? : null} + +
+ + { + if (savingRef.current) return + setOpen(false) + }} + onOk={() => void save()} + > +
+ + 当前金额:{currentFen === null ? '-' : formatMoney(currentFen)} + + void save()} + /> + setReason(event.target.value)} + /> + + 修改会记录到工单流转记录与后台审计日志;财务统计按下一次定时同步更新。 + +
+
+ + ) +} + export function AmountEditableCell({ order, field, diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 0b9e04d2..ebc41860 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -130,11 +130,14 @@ import { resolveSharingShareStatusColor, resolveStatusColor, } from './shared' -import { AmountEditableCell, ProductNameEditableCell } from './WorkOrderTableCells' +import { + AmountEditableCell, + ProductNameEditableCell, + UserPaymentAmountEditableCell, +} from './WorkOrderTableCells' import { MockWorkOrderForm, WorkOrderStatisticsBar } from './WorkOrderPanelParts' import { formatRequirementFieldsText, - formatUserPaymentAmount, getDisplayOrderNo, getRewardAbovePayment, parseWorkerMaterialText, @@ -1724,7 +1727,7 @@ export default function WorkOrdersPanel() { { title: '用户付款金额', width: 130, - render: (_, row) => formatUserPaymentAmount(row), + render: (_, row) => , }, { title: '发单金额', diff --git a/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx b/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx index 60cdc40f..a582b2ad 100644 --- a/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx +++ b/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx @@ -3,9 +3,6 @@ import { Space, Tag, Typography } from 'antd' import type { CollectField, WorkOrder } from '@/types/worker-platform' import { resolveConsignmentWorkerDisplay } from '@/utils/consignment-display' -import { formatMoney } from './shared' -import { getUserPaymentFen } from './work-order-payment' - export { getRewardAbovePayment, getUserPaymentFen } from './work-order-payment' const SKIP_PUBLISH_CONFIRM_KEY = 'admin-work-order-skip-publish-confirm' @@ -26,11 +23,6 @@ export function setSkipPublishConfirm() { } } -export function formatUserPaymentAmount(order: WorkOrder): string { - const paymentFen = getUserPaymentFen(order) - return paymentFen === null ? '-' : formatMoney(paymentFen) -} - /** 渲染工单关联打手:普通打手 / 拼单参与打手 / 寄卖供货打手与现货资料 */ export function renderWorkOrderWorkers(order: WorkOrder) { if (order.worker) return order.worker.displayName || order.worker.username || '-' diff --git a/apps/frontend/src/pages/admin/panels/work-order-payment.ts b/apps/frontend/src/pages/admin/panels/work-order-payment.ts index 04bcb46b..91c5e280 100644 --- a/apps/frontend/src/pages/admin/panels/work-order-payment.ts +++ b/apps/frontend/src/pages/admin/panels/work-order-payment.ts @@ -1,6 +1,8 @@ type PaymentSourceOrder = { material?: unknown rewardAmount?: number + /** 后台列表接口返回的 083 派生列(分);来源 JSON 缺失时兜底 */ + sourceOrderAmount?: number } function asRecord(value: unknown): Record { @@ -9,13 +11,26 @@ function asRecord(value: unknown): Record { : {} } +function parsePaymentFen(value: unknown): number | null { + if (value === undefined || value === null) return null + const text = String(value).trim() + if (!text) return null + const parsed = Number(text) + return Number.isFinite(parsed) ? parsed : null +} + /** 用户付款金额(分);未知返回 null(不参与超价提示) */ export function getUserPaymentFen(order: PaymentSourceOrder): number | null { const material = asRecord(order.material) const source = asRecord(material.source) const sendCode = asRecord(source.kuaishouSendCode) - const paymentFen = Number(sendCode.paymentFen ?? source.orderAmountFen) - return Number.isFinite(paymentFen) ? paymentFen : null + const fromMaterial = + parsePaymentFen(sendCode.paymentFen) ?? parsePaymentFen(source.orderAmountFen) + if (fromMaterial !== null) return fromMaterial + + // 历史单可能只有派生列;0 同时代表「NULL 回退值」,按未知处理。 + const fromColumn = parsePaymentFen(order.sourceOrderAmount) + return fromColumn !== null && fromColumn > 0 ? fromColumn : null } /** 发单金额高于用户付款时返回两边金额,否则 null(付款未知时不提示) */ diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 0c3bbee6..df50a26c 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -806,6 +806,24 @@ export function updateAdminWorkOrder( ) } +export function updateAdminWorkOrderPaymentAmount( + workOrderId: number, + payload: { + /** 用户付款金额(元) */ + paymentAmount: number + /** 修改原因(选填,≤200 字) */ + reason?: string + }, +) { + return apiPut<{ + order: WorkOrder + previousAmountFen: number | null + amountFen: number + reason: string + unchanged: boolean + }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/payment-amount`, payload) +} + export function updateAdminWorkOrderNote(workOrderId: number, note: string) { return apiPut<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/note`, { note, diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index acd913b8..d762d61b 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -37,6 +37,7 @@ export function formatAuditAction(action: string) { work_order_published: '工单发布', work_order_unpublished: '工单下架', work_order_updated: '工单信息更新', + work_order_payment_amount_updated: '用户付款金额修改', work_order_deleted: '工单删除', work_order_accepted: '工单验收', work_orders_batch_accepted: '批量验收', diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index fd6f2013..1cca9dbc 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -87,6 +87,7 @@ export const adminAuditActionOptions = [ { label: '工单发布', value: 'work_order_published' }, { label: '工单下架', value: 'work_order_unpublished' }, { label: '工单信息更新', value: 'work_order_updated' }, + { label: '用户付款金额修改', value: 'work_order_payment_amount_updated' }, { label: '工单删除', value: 'work_order_deleted' }, { label: '工单验收', value: 'work_order_accepted' }, { label: '批量验收', value: 'work_orders_batch_accepted' }, diff --git a/apps/frontend/tests/work-order-payment-loss.test.ts b/apps/frontend/tests/work-order-payment-loss.test.ts index 326281e9..4445c182 100644 --- a/apps/frontend/tests/work-order-payment-loss.test.ts +++ b/apps/frontend/tests/work-order-payment-loss.test.ts @@ -1,7 +1,10 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { getRewardAbovePayment } from '../src/pages/admin/panels/work-order-payment.ts' +import { + getRewardAbovePayment, + getUserPaymentFen, +} from '../src/pages/admin/panels/work-order-payment.ts' function orderWithPayment(paymentFen: number, rewardFen: number) { return { @@ -35,3 +38,27 @@ test('可传入即将保存的发单金额做预检', () => { assert.equal(getRewardAbovePayment(order), null) assert.ok(getRewardAbovePayment(order, 1000)) }) + +test('getUserPaymentFen:送码实付优先,其次来源金额,最后派生列', () => { + assert.equal( + getUserPaymentFen({ + material: { source: { orderAmountFen: 1000, kuaishouSendCode: { paymentFen: 900 } } }, + sourceOrderAmount: 800, + }), + 900, + ) + assert.equal( + getUserPaymentFen({ + material: { source: { orderAmountFen: 1000 } }, + sourceOrderAmount: 800, + }), + 1000, + ) + assert.equal(getUserPaymentFen({ material: {}, sourceOrderAmount: 800 }), 800) +}) + +test('getUserPaymentFen:空值或非法值不误判成 0 元', () => { + assert.equal(getUserPaymentFen({ material: { source: { orderAmountFen: '' } } }), null) + assert.equal(getUserPaymentFen({ material: { source: { orderAmountFen: 'abc' } } }), null) + assert.equal(getUserPaymentFen({ material: {}, sourceOrderAmount: 0 }), null) +}) From 4fd533c678a6b74119f4ecfa37153f377cf2dd99 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 15:12:11 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(worker):=20=E6=8B=BC=E5=8D=95=E5=8D=95?= =?UTF-8?q?=E4=BA=BA=E6=92=A4=E5=8D=95=E5=90=8E=E7=A9=BA=E5=87=BA=E4=BB=BD?= =?UTF-8?q?=E9=A2=9D=E5=9B=9E=E5=A4=A7=E5=8E=85=E5=B9=B6=E9=87=8D=E7=AE=97?= =?UTF-8?q?=E6=AF=8D=E5=8D=95=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker-platform/work-order-issue-repo.ts | 2 +- .../work-order-settlement-repo.ts | 28 +++++++++++------ .../worker-platform/work-order-share-repo.ts | 23 +++++++++++--- .../admin/worker-platform/acceptance.ts | 2 +- .../admin-work-order-acceptance-service.ts | 3 +- .../worker-platform-service.test.ts | 31 +++++++++++++++---- .../src/components/WorkOrderEventTimeline.tsx | 19 ++++++++++-- .../pages/admin/panels/WorkOrdersPanel.tsx | 6 ++-- 8 files changed, 85 insertions(+), 29 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index 27919778..81e87951 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -539,7 +539,7 @@ export async function disposeWorkOrderShareProblem(input: { ) } - // 母单状态保持不变:与「后台单人撤单」同口径,只处置这一份。 + // 份额级问题单只处置这一份,不重算母单状态:被标记的份额不走「退回大厅」的份额释放口径。 await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index b9900a26..84ae7f23 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -821,9 +821,9 @@ export async function acceptWorkOrderShareAndSettle(input: { /** * 后台撤销单个拼单份额:退还该打手押金、释放这一份的占用。 * - * **不改母工单状态**:后台撤单常用于剔除错误参与者/纠错,撤掉一份不应把 - * 待验收(或进行中)的母单踢回大厅;需要改母单状态时走「拼单撤单」的 - * 退回大厅/取消整单显式操作。 + * 份额取消后空出的份数必须回到接单大厅,所以这里用统一口径重算母单状态: + * 还有其它有效份额 → open(后台按份额推导为代练中);一份不剩 → open(待抢单)。 + * 重算时保留原 published_at / hall_queued_at,空出的份额沿用原大厅队列位置。 */ export async function cancelWorkOrderShare(input: { workOrderId: number @@ -936,22 +936,27 @@ export async function cancelWorkOrderShare(input: { WHERE id = $3`, [input.now, String(input.reason || '').trim(), input.shareId], ) - // 母单状态保持不变:这里只取消这一份份额,不再按剩余份额重推母单状态。 - // (旧实现会 recompute:待验收的母单撤掉一份后 allocated < total → 变回 open, - // 等于整单被踢回抢单大厅,与「后台撤单只影响该份额」的预期不符。) + // 空出的份数回大厅:母单状态必须跟着剩余份额重算,不能停留在待验收。 + // 保留原队列位置,避免撤掉一份就把整单排到大厅队尾。 + await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now, { + preserveHallQueue: true, + }) + const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', actorId: input.actorName || '', eventType: 'sharing_share_cancelled_by_admin', fromStatus: workOrder.status, - toStatus: workOrder.status, + toStatus: updatedOrder?.status || workOrder.status, payloadJson: JSON.stringify({ shareId: input.shareId, workerId, quantity: Number(share.quantity || 0), releasedDepositAmount, reason: String(input.reason || '').trim(), + orderStatusAfter: updatedOrder?.status || workOrder.status, + activeShareQuantity: Number(updatedOrder?.active_share_quantity || 0), }), now: input.now, }) @@ -961,7 +966,7 @@ export async function cancelWorkOrderShare(input: { [input.shareId], ) return { - order: await getWorkOrderByIdWithClient(client, input.workOrderId), + order: updatedOrder || workOrder, share: updatedShareResult.rows[0] || null, releasedDepositAmount, failureReason: null, @@ -1127,8 +1132,11 @@ export async function settleOverdueWorkOrderShare(input: { [input.now, input.shareId], ) - // 母单状态只由份额集合推导:这一份超时不应把其他已提交份额的母单踢回大厅 - await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now) + // 母单状态只由份额集合推导:这一份超时不应把其他已提交份额的母单踢回大厅; + // 空出的份数回大厅时保留原队列位置,避免超时一次就重排到队尾。 + await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now, { + preserveHallQueue: true, + }) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, diff --git a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts index 69f84af3..8db212d1 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts @@ -497,13 +497,19 @@ export async function reduceWorkOrderShareQuantity(input: { ], ) + // 减少的份数会回到大厅,母单状态跟着剩余份额重算;保留原队列位置。 + await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now, { + preserveHallQueue: true, + }) + const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId) + await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', actorId: input.actorName || '', eventType: 'sharing_share_quantity_reduced', fromStatus: workOrder.status, - toStatus: workOrder.status, + toStatus: updatedOrder?.status || workOrder.status, payloadJson: JSON.stringify({ shareId: input.shareId, workerId: Number(share.worker_id || 0), @@ -515,6 +521,8 @@ export async function reduceWorkOrderShareQuantity(input: { // 押金口径:本次保持原冻结额,不退不补 shareDeposit: Number(share.share_deposit || 0), note: String(input.note || '').trim(), + orderStatusAfter: updatedOrder?.status || workOrder.status, + activeShareQuantity: Number(updatedOrder?.active_share_quantity || 0), }), now: input.now, }) @@ -524,7 +532,6 @@ export async function reduceWorkOrderShareQuantity(input: { [input.shareId], ) // 必须回读聚合口径的母单(active_share_quantity 等),否则前端拿到的拼单进度会归零 - const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId) return { order: updatedOrder || workOrder, share: updatedShare.rows[0] || null, @@ -625,11 +632,15 @@ export async function submitWorkOrderShareAcceptance(input: { * 母单 accepted / cancelled → 不动 * * 返回更新后的母单行(供调用方回给前端)。 + * + * preserveHallQueue:份额退回大厅时保留原 published_at / hall_queued_at, + * 避免因为母单状态从待验收回退到 open 而被重新排到大厅队尾。 */ export async function recomputeWorkOrderSharingStatusWithClient( client: PoolClient, workOrderId: number, now: string, + options: { preserveHallQueue?: boolean } = {}, ): Promise { const orderResult = await client.query( `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, @@ -676,13 +687,17 @@ export async function recomputeWorkOrderSharingStatusWithClient( return workOrder } + const hallQueueAssignments = options.preserveHallQueue + ? '' + : `published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END, + hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END,` + const updated = await client.query( ` UPDATE work_orders SET status = $1, - published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END, - hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END, + ${hallQueueAssignments} submitted_at = CASE WHEN $1 = 'pending_acceptance' THEN COALESCE(submitted_at, $2) ELSE submitted_at diff --git a/apps/backend/src/routes/admin/worker-platform/acceptance.ts b/apps/backend/src/routes/admin/worker-platform/acceptance.ts index fe5c1121..ecdceba5 100644 --- a/apps/backend/src/routes/admin/worker-platform/acceptance.ts +++ b/apps/backend/src/routes/admin/worker-platform/acceptance.ts @@ -177,7 +177,7 @@ router.post( req.adminSession?.displayName || req.adminSession?.username || '', ), { - successMessage: '拼单参与者已撤单,押金已退还,母单状态保持不变', + successMessage: '拼单参与者已撤单,押金已退还,空出的份数已回到接单大厅', errorMessage: '撤销拼单参与者失败', scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/cancel]', audit: (req, data) => ({ diff --git a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts index a381af92..59547dbd 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts @@ -196,7 +196,8 @@ export async function cancelAdminWorkOrderShare( * 后台按份数撤销某个打手的部分份额。 * * 减少的份数会随「大厅余量 = 总份数 − Σ有效份额数量」自动回到大厅;押金保持原冻结额, - * 母单状态与验收资料都不动(之后仍可对该份额「单独验收」)。 + * 母单状态按剩余份额重算(可能从待验收回到代练中,保留原大厅队列位置), + * 该份额的验收资料不动(之后仍可对该份额「单独验收」)。 */ export async function reduceAdminWorkOrderShareQuantity( workOrderId: number | string, diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 8943edaf..190b875f 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -3510,13 +3510,21 @@ type ShareAggregate = { async function runRecompute( workOrder: Partial, aggregate: ShareAggregate, -): Promise<{ status: string | null; updates: unknown[][]; shareQueries: number }> { + options: { preserveHallQueue?: boolean } = {}, +): Promise<{ + status: string | null + updates: unknown[][] + shareQueries: number + updateSqls: string[] +}> { const updates: unknown[][] = [] + const updateSqls: string[] = [] let shareQueries = 0 const client = { query: async (sql: string, params: unknown[] = []) => { if (sql.includes('UPDATE work_orders')) { updates.push(params) + updateSqls.push(sql) return { rows: [{ ...buildWorkOrderRow(workOrder), status: params[0] }] } } if (sql.includes('FROM work_orders')) { @@ -3534,8 +3542,9 @@ async function runRecompute( client, 1, '2026-10-23T12:00:00.000Z', + options, ) - return { status: updated ? String(updated.status) : null, updates, shareQueries } + return { status: updated ? String(updated.status) : null, updates, shareQueries, updateSqls } } test('拼单母单状态由份额集合唯一推导:满额且无待履约份额才转待验收', async () => { @@ -3574,7 +3583,7 @@ test('拼单母单已验收或已取消时 recompute 不回头改状态', async } }) -test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收敛', async () => { +test('后台单人撤单重算母单状态,空出份额回大厅并保留原队列位置', async () => { const src = fs.readFileSync( new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url), 'utf8', @@ -3585,14 +3594,18 @@ test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收 ) assert.ok(cancelBlock.length > 0, 'cancelWorkOrderShare 片段必须存在') assert.ok( - !cancelBlock.includes('recomputeWorkOrderSharingStatusWithClient('), - '后台单人撤单不得重算母单状态(撤一份会把待验收的母单踢回大厅)', + cancelBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '后台单人撤单必须重算母单状态,空出的份数才能回到大厅', ) + assert.ok(cancelBlock.includes('preserveHallQueue: true'), '后台单人撤单回大厅必须保留原队列位置') assert.ok( !/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(cancelBlock), '后台单人撤单不得自己写母单 status', ) - assert.ok(cancelBlock.includes('toStatus: workOrder.status'), '撤单事件不得记录母单状态迁移') + assert.ok( + cancelBlock.includes('toStatus: updatedOrder?.status || workOrder.status'), + '撤单事件必须记录母单重算后的状态', + ) const timeoutBlock = src.slice( src.indexOf('export async function settleOverdueWorkOrderShare('), @@ -3603,6 +3616,7 @@ test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收 timeoutBlock.includes('recomputeWorkOrderSharingStatusWithClient('), '份额超时必须用 recompute 收敛母单状态', ) + assert.ok(timeoutBlock.includes('preserveHallQueue: true'), '份额超时回大厅必须保留原队列位置') assert.ok( !/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(timeoutBlock), '份额超时不得自己写母单 status(只能由 recompute 推导)', @@ -3642,6 +3656,11 @@ test('撤份额与单人撤单都把原因和份数写进份额行,列表才 shareSrc.indexOf('export async function submitWorkOrderShareAcceptance('), ) assert.ok(reduceBlock.length > 0, 'reduceWorkOrderShareQuantity 片段必须存在') + assert.ok( + reduceBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '撤份额必须重算母单状态,减少的份数才能回大厅', + ) + assert.ok(reduceBlock.includes('preserveHallQueue: true'), '撤份额回大厅必须保留原队列位置') assert.ok(reduceBlock.includes('cancel_note = $'), '撤份额必须把原因写进 cancel_note') assert.ok( reduceBlock.includes('String(share.cancel_note'), diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index a06ad096..738a58b7 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -158,8 +158,16 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { title: note ? `客服已处理问题反馈:${note}` : '客服已处理问题反馈', color: 'green', } - case 'timeout_reopen': - return { title: '订单超时未完成,已退回抢单大厅', color: 'orange' } + case 'timeout_reopen': { + const isSharing = Boolean(event.payload?.isSharing) + const quantity = readPayloadAmount(event, 'quantity') + return { + title: isSharing + ? `拼单份额超时未完成${quantity > 0 ? `(${quantity} 份)` : ''},已退回抢单大厅` + : '订单超时未完成,已退回抢单大厅', + color: 'orange', + } + } // —— 资料节点 —— case 'material_submitted': @@ -418,9 +426,14 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { case 'sharing_share_cancelled_by_admin': { const workerLabel = resolveEventWorkerLabel(event) const quantity = readPayload(event, 'quantity') + const details = [ + quantity ? `撤销 ${quantity} 份` : '', + '空出的份数已回到接单大厅', + note ? `原因:${note}` : '', + ].filter(Boolean) return { title: `撤销拼单参与者${workerLabel ? `(${workerLabel})` : ''}`, - description: `${quantity ? `撤销 ${quantity} 份;` : ''}${note ? `原因:${note}` : '份数已退回接单大厅'}`, + description: details.join(';'), color: 'red', } } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 0b9e04d2..fb1a9b68 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -780,7 +780,7 @@ export default function WorkOrdersPanel() { cancelAdminWorkOrderShare(workOrderId, cancelShare.shareId, { reason: String(values.reason || '').trim(), }), - '该参与者已撤单,押金已退还,母单状态未变更', + '该参与者已撤单,押金已退还,空出的份数已回到接单大厅', ) if (!succeeded) return await queryClient.invalidateQueries({ @@ -3714,7 +3714,7 @@ export default function WorkOrdersPanel() { danger size="small" icon={} - title="只撤销当前参与者的拼单份额,其他参与者不受影响" + title="只撤销当前参与者的拼单份额,空出的份数回到接单大厅,其他参与者不受影响" onClick={() => { setCancelShare(share) cancelShareForm.setFieldsValue({ reason: '' }) @@ -3776,7 +3776,7 @@ export default function WorkOrdersPanel() { showIcon type="warning" message={`将撤销该打手承接的 ${cancelShare.quantity} 份拼单`} - description="撤单只释放这一份份额并退还该份额冻结的押金,母单状态保持不变(待验收/进行中的单不会被踢回大厅);已提交的验收资料会保留在后台记录中。" + description="撤单只释放这一份份额并退还该份额冻结的押金;空出的份数会回到接单大厅,母单状态按剩余份额重新计算;该份额已提交的验收资料会被清空,仅保留撤单记录。" style={{ marginBottom: 16 }} /> From 2ce8f601dfc29e26a2a887ece00ef179cdb2e0bf Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 15:43:20 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(worker):=20=E4=B8=8D=E6=BB=A1=E9=A2=9D?= =?UTF-8?q?=E6=8B=BC=E5=8D=95=E6=AF=8D=E5=8D=95=E6=98=BE=E7=A4=BA=E5=BE=85?= =?UTF-8?q?=E6=8A=A2=E5=8D=95=EF=BC=8C=E6=BB=A1=E9=A2=9D=E5=90=8E=E6=89=8D?= =?UTF-8?q?=E6=8C=89=E4=BB=BD=E9=A2=9D=E7=8A=B6=E6=80=81=E6=8E=A8=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker-platform/work-order-query-repo.ts | 24 ++++++--- .../work-order-settlement-repo.ts | 2 +- .../worker-platform/work-order-share-repo.ts | 7 +-- .../admin-work-order-acceptance-service.ts | 2 +- .../work-order-display-mappers.ts | 8 +-- .../worker-platform-service.test.ts | 54 ++++++++++++++++++- .../pages/admin/panels/WorkOrdersPanel.tsx | 4 +- 7 files changed, 83 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 5d64caeb..177da82c 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -99,16 +99,23 @@ const TIMEOUT_COUNT_CACHE_TTL_MS = 5_000 const TIMEOUT_COUNT_CACHE_MAX_ENTRIES = 256 const timeoutCountCache = new Map() -/** 后台按母工单拼单参与情况计算业务状态,open 只代表大厅生命周期。 */ +/** + * 后台按母工单拼单参与情况计算业务状态,open 只代表大厅生命周期。 + * + * 有份额回到大厅(有效份数 < 总份数)时必须先显示「待抢单」:空位还在, + * 母单就还没到按份额状态推进的时候。满额后才按份额状态收敛: + * - 仍有 joined 份额 → in_progress(代练中) + * - 无 joined 份额 → pending_acceptance(待验收) + */ function adminOperationalStatusExpression(alias = 'wo', shareSummaryAlias = '') { if (shareSummaryAlias) { return `CASE WHEN ${alias}.status = 'open' AND ${alias}.sharing_enabled IS TRUE - AND COALESCE(${shareSummaryAlias}.active_share_quantity, 0) > 0 THEN CASE - WHEN COALESCE(${shareSummaryAlias}.active_share_quantity, 0) >= ${alias}.sharing_total_quantity - AND COALESCE(${shareSummaryAlias}.pending_share_count, 0) = 0 + WHEN COALESCE(${shareSummaryAlias}.active_share_quantity, 0) < ${alias}.sharing_total_quantity + THEN 'open' + WHEN COALESCE(${shareSummaryAlias}.pending_share_count, 0) = 0 THEN 'pending_acceptance' ELSE 'in_progress' END @@ -116,13 +123,16 @@ function adminOperationalStatusExpression(alias = 'wo', shareSummaryAlias = '') END` } + const activeShareQuantitySql = `COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0)` + const pendingShareCountSql = `COALESCE((SELECT COUNT(*) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status = 'joined'), 0)` + return `CASE WHEN ${alias}.status = 'open' AND ${alias}.sharing_enabled IS TRUE - AND COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) > 0 THEN CASE - WHEN COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) >= ${alias}.sharing_total_quantity - AND COALESCE((SELECT COUNT(*) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status = 'joined'), 0) = 0 + WHEN ${activeShareQuantitySql} < ${alias}.sharing_total_quantity + THEN 'open' + WHEN ${pendingShareCountSql} = 0 THEN 'pending_acceptance' ELSE 'in_progress' END diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index 84ae7f23..a24f0a45 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -822,7 +822,7 @@ export async function acceptWorkOrderShareAndSettle(input: { * 后台撤销单个拼单份额:退还该打手押金、释放这一份的占用。 * * 份额取消后空出的份数必须回到接单大厅,所以这里用统一口径重算母单状态: - * 还有其它有效份额 → open(后台按份额推导为代练中);一份不剩 → open(待抢单)。 + * 存在空位 → open(后台显示待抢单);一份不剩 → open(同样待抢单)。 * 重算时保留原 published_at / hall_queued_at,空出的份额沿用原大厅队列位置。 */ export async function cancelWorkOrderShare(input: { diff --git a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts index 8db212d1..895f4b48 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts @@ -626,9 +626,10 @@ export async function submitWorkOrderShareAcceptance(input: { /** * 按份额集合推导母单拼单状态(唯一口径,禁止各调用点各自 UPDATE work_orders.status)。 * - * allocated >= total && joined == 0 && submitted > 0 → pending_acceptance - * allocated < total 或 joined > 0 → open - * allocated == 0(全部取消) → open(重新可参与) + * allocated >= total && joined == 0 && submitted > 0 → pending_acceptance(待验收) + * allocated < total → open(后台显示待抢单:空位还在) + * allocated >= total && joined > 0 → open(后台显示代练中:还有份额在做) + * allocated == 0(全部取消) → open(待抢单,重新可参与) * 母单 accepted / cancelled → 不动 * * 返回更新后的母单行(供调用方回给前端)。 diff --git a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts index 59547dbd..0fb128f7 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts @@ -196,7 +196,7 @@ export async function cancelAdminWorkOrderShare( * 后台按份数撤销某个打手的部分份额。 * * 减少的份数会随「大厅余量 = 总份数 − Σ有效份额数量」自动回到大厅;押金保持原冻结额, - * 母单状态按剩余份额重算(可能从待验收回到代练中,保留原大厅队列位置), + * 母单状态按剩余份额重算(可能从待验收回到待抢单,保留原大厅队列位置), * 该份额的验收资料不动(之后仍可对该份额「单独验收」)。 */ export async function reduceAdminWorkOrderShareQuantity( diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index c267a924..c0f18d70 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -198,12 +198,14 @@ export function resolveWorkOrderOperationalStatus(workOrder: WorkOrderRow): stri const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0)) const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0)) const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) - const hasActiveShares = activeShareQuantity > 0 const sharingFilled = workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity - if (workOrder.status === WORK_ORDER_STATUS.OPEN && workOrder.sharing_enabled && hasActiveShares) { - return sharingFilled && pendingShareCount === 0 + if (workOrder.status === WORK_ORDER_STATUS.OPEN && workOrder.sharing_enabled) { + // 有份额回到大厅(未满额)时,母单先回到「待抢单」:空位还在,就还没到按份额状态推进的时候。 + // 满额后才按份额状态收敛:仍有 joined → 代练中;全部已提交 → 待验收。 + if (!sharingFilled) return WORK_ORDER_STATUS.OPEN + return pendingShareCount === 0 ? WORK_ORDER_STATUS.PENDING_ACCEPTANCE : WORK_ORDER_STATUS.IN_PROGRESS } diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 190b875f..a5ac8ad6 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -2046,6 +2046,7 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => { assert.equal(waitingForClaim.status, 'open') assert.equal(waitingForClaim.capabilities.canUpdateMaterial, true) + // 有份额回到大厅(未满额):空位还在,母单必须先显示「待抢单」。 const partial = mapWorkOrderAdmin( buildWorkOrderRow({ status: 'open', @@ -2055,7 +2056,7 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => { pending_share_count: 1, }), ) - assert.equal(partial.status, 'in_progress') + assert.equal(partial.status, 'open') assert.equal(partial.rawStatus, 'open') assert.equal(partial.hallAvailable, true) assert.equal(partial.capabilities.canEdit, false) @@ -2063,6 +2064,25 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => { assert.equal(partial.capabilities.canAssign, false) assert.equal(partial.capabilities.canUpdateMaterial, true) assert.equal(partial.capabilities.canCancelSharing, true) + // 整单验收 / 整单标记问题单都要求满额且所有份额满足,不满额时不能走整单动作。 + assert.equal(partial.capabilities.canAccept, false) + assert.equal(partial.capabilities.canMarkProblem, false) + + // 满额但还有份额没提交:母单代练中,整单验收同样不可用。 + const filledInProgress = mapWorkOrderAdmin( + buildWorkOrderRow({ + status: 'open', + sharing_enabled: true, + sharing_total_quantity: 15, + active_share_quantity: 15, + pending_share_count: 1, + }), + ) + assert.equal(filledInProgress.status, 'in_progress') + assert.equal(filledInProgress.rawStatus, 'open') + assert.equal(filledInProgress.hallAvailable, false) + assert.equal(filledInProgress.capabilities.canAccept, false) + assert.equal(filledInProgress.capabilities.canMarkProblem, true) const filled = mapWorkOrderAdmin( buildWorkOrderRow({ @@ -2080,6 +2100,38 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => { assert.equal(filled.capabilities.canCancelSharing, true) }) +test('后台运营态先判空位:不满额必须显示待抢单,满额后才按份额推进', async () => { + const { readFile } = await import('node:fs/promises') + const queryRepo = await readFile( + new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url), + 'utf8', + ) + const start = queryRepo.indexOf('function adminOperationalStatusExpression') + assert.ok(start >= 0, '缺少 adminOperationalStatusExpression') + const end = queryRepo.indexOf('/** 工单统一搜索字段', start) + const block = queryRepo.slice(start, end > start ? end : start + 4000) + + // 两个分支(带 share summary 别名 / 不带别名)都必须先判「有效份数 < 总份数」。 + assert.equal( + (block.match(/< \$\{alias\}\.sharing_total_quantity/g) || []).length, + 2, + '空位判定必须在两版运营态表达式里都存在', + ) + assert.ok( + block.indexOf("THEN 'open'") < block.indexOf("THEN 'pending_acceptance'"), + '空位分支必须排在待验收之前,否则不满额拼单会被误判为待验收', + ) + assert.ok( + block.indexOf("THEN 'open'") < block.indexOf("ELSE 'in_progress'"), + '空位分支必须排在代练中之前,否则不满额拼单会被误判为代练中', + ) + assert.equal( + (block.match(/THEN 'open'/g) || []).length, + 2, + '空位分支必须在两版运营态表达式里都以待抢单收口', + ) +}) + test('已取消工单可重新启用,恢复未分配后可编辑和发布', () => { const cancelled = mapWorkOrderAdmin(buildWorkOrderRow({ status: 'cancelled' })) assert.equal(cancelled.capabilities.canReopen, true) diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index fb1a9b68..cb819745 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -3776,7 +3776,7 @@ export default function WorkOrdersPanel() { showIcon type="warning" message={`将撤销该打手承接的 ${cancelShare.quantity} 份拼单`} - description="撤单只释放这一份份额并退还该份额冻结的押金;空出的份数会回到接单大厅,母单状态按剩余份额重新计算;该份额已提交的验收资料会被清空,仅保留撤单记录。" + description="撤单只释放这一份份额并退还该份额冻结的押金;空出的份数会回到接单大厅,母单显示待抢单;重新拼满后再按各份额状态推进。该份额已提交的验收资料会被清空,仅保留撤单记录。" style={{ marginBottom: 16 }} /> @@ -3810,7 +3810,7 @@ export default function WorkOrdersPanel() { showIcon type="warning" message={`将从该打手的 ${reduceShare.quantity} 份中撤掉指定份数`} - description="撤掉的份数会立刻回到抢单大厅;押金保持原冻结额不退还;验收资料保留,之后仍可单独验收剩余份数。" + description="撤掉的份数会立刻回到抢单大厅,母单显示待抢单;押金保持原冻结额不退还;验收资料保留,之后仍可单独验收剩余份数。" style={{ marginBottom: 16 }} /> Date: Sun, 1 Nov 2026 16:15:47 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(worker):=20=E7=94=A8=E6=88=B7=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E9=87=91=E9=A2=9D=E6=9C=AA=E5=8F=98=E5=8C=96=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=86=99=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/admin/worker-platform/orders.ts | 15 +++----- .../work-order-payment-amount.test.ts | 36 +++++++++++++++++++ .../work-order-payment-amount.ts | 36 +++++++++++++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index 1fc67840..81da73c4 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -31,6 +31,7 @@ import { updateAdminWorkOrderPaymentAmount, updateAdminWorkOrderSharing, } from '../../../services/worker-platform/index.js' +import { buildWorkOrderPaymentAmountAuditData } from '../../../services/worker-platform/work-order-payment-amount.js' import { createJsonHandler, requireAdminPermission, @@ -251,20 +252,14 @@ router.put( errorMessage: '修改用户付款金额失败', scope: '[admin/worker-platform/orders/:workOrderId/payment-amount]', audit: (req, data) => { - const result = (data || {}) as { - previousAmountFen?: number | null - amountFen?: number - reason?: string - } + // 金额未变化时服务层不会写工单事件,这里也不留下一条“已更新”的审计记录。 + const auditData = buildWorkOrderPaymentAmountAuditData(data) + if (!auditData) return null return { action: 'work_order_payment_amount_updated', targetType: 'work_order', targetId: String(req.params.workOrderId || ''), - data: { - previousAmountFen: result.previousAmountFen ?? null, - amountFen: result.amountFen ?? null, - reason: result.reason || '', - }, + data: auditData, } }, }, diff --git a/apps/backend/src/services/worker-platform/work-order-payment-amount.test.ts b/apps/backend/src/services/worker-platform/work-order-payment-amount.test.ts index 00495eea..db5e5c4d 100644 --- a/apps/backend/src/services/worker-platform/work-order-payment-amount.test.ts +++ b/apps/backend/src/services/worker-platform/work-order-payment-amount.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + buildWorkOrderPaymentAmountAuditData, mergeUserPaymentAmountIntoMaterial, normalizeUserPaymentAmountFen, resolveDisplayedUserPaymentAmountFen, @@ -87,3 +88,38 @@ test('mergeUserPaymentAmountIntoMaterial:兼容 JSON 字符串与异常结构' source: { orderAmountFen: 1234 }, }) }) + +test('buildWorkOrderPaymentAmountAuditData:未变化不写审计,变化时保留原值', () => { + assert.equal( + buildWorkOrderPaymentAmountAuditData({ + unchanged: true, + previousAmountFen: 1000, + amountFen: 1000, + reason: '', + }), + null, + '未变化提交不能留下「已更新」审计', + ) + + assert.deepEqual( + buildWorkOrderPaymentAmountAuditData({ + unchanged: false, + previousAmountFen: 1000, + amountFen: 1234, + reason: ' 修正 ', + }), + { previousAmountFen: 1000, amountFen: 1234, reason: '修正' }, + ) + + assert.deepEqual( + buildWorkOrderPaymentAmountAuditData({ + unchanged: false, + previousAmountFen: null, + amountFen: 1234, + }), + { previousAmountFen: null, amountFen: 1234, reason: '' }, + '原标题金额缺失时保留 null,不能误写成 0', + ) + + assert.equal(buildWorkOrderPaymentAmountAuditData(null), null) +}) diff --git a/apps/backend/src/services/worker-platform/work-order-payment-amount.ts b/apps/backend/src/services/worker-platform/work-order-payment-amount.ts index f8d7dbc6..2be94b20 100644 --- a/apps/backend/src/services/worker-platform/work-order-payment-amount.ts +++ b/apps/backend/src/services/worker-platform/work-order-payment-amount.ts @@ -78,3 +78,39 @@ export function mergeUserPaymentAmountIntoMaterial( } return { ...material, source: nextSource } } + +export type WorkOrderPaymentAmountAuditData = { + previousAmountFen: number | null + amountFen: number | null + reason: string +} + +function toAmountFenOrNull(value: unknown): number | null { + if (value === null || value === undefined || value === '') return null + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +/** + * 后台审计载荷:金额未变化时不写「已更新」审计。 + * + * 前端已拦截未变化提交,但接口仍可能被直接调用或重放;此时服务层不会写 + * payment_amount_updated 工单事件,审计也不能留下一条原金额等于新金额的修改记录。 + */ +export function buildWorkOrderPaymentAmountAuditData( + data: unknown, +): WorkOrderPaymentAmountAuditData | null { + if (!data || typeof data !== 'object' || Array.isArray(data)) return null + const result = data as { + previousAmountFen?: unknown + amountFen?: unknown + reason?: unknown + unchanged?: unknown + } + if (result.unchanged === true) return null + return { + previousAmountFen: toAmountFenOrNull(result.previousAmountFen), + amountFen: toAmountFenOrNull(result.amountFen), + reason: String(result.reason || '').trim(), + } +} From 5879568ad09ccb0f98ed67316680745951a9f9b8 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 16:22:45 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(worker):=20=E7=94=A8=E6=88=B7=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E9=87=91=E9=A2=9D=E4=BB=85=E5=BE=85=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=8F=AF=E4=BF=AE=E6=94=B9=E5=B9=B6=E8=A1=A5=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../work-order-management-repo.ts | 13 ++------- .../admin-work-order-management-service.ts | 23 +++++++-------- .../work-order-payment-amount.test.ts | 23 +++++++++++++++ .../work-order-payment-amount.ts | 11 +++++++ .../admin/panels/WorkOrderTableCells.tsx | 8 +++-- .../admin/panels/work-order-panel-utils.tsx | 6 +++- .../pages/admin/panels/work-order-payment.ts | 5 ++++ .../tests/work-order-payment-loss.test.ts | 29 +++++++++++++++++++ 8 files changed, 91 insertions(+), 27 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts index 3a6a0083..43ff9261 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts @@ -308,8 +308,8 @@ export async function updateWorkOrderBasic( /** * 后台修正「用户付款金额」:同时更新 083 派生列与 material_json 来源快照。 * - * WHERE 里再守一次状态与接单人,避免读完到写入之间被打手接走; - * 待抢单(open)还要求没有有效拼单份额,与 capabilities.canEdit 口径一致。 + * 只允许待完善(pending_material)阶段;WHERE 里再守一次状态与接单人, + * 避免读完到写入之间状态已推进或被打手接走。 */ export async function updateWorkOrderPaymentAmount(input: { workOrderId: number | string @@ -327,15 +327,6 @@ export async function updateWorkOrderPaymentAmount(input: { WHERE id = $4 AND assigned_worker_id IS NULL AND status = ANY($5::text[]) - AND ( - status <> 'open' - OR NOT EXISTS ( - SELECT 1 - FROM work_order_shares wos - WHERE wos.work_order_id = work_orders.id - AND wos.status <> 'cancelled' - ) - ) RETURNING id `, [ diff --git a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts index aeb64468..12fb049d 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts @@ -37,6 +37,7 @@ import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-s import { getWorkerHallConfig } from './worker-hall-config-service.js' import { USER_PAYMENT_AMOUNT_REASON_MAX_LENGTH, + canModifyUserPaymentAmountStatus, mergeUserPaymentAmountIntoMaterial, normalizeUserPaymentAmountFen, resolveDisplayedUserPaymentAmountFen, @@ -130,9 +131,9 @@ export async function updateAdminWorkOrder( /** * 后台修正「用户付款金额」。 * - * 只允许和普通编辑相同的状态(待完善/未分配/待抢单且未指派), - * 金额按元提交、按分落库;修改会写入 work_order_events + 后台审计。 - * 同步 material_json.source 快照,避免列表展示与 083 派生列分叉。 + * 只允许「待完善(pending_material)」阶段修改:金额进入未分配/待抢单后已参与 + * 发单与大厅展示,再改会破坏对账口径。金额按元提交、按分落库;修改会写入 + * work_order_events + 后台审计,并同步 material_json.source 快照。 */ export async function updateAdminWorkOrderPaymentAmount( workOrderId: number | string, @@ -140,16 +141,16 @@ export async function updateAdminWorkOrderPaymentAmount( options: { actorName?: string } = {}, ) { const workOrder = await getRequiredWorkOrder(workOrderId) - if (!mapWorkOrderAdmin(workOrder).capabilities.canEdit) { - throw createHttpError('只有待完善、未分配或待抢单的工单可以编辑', { + if (!canModifyUserPaymentAmountStatus(workOrder.status)) { + throw createHttpError('只有待完善的工单可以修改用户付款金额', { statusCode: 409, - errorCode: 'work_order_edit_status_invalid', + errorCode: 'work_order_payment_amount_status_invalid', }) } if (workOrder.assigned_worker_id) { - throw createHttpError('工单已被打手接走,不能编辑', { + throw createHttpError('工单已被打手接走,不能修改用户付款金额', { statusCode: 409, - errorCode: 'work_order_edit_assigned_invalid', + errorCode: 'work_order_payment_amount_assigned_invalid', }) } @@ -184,11 +185,7 @@ export async function updateAdminWorkOrderPaymentAmount( workOrderId: workOrder.id, amountFen, materialJson: mergeUserPaymentAmountIntoMaterial(workOrder.material_json, amountFen), - allowedStatuses: [ - WORK_ORDER_STATUS.PENDING_MATERIAL, - WORK_ORDER_STATUS.UNASSIGNED, - WORK_ORDER_STATUS.OPEN, - ], + allowedStatuses: [WORK_ORDER_STATUS.PENDING_MATERIAL], now, }) if (!updated) { diff --git a/apps/backend/src/services/worker-platform/work-order-payment-amount.test.ts b/apps/backend/src/services/worker-platform/work-order-payment-amount.test.ts index db5e5c4d..59ff8a2a 100644 --- a/apps/backend/src/services/worker-platform/work-order-payment-amount.test.ts +++ b/apps/backend/src/services/worker-platform/work-order-payment-amount.test.ts @@ -3,6 +3,7 @@ import test from 'node:test' import { buildWorkOrderPaymentAmountAuditData, + canModifyUserPaymentAmountStatus, mergeUserPaymentAmountIntoMaterial, normalizeUserPaymentAmountFen, resolveDisplayedUserPaymentAmountFen, @@ -21,6 +22,28 @@ test('normalizeUserPaymentAmountFen:0、负数、超上限与非数字都判 } }) +test('canModifyUserPaymentAmountStatus:只有待完善(pending_material)可改', () => { + assert.equal(canModifyUserPaymentAmountStatus('pending_material'), true) + for (const status of [ + 'unassigned', + 'open', + 'in_progress', + 'pending_acceptance', + 'problem', + 'accepted', + 'cancelled', + '', + null, + undefined, + ]) { + assert.equal( + canModifyUserPaymentAmountStatus(status), + false, + `${String(status)} 不应允许修改用户付款金额`, + ) + } +}) + test('resolveDisplayedUserPaymentAmountFen:优先送码实付,其次来源金额,最后派生列', () => { assert.equal( resolveDisplayedUserPaymentAmountFen({ diff --git a/apps/backend/src/services/worker-platform/work-order-payment-amount.ts b/apps/backend/src/services/worker-platform/work-order-payment-amount.ts index 2be94b20..53cf6986 100644 --- a/apps/backend/src/services/worker-platform/work-order-payment-amount.ts +++ b/apps/backend/src/services/worker-platform/work-order-payment-amount.ts @@ -1,3 +1,4 @@ +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import type { JsonObject } from '../../types/json.js' import { safeParseJson } from '../admin/admin-query-utils.js' @@ -29,6 +30,16 @@ export function normalizeUserPaymentAmountFen(value: unknown): number | null { return fen } +/** + * 用户付款金额只允许在「待完善」阶段修改。 + * + * 进入未分配/待抢单后金额已经参与发单与大厅展示,后台再改会破坏对账口径; + * 已接单/已验收阶段更不允许改动。 + */ +export function canModifyUserPaymentAmountStatus(status: unknown): boolean { + return String(status || '').trim() === WORK_ORDER_STATUS.PENDING_MATERIAL +} + /** * 读取工单当前展示的「用户付款金额」(分)。 * diff --git a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx index 4faa85a1..9fca83c1 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx @@ -6,7 +6,11 @@ import { updateAdminWorkOrder, updateAdminWorkOrderPaymentAmount } from '@/servi import type { WorkOrder } from '@/types/worker-platform' import { formatMoney } from './shared' -import { getRewardAbovePayment, getUserPaymentFen } from './work-order-panel-utils' +import { + canEditUserPaymentAmount, + getRewardAbovePayment, + getUserPaymentFen, +} from './work-order-panel-utils' /** 商品名:单击看详情,双击原地修改(仅未接走工单) */ export function ProductNameEditableCell({ @@ -101,7 +105,7 @@ export function UserPaymentAmountEditableCell({ onSaved: () => Promise | void }) { const { message } = App.useApp() - const editable = order.capabilities?.canEdit === true + const editable = canEditUserPaymentAmount(order) const currentFen = getUserPaymentFen(order) const [open, setOpen] = useState(false) const [draft, setDraft] = useState(null) diff --git a/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx b/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx index a582b2ad..826e08fe 100644 --- a/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx +++ b/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx @@ -3,7 +3,11 @@ import { Space, Tag, Typography } from 'antd' import type { CollectField, WorkOrder } from '@/types/worker-platform' import { resolveConsignmentWorkerDisplay } from '@/utils/consignment-display' -export { getRewardAbovePayment, getUserPaymentFen } from './work-order-payment' +export { + canEditUserPaymentAmount, + getRewardAbovePayment, + getUserPaymentFen, +} from './work-order-payment' const SKIP_PUBLISH_CONFIRM_KEY = 'admin-work-order-skip-publish-confirm' diff --git a/apps/frontend/src/pages/admin/panels/work-order-payment.ts b/apps/frontend/src/pages/admin/panels/work-order-payment.ts index 91c5e280..ff0b912a 100644 --- a/apps/frontend/src/pages/admin/panels/work-order-payment.ts +++ b/apps/frontend/src/pages/admin/panels/work-order-payment.ts @@ -33,6 +33,11 @@ export function getUserPaymentFen(order: PaymentSourceOrder): number | null { return fromColumn !== null && fromColumn > 0 ? fromColumn : null } +/** 用户付款金额仅在「待完善」阶段可编辑,与后端校验同口径。 */ +export function canEditUserPaymentAmount(order: { status?: string; rawStatus?: string }): boolean { + return String(order.rawStatus || order.status || '').trim() === 'pending_material' +} + /** 发单金额高于用户付款时返回两边金额,否则 null(付款未知时不提示) */ export function getRewardAbovePayment(order: PaymentSourceOrder, rewardFen?: number) { const paymentFen = getUserPaymentFen(order) diff --git a/apps/frontend/tests/work-order-payment-loss.test.ts b/apps/frontend/tests/work-order-payment-loss.test.ts index 4445c182..47ce33fe 100644 --- a/apps/frontend/tests/work-order-payment-loss.test.ts +++ b/apps/frontend/tests/work-order-payment-loss.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + canEditUserPaymentAmount, getRewardAbovePayment, getUserPaymentFen, } from '../src/pages/admin/panels/work-order-payment.ts' @@ -62,3 +63,31 @@ test('getUserPaymentFen:空值或非法值不误判成 0 元', () => { assert.equal(getUserPaymentFen({ material: { source: { orderAmountFen: 'abc' } } }), null) assert.equal(getUserPaymentFen({ material: {}, sourceOrderAmount: 0 }), null) }) + +test('用户付款金额仅在待完善(pending_material)可编辑', () => { + assert.equal(canEditUserPaymentAmount({ status: 'pending_material' }), true) + assert.equal( + canEditUserPaymentAmount({ status: 'pending_material', rawStatus: 'pending_material' }), + true, + ) + + for (const status of [ + 'unassigned', + 'open', + 'in_progress', + 'pending_acceptance', + 'problem', + 'accepted', + 'cancelled', + ]) { + assert.equal( + canEditUserPaymentAmount({ status, rawStatus: status }), + false, + `${status} 不应允许编辑用户付款金额`, + ) + } + + // rawStatus 是后端原始状态,优先级高于运营态 status + assert.equal(canEditUserPaymentAmount({ status: 'open', rawStatus: 'pending_material' }), true) + assert.equal(canEditUserPaymentAmount({ status: 'pending_material', rawStatus: 'open' }), false) +}) From 53d9ff39ad867f9004d1ba0287166465c8cd9755 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 16:26:40 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(admin):=20=E7=94=A8=E6=88=B7=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E9=87=91=E9=A2=9D=E5=8D=95=E5=85=83=E6=A0=BC=E9=87=91?= =?UTF-8?q?=E9=A2=9D=E4=B8=8E=E7=BC=96=E8=BE=91=E5=9B=BE=E6=A0=87=E4=B8=8D?= =?UTF-8?q?=E6=8D=A2=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx index 9fca83c1..4632619b 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx @@ -159,10 +159,10 @@ export function UserPaymentAmountEditableCell({ <>
- + {currentFen === null ? '-' : formatMoney(currentFen)} From aed76e047efa3c85da46a85851f5f10087969bd3 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 16:30:22 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(admin):=20=E7=94=A8=E6=88=B7=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E9=87=91=E9=A2=9D=E6=94=B9=E4=B8=BA=E5=8D=95=E5=87=BB?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/admin/panels/WorkOrderTableCells.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx index 4632619b..7cf892d8 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx @@ -96,7 +96,7 @@ export function ProductNameEditableCell({ ) } -/** 用户付款金额:双击弹窗修改,保留修改原因并写入工单流转记录 */ +/** 用户付款金额:单击弹窗修改,保留修改原因并写入工单流转记录 */ export function UserPaymentAmountEditableCell({ order, onSaved, @@ -158,9 +158,12 @@ export function UserPaymentAmountEditableCell({ return ( <>
{ + event.stopPropagation() + openEditor() + }} >