From c6dd6a8b7ceedf42dd430417c75825939f7f323b Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 1 Sep 2026 14:13:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=A2=E5=8D=95=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E4=B8=8E=E6=A0=B8=E9=94=80=E5=B9=B6=E5=8F=91=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../work-order-management-repo.ts | 63 ++++- .../admin-work-order-management-service.ts | 88 +++--- .../src/components/WorkOrderEventTimeline.tsx | 3 +- .../pages/admin/panels/WorkOrdersPanel.tsx | 18 +- publish-atomic-artifacts/DIFF_FILE.patch | 262 ++++++++++++++++++ publish-atomic-artifacts/MODIFIED_FILE | 247 +++++++++++++++++ publish-atomic-artifacts/ROLLBACK.sh | 8 + publish-atomic-artifacts/VERIFICATION.txt | 35 +++ 8 files changed, 667 insertions(+), 57 deletions(-) create mode 100644 publish-atomic-artifacts/DIFF_FILE.patch create mode 100644 publish-atomic-artifacts/MODIFIED_FILE create mode 100755 publish-atomic-artifacts/ROLLBACK.sh create mode 100644 publish-atomic-artifacts/VERIFICATION.txt 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( + ` + 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) - if (!voucherConsume.ok) { - const failedAt = nowIso() - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'admin', - actorId: actorName, - eventType: 'publish_voucher_consume_failed', - fromStatus: workOrder.status, - toStatus: workOrder.status, - payloadJson: JSON.stringify(voucherConsume), - now: failedAt, - }) - throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未发布', { + const now = nowIso() + const published = await publishUnassignedWorkOrder({ + workOrderId: workOrder.id, + now, + actorName, + }) + if (!published.order) { + throw createHttpError('订单状态已变化,无法发布,请刷新后重试', { statusCode: 409, - errorCode: 'work_order_publish_voucher_consume_failed', - context: voucherConsume, + errorCode: 'work_order_publish_conflict', }) } - 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, - }, - ) + 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 : '电子凭证核销失败', + } } - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'admin', - actorId: actorName, - eventType: 'published', - fromStatus: workOrder.status, - toStatus: WORK_ORDER_STATUS.OPEN, - payloadJson: JSON.stringify({ voucherConsume }), - now, - }) + if (!voucherConsume.ok) { + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'system', + actorId: 'kuaishou_send_code', + eventType: 'publish_voucher_consume_failed', + fromStatus: WORK_ORDER_STATUS.OPEN, + toStatus: WORK_ORDER_STATUS.OPEN, + payloadJson: JSON.stringify(voucherConsume), + now: nowIso(), + }) + } + + 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: (
- 如订单关联电子凭证,系统会先自动核销,成功后再发布到大厅。核销后即使下架也不会自动恢复凭证。 + 如订单关联电子凭证,系统会在发布后顺带尝试核销;核销失败不影响订单发布。 { @@ -597,7 +599,7 @@ export default function WorkOrdersPanel() {
), - okText: '发布并核销', + okText: '发布订单', cancelText: '取消', onOk: () => { if (skipFutureConfirm) setSkipPublishConfirm() diff --git a/publish-atomic-artifacts/DIFF_FILE.patch b/publish-atomic-artifacts/DIFF_FILE.patch new file mode 100644 index 00000000..681f16c7 --- /dev/null +++ b/publish-atomic-artifacts/DIFF_FILE.patch @@ -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( ++ ` ++ 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: ( +
+ +- 如订单关联电子凭证,系统会先自动核销,成功后再发布到大厅。核销后即使下架也不会自动恢复凭证。 ++ 如订单关联电子凭证,系统会在发布后顺带尝试核销;核销失败不影响订单发布。 + + { +@@ -597,7 +599,7 @@ export default function WorkOrdersPanel() { + +
+ ), +- okText: '发布并核销', ++ okText: '发布订单', + cancelText: '取消', + onOk: () => { + if (skipFutureConfirm) setSkipPublishConfirm() diff --git a/publish-atomic-artifacts/MODIFIED_FILE b/publish-atomic-artifacts/MODIFIED_FILE new file mode 100644 index 00000000..88652ebb --- /dev/null +++ b/publish-atomic-artifacts/MODIFIED_FILE @@ -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 { + 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 { + 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( + ` + 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 { + 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 { + 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 { + const result = await query('DELETE FROM work_orders WHERE id = $1', [Number(workOrderId)]) + return Number(result.rowCount || 0) > 0 +} diff --git a/publish-atomic-artifacts/ROLLBACK.sh b/publish-atomic-artifacts/ROLLBACK.sh new file mode 100755 index 00000000..c8e09f85 --- /dev/null +++ b/publish-atomic-artifacts/ROLLBACK.sh @@ -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" diff --git a/publish-atomic-artifacts/VERIFICATION.txt b/publish-atomic-artifacts/VERIFICATION.txt new file mode 100644 index 00000000..8f6147ac --- /dev/null +++ b/publish-atomic-artifacts/VERIFICATION.txt @@ -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 保持修改内容。