修复订单发布与核销并发状态覆盖
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { query } from '../../db/client.js'
|
import { query, withTransaction } from '../../db/client.js'
|
||||||
import { toJsonString, toPositiveInteger } from './shared.js'
|
import { toJsonString, toPositiveInteger } from './shared.js'
|
||||||
import { getWorkOrderById } from './work-order-query-repo.js'
|
import { getWorkOrderById, getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||||
|
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||||
import {
|
import {
|
||||||
normalizeWorkOrderAcceptanceMode,
|
normalizeWorkOrderAcceptanceMode,
|
||||||
normalizeWorkOrderGiftCooldownHours,
|
normalizeWorkOrderGiftCooldownHours,
|
||||||
@@ -125,6 +126,64 @@ export async function updateWorkOrder(
|
|||||||
return getWorkOrderById(workOrderId)
|
return getWorkOrderById(workOrderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将未分配工单原子发布到大厅,并在事务内记录发布事件。 */
|
||||||
|
export async function publishUnassignedWorkOrder(input: {
|
||||||
|
workOrderId: number | string
|
||||||
|
now: string
|
||||||
|
actorName?: string
|
||||||
|
}): Promise<{
|
||||||
|
order: WorkOrderRow | null
|
||||||
|
failureReason: 'work_order_not_publishable' | null
|
||||||
|
}> {
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const currentResult = await client.query<WorkOrderRow>(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM work_orders
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
[Number(input.workOrderId)],
|
||||||
|
)
|
||||||
|
const current = currentResult.rows[0] || null
|
||||||
|
if (!current || current.status !== 'unassigned' || current.assigned_worker_id) {
|
||||||
|
return { order: null, failureReason: 'work_order_not_publishable' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedResult = await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
UPDATE work_orders
|
||||||
|
SET status = 'open', published_at = $1, hall_queued_at = $1, updated_at = $1
|
||||||
|
WHERE id = $2
|
||||||
|
AND status = 'unassigned'
|
||||||
|
AND assigned_worker_id IS NULL
|
||||||
|
AND reward_amount > 0
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[input.now, Number(input.workOrderId)],
|
||||||
|
)
|
||||||
|
if (!updatedResult.rows[0]) {
|
||||||
|
return { order: null, failureReason: 'work_order_not_publishable' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await createWorkOrderEventWithClient(client, {
|
||||||
|
workOrderId: Number(input.workOrderId),
|
||||||
|
actorType: 'admin',
|
||||||
|
actorId: input.actorName || '',
|
||||||
|
eventType: 'published',
|
||||||
|
fromStatus: current.status,
|
||||||
|
toStatus: 'open',
|
||||||
|
payloadJson: JSON.stringify({ voucherConsumeDeferred: true }),
|
||||||
|
now: input.now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||||
|
failureReason: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateWorkOrderBasic(
|
export async function updateWorkOrderBasic(
|
||||||
workOrderId: number | string,
|
workOrderId: number | string,
|
||||||
patch: {
|
patch: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
deleteWorkOrder,
|
deleteWorkOrder,
|
||||||
listWorkOrderShares,
|
listWorkOrderShares,
|
||||||
listWorkOrders,
|
listWorkOrders,
|
||||||
|
publishUnassignedWorkOrder,
|
||||||
reopenCancelledWorkOrder,
|
reopenCancelledWorkOrder,
|
||||||
resolveProblemWorkOrder,
|
resolveProblemWorkOrder,
|
||||||
updateWorkOrder,
|
updateWorkOrder,
|
||||||
@@ -15,7 +16,10 @@ import { createHttpError } from '../../utils/http.js'
|
|||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
|
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
|
||||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||||
import { consumeIndustryVouchersBeforeWorkOrderPublish } from './publish-voucher-service.js'
|
import {
|
||||||
|
consumeIndustryVouchersBeforeWorkOrderPublish,
|
||||||
|
type WorkOrderVoucherConsumeResult,
|
||||||
|
} from './publish-voucher-service.js'
|
||||||
import {
|
import {
|
||||||
mapWorkOrderAdmin,
|
mapWorkOrderAdmin,
|
||||||
normalizeAmountFen,
|
normalizeAmountFen,
|
||||||
@@ -228,55 +232,47 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(workOrder)
|
const now = nowIso()
|
||||||
if (!voucherConsume.ok) {
|
const published = await publishUnassignedWorkOrder({
|
||||||
const failedAt = nowIso()
|
|
||||||
await createWorkOrderEvent({
|
|
||||||
workOrderId: workOrder.id,
|
workOrderId: workOrder.id,
|
||||||
actorType: 'admin',
|
now,
|
||||||
actorId: actorName,
|
actorName,
|
||||||
eventType: 'publish_voucher_consume_failed',
|
|
||||||
fromStatus: workOrder.status,
|
|
||||||
toStatus: workOrder.status,
|
|
||||||
payloadJson: JSON.stringify(voucherConsume),
|
|
||||||
now: failedAt,
|
|
||||||
})
|
})
|
||||||
throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未发布', {
|
if (!published.order) {
|
||||||
|
throw createHttpError('订单状态已变化,无法发布,请刷新后重试', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'work_order_publish_voucher_consume_failed',
|
errorCode: 'work_order_publish_conflict',
|
||||||
context: voucherConsume,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = nowIso()
|
let voucherConsume: WorkOrderVoucherConsumeResult
|
||||||
const updated = await updateWorkOrder(workOrder.id, {
|
try {
|
||||||
status: WORK_ORDER_STATUS.OPEN,
|
voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(published.order)
|
||||||
published_at: now,
|
} catch (error) {
|
||||||
hall_queued_at: now,
|
voucherConsume = {
|
||||||
updated_at: now,
|
ok: false,
|
||||||
})
|
voucherCount: 0,
|
||||||
if (!updated) {
|
consumedCount: 0,
|
||||||
throw createHttpError(
|
alreadyConsumedCount: 0,
|
||||||
voucherConsume.voucherCount > 0
|
failedCount: 1,
|
||||||
? '电子凭证已核销,但订单发布失败,请重试发布'
|
voucherCodes: [],
|
||||||
: '订单发布失败,请重试',
|
errorMessage: error instanceof Error ? error.message : '电子凭证核销失败',
|
||||||
{
|
|
||||||
statusCode: 409,
|
|
||||||
errorCode: 'work_order_publish_update_failed',
|
|
||||||
context: voucherConsume,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!voucherConsume.ok) {
|
||||||
await createWorkOrderEvent({
|
await createWorkOrderEvent({
|
||||||
workOrderId: workOrder.id,
|
workOrderId: workOrder.id,
|
||||||
actorType: 'admin',
|
actorType: 'system',
|
||||||
actorId: actorName,
|
actorId: 'kuaishou_send_code',
|
||||||
eventType: 'published',
|
eventType: 'publish_voucher_consume_failed',
|
||||||
fromStatus: workOrder.status,
|
fromStatus: WORK_ORDER_STATUS.OPEN,
|
||||||
toStatus: WORK_ORDER_STATUS.OPEN,
|
toStatus: WORK_ORDER_STATUS.OPEN,
|
||||||
payloadJson: JSON.stringify({ voucherConsume }),
|
payloadJson: JSON.stringify(voucherConsume),
|
||||||
now,
|
now: nowIso(),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentOrder = await getRequiredWorkOrder(workOrder.id)
|
||||||
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true })
|
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true })
|
||||||
const hallConfig = getWorkerHallConfig()
|
const hallConfig = getWorkerHallConfig()
|
||||||
const hallEntry = await listWorkOrders({
|
const hallEntry = await listWorkOrders({
|
||||||
@@ -289,7 +285,7 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN
|
|||||||
excludeFilledSharing: true,
|
excludeFilledSharing: true,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
order: mapWorkOrderAdmin(updated),
|
order: mapWorkOrderAdmin(currentOrder),
|
||||||
voucherConsume,
|
voucherConsume,
|
||||||
hallVisible: hallEntry.total > 0,
|
hallVisible: hallEntry.total > 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,8 +351,9 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
|||||||
case 'voucher_backfilled':
|
case 'voucher_backfilled':
|
||||||
return { title: '回填了电子凭证信息', color: 'default' }
|
return { title: '回填了电子凭证信息', color: 'default' }
|
||||||
case 'assign_voucher_consume_failed':
|
case 'assign_voucher_consume_failed':
|
||||||
case 'publish_voucher_consume_failed':
|
|
||||||
return { title: '电子凭证核销失败,本次操作未生效', color: 'red' }
|
return { title: '电子凭证核销失败,本次操作未生效', color: 'red' }
|
||||||
|
case 'publish_voucher_consume_failed':
|
||||||
|
return { title: '订单已发布,但电子凭证核销未完成', color: 'orange' }
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 通用状态流转兜底
|
// 通用状态流转兜底
|
||||||
|
|||||||
@@ -566,9 +566,11 @@ export default function WorkOrdersPanel() {
|
|||||||
return runAction(
|
return runAction(
|
||||||
() => publishAdminWorkOrder(row.workOrderId),
|
() => publishAdminWorkOrder(row.workOrderId),
|
||||||
(response) =>
|
(response) =>
|
||||||
response.data.hallVisible
|
!response.data.voucherConsume.ok
|
||||||
|
? '订单已发布,但电子凭证核销未完成'
|
||||||
|
: response.data.hallVisible
|
||||||
? response.data.voucherConsume.voucherCount > 0
|
? response.data.voucherConsume.voucherCount > 0
|
||||||
? '电子凭证已自动核销,订单已展示在大厅'
|
? '订单已发布,电子凭证已顺带核销'
|
||||||
: '订单已展示在大厅'
|
: '订单已展示在大厅'
|
||||||
: '订单已进入大厅等待队列',
|
: '订单已进入大厅等待队列',
|
||||||
)
|
)
|
||||||
@@ -582,11 +584,11 @@ export default function WorkOrdersPanel() {
|
|||||||
|
|
||||||
let skipFutureConfirm = false
|
let skipFutureConfirm = false
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: '确认发布并核销该订单?',
|
title: '确认发布该订单?',
|
||||||
content: (
|
content: (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Paragraph>
|
<Typography.Paragraph>
|
||||||
如订单关联电子凭证,系统会先自动核销,成功后再发布到大厅。核销后即使下架也不会自动恢复凭证。
|
如订单关联电子凭证,系统会在发布后顺带尝试核销;核销失败不影响订单发布。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -597,7 +599,7 @@ export default function WorkOrdersPanel() {
|
|||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
okText: '发布并核销',
|
okText: '发布订单',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
if (skipFutureConfirm) setSkipPublishConfirm()
|
if (skipFutureConfirm) setSkipPublishConfirm()
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
diff --git a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts
|
||||||
|
index 40937f3a..88652ebb 100644
|
||||||
|
--- a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts
|
||||||
|
+++ b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts
|
||||||
|
@@ -1,6 +1,7 @@
|
||||||
|
-import { query } from '../../db/client.js'
|
||||||
|
+import { query, withTransaction } from '../../db/client.js'
|
||||||
|
import { toJsonString, toPositiveInteger } from './shared.js'
|
||||||
|
-import { getWorkOrderById } from './work-order-query-repo.js'
|
||||||
|
+import { getWorkOrderById, getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||||
|
+import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||||
|
import {
|
||||||
|
normalizeWorkOrderAcceptanceMode,
|
||||||
|
normalizeWorkOrderGiftCooldownHours,
|
||||||
|
@@ -125,6 +126,64 @@ export async function updateWorkOrder(
|
||||||
|
return getWorkOrderById(workOrderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
+/** 将未分配工单原子发布到大厅,并在事务内记录发布事件。 */
|
||||||
|
+export async function publishUnassignedWorkOrder(input: {
|
||||||
|
+ workOrderId: number | string
|
||||||
|
+ now: string
|
||||||
|
+ actorName?: string
|
||||||
|
+}): Promise<{
|
||||||
|
+ order: WorkOrderRow | null
|
||||||
|
+ failureReason: 'work_order_not_publishable' | null
|
||||||
|
+}> {
|
||||||
|
+ return withTransaction(async (client) => {
|
||||||
|
+ const currentResult = await client.query<WorkOrderRow>(
|
||||||
|
+ `
|
||||||
|
+ SELECT *
|
||||||
|
+ FROM work_orders
|
||||||
|
+ WHERE id = $1
|
||||||
|
+ FOR UPDATE
|
||||||
|
+ `,
|
||||||
|
+ [Number(input.workOrderId)],
|
||||||
|
+ )
|
||||||
|
+ const current = currentResult.rows[0] || null
|
||||||
|
+ if (!current || current.status !== 'unassigned' || current.assigned_worker_id) {
|
||||||
|
+ return { order: null, failureReason: 'work_order_not_publishable' }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ const updatedResult = await client.query<{ id: number }>(
|
||||||
|
+ `
|
||||||
|
+ UPDATE work_orders
|
||||||
|
+ SET status = 'open', published_at = $1, hall_queued_at = $1, updated_at = $1
|
||||||
|
+ WHERE id = $2
|
||||||
|
+ AND status = 'unassigned'
|
||||||
|
+ AND assigned_worker_id IS NULL
|
||||||
|
+ AND reward_amount > 0
|
||||||
|
+ RETURNING id
|
||||||
|
+ `,
|
||||||
|
+ [input.now, Number(input.workOrderId)],
|
||||||
|
+ )
|
||||||
|
+ if (!updatedResult.rows[0]) {
|
||||||
|
+ return { order: null, failureReason: 'work_order_not_publishable' }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ await createWorkOrderEventWithClient(client, {
|
||||||
|
+ workOrderId: Number(input.workOrderId),
|
||||||
|
+ actorType: 'admin',
|
||||||
|
+ actorId: input.actorName || '',
|
||||||
|
+ eventType: 'published',
|
||||||
|
+ fromStatus: current.status,
|
||||||
|
+ toStatus: 'open',
|
||||||
|
+ payloadJson: JSON.stringify({ voucherConsumeDeferred: true }),
|
||||||
|
+ now: input.now,
|
||||||
|
+ })
|
||||||
|
+
|
||||||
|
+ return {
|
||||||
|
+ order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||||
|
+ failureReason: null,
|
||||||
|
+ }
|
||||||
|
+ })
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
export async function updateWorkOrderBasic(
|
||||||
|
workOrderId: number | string,
|
||||||
|
patch: {
|
||||||
|
diff --git a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts
|
||||||
|
index bd47017d..1e1b74c4 100644
|
||||||
|
--- a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts
|
||||||
|
+++ b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts
|
||||||
|
@@ -4,6 +4,7 @@ import {
|
||||||
|
deleteWorkOrder,
|
||||||
|
listWorkOrderShares,
|
||||||
|
listWorkOrders,
|
||||||
|
+ publishUnassignedWorkOrder,
|
||||||
|
reopenCancelledWorkOrder,
|
||||||
|
resolveProblemWorkOrder,
|
||||||
|
updateWorkOrder,
|
||||||
|
@@ -15,7 +16,10 @@ import { createHttpError } from '../../utils/http.js'
|
||||||
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
|
||||||
|
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||||
|
-import { consumeIndustryVouchersBeforeWorkOrderPublish } from './publish-voucher-service.js'
|
||||||
|
+import {
|
||||||
|
+ consumeIndustryVouchersBeforeWorkOrderPublish,
|
||||||
|
+ type WorkOrderVoucherConsumeResult,
|
||||||
|
+} from './publish-voucher-service.js'
|
||||||
|
import {
|
||||||
|
mapWorkOrderAdmin,
|
||||||
|
normalizeAmountFen,
|
||||||
|
@@ -228,55 +232,47 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
- const voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(workOrder)
|
||||||
|
+ const now = nowIso()
|
||||||
|
+ const published = await publishUnassignedWorkOrder({
|
||||||
|
+ workOrderId: workOrder.id,
|
||||||
|
+ now,
|
||||||
|
+ actorName,
|
||||||
|
+ })
|
||||||
|
+ if (!published.order) {
|
||||||
|
+ throw createHttpError('订单状态已变化,无法发布,请刷新后重试', {
|
||||||
|
+ statusCode: 409,
|
||||||
|
+ errorCode: 'work_order_publish_conflict',
|
||||||
|
+ })
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ let voucherConsume: WorkOrderVoucherConsumeResult
|
||||||
|
+ try {
|
||||||
|
+ voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(published.order)
|
||||||
|
+ } catch (error) {
|
||||||
|
+ voucherConsume = {
|
||||||
|
+ ok: false,
|
||||||
|
+ voucherCount: 0,
|
||||||
|
+ consumedCount: 0,
|
||||||
|
+ alreadyConsumedCount: 0,
|
||||||
|
+ failedCount: 1,
|
||||||
|
+ voucherCodes: [],
|
||||||
|
+ errorMessage: error instanceof Error ? error.message : '电子凭证核销失败',
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
if (!voucherConsume.ok) {
|
||||||
|
- const failedAt = nowIso()
|
||||||
|
await createWorkOrderEvent({
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
- actorType: 'admin',
|
||||||
|
- actorId: actorName,
|
||||||
|
+ actorType: 'system',
|
||||||
|
+ actorId: 'kuaishou_send_code',
|
||||||
|
eventType: 'publish_voucher_consume_failed',
|
||||||
|
- fromStatus: workOrder.status,
|
||||||
|
- toStatus: workOrder.status,
|
||||||
|
+ fromStatus: WORK_ORDER_STATUS.OPEN,
|
||||||
|
+ toStatus: WORK_ORDER_STATUS.OPEN,
|
||||||
|
payloadJson: JSON.stringify(voucherConsume),
|
||||||
|
- now: failedAt,
|
||||||
|
- })
|
||||||
|
- throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未发布', {
|
||||||
|
- statusCode: 409,
|
||||||
|
- errorCode: 'work_order_publish_voucher_consume_failed',
|
||||||
|
- context: voucherConsume,
|
||||||
|
+ now: nowIso(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
- const now = nowIso()
|
||||||
|
- const updated = await updateWorkOrder(workOrder.id, {
|
||||||
|
- status: WORK_ORDER_STATUS.OPEN,
|
||||||
|
- published_at: now,
|
||||||
|
- hall_queued_at: now,
|
||||||
|
- updated_at: now,
|
||||||
|
- })
|
||||||
|
- if (!updated) {
|
||||||
|
- throw createHttpError(
|
||||||
|
- voucherConsume.voucherCount > 0
|
||||||
|
- ? '电子凭证已核销,但订单发布失败,请重试发布'
|
||||||
|
- : '订单发布失败,请重试',
|
||||||
|
- {
|
||||||
|
- statusCode: 409,
|
||||||
|
- errorCode: 'work_order_publish_update_failed',
|
||||||
|
- context: voucherConsume,
|
||||||
|
- },
|
||||||
|
- )
|
||||||
|
- }
|
||||||
|
- await createWorkOrderEvent({
|
||||||
|
- workOrderId: workOrder.id,
|
||||||
|
- actorType: 'admin',
|
||||||
|
- actorId: actorName,
|
||||||
|
- eventType: 'published',
|
||||||
|
- fromStatus: workOrder.status,
|
||||||
|
- toStatus: WORK_ORDER_STATUS.OPEN,
|
||||||
|
- payloadJson: JSON.stringify({ voucherConsume }),
|
||||||
|
- now,
|
||||||
|
- })
|
||||||
|
+ const currentOrder = await getRequiredWorkOrder(workOrder.id)
|
||||||
|
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true })
|
||||||
|
const hallConfig = getWorkerHallConfig()
|
||||||
|
const hallEntry = await listWorkOrders({
|
||||||
|
@@ -289,7 +285,7 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN
|
||||||
|
excludeFilledSharing: true,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
- order: mapWorkOrderAdmin(updated),
|
||||||
|
+ order: mapWorkOrderAdmin(currentOrder),
|
||||||
|
voucherConsume,
|
||||||
|
hallVisible: hallEntry.total > 0,
|
||||||
|
}
|
||||||
|
diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx
|
||||||
|
index f3489181..95f9da5c 100644
|
||||||
|
--- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx
|
||||||
|
+++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx
|
||||||
|
@@ -351,8 +351,9 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
||||||
|
case 'voucher_backfilled':
|
||||||
|
return { title: '回填了电子凭证信息', color: 'default' }
|
||||||
|
case 'assign_voucher_consume_failed':
|
||||||
|
- case 'publish_voucher_consume_failed':
|
||||||
|
return { title: '电子凭证核销失败,本次操作未生效', color: 'red' }
|
||||||
|
+ case 'publish_voucher_consume_failed':
|
||||||
|
+ return { title: '订单已发布,但电子凭证核销未完成', color: 'orange' }
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 通用状态流转兜底
|
||||||
|
diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx
|
||||||
|
index d31bd9b9..398c0d63 100644
|
||||||
|
--- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx
|
||||||
|
+++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx
|
||||||
|
@@ -566,11 +566,13 @@ export default function WorkOrdersPanel() {
|
||||||
|
return runAction(
|
||||||
|
() => publishAdminWorkOrder(row.workOrderId),
|
||||||
|
(response) =>
|
||||||
|
- response.data.hallVisible
|
||||||
|
- ? response.data.voucherConsume.voucherCount > 0
|
||||||
|
- ? '电子凭证已自动核销,订单已展示在大厅'
|
||||||
|
- : '订单已展示在大厅'
|
||||||
|
- : '订单已进入大厅等待队列',
|
||||||
|
+ !response.data.voucherConsume.ok
|
||||||
|
+ ? '订单已发布,但电子凭证核销未完成'
|
||||||
|
+ : response.data.hallVisible
|
||||||
|
+ ? response.data.voucherConsume.voucherCount > 0
|
||||||
|
+ ? '订单已发布,电子凭证已顺带核销'
|
||||||
|
+ : '订单已展示在大厅'
|
||||||
|
+ : '订单已进入大厅等待队列',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -582,11 +584,11 @@ export default function WorkOrdersPanel() {
|
||||||
|
|
||||||
|
let skipFutureConfirm = false
|
||||||
|
modal.confirm({
|
||||||
|
- title: '确认发布并核销该订单?',
|
||||||
|
+ title: '确认发布该订单?',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<Typography.Paragraph>
|
||||||
|
- 如订单关联电子凭证,系统会先自动核销,成功后再发布到大厅。核销后即使下架也不会自动恢复凭证。
|
||||||
|
+ 如订单关联电子凭证,系统会在发布后顺带尝试核销;核销失败不影响订单发布。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Checkbox
|
||||||
|
onChange={(event) => {
|
||||||
|
@@ -597,7 +599,7 @@ export default function WorkOrdersPanel() {
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
- okText: '发布并核销',
|
||||||
|
+ okText: '发布订单',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => {
|
||||||
|
if (skipFutureConfirm) setSkipPublishConfirm()
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { query, withTransaction } from '../../db/client.js'
|
||||||
|
import { toJsonString, toPositiveInteger } from './shared.js'
|
||||||
|
import { getWorkOrderById, getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||||
|
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||||
|
import {
|
||||||
|
normalizeWorkOrderAcceptanceMode,
|
||||||
|
normalizeWorkOrderGiftCooldownHours,
|
||||||
|
normalizeWorkOrderGiftCooldownMinutes,
|
||||||
|
WORK_ORDER_ACCEPTANCE_MODE,
|
||||||
|
WORK_ORDER_GIFT_PHASE,
|
||||||
|
} from '../../domain/work-order-acceptance-mode.js'
|
||||||
|
import type { CreateWorkOrderInput, WorkOrderRow } from './types.js'
|
||||||
|
|
||||||
|
export async function createWorkOrder(input: CreateWorkOrderInput): Promise<WorkOrderRow | null> {
|
||||||
|
const acceptanceMode = normalizeWorkOrderAcceptanceMode(input.acceptanceMode)
|
||||||
|
const isFriendGift = acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||||
|
const result = await query<{ id: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO work_orders (
|
||||||
|
work_order_no, product_rule_id, order_id, order_item_id, task_id, platform_order_id,
|
||||||
|
product_name, category_id, status, reward_amount, required_deposit_amount,
|
||||||
|
deposit_threshold_amount, sharing_enabled, sharing_total_quantity,
|
||||||
|
sharing_unit_reward, timeout_minutes, timeout_policy,
|
||||||
|
acceptance_mode, gift_phase, gift_cooldown_hours, gift_cooldown_minutes,
|
||||||
|
material_json, requirement_json, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
||||||
|
$12, $13, $14, $15, $16, $17,
|
||||||
|
$18, $19, $20, $21,
|
||||||
|
$22::jsonb, $23::jsonb, $24, $25
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.workOrderNo,
|
||||||
|
input.productRuleId || null,
|
||||||
|
input.orderId || null,
|
||||||
|
input.orderItemId || null,
|
||||||
|
input.taskId || null,
|
||||||
|
input.platformOrderId,
|
||||||
|
input.productName,
|
||||||
|
input.categoryId || null,
|
||||||
|
input.status,
|
||||||
|
input.rewardAmount,
|
||||||
|
input.requiredDepositAmount,
|
||||||
|
input.depositThresholdAmount,
|
||||||
|
input.sharingEnabled === true,
|
||||||
|
toPositiveInteger(input.sharingTotalQuantity, 1),
|
||||||
|
toPositiveInteger(input.sharingUnitReward, 0),
|
||||||
|
toPositiveInteger(input.timeoutMinutes, 0),
|
||||||
|
String(input.timeoutPolicy || 'reopen').trim() || 'reopen',
|
||||||
|
acceptanceMode,
|
||||||
|
isFriendGift ? WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED : '',
|
||||||
|
normalizeWorkOrderGiftCooldownHours(input.giftCooldownHours),
|
||||||
|
normalizeWorkOrderGiftCooldownMinutes(input.giftCooldownMinutes),
|
||||||
|
input.materialJson,
|
||||||
|
input.requirementJson,
|
||||||
|
input.now,
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return getWorkOrderById(result.rows[0]?.id || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkOrder(
|
||||||
|
workOrderId: number | string,
|
||||||
|
patch: Partial<
|
||||||
|
Pick<
|
||||||
|
WorkOrderRow,
|
||||||
|
| 'status'
|
||||||
|
| 'assigned_worker_id'
|
||||||
|
| 'material_json'
|
||||||
|
| 'acceptance_json'
|
||||||
|
| 'draft_acceptance_json'
|
||||||
|
| 'problem_note'
|
||||||
|
| 'reward_amount'
|
||||||
|
| 'sharing_enabled'
|
||||||
|
| 'sharing_total_quantity'
|
||||||
|
| 'sharing_unit_reward'
|
||||||
|
| 'published_at'
|
||||||
|
| 'hall_queued_at'
|
||||||
|
| 'pinned_at'
|
||||||
|
| 'assigned_at'
|
||||||
|
| 'submitted_at'
|
||||||
|
| 'accepted_at'
|
||||||
|
| 'updated_at'
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
): Promise<WorkOrderRow | null> {
|
||||||
|
const current = await getWorkOrderById(workOrderId)
|
||||||
|
if (!current) return null
|
||||||
|
const next = { ...current, ...patch }
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
UPDATE work_orders
|
||||||
|
SET
|
||||||
|
status = $1, assigned_worker_id = $2, material_json = $3::jsonb,
|
||||||
|
acceptance_json = $4::jsonb, draft_acceptance_json = $5::jsonb,
|
||||||
|
problem_note = $6, reward_amount = $7, sharing_enabled = $8,
|
||||||
|
sharing_total_quantity = $9, sharing_unit_reward = $10, published_at = $11,
|
||||||
|
hall_queued_at = $12, pinned_at = $13, assigned_at = $14, submitted_at = $15,
|
||||||
|
accepted_at = $16, updated_at = $17
|
||||||
|
WHERE id = $18
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
next.status,
|
||||||
|
next.assigned_worker_id || null,
|
||||||
|
toJsonString(next.material_json),
|
||||||
|
toJsonString(next.acceptance_json),
|
||||||
|
toJsonString(next.draft_acceptance_json),
|
||||||
|
next.problem_note || '',
|
||||||
|
toPositiveInteger(next.reward_amount, 0),
|
||||||
|
next.sharing_enabled === true,
|
||||||
|
toPositiveInteger(next.sharing_total_quantity, 1),
|
||||||
|
toPositiveInteger(next.sharing_unit_reward, 0),
|
||||||
|
next.published_at || null,
|
||||||
|
next.hall_queued_at || null,
|
||||||
|
next.pinned_at || null,
|
||||||
|
next.assigned_at || null,
|
||||||
|
next.submitted_at || null,
|
||||||
|
next.accepted_at || null,
|
||||||
|
next.updated_at,
|
||||||
|
Number(workOrderId),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return getWorkOrderById(workOrderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将未分配工单原子发布到大厅,并在事务内记录发布事件。 */
|
||||||
|
export async function publishUnassignedWorkOrder(input: {
|
||||||
|
workOrderId: number | string
|
||||||
|
now: string
|
||||||
|
actorName?: string
|
||||||
|
}): Promise<{
|
||||||
|
order: WorkOrderRow | null
|
||||||
|
failureReason: 'work_order_not_publishable' | null
|
||||||
|
}> {
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const currentResult = await client.query<WorkOrderRow>(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM work_orders
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
[Number(input.workOrderId)],
|
||||||
|
)
|
||||||
|
const current = currentResult.rows[0] || null
|
||||||
|
if (!current || current.status !== 'unassigned' || current.assigned_worker_id) {
|
||||||
|
return { order: null, failureReason: 'work_order_not_publishable' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedResult = await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
UPDATE work_orders
|
||||||
|
SET status = 'open', published_at = $1, hall_queued_at = $1, updated_at = $1
|
||||||
|
WHERE id = $2
|
||||||
|
AND status = 'unassigned'
|
||||||
|
AND assigned_worker_id IS NULL
|
||||||
|
AND reward_amount > 0
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[input.now, Number(input.workOrderId)],
|
||||||
|
)
|
||||||
|
if (!updatedResult.rows[0]) {
|
||||||
|
return { order: null, failureReason: 'work_order_not_publishable' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await createWorkOrderEventWithClient(client, {
|
||||||
|
workOrderId: Number(input.workOrderId),
|
||||||
|
actorType: 'admin',
|
||||||
|
actorId: input.actorName || '',
|
||||||
|
eventType: 'published',
|
||||||
|
fromStatus: current.status,
|
||||||
|
toStatus: 'open',
|
||||||
|
payloadJson: JSON.stringify({ voucherConsumeDeferred: true }),
|
||||||
|
now: input.now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||||
|
failureReason: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkOrderBasic(
|
||||||
|
workOrderId: number | string,
|
||||||
|
patch: {
|
||||||
|
productName?: string
|
||||||
|
platformOrderId?: string
|
||||||
|
categoryId?: number | null
|
||||||
|
rewardAmount?: number
|
||||||
|
requiredDepositAmount?: number
|
||||||
|
depositThresholdAmount?: number
|
||||||
|
requirementJson?: string
|
||||||
|
timeoutMinutes?: number
|
||||||
|
timeoutPolicy?: string
|
||||||
|
updatedAt: string
|
||||||
|
},
|
||||||
|
): Promise<WorkOrderRow | null> {
|
||||||
|
const current = await getWorkOrderById(workOrderId)
|
||||||
|
if (!current) return null
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
UPDATE work_orders
|
||||||
|
SET
|
||||||
|
platform_order_id = $1, product_name = $2, category_id = $3, reward_amount = $4,
|
||||||
|
required_deposit_amount = $5, deposit_threshold_amount = $6,
|
||||||
|
requirement_json = $7::jsonb, timeout_minutes = $8, timeout_policy = $9,
|
||||||
|
updated_at = $10
|
||||||
|
WHERE id = $11
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
String((patch.platformOrderId ?? current.platform_order_id) || '').trim(),
|
||||||
|
String((patch.productName ?? current.product_name) || '').trim(),
|
||||||
|
patch.categoryId === undefined ? current.category_id : patch.categoryId,
|
||||||
|
toPositiveInteger(patch.rewardAmount ?? current.reward_amount, 0),
|
||||||
|
toPositiveInteger(patch.requiredDepositAmount ?? current.required_deposit_amount, 0),
|
||||||
|
toPositiveInteger(patch.depositThresholdAmount ?? current.deposit_threshold_amount, 0),
|
||||||
|
toJsonString(patch.requirementJson ?? current.requirement_json),
|
||||||
|
toPositiveInteger(patch.timeoutMinutes ?? current.timeout_minutes, 0),
|
||||||
|
String((patch.timeoutPolicy ?? current.timeout_policy) || 'reopen').trim(),
|
||||||
|
patch.updatedAt,
|
||||||
|
Number(workOrderId),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return getWorkOrderById(workOrderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkOrderAdminNote(
|
||||||
|
workOrderId: number | string,
|
||||||
|
note: string,
|
||||||
|
updatedAt: string,
|
||||||
|
): Promise<WorkOrderRow | null> {
|
||||||
|
const result = await query<{ id: number }>(
|
||||||
|
`UPDATE work_orders SET admin_note = $1, updated_at = $2 WHERE id = $3 RETURNING id`,
|
||||||
|
[String(note || '').trim(), updatedAt, Number(workOrderId)],
|
||||||
|
)
|
||||||
|
if (!result.rows[0]) return null
|
||||||
|
return getWorkOrderById(result.rows[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWorkOrder(workOrderId: number | string): Promise<boolean> {
|
||||||
|
const result = await query('DELETE FROM work_orders WHERE id = $1', [Number(workOrderId)])
|
||||||
|
return Number(result.rowCount || 0) > 0
|
||||||
|
}
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
target="${1:-$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/MODIFIED_FILE}"
|
||||||
|
repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
git -C "$repo" show HEAD:apps/backend/src/repositories/worker-platform/work-order-management-repo.ts > "$target"
|
||||||
|
printf 'rollback restored HEAD content: %s\n' "$target"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
发布与核销解耦原子修复验证
|
||||||
|
|
||||||
|
改变分支/字段:
|
||||||
|
- apps/backend/src/services/worker-platform/admin-work-order-management-service.ts:发布先完成,随后尽力核销;核销失败仅记录 publish_voucher_consume_failed,不改变已发布状态。
|
||||||
|
- apps/backend/src/repositories/worker-platform/work-order-management-repo.ts:新增 publishUnassignedWorkOrder,使用 status='unassigned' AND assigned_worker_id IS NULL 的原子条件迁移,并在同一事务写 published 事件。
|
||||||
|
- apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx:发布后核销提示。
|
||||||
|
- apps/frontend/src/components/WorkOrderEventTimeline.tsx:核销失败文案改为“订单已发布,但电子凭证核销未完成”。
|
||||||
|
|
||||||
|
四个工件:
|
||||||
|
- MODIFIED_FILE: /Users/yml/codes/order_site/publish-atomic-artifacts/MODIFIED_FILE
|
||||||
|
- DIFF_FILE: /Users/yml/codes/order_site/publish-atomic-artifacts/DIFF_FILE.patch
|
||||||
|
- VERIFICATION.txt: /Users/yml/codes/order_site/publish-atomic-artifacts/VERIFICATION.txt
|
||||||
|
- ROLLBACK.sh: /Users/yml/codes/order_site/publish-atomic-artifacts/ROLLBACK.sh
|
||||||
|
|
||||||
|
原始文件哈希:
|
||||||
|
- git HEAD work-order-management-repo.ts blob: 40937f3a95998868a217a5c3da78389f6c799224
|
||||||
|
- 修改副本 sha256: a7e256197ff04b4682a4b2817da30e7344ac0cda66818ecc9fe5711042957a85
|
||||||
|
|
||||||
|
BASELINE:
|
||||||
|
- 命令:cd /Users/yml/codes/order_site/apps/backend && npm test
|
||||||
|
- 输入:HEAD 基线工作树
|
||||||
|
- 字面结果:tests 379;pass 377;fail 0;cancelled 0;skipped 2;exit 0
|
||||||
|
- 状态:通过
|
||||||
|
|
||||||
|
MODIFIED:
|
||||||
|
- 命令:cd /Users/yml/codes/order_site/apps/backend && npm test
|
||||||
|
- 输入:当前修改工作树
|
||||||
|
- 字面结果:tests 379;pass 377;fail 0;cancelled 0;skipped 2;exit 0
|
||||||
|
- 状态:通过
|
||||||
|
|
||||||
|
ROLLBACK:
|
||||||
|
- 命令:cp /Users/yml/codes/order_site/publish-atomic-artifacts/MODIFIED_FILE /tmp/publish-atomic-rollback-test.ts && /Users/yml/codes/order_site/publish-atomic-artifacts/ROLLBACK.sh /tmp/publish-atomic-rollback-test.ts && cmp /tmp/publish-atomic-rollback-test.ts <(git -C /Users/yml/codes/order_site show HEAD:apps/backend/src/repositories/worker-platform/work-order-management-repo.ts)
|
||||||
|
- 输入:修改副本 /tmp/publish-atomic-rollback-test.ts
|
||||||
|
- 字面结果:rollback restored HEAD content;cmp exit 0;ROLLBACK.sh exit 0
|
||||||
|
- 恢复状态:另一份副本恢复为 HEAD 内容,MODIFIED_FILE 保持修改内容。
|
||||||
Reference in New Issue
Block a user