diff --git a/apps/backend/src/db/migrations/120_work_order_user_payment_amount_audit.sql b/apps/backend/src/db/migrations/120_work_order_user_payment_amount_audit.sql new file mode 100644 index 00000000..d37143e4 --- /dev/null +++ b/apps/backend/src/db/migrations/120_work_order_user_payment_amount_audit.sql @@ -0,0 +1,15 @@ +-- 120_work_order_user_payment_amount_audit.sql +-- 工单「用户付款金额」最近一次修正快照:旧价、修改时间、修改人。 +-- 完整修改历史仍在 work_order_events.payment_amount_updated。 + +ALTER TABLE work_orders + ADD COLUMN IF NOT EXISTS user_payment_amount_previous_fen BIGINT, + ADD COLUMN IF NOT EXISTS user_payment_amount_updated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS user_payment_amount_updated_by TEXT; + +COMMENT ON COLUMN work_orders.user_payment_amount_previous_fen IS + '最近一次修改用户付款金额前的金额(分);NULL 表示首次修改前金额未知'; +COMMENT ON COLUMN work_orders.user_payment_amount_updated_at IS + '最近一次修改用户付款金额的时间'; +COMMENT ON COLUMN work_orders.user_payment_amount_updated_by IS + '最近一次修改用户付款金额的操作人展示名/用户名快照'; diff --git a/apps/backend/src/db/migrations/121_work_order_payment_amount_history.sql b/apps/backend/src/db/migrations/121_work_order_payment_amount_history.sql new file mode 100644 index 00000000..c7128a77 --- /dev/null +++ b/apps/backend/src/db/migrations/121_work_order_payment_amount_history.sql @@ -0,0 +1,72 @@ +-- 121_work_order_payment_amount_history.sql +-- 工单「用户付款金额」完整修改历史:每次修改一行。 +-- 独立于 work_order_events(默认只保留 180 天),工单删除时级联删除。 + +CREATE TABLE IF NOT EXISTS work_order_payment_amount_history ( + id BIGSERIAL PRIMARY KEY, + work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + previous_amount_fen BIGINT, + amount_fen BIGINT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + changed_by TEXT NOT NULL DEFAULT '', + changed_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_work_order_payment_amount_history_order + ON work_order_payment_amount_history(work_order_id, changed_at DESC, id DESC); + +COMMENT ON TABLE work_order_payment_amount_history IS + '工单用户付款金额完整修改历史;每次实际修改一行,供后台价格历史查看'; +COMMENT ON COLUMN work_order_payment_amount_history.previous_amount_fen IS + '本次修改前的金额(分);NULL 表示首次修改前金额未知'; +COMMENT ON COLUMN work_order_payment_amount_history.amount_fen IS + '本次修改后的金额(分)'; +COMMENT ON COLUMN work_order_payment_amount_history.reason IS + '修改原因(选填,最多 200 字)'; +COMMENT ON COLUMN work_order_payment_amount_history.changed_by IS + '修改人展示名/用户名快照'; +COMMENT ON COLUMN work_order_payment_amount_history.changed_at IS + '本次修改时间'; + +-- 回填已有的 payment_amount_updated 事件。迁移只执行一次,仍按 +-- (work_order_id, changed_at, amount_fen) 防重,避免手工重跑时重复插入。 +WITH parsed AS ( + SELECT + e.work_order_id, + CASE + WHEN e.payload_json->>'previousAmountFen' ~ '^-?[0-9]+$' + THEN (e.payload_json->>'previousAmountFen')::bigint + ELSE NULL + END AS previous_amount_fen, + (e.payload_json->>'amountFen')::bigint AS amount_fen, + COALESCE(e.payload_json->>'reason', '') AS reason, + COALESCE(e.actor_id, '') AS changed_by, + e.created_at + FROM work_order_events e + WHERE e.event_type = 'payment_amount_updated' + AND e.payload_json->>'amountFen' ~ '^-?[0-9]+$' +) +INSERT INTO work_order_payment_amount_history ( + work_order_id, + previous_amount_fen, + amount_fen, + reason, + changed_by, + changed_at +) +SELECT + p.work_order_id, + p.previous_amount_fen, + p.amount_fen, + p.reason, + p.changed_by, + p.created_at +FROM parsed p +WHERE NOT EXISTS ( + SELECT 1 + FROM work_order_payment_amount_history h + WHERE h.work_order_id = p.work_order_id + AND h.changed_at = p.created_at + AND h.amount_fen = p.amount_fen +); diff --git a/apps/backend/src/repositories/worker-platform/index.ts b/apps/backend/src/repositories/worker-platform/index.ts index 8d396dc2..3f82ac52 100644 --- a/apps/backend/src/repositories/worker-platform/index.ts +++ b/apps/backend/src/repositories/worker-platform/index.ts @@ -9,6 +9,7 @@ export * from './worker-ranking-repo.js' export * from './work-order-repo.js' export * from './work-order-query-repo.js' export * from './work-order-management-repo.js' +export * from './work-order-payment-history-repo.js' export * from './work-order-grab-repo.js' export * from './work-order-cancel-repo.js' export * from './work-order-issue-repo.js' diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 9511a1b0..f7c4c419 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -399,6 +399,12 @@ export type WorkOrderRow = { last_assigned_worker_id: number | null /** 来源订单实付金额(分);取消部分时用于按比例计算退款。 */ source_order_amount_fen: number + /** 最近一次修改用户付款金额前的金额(分);NULL 表示首次修改前金额未知。 */ + user_payment_amount_previous_fen?: number | null + /** 最近一次修改用户付款金额的时间。 */ + user_payment_amount_updated_at?: string | null + /** 最近一次修改用户付款金额的操作人展示名/用户名快照。 */ + user_payment_amount_updated_by?: string | null material_json: string | Record requirement_json: string | Record acceptance_json: string | Record 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 43ff9261..1204bd79 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 @@ -2,6 +2,7 @@ import { query, withTransaction } from '../../db/client.js' import { resolveSourceOrderAmountFen, toJsonString, toPositiveInteger } from './shared.js' import { getWorkOrderById, getWorkOrderByIdWithClient } from './work-order-query-repo.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' +import { insertWorkOrderPaymentAmountHistoryWithClient } from './work-order-payment-history-repo.js' import { normalizeWorkOrderAcceptanceMode, normalizeWorkOrderGiftCooldownHours, @@ -306,39 +307,83 @@ export async function updateWorkOrderBasic( } /** - * 后台修正「用户付款金额」:同时更新 083 派生列与 material_json 来源快照。 + * 后台修正「用户付款金额」:同时更新 083 派生列、material_json 来源快照、 + * 最近一次修改痕迹,并同步来源订单 total_amount。 * - * 只允许待完善(pending_material)阶段;WHERE 里再守一次状态与接单人, - * 避免读完到写入之间状态已推进或被打手接走。 + * 状态与接单人在 WHERE 里再守一次,避免读完到写入之间状态已推进或被打手接走。 + * 同一来源订单的其它工单不联动(按产品确认:只改当前工单)。 */ export async function updateWorkOrderPaymentAmount(input: { workOrderId: number | string amountFen: number materialJson: unknown + previousAmountFen: number | null + reason: string + updatedBy: string 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[]) - 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) + const updatedId = await withTransaction(async (client) => { + const result = await client.query<{ id: number; order_id: number | null }>( + ` + UPDATE work_orders + SET source_order_amount_fen = $1::bigint, + material_json = $2::jsonb, + user_payment_amount_previous_fen = $3::bigint, + user_payment_amount_updated_at = $4::timestamptz, + user_payment_amount_updated_by = $5::text, + updated_at = $4::timestamptz + WHERE id = $6 + AND assigned_worker_id IS NULL + AND status = ANY($7::text[]) + AND ( + sharing_enabled = FALSE + OR COALESCE(( + SELECT SUM(wos.quantity) + FROM work_order_shares wos + WHERE wos.work_order_id = work_orders.id + AND wos.status != 'cancelled' + ), 0) < sharing_total_quantity + ) + RETURNING id, order_id + `, + [ + input.amountFen, + toJsonString(input.materialJson), + input.previousAmountFen, + input.now, + input.updatedBy, + Number(input.workOrderId), + input.allowedStatuses, + ], + ) + const row = result.rows[0] + if (!row) return null + + if (row.order_id) { + const orderUpdate = await client.query( + 'UPDATE orders SET total_amount = $1::bigint, updated_at = $2::timestamptz WHERE id = $3', + [input.amountFen, input.now, Number(row.order_id)], + ) + if ((orderUpdate.rowCount || 0) === 0) { + throw new Error('work_order_payment_amount_order_missing') + } + } + + await insertWorkOrderPaymentAmountHistoryWithClient(client, { + workOrderId: row.id, + previousAmountFen: input.previousAmountFen, + amountFen: input.amountFen, + reason: input.reason, + changedBy: input.updatedBy, + changedAt: input.now, + }) + + return Number(row.id) + }) + + if (!updatedId) return null + return getWorkOrderById(updatedId) } export async function updateWorkOrderAdminNote( diff --git a/apps/backend/src/repositories/worker-platform/work-order-payment-history-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-payment-history-repo.ts new file mode 100644 index 00000000..0202c8c0 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/work-order-payment-history-repo.ts @@ -0,0 +1,74 @@ +import type { PoolClient } from 'pg' + +import { query } from '../../db/client.js' + +export type WorkOrderPaymentAmountHistoryRow = { + id: number | string + work_order_id: number | string + previous_amount_fen: number | string | null + amount_fen: number | string + reason: string + changed_by: string + changed_at: string +} + +/** 按时间倒序读取单个工单的全部付款金额修改记录。 */ +export async function listWorkOrderPaymentAmountHistory( + workOrderId: number | string, +): Promise { + const result = await query( + ` + SELECT id, work_order_id, previous_amount_fen, amount_fen, reason, changed_by, changed_at + FROM work_order_payment_amount_history + WHERE work_order_id = $1 + ORDER BY changed_at DESC, id DESC + `, + [Number(workOrderId)], + ) + return result.rows +} + +/** 列表页批量判断哪些工单存在价格历史,避免逐行查询。 */ +export async function listWorkOrderIdsWithPaymentAmountHistory( + workOrderIds: number[], +): Promise> { + if (workOrderIds.length === 0) return new Set() + const result = await query<{ work_order_id: number | string }>( + ` + SELECT DISTINCT work_order_id + FROM work_order_payment_amount_history + WHERE work_order_id = ANY($1::bigint[]) + `, + [workOrderIds], + ) + return new Set(result.rows.map((row) => Number(row.work_order_id))) +} + +/** 与工单金额修改在同一事务内写入,保证金额和历史同时成功或同时回滚。 */ +export async function insertWorkOrderPaymentAmountHistoryWithClient( + client: PoolClient, + input: { + workOrderId: number | string + previousAmountFen: number | null + amountFen: number + reason: string + changedBy: string + changedAt: string + }, +): Promise { + await client.query( + ` + INSERT INTO work_order_payment_amount_history ( + work_order_id, previous_amount_fen, amount_fen, reason, changed_by, changed_at + ) VALUES ($1, $2, $3, $4, $5, $6) + `, + [ + Number(input.workOrderId), + input.previousAmountFen, + input.amountFen, + input.reason, + input.changedBy, + input.changedAt, + ], + ) +} diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index 19c49fb3..d89a7649 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -12,6 +12,7 @@ import { deleteAdminMockCustomShop, deleteAdminWorkOrder, getAdminWorkOrderEvents, + getAdminWorkOrderPaymentAmountHistory, getAdminWorkOrderSharing, listAdminMockCustomShops, listAdminWorkOrderShopNames, @@ -558,6 +559,19 @@ router.get( }), ) +router.get( + '/worker-platform/orders/:workOrderId/payment-amount-history', + requireAdminPermission('worker_order.view'), + createJsonHandler( + (req) => getAdminWorkOrderPaymentAmountHistory(String(req.params.workOrderId || '')), + { + successMessage: 'ok', + errorMessage: '读取价格历史失败', + scope: '[admin/worker-platform/orders/:workOrderId/payment-amount-history]', + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/sharing', requireAdminPermission('worker_order.configure_share'), 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 12fb049d..f09e6fe3 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 @@ -32,16 +32,19 @@ import { normalizeProblemResolutionAction, normalizeRequirementFieldsFromPayload, resolveRequirementFields, + resolveWorkOrderOperationalStatus, } from './mappers.js' import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js' import { getWorkerHallConfig } from './worker-hall-config-service.js' import { + USER_PAYMENT_AMOUNT_EDITABLE_STATUSES, USER_PAYMENT_AMOUNT_REASON_MAX_LENGTH, canModifyUserPaymentAmountStatus, mergeUserPaymentAmountIntoMaterial, normalizeUserPaymentAmountFen, resolveDisplayedUserPaymentAmountFen, } from './work-order-payment-amount.js' +import { invalidateAdminWorkOrderStatisticsCache } from './admin-work-order-query-service.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' @@ -131,8 +134,8 @@ export async function updateAdminWorkOrder( /** * 后台修正「用户付款金额」。 * - * 只允许「待完善(pending_material)」阶段修改:金额进入未分配/待抢单后已参与 - * 发单与大厅展示,再改会破坏对账口径。金额按元提交、按分落库;修改会写入 + * 允许「待完善 / 待分配 / 待抢单」阶段修改;修改会同步来源订单 total_amount, + * 并记录最近一次旧价、修改时间、修改人。金额按元提交、按分落库;修改会写入 * work_order_events + 后台审计,并同步 material_json.source 快照。 */ export async function updateAdminWorkOrderPaymentAmount( @@ -141,8 +144,10 @@ export async function updateAdminWorkOrderPaymentAmount( options: { actorName?: string } = {}, ) { const workOrder = await getRequiredWorkOrder(workOrderId) - if (!canModifyUserPaymentAmountStatus(workOrder.status)) { - throw createHttpError('只有待完善的工单可以修改用户付款金额', { + // 按运营态判断:拼单母单 rawStatus 仍是 open,但满额后展示为代练中/待验收,不能再改。 + const operationalStatus = resolveWorkOrderOperationalStatus(workOrder) + if (!canModifyUserPaymentAmountStatus(operationalStatus)) { + throw createHttpError('只有待完善、待分配或待抢单的工单可以修改用户付款金额', { statusCode: 409, errorCode: 'work_order_payment_amount_status_invalid', }) @@ -181,13 +186,27 @@ export async function updateAdminWorkOrderPaymentAmount( } const now = nowIso() - const updated = await updateWorkOrderPaymentAmount({ - workOrderId: workOrder.id, - amountFen, - materialJson: mergeUserPaymentAmountIntoMaterial(workOrder.material_json, amountFen), - allowedStatuses: [WORK_ORDER_STATUS.PENDING_MATERIAL], - now, - }) + let updated: Awaited> + try { + updated = await updateWorkOrderPaymentAmount({ + workOrderId: workOrder.id, + amountFen, + materialJson: mergeUserPaymentAmountIntoMaterial(workOrder.material_json, amountFen), + previousAmountFen, + reason, + updatedBy: options.actorName || '', + allowedStatuses: [...USER_PAYMENT_AMOUNT_EDITABLE_STATUSES], + now, + }) + } catch (error) { + if (error instanceof Error && error.message === 'work_order_payment_amount_order_missing') { + throw createHttpError('来源订单不存在,无法同步用户付款金额', { + statusCode: 409, + errorCode: 'work_order_payment_amount_order_missing', + }) + } + throw error + } if (!updated) { throw createHttpError('订单状态已变化,无法修改用户付款金额,请刷新后重试', { statusCode: 409, @@ -195,6 +214,8 @@ export async function updateAdminWorkOrderPaymentAmount( }) } + invalidateAdminWorkOrderStatisticsCache() + await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'admin', diff --git a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts index 4a17333e..dd31d842 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts @@ -2,6 +2,8 @@ import { getWorkOrderStatistics, listConsignmentInventoriesByIds, listWorkOrderEventsByOrderId, + listWorkOrderIdsWithPaymentAmountHistory, + listWorkOrderPaymentAmountHistory, listWorkOrderSharesByOrderIds, listWorkOrders, sumPendingUnfreezeByOrderIds, @@ -43,6 +45,11 @@ import { getRequiredWorkOrder } from './worker-session-context-service.js' const WORK_ORDER_STATISTICS_CACHE_TTL_MS = 3_000 const workOrderStatisticsCache = new WorkerListCache(WORK_ORDER_STATISTICS_CACHE_TTL_MS, 32) +/** 工单金额等影响统计口径的字段修改后,主动逐出短缓存。 */ +export function invalidateAdminWorkOrderStatisticsCache() { + workOrderStatisticsCache.clear() +} + /** 只把真正影响统计结果的筛选字段拼进指纹,避免无关参数造成缓存穿透。 */ function buildWorkOrderStatisticsCacheKey(filters: JsonObject): string { return JSON.stringify([ @@ -70,6 +77,25 @@ export async function getAdminWorkOrderEvents(workOrderId: number | string) { return { items: await mapWorkOrderEvents(events, { includeWorkerSummary: true }) } } +/** 后台「价格历史」:只返回用户付款金额的实际修改记录,按时间倒序。 */ +export async function getAdminWorkOrderPaymentAmountHistory(workOrderId: number | string) { + await getRequiredWorkOrder(workOrderId) + const rows = await listWorkOrderPaymentAmountHistory(workOrderId) + return { + items: rows.map((row) => ({ + id: Number(row.id), + previousAmountFen: + row.previous_amount_fen === null || row.previous_amount_fen === undefined + ? null + : Number(row.previous_amount_fen), + amountFen: Number(row.amount_fen), + reason: String(row.reason || '').trim(), + changedBy: String(row.changed_by || '').trim(), + changedAt: new Date(row.changed_at).toISOString(), + })), + } +} + /** * 后台接单工单列表的筛选条件(纯函数,便于单测)。 * @@ -154,13 +180,16 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { } } - const [pendingUnfreezeByOrderId, shares, inventories] = await Promise.all([ - sumPendingUnfreezeByOrderIds(itemIds), - listWorkOrderSharesByOrderIds(itemIds), - inventoryIds.size > 0 - ? listConsignmentInventoriesByIds([...inventoryIds]) - : Promise.resolve([]), - ]) + const [pendingUnfreezeByOrderId, shares, inventories, paymentHistoryOrderIds] = await Promise.all( + [ + sumPendingUnfreezeByOrderIds(itemIds), + listWorkOrderSharesByOrderIds(itemIds), + inventoryIds.size > 0 + ? listConsignmentInventoriesByIds([...inventoryIds]) + : Promise.resolve([]), + listWorkOrderIdsWithPaymentAmountHistory(itemIds), + ], + ) const inventoriesById = new Map() for (const inv of inventories) { @@ -181,6 +210,7 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { const inv = invId > 0 ? inventoriesById.get(invId) || null : null return { ...mapWorkOrderAdmin(item, sharesByOrderId.get(Number(item.id)) || [], inv), + hasPaymentAmountHistory: paymentHistoryOrderIds.has(Number(item.id)), pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(Number(item.id)) || 0, } }), 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 f32a1b16..d2de63c7 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 @@ -396,6 +396,14 @@ export function mapWorkOrderAdmin( requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), /** 来源订单实付金额(分):取消部分时用于预填退款金额。 */ sourceOrderAmount: Number(workOrder.source_order_amount_fen || 0), + /** 最近一次修改用户付款金额的痕迹,仅后台可见。 */ + userPaymentAmountPreviousFen: + workOrder.user_payment_amount_previous_fen === null || + workOrder.user_payment_amount_previous_fen === undefined + ? null + : Number(workOrder.user_payment_amount_previous_fen), + userPaymentAmountUpdatedAt: workOrder.user_payment_amount_updated_at || null, + userPaymentAmountUpdatedBy: String(workOrder.user_payment_amount_updated_by || '').trim(), sharing: { enabled: workOrder.sharing_enabled === true, totalQuantity: Number(workOrder.sharing_total_quantity || 1), @@ -552,6 +560,9 @@ export function mapWorkOrderForWorker( hallAvailable: _hallAvailable, capabilities: _capabilities, adminNote: _adminNote, + userPaymentAmountPreviousFen: _userPaymentAmountPreviousFen, + userPaymentAmountUpdatedAt: _userPaymentAmountUpdatedAt, + userPaymentAmountUpdatedBy: _userPaymentAmountUpdatedBy, ...visibleOrder } = mapped const isTimedOutForMe = 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 59ff8a2a..cedfd8a1 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 @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' import test from 'node:test' import { @@ -22,11 +23,11 @@ test('normalizeUserPaymentAmountFen:0、负数、超上限与非数字都判 } }) -test('canModifyUserPaymentAmountStatus:只有待完善(pending_material)可改', () => { - assert.equal(canModifyUserPaymentAmountStatus('pending_material'), true) +test('canModifyUserPaymentAmountStatus:待完善、待分配、待抢单可改', () => { + for (const status of ['pending_material', 'unassigned', 'open']) { + assert.equal(canModifyUserPaymentAmountStatus(status), true, `${status} 应允许修改用户付款金额`) + } for (const status of [ - 'unassigned', - 'open', 'in_progress', 'pending_acceptance', 'problem', @@ -146,3 +147,33 @@ test('buildWorkOrderPaymentAmountAuditData:未变化不写审计,变化时 assert.equal(buildWorkOrderPaymentAmountAuditData(null), null) }) + +test('付款金额更新仓储同时写旧价/时间/修改人并同步来源订单金额', async () => { + const source = await readFile( + new URL('../../repositories/worker-platform/work-order-management-repo.ts', import.meta.url), + 'utf8', + ) + assert.ok(source.includes('user_payment_amount_previous_fen = $3::bigint')) + assert.ok(source.includes('user_payment_amount_updated_at = $4::timestamptz')) + assert.ok(source.includes('user_payment_amount_updated_by = $5::text')) + assert.ok(source.includes('UPDATE orders SET total_amount = $1::bigint')) + assert.ok(source.includes('status = ANY($7::text[])')) + assert.ok(source.includes('assigned_worker_id IS NULL')) + assert.ok(source.includes('insertWorkOrderPaymentAmountHistoryWithClient(client')) +}) + +test('付款金额可修改状态按运营态判断,拼单满额后禁止', async () => { + const service = await readFile( + new URL('./admin-work-order-management-service.ts', import.meta.url), + 'utf8', + ) + assert.ok(service.includes('resolveWorkOrderOperationalStatus(workOrder)')) + assert.ok(service.includes('canModifyUserPaymentAmountStatus(operationalStatus)')) + + const repo = await readFile( + new URL('../../repositories/worker-platform/work-order-management-repo.ts', import.meta.url), + 'utf8', + ) + assert.ok(repo.includes('SUM(wos.quantity)')) + assert.ok(repo.includes('sharing_total_quantity')) +}) 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 53cf6986..bff68569 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 @@ -30,14 +30,22 @@ export function normalizeUserPaymentAmountFen(value: unknown): number | null { return fen } +/** 允许后台修正用户付款金额的工单状态。 */ +export const USER_PAYMENT_AMOUNT_EDITABLE_STATUSES = [ + WORK_ORDER_STATUS.PENDING_MATERIAL, + WORK_ORDER_STATUS.UNASSIGNED, + WORK_ORDER_STATUS.OPEN, +] as const + /** - * 用户付款金额只允许在「待完善」阶段修改。 + * 用户付款金额允许在「待完善 / 待分配 / 待抢单」阶段修改。 * - * 进入未分配/待抢单后金额已经参与发单与大厅展示,后台再改会破坏对账口径; - * 已接单/已验收阶段更不允许改动。 + * 待分配、待抢单也会允许修改,修改会同步来源订单金额并保留修改痕迹; + * 已接单/已验收/已取消阶段仍然不允许改动。 */ export function canModifyUserPaymentAmountStatus(status: unknown): boolean { - return String(status || '').trim() === WORK_ORDER_STATUS.PENDING_MATERIAL + const normalized = String(status || '').trim() + return (USER_PAYMENT_AMOUNT_EDITABLE_STATUSES as readonly string[]).includes(normalized) } /** diff --git a/apps/backend/src/services/worker-platform/work-order-payment-history.test.ts b/apps/backend/src/services/worker-platform/work-order-payment-history.test.ts new file mode 100644 index 00000000..536ad9c7 --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-payment-history.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +function read(relativeUrl: string) { + return readFile(new URL(relativeUrl, import.meta.url), 'utf8') +} + +test('价格历史迁移建表、建索引并回填 payment_amount_updated 事件', async () => { + const sql = await read('../../db/migrations/121_work_order_payment_amount_history.sql') + assert.ok(sql.includes('CREATE TABLE IF NOT EXISTS work_order_payment_amount_history')) + assert.ok(sql.includes('idx_work_order_payment_amount_history_order')) + assert.ok(sql.includes("e.event_type = 'payment_amount_updated'")) + assert.ok(sql.includes("e.payload_json->>'amountFen' ~ '^-?[0-9]+$'")) + assert.ok(sql.includes('WHERE NOT EXISTS')) +}) + +test('价格历史仓储按时间倒序读取、批量判断并写入', async () => { + const source = await read('../../repositories/worker-platform/work-order-payment-history-repo.ts') + assert.ok(source.includes('ORDER BY changed_at DESC, id DESC')) + assert.ok(source.includes('SELECT DISTINCT work_order_id')) + assert.ok(source.includes('INSERT INTO work_order_payment_amount_history')) +}) + +test('后台列表返回是否有价格历史,并注册历史查询接口', async () => { + const service = await read('./admin-work-order-query-service.ts') + assert.ok(service.includes('hasPaymentAmountHistory: paymentHistoryOrderIds.has')) + assert.ok(service.includes('getAdminWorkOrderPaymentAmountHistory')) + + const route = await read('../../routes/admin/worker-platform/orders.ts') + assert.ok(route.includes("'/worker-platform/orders/:workOrderId/payment-amount-history'")) +}) diff --git a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx index 64ebbd28..84609071 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx @@ -1,9 +1,28 @@ -import { EditOutlined } from '@ant-design/icons' -import { App, Input, InputNumber, Modal, Space, Tag, Typography } from 'antd' +import { EditOutlined, HistoryOutlined } from '@ant-design/icons' +import { + App, + Drawer, + Empty, + Input, + InputNumber, + List, + Modal, + Space, + Spin, + Tag, + Tooltip, + Typography, +} from 'antd' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useRef, useState } from 'react' -import { updateAdminWorkOrder, updateAdminWorkOrderPaymentAmount } from '@/services/admin' +import { + fetchAdminWorkOrderPaymentAmountHistory, + updateAdminWorkOrder, + updateAdminWorkOrderPaymentAmount, +} from '@/services/admin' import type { WorkOrder } from '@/types/worker-platform' +import { formatDateTime } from '@/utils/date-time' import { appendProductNameSourceSuffix } from '@/utils/product-name-source-suffix' import { formatMoney } from './shared' @@ -111,13 +130,37 @@ export function UserPaymentAmountEditableCell({ onSaved: () => Promise | void }) { const { message } = App.useApp() + const queryClient = useQueryClient() const editable = canEditUserPaymentAmount(order) const currentFen = getUserPaymentFen(order) + const lastModifiedText = order.userPaymentAmountUpdatedAt + ? `上次修改:${ + order.userPaymentAmountPreviousFen === null || + order.userPaymentAmountPreviousFen === undefined + ? '-' + : formatMoney(order.userPaymentAmountPreviousFen) + } → ${currentFen === null ? '-' : formatMoney(currentFen)} · ${formatDateTime( + order.userPaymentAmountUpdatedAt, + )}${order.userPaymentAmountUpdatedBy ? ` · ${order.userPaymentAmountUpdatedBy}` : ''}` + : '' + // 三个可编辑状态共用同一套弹窗;没有修改记录时也保留占位,避免高度和内容随状态变化。 + const lastModifiedDisplay = lastModifiedText || '上次修改:暂无记录' + const cellTitle = [editable ? '点击修改用户付款金额' : '', lastModifiedText] + .filter(Boolean) + .join('\n') const [open, setOpen] = useState(false) const [draft, setDraft] = useState(null) const [reason, setReason] = useState('') const [saving, setSaving] = useState(false) const savingRef = useRef(false) + const [historyOpen, setHistoryOpen] = useState(false) + const historyQuery = useQuery({ + queryKey: ['work-order-payment-history', order.workOrderId], + queryFn: () => fetchAdminWorkOrderPaymentAmountHistory(order.workOrderId), + enabled: historyOpen, + retry: false, + }) + const historyItems = historyQuery.data?.data?.items || [] function openEditor() { if (!editable || savingRef.current) return @@ -152,6 +195,9 @@ export function UserPaymentAmountEditableCell({ }) message.success('用户付款金额已更新') setOpen(false) + await queryClient.invalidateQueries({ + queryKey: ['work-order-payment-history', order.workOrderId], + }) await onSaved() } catch (error) { message.error(error instanceof Error ? error.message : '保存失败') @@ -164,7 +210,6 @@ export function UserPaymentAmountEditableCell({ return ( <>
{ event.stopPropagation() @@ -172,10 +217,24 @@ export function UserPaymentAmountEditableCell({ }} > - + {currentFen === null ? '-' : formatMoney(currentFen)} {editable ? : null} + {order.hasPaymentAmountHistory ? ( + + { + event.stopPropagation() + setHistoryOpen(true) + }} + /> + + ) : null}
@@ -197,6 +256,9 @@ export function UserPaymentAmountEditableCell({ 当前金额:{currentFen === null ? '-' : formatMoney(currentFen)} + + {lastModifiedDisplay} + setReason(event.target.value)} /> - 修改会记录到工单流转记录与后台审计日志;财务统计按下一次定时同步更新。 + 修改会同步来源订单金额,并记录到工单流转记录与后台审计日志;财务统计会同步更新,不会改变发单金额或已加入份额的报酬。 + + setHistoryOpen(false)} + > +
+ + 工单 {order.workOrderNo} · 共 {historyItems.length} 次修改 + + {historyQuery.isLoading ? ( + + ) : historyQuery.error ? ( + + {historyQuery.error instanceof Error + ? historyQuery.error.message + : '读取价格历史失败'} + + ) : historyItems.length === 0 ? ( + + ) : ( + ( + +
+ + {item.previousAmountFen === null + ? `- → ${formatMoney(item.amountFen)}` + : `${formatMoney(item.previousAmountFen)} → ${formatMoney(item.amountFen)}`} + + {index === 0 ? 最近一次 : null} +
+ + {formatDateTime(item.changedAt)} · {item.changedBy || '未知'} + + + 原因:{item.reason || '未填写'} + +
+ )} + /> + )} +
+
) } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 06bb7470..f84a9a27 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -1760,7 +1760,7 @@ export default function WorkOrdersPanel() { }, { title: '用户付款金额', - width: 130, + width: 160, render: (_, row) => , }, { 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 ff0b912a..4479eb14 100644 --- a/apps/frontend/src/pages/admin/panels/work-order-payment.ts +++ b/apps/frontend/src/pages/admin/panels/work-order-payment.ts @@ -33,9 +33,13 @@ export function getUserPaymentFen(order: PaymentSourceOrder): number | null { return fromColumn !== null && fromColumn > 0 ? fromColumn : null } -/** 用户付款金额仅在「待完善」阶段可编辑,与后端校验同口径。 */ +const USER_PAYMENT_AMOUNT_EDITABLE_STATUSES = new Set(['pending_material', 'unassigned', 'open']) + +/** 用户付款金额在「待完善 / 待分配 / 待抢单」可编辑,与后端校验同口径。 */ export function canEditUserPaymentAmount(order: { status?: string; rawStatus?: string }): boolean { - return String(order.rawStatus || order.status || '').trim() === 'pending_material' + // 运营态优先:拼单母单 rawStatus 仍是 open,但满额后界面展示为代练中/待验收,不能继续改。 + const effectiveStatus = String(order.status || order.rawStatus || '').trim() + return USER_PAYMENT_AMOUNT_EDITABLE_STATUSES.has(effectiveStatus) } /** 发单金额高于用户付款时返回两边金额,否则 null(付款未知时不提示) */ diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index ab9cc411..66656210 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -6,6 +6,7 @@ import type { WorkCategory, WorkOrder, WorkOrderEvent, + WorkOrderPaymentAmountHistoryItem, WorkOrderShare, WorkOrderStatistics, WorkProductRule, @@ -726,6 +727,12 @@ export function fetchAdminWorkOrderEvents(workOrderId: number) { ) } +export function fetchAdminWorkOrderPaymentAmountHistory(workOrderId: number) { + return apiGet<{ items: WorkOrderPaymentAmountHistoryItem[] }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/payment-amount-history`, + ) +} + export function fetchAdminMockCustomShops() { return apiGet<{ items: string[] }>('/api/v1/admin/worker-platform/orders/mock/custom-shops') } diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 542fb12d..585cac1a 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -684,6 +684,14 @@ export type WorkOrder = { rewardAmount: number /** 来源订单实付金额(分):取消部分时用于预填退款金额。 */ sourceOrderAmount?: number + /** 最近一次修改用户付款金额前的金额(分);NULL 表示修改前金额未知,仅后台可见。 */ + userPaymentAmountPreviousFen?: number | null + /** 最近一次修改用户付款金额的时间,仅后台可见。 */ + userPaymentAmountUpdatedAt?: string | null + /** 最近一次修改用户付款金额的操作人展示名/用户名快照,仅后台可见。 */ + userPaymentAmountUpdatedBy?: string + /** 是否存在用户付款金额修改历史,仅后台列表返回;用于显示「价格历史」入口。 */ + hasPaymentAmountHistory?: boolean /** 当前打手生效的免押额度,仅打手端订单数据返回。 */ depositFreeAmount?: number requiredDepositAmount?: number @@ -771,6 +779,16 @@ export type WorkOrderStatistics = { statusCounts: Record } +/** 用户付款金额的一次修改记录,仅后台可见。 */ +export type WorkOrderPaymentAmountHistoryItem = { + id: number + previousAmountFen: number | null + amountFen: number + reason: string + changedBy: string + changedAt: string +} + /** 工单流转事件(订单详情中的操作日志) */ export type WorkOrderEvent = { id: number diff --git a/apps/frontend/src/utils/admin-realtime-work-orders.ts b/apps/frontend/src/utils/admin-realtime-work-orders.ts index e6298a01..0751288d 100644 --- a/apps/frontend/src/utils/admin-realtime-work-orders.ts +++ b/apps/frontend/src/utils/admin-realtime-work-orders.ts @@ -318,7 +318,16 @@ export function patchAdminWorkOrderListItems( ) if (index >= 0) { previousRow = items[index] - nextItems = items.map((item, itemIndex) => (itemIndex === index ? snapshot : item)) + nextItems = items.map((item, itemIndex) => + itemIndex === index + ? { + ...(snapshot as Record), + // 列表专用派生字段不在实时快照里,沿用旧值,避免价格历史入口消失。 + hasPaymentAmountHistory: + (item as { hasPaymentAmountHistory?: boolean }).hasPaymentAmountHistory === true, + } + : item, + ) } else if (options.insert) { nextItems = [snapshot, ...items] } else { diff --git a/apps/frontend/tests/admin-realtime-work-orders.test.ts b/apps/frontend/tests/admin-realtime-work-orders.test.ts index 8ca8e661..65440072 100644 --- a/apps/frontend/tests/admin-realtime-work-orders.test.ts +++ b/apps/frontend/tests/admin-realtime-work-orders.test.ts @@ -287,6 +287,20 @@ test('已存在的行只做原地替换,不改变位置', () => { assert.equal(totalOf(patched), 124, '行数没变,总数不能变') }) +test('实时替换行时保留价格历史标记,避免入口消失', () => { + const existing = { + ...buildSnapshot({ workOrderId: 100 }), + hasPaymentAmountHistory: true, + } + const patched = patchAdminWorkOrderListItems( + buildEnvelope([existing]), + buildSnapshot({ workOrderId: 100, status: 'in_progress' }), + { remove: false, insert: false }, + ) + const row = itemsOfWorkOrders(patched)[0] as { hasPaymentAmountHistory?: boolean } + assert.equal(row.hasPaymentAmountHistory, true) +}) + test('状态筛选:已不命中的工单会被移除并扣减总数', () => { const filters = parseAdminWorkOrderListFilters(buildQueryKey({ statuses: 'open' }))! const accepted = buildSnapshot({ workOrderId: 100, status: 'accepted' }) diff --git a/apps/frontend/tests/work-order-payment-history.test.ts b/apps/frontend/tests/work-order-payment-history.test.ts new file mode 100644 index 00000000..59ece040 --- /dev/null +++ b/apps/frontend/tests/work-order-payment-history.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('价格历史入口只在 hasPaymentAmountHistory 为真时显示,并调用专用接口', async () => { + const cell = await readFile( + new URL('../src/pages/admin/panels/WorkOrderTableCells.tsx', import.meta.url), + 'utf8', + ) + assert.ok(cell.includes('order.hasPaymentAmountHistory ?')) + assert.ok(cell.includes('fetchAdminWorkOrderPaymentAmountHistory')) + assert.ok(cell.includes('价格历史记录')) + + const service = await readFile( + new URL('../src/services/admin/worker-platform.ts', import.meta.url), + 'utf8', + ) + assert.ok(service.includes('/payment-amount-history')) +}) diff --git a/apps/frontend/tests/work-order-payment-loss.test.ts b/apps/frontend/tests/work-order-payment-loss.test.ts index 47ce33fe..11b97f12 100644 --- a/apps/frontend/tests/work-order-payment-loss.test.ts +++ b/apps/frontend/tests/work-order-payment-loss.test.ts @@ -64,22 +64,16 @@ test('getUserPaymentFen:空值或非法值不误判成 0 元', () => { 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, - ) +test('用户付款金额在待完善、待分配、待抢单可编辑', () => { + for (const status of ['pending_material', 'unassigned', 'open']) { + assert.equal( + canEditUserPaymentAmount({ status, rawStatus: status }), + true, + `${status} 应允许编辑用户付款金额`, + ) + } - for (const status of [ - 'unassigned', - 'open', - 'in_progress', - 'pending_acceptance', - 'problem', - 'accepted', - 'cancelled', - ]) { + for (const status of ['in_progress', 'pending_acceptance', 'problem', 'accepted', 'cancelled']) { assert.equal( canEditUserPaymentAmount({ status, rawStatus: status }), false, @@ -87,7 +81,11 @@ test('用户付款金额仅在待完善(pending_material)可编辑', () => { ) } - // rawStatus 是后端原始状态,优先级高于运营态 status - assert.equal(canEditUserPaymentAmount({ status: 'open', rawStatus: 'pending_material' }), true) - assert.equal(canEditUserPaymentAmount({ status: 'pending_material', rawStatus: 'open' }), false) + // 运营态优先:拼单母单 rawStatus 仍是 open,但满额后界面显示代练中/待验收,不能再改 + assert.equal(canEditUserPaymentAmount({ status: 'in_progress', rawStatus: 'open' }), false) + assert.equal(canEditUserPaymentAmount({ status: 'pending_acceptance', rawStatus: 'open' }), false) + // 未满额时运营态仍是 open,继续允许 + assert.equal(canEditUserPaymentAmount({ status: 'open', rawStatus: 'open' }), true) + // 老数据没有运营态时回退 rawStatus + assert.equal(canEditUserPaymentAmount({ status: '', rawStatus: 'pending_material' }), true) })