feat: 支持待分配/待抢单修改付款金额并增加价格历史
This commit is contained in:
@@ -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<ReturnType<typeof updateWorkOrderPaymentAmount>>
|
||||
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',
|
||||
|
||||
@@ -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<number, ConsignmentInventoryRow>()
|
||||
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,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'"))
|
||||
})
|
||||
Reference in New Issue
Block a user