Merge remote-tracking branch 'gitea-aliyun/main'

This commit is contained in:
yml2213
2026-11-02 07:54:57 +08:00
23 changed files with 868 additions and 64 deletions
@@ -196,7 +196,8 @@ export async function cancelAdminWorkOrderShare(
* 后台按份数撤销某个打手的部分份额。
*
* 减少的份数会随「大厅余量 = 总份数 − Σ有效份额数量」自动回到大厅;押金保持原冻结额,
* 母单状态与验收资料都不动(之后仍可对该份额「单独验收」)。
* 母单状态按剩余份额重算(可能从待验收回到待抢单,保留原大厅队列位置),
* 该份额的验收资料不动(之后仍可对该份额「单独验收」)。
*/
export async function reduceAdminWorkOrderShareQuantity(
workOrderId: number | string,
@@ -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,13 @@ 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,
canModifyUserPaymentAmountStatus,
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 +128,96 @@ export async function updateAdminWorkOrder(
return { order: mapWorkOrderAdmin(updated || workOrder) }
}
/**
* 后台修正「用户付款金额」。
*
* 只允许「待完善(pending_material)」阶段修改:金额进入未分配/待抢单后已参与
* 发单与大厅展示,再改会破坏对账口径。金额按元提交、按分落库;修改会写入
* work_order_events + 后台审计,并同步 material_json.source 快照。
*/
export async function updateAdminWorkOrderPaymentAmount(
workOrderId: number | string,
payload: JsonObject = {},
options: { actorName?: string } = {},
) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (!canModifyUserPaymentAmountStatus(workOrder.status)) {
throw createHttpError('只有待完善的工单可以修改用户付款金额', {
statusCode: 409,
errorCode: 'work_order_payment_amount_status_invalid',
})
}
if (workOrder.assigned_worker_id) {
throw createHttpError('工单已被打手接走,不能修改用户付款金额', {
statusCode: 409,
errorCode: 'work_order_payment_amount_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],
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,
@@ -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
}
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildWorkOrderPaymentAmountAuditData,
canModifyUserPaymentAmountStatus,
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('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({
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 },
})
})
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)
})
@@ -0,0 +1,127 @@
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'
/** 用户付款金额允许范围: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
}
/**
* 用户付款金额只允许在「待完善」阶段修改。
*
* 进入未分配/待抢单后金额已经参与发单与大厅展示,后台再改会破坏对账口径;
* 已接单/已验收阶段更不允许改动。
*/
export function canModifyUserPaymentAmountStatus(status: unknown): boolean {
return String(status || '').trim() === WORK_ORDER_STATUS.PENDING_MATERIAL
}
/**
* 读取工单当前展示的「用户付款金额」(分)。
*
* 与前端列表口径一致:优先 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 }
}
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(),
}
}
@@ -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) }
}
@@ -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)
@@ -3510,13 +3562,21 @@ type ShareAggregate = {
async function runRecompute(
workOrder: Partial<WorkOrderRow>,
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 +3594,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 +3635,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 +3646,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 +3668,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 +3708,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'),