feat(work-order): 后台支持修改用户付款金额并留痕
This commit is contained in:
@@ -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<WorkOrderRow | null> {
|
||||
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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user