fix(worker-platform): 资料更新不再覆盖并发抢单状态
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import { createWorkOrderEvent, updateWorkOrder } from '../../repositories/worker-platform/index.js'
|
||||
import type { WorkOrderUpdatePatch } from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
@@ -21,59 +22,15 @@ export async function submitAdminWorkOrderMaterial(
|
||||
payload: JsonObject = {},
|
||||
actorName = '',
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const materialCapabilities = mapWorkOrderAdmin(workOrder).capabilities
|
||||
if (!materialCapabilities.canUpdateMaterial) {
|
||||
throw createHttpError('当前状态不能更新订单资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_material_status_invalid',
|
||||
})
|
||||
}
|
||||
const saved = await persistAdminWorkOrderMaterial(workOrderId, payload)
|
||||
const { workOrder, updated, complete, nextStatus, rewardAmount } = saved
|
||||
|
||||
const fields = resolveRequirementFields(workOrder)
|
||||
const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload)
|
||||
const complete = fields.every(
|
||||
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
const now = nowIso()
|
||||
const screenshots = normalizeUploadedFiles(payload.screenshots || payload.files)
|
||||
const hasActiveShares = Number(workOrder.active_share_quantity || 0) > 0
|
||||
const rewardAmount = resolveAdminMaterialRewardAmount({
|
||||
currentRewardAmount: Number(workOrder.reward_amount || 0),
|
||||
requestedRewardAmount: payload.rewardAmount,
|
||||
sharingEnabled: workOrder.sharing_enabled === true,
|
||||
activeShareQuantity: Number(workOrder.active_share_quantity || 0),
|
||||
})
|
||||
const material = {
|
||||
...safeParseJson(workOrder.material_json),
|
||||
collect: {
|
||||
fields: submittedFields,
|
||||
screenshots,
|
||||
submittedAt: now,
|
||||
complete,
|
||||
source: 'admin',
|
||||
},
|
||||
}
|
||||
const nextStatus =
|
||||
hasActiveShares ||
|
||||
workOrder.status === WORK_ORDER_STATUS.OPEN ||
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS
|
||||
? workOrder.status
|
||||
: complete
|
||||
? WORK_ORDER_STATUS.UNASSIGNED
|
||||
: WORK_ORDER_STATUS.PENDING_MATERIAL
|
||||
const updated = await updateWorkOrder(workOrder.id, {
|
||||
status: nextStatus,
|
||||
material_json: material,
|
||||
reward_amount: rewardAmount,
|
||||
updated_at: now,
|
||||
})
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'admin',
|
||||
actorId: actorName,
|
||||
eventType:
|
||||
hasActiveShares ||
|
||||
saved.hasActiveShares ||
|
||||
workOrder.status === WORK_ORDER_STATUS.OPEN ||
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS
|
||||
? 'material_updated'
|
||||
@@ -81,12 +38,12 @@ export async function submitAdminWorkOrderMaterial(
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: nextStatus,
|
||||
payloadJson: JSON.stringify({
|
||||
fields: submittedFields,
|
||||
fields: saved.submittedFields,
|
||||
complete,
|
||||
previousRewardAmount: Number(workOrder.reward_amount || 0),
|
||||
rewardAmount,
|
||||
}),
|
||||
now,
|
||||
now: saved.now,
|
||||
})
|
||||
if (nextStatus !== WORK_ORDER_STATUS.PENDING_MATERIAL) {
|
||||
await resolveAdminNotificationEntity('work_order', Number(workOrder.id), 'material_completed')
|
||||
@@ -98,6 +55,87 @@ export async function submitAdminWorkOrderMaterial(
|
||||
return { order: mapWorkOrderAdmin(updated || workOrder), complete }
|
||||
}
|
||||
|
||||
const MATERIAL_UPDATE_MAX_ATTEMPTS = 3
|
||||
|
||||
/**
|
||||
* 资料更新只写资料相关列;仅当状态确实需要流转时才写 status,并带 expectedStatus 乐观锁。
|
||||
* 并发抢单把 open 改成 in_progress 时,第一次更新会冲突,下一次按新状态重试,
|
||||
* 避免把忙碌中的工单写回大厅。
|
||||
*/
|
||||
async function persistAdminWorkOrderMaterial(workOrderId: number | string, payload: JsonObject) {
|
||||
for (let attempt = 0; attempt < MATERIAL_UPDATE_MAX_ATTEMPTS; attempt += 1) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const materialCapabilities = mapWorkOrderAdmin(workOrder).capabilities
|
||||
if (!materialCapabilities.canUpdateMaterial) {
|
||||
throw createHttpError('当前状态不能更新订单资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_material_status_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const fields = resolveRequirementFields(workOrder)
|
||||
const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload)
|
||||
const complete = fields.every(
|
||||
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
const now = nowIso()
|
||||
const screenshots = normalizeUploadedFiles(payload.screenshots || payload.files)
|
||||
const hasActiveShares = Number(workOrder.active_share_quantity || 0) > 0
|
||||
const rewardAmount = resolveAdminMaterialRewardAmount({
|
||||
currentRewardAmount: Number(workOrder.reward_amount || 0),
|
||||
requestedRewardAmount: payload.rewardAmount,
|
||||
sharingEnabled: workOrder.sharing_enabled === true,
|
||||
activeShareQuantity: Number(workOrder.active_share_quantity || 0),
|
||||
})
|
||||
const material = {
|
||||
...safeParseJson(workOrder.material_json),
|
||||
collect: {
|
||||
fields: submittedFields,
|
||||
screenshots,
|
||||
submittedAt: now,
|
||||
complete,
|
||||
source: 'admin',
|
||||
},
|
||||
}
|
||||
const nextStatus =
|
||||
hasActiveShares ||
|
||||
workOrder.status === WORK_ORDER_STATUS.OPEN ||
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS
|
||||
? workOrder.status
|
||||
: complete
|
||||
? WORK_ORDER_STATUS.UNASSIGNED
|
||||
: WORK_ORDER_STATUS.PENDING_MATERIAL
|
||||
const patch: WorkOrderUpdatePatch = {
|
||||
material_json: material,
|
||||
reward_amount: rewardAmount,
|
||||
updated_at: now,
|
||||
}
|
||||
// status 与读取时一致时无需重写;只有真正的流转(补资料/进大厅)才写入。
|
||||
if (nextStatus !== workOrder.status) patch.status = nextStatus
|
||||
|
||||
const updated = await updateWorkOrder(workOrder.id, patch, {
|
||||
expectedStatus: workOrder.status,
|
||||
})
|
||||
if (updated) {
|
||||
return {
|
||||
workOrder,
|
||||
updated,
|
||||
complete,
|
||||
nextStatus,
|
||||
rewardAmount,
|
||||
hasActiveShares,
|
||||
submittedFields,
|
||||
now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw createHttpError('订单状态已变化,资料未保存,请刷新后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_material_conflict',
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新资料时仅普通接单可改价;已有参与者的拼单必须保留份额价格快照。 */
|
||||
export function resolveAdminMaterialRewardAmount(input: {
|
||||
currentRewardAmount: number
|
||||
|
||||
@@ -2855,7 +2855,12 @@ test('筛选统计必须优先读派生列 source_order_amount_fen,不再逐
|
||||
)
|
||||
const writes = managementRepo.split('resolveSourceOrderAmountFen(').length - 1
|
||||
assert.equal(writes, 2, '派生列写入点必须正好两处:建单 INSERT 与通用 UPDATE')
|
||||
assert.ok(managementRepo.includes('source_order_amount_fen = $17'))
|
||||
assert.ok(
|
||||
managementRepo.includes(
|
||||
"assign('source_order_amount_fen', resolveSourceOrderAmountFen(patch.material_json))",
|
||||
),
|
||||
'通用 UPDATE 必须在 material_json 变更时同步维护派生列',
|
||||
)
|
||||
})
|
||||
|
||||
test('会话 touch 必须同时保留鉴权语义与写入节流', async () => {
|
||||
|
||||
Reference in New Issue
Block a user