Merge remote-tracking branch 'gitea-aliyun/main'
This commit is contained in:
@@ -539,7 +539,7 @@ export async function disposeWorkOrderShareProblem(input: {
|
||||
)
|
||||
}
|
||||
|
||||
// 母单状态保持不变:与「后台单人撤单」同口径,只处置这一份。
|
||||
// 份额级问题单只处置这一份,不重算母单状态:被标记的份额不走「退回大厅」的份额释放口径。
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
|
||||
@@ -305,6 +305,42 @@ export async function updateWorkOrderBasic(
|
||||
return getWorkOrderById(workOrderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台修正「用户付款金额」:同时更新 083 派生列与 material_json 来源快照。
|
||||
*
|
||||
* 只允许待完善(pending_material)阶段;WHERE 里再守一次状态与接单人,
|
||||
* 避免读完到写入之间状态已推进或被打手接走。
|
||||
*/
|
||||
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[])
|
||||
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,
|
||||
|
||||
@@ -99,16 +99,23 @@ const TIMEOUT_COUNT_CACHE_TTL_MS = 5_000
|
||||
const TIMEOUT_COUNT_CACHE_MAX_ENTRIES = 256
|
||||
const timeoutCountCache = new Map<string, TimeoutCountCacheEntry>()
|
||||
|
||||
/** 后台按母工单拼单参与情况计算业务状态,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -619,17 +626,22 @@ 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 → 不动
|
||||
*
|
||||
* 返回更新后的母单行(供调用方回给前端)。
|
||||
*
|
||||
* preserveHallQueue:份额退回大厅时保留原 published_at / hall_queued_at,
|
||||
* 避免因为母单状态从待验收回退到 open 而被重新排到大厅队尾。
|
||||
*/
|
||||
export async function recomputeWorkOrderSharingStatusWithClient(
|
||||
client: PoolClient,
|
||||
workOrderId: number,
|
||||
now: string,
|
||||
options: { preserveHallQueue?: boolean } = {},
|
||||
): Promise<WorkOrderRow | null> {
|
||||
const orderResult = await client.query<WorkOrderRow>(
|
||||
`SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`,
|
||||
@@ -676,13 +688,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<WorkOrderRow>(
|
||||
`
|
||||
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
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -28,8 +28,10 @@ import {
|
||||
unpublishAdminWorkOrder,
|
||||
updateAdminWorkOrder,
|
||||
updateAdminWorkOrderNote,
|
||||
updateAdminWorkOrderPaymentAmount,
|
||||
updateAdminWorkOrderSharing,
|
||||
} from '../../../services/worker-platform/index.js'
|
||||
import { buildWorkOrderPaymentAmountAuditData } from '../../../services/worker-platform/work-order-payment-amount.js'
|
||||
import {
|
||||
createJsonHandler,
|
||||
requireAdminPermission,
|
||||
@@ -237,6 +239,33 @@ 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 auditData = buildWorkOrderPaymentAmountAuditData(data)
|
||||
if (!auditData) return null
|
||||
return {
|
||||
action: 'work_order_payment_amount_updated',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: auditData,
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.put(
|
||||
'/worker-platform/orders/:workOrderId/note',
|
||||
requireAdminPermission('worker_order.edit'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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':
|
||||
@@ -304,6 +312,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')
|
||||
@@ -418,9 +443,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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
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 {
|
||||
canEditUserPaymentAmount,
|
||||
getRewardAbovePayment,
|
||||
getUserPaymentFen,
|
||||
} from './work-order-panel-utils'
|
||||
|
||||
/** 商品名:单击看详情,双击原地修改(仅未接走工单) */
|
||||
export function ProductNameEditableCell({
|
||||
@@ -91,6 +96,131 @@ export function ProductNameEditableCell({
|
||||
)
|
||||
}
|
||||
|
||||
/** 用户付款金额:单击弹窗修改,保留修改原因并写入工单流转记录 */
|
||||
export function UserPaymentAmountEditableCell({
|
||||
order,
|
||||
onSaved,
|
||||
}: {
|
||||
order: WorkOrder
|
||||
onSaved: () => Promise<void> | void
|
||||
}) {
|
||||
const { message } = App.useApp()
|
||||
const editable = canEditUserPaymentAmount(order)
|
||||
const currentFen = getUserPaymentFen(order)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [draft, setDraft] = useState<number | null>(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 (
|
||||
<>
|
||||
<div
|
||||
title={editable ? '点击修改用户付款金额' : undefined}
|
||||
style={{ cursor: editable ? 'pointer' : 'default', whiteSpace: 'nowrap' }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
openEditor()
|
||||
}}
|
||||
>
|
||||
<Space size={4} align="center">
|
||||
<Typography.Text style={editable ? { color: 'rgba(0,0,0,0.65)' } : undefined}>
|
||||
{currentFen === null ? '-' : formatMoney(currentFen)}
|
||||
</Typography.Text>
|
||||
{editable ? <EditOutlined style={{ color: 'rgba(0,0,0,0.35)', fontSize: 12 }} /> : null}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="修改用户付款金额"
|
||||
open={open}
|
||||
width={420}
|
||||
okText="确认修改"
|
||||
cancelText="取消"
|
||||
confirmLoading={saving}
|
||||
destroyOnHidden
|
||||
onCancel={() => {
|
||||
if (savingRef.current) return
|
||||
setOpen(false)
|
||||
}}
|
||||
onOk={() => void save()}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<Typography.Text type="secondary">
|
||||
当前金额:{currentFen === null ? '-' : formatMoney(currentFen)}
|
||||
</Typography.Text>
|
||||
<InputNumber
|
||||
autoFocus
|
||||
min={0.01}
|
||||
max={1000000}
|
||||
precision={2}
|
||||
step={1}
|
||||
addonBefore="¥"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请输入用户实际付款金额"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onPressEnter={() => void save()}
|
||||
/>
|
||||
<Input.TextArea
|
||||
value={reason}
|
||||
maxLength={200}
|
||||
showCount
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
placeholder="修改原因(选填,最多 200 字)"
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
修改会记录到工单流转记录与后台审计日志;财务统计按下一次定时同步更新。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AmountEditableCell({
|
||||
order,
|
||||
field,
|
||||
|
||||
@@ -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,
|
||||
@@ -780,7 +783,7 @@ export default function WorkOrdersPanel() {
|
||||
cancelAdminWorkOrderShare(workOrderId, cancelShare.shareId, {
|
||||
reason: String(values.reason || '').trim(),
|
||||
}),
|
||||
'该参与者已撤单,押金已退还,母单状态未变更',
|
||||
'该参与者已撤单,押金已退还,空出的份数已回到接单大厅',
|
||||
)
|
||||
if (!succeeded) return
|
||||
await queryClient.invalidateQueries({
|
||||
@@ -1724,7 +1727,7 @@ export default function WorkOrdersPanel() {
|
||||
{
|
||||
title: '用户付款金额',
|
||||
width: 130,
|
||||
render: (_, row) => formatUserPaymentAmount(row),
|
||||
render: (_, row) => <UserPaymentAmountEditableCell order={row} onSaved={refreshAll} />,
|
||||
},
|
||||
{
|
||||
title: '发单金额',
|
||||
@@ -3714,7 +3717,7 @@ export default function WorkOrdersPanel() {
|
||||
danger
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
title="只撤销当前参与者的拼单份额,其他参与者不受影响"
|
||||
title="只撤销当前参与者的拼单份额,空出的份数回到接单大厅,其他参与者不受影响"
|
||||
onClick={() => {
|
||||
setCancelShare(share)
|
||||
cancelShareForm.setFieldsValue({ reason: '' })
|
||||
@@ -3776,7 +3779,7 @@ export default function WorkOrdersPanel() {
|
||||
showIcon
|
||||
type="warning"
|
||||
message={`将撤销该打手承接的 ${cancelShare.quantity} 份拼单`}
|
||||
description="撤单只释放这一份份额并退还该份额冻结的押金,母单状态保持不变(待验收/进行中的单不会被踢回大厅);已提交的验收资料会保留在后台记录中。"
|
||||
description="撤单只释放这一份份额并退还该份额冻结的押金;空出的份数会回到接单大厅,母单显示待抢单;重新拼满后再按各份额状态推进。该份额已提交的验收资料会被清空,仅保留撤单记录。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form.Item label="撤单原因" name="reason">
|
||||
@@ -3810,7 +3813,7 @@ export default function WorkOrdersPanel() {
|
||||
showIcon
|
||||
type="warning"
|
||||
message={`将从该打手的 ${reduceShare.quantity} 份中撤掉指定份数`}
|
||||
description="撤掉的份数会立刻回到抢单大厅;押金保持原冻结额不退还;验收资料保留,之后仍可单独验收剩余份数。"
|
||||
description="撤掉的份数会立刻回到抢单大厅,母单显示待抢单;押金保持原冻结额不退还;验收资料保留,之后仍可单独验收剩余份数。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form.Item
|
||||
|
||||
@@ -3,10 +3,11 @@ 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'
|
||||
export {
|
||||
canEditUserPaymentAmount,
|
||||
getRewardAbovePayment,
|
||||
getUserPaymentFen,
|
||||
} from './work-order-payment'
|
||||
|
||||
const SKIP_PUBLISH_CONFIRM_KEY = 'admin-work-order-skip-publish-confirm'
|
||||
|
||||
@@ -26,11 +27,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 || '-'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
type PaymentSourceOrder = {
|
||||
material?: unknown
|
||||
rewardAmount?: number
|
||||
/** 后台列表接口返回的 083 派生列(分);来源 JSON 缺失时兜底 */
|
||||
sourceOrderAmount?: number
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
@@ -9,13 +11,31 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||
: {}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** 用户付款金额仅在「待完善」阶段可编辑,与后端校验同口径。 */
|
||||
export function canEditUserPaymentAmount(order: { status?: string; rawStatus?: string }): boolean {
|
||||
return String(order.rawStatus || order.status || '').trim() === 'pending_material'
|
||||
}
|
||||
|
||||
/** 发单金额高于用户付款时返回两边金额,否则 null(付款未知时不提示) */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '批量验收',
|
||||
|
||||
@@ -88,6 +88,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' },
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { getRewardAbovePayment } from '../src/pages/admin/panels/work-order-payment.ts'
|
||||
import {
|
||||
canEditUserPaymentAmount,
|
||||
getRewardAbovePayment,
|
||||
getUserPaymentFen,
|
||||
} from '../src/pages/admin/panels/work-order-payment.ts'
|
||||
|
||||
function orderWithPayment(paymentFen: number, rewardFen: number) {
|
||||
return {
|
||||
@@ -35,3 +39,55 @@ 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)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user