From e05a6797a309a0b77a7274f76d55193cf96b6112 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 20 Aug 2026 20:24:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8E=A5=E5=8D=95=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E4=BB=B7=E6=A0=BC=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../045_work_order_source_product_rule.sql | 25 +++ .../src/repositories/worker-platform/types.ts | 3 + .../worker-platform/work-order-repo.ts | 184 +++++++++++++++++- .../src/routes/admin/worker-platform.ts | 24 +++ .../services/worker-platform/admin-service.ts | 101 ++++++++++ .../worker-platform-service.test.ts | 43 ++++ .../src/components/WorkOrderEventTimeline.tsx | 10 + .../pages/admin/panels/ProductRulesPanel.tsx | 38 ++++ .../src/services/admin/worker-platform.ts | 12 ++ 9 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 apps/backend/src/db/migrations/045_work_order_source_product_rule.sql diff --git a/apps/backend/src/db/migrations/045_work_order_source_product_rule.sql b/apps/backend/src/db/migrations/045_work_order_source_product_rule.sql new file mode 100644 index 00000000..9530b7a7 --- /dev/null +++ b/apps/backend/src/db/migrations/045_work_order_source_product_rule.sql @@ -0,0 +1,25 @@ +-- 045_work_order_source_product_rule.sql —— 记录工单的来源接单模板,支持后续按模板同步价格。 + +ALTER TABLE work_orders + ADD COLUMN IF NOT EXISTS product_rule_id BIGINT + REFERENCES work_product_rules(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_work_orders_product_rule_open + ON work_orders(product_rule_id, id DESC) + WHERE status = 'open'; + +-- 历史自动建单已在 source_synced 事件中记录 ruleKey,迁移时尽可能补齐关联。 +UPDATE work_orders wo +SET product_rule_id = wpr.id +FROM work_product_rules wpr +WHERE wo.product_rule_id IS NULL + AND EXISTS ( + SELECT 1 + FROM work_order_events event + WHERE event.work_order_id = wo.id + AND event.event_type = 'source_synced' + AND event.payload_json ->> 'ruleKey' = wpr.rule_key + ); + +COMMENT ON COLUMN work_orders.product_rule_id + IS '自动建单时命中的接单模板;用于模板价格同步,删除模板后保留工单但置空'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 87be8de3..7e425ebc 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -260,6 +260,8 @@ export type WorkOrderShareRow = { export type WorkOrderRow = { id: number work_order_no: string + /** 自动建单命中的接单模板;历史或人工工单可能为空。 */ + product_rule_id?: number | null order_id: number | null order_item_id: number | null task_id: number | null @@ -411,6 +413,7 @@ export type CreateWorkerInput = { export type CreateWorkOrderInput = { workOrderNo: string + productRuleId?: number | null orderId?: number | null orderItemId?: number | null taskId?: number | null diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index db7ce8be..a4216c41 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -867,6 +867,183 @@ export async function getWorkProductRuleById( return result.rows[0] || null } +/** 查询仍在接单大厅、且明确来源于指定模板的工单。 */ +export async function listOpenWorkOrdersByProductRuleId( + ruleId: number | string, +): Promise { + const result = await query( + `${WORK_ORDER_SELECT} + WHERE wo.product_rule_id = $1 + AND wo.status = 'open' + ORDER BY wo.id ASC`, + [Number(ruleId)], + ) + return result.rows +} + +export type SyncOpenWorkOrderPriceResult = { + updated: boolean + reason: 'updated' | 'price_unchanged' | 'not_open' | 'assigned' | 'sharing_filled' + workOrderId: number + sharingPartial: boolean + workerIds: number[] +} + +/** 拼单改价时保留已加入份额的报酬,只按新价计算剩余份额。 */ +export function resolveOpenSharingSyncReward(input: { + totalQuantity: number + joinedQuantity: number + lockedShareReward: number + nextUnitReward: number +}) { + const totalQuantity = toPositiveInteger(input.totalQuantity, 1) + const joinedQuantity = Math.min(totalQuantity, Math.max(0, Number(input.joinedQuantity || 0))) + return ( + Math.max(0, Number(input.lockedShareReward || 0)) + + (totalQuantity - joinedQuantity) * toPositiveInteger(input.nextUnitReward, 0) + ) +} + +/** + * 同步大厅工单的模板价格。 + * 已加入的拼单份额报酬是合同快照,只更新尚未加入的剩余份额价格。 + */ +export async function syncOpenWorkOrderPriceFromRule(input: { + workOrderId: number + productRuleId: number + rewardAmount: number + sharingUnitReward?: number + actorName: string + now: string +}): Promise { + return withTransaction(async (client) => { + const orderResult = await client.query( + `SELECT * + FROM work_orders + WHERE id = $1 AND product_rule_id = $2 + FOR UPDATE`, + [input.workOrderId, input.productRuleId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder || workOrder.status !== 'open') { + return { + updated: false, + reason: 'not_open', + workOrderId: input.workOrderId, + sharingPartial: false, + workerIds: [], + } + } + if (workOrder.assigned_worker_id) { + return { + updated: false, + reason: 'assigned', + workOrderId: input.workOrderId, + sharingPartial: false, + workerIds: [], + } + } + + const sharesResult = await client.query( + `SELECT * + FROM work_order_shares + WHERE work_order_id = $1 + FOR UPDATE`, + [workOrder.id], + ) + const activeShares = sharesResult.rows.filter((share) => share.status !== 'cancelled') + const joinedQuantity = activeShares.reduce( + (sum, share) => sum + Math.max(0, Number(share.quantity || 0)), + 0, + ) + const workerIds = [ + ...new Set( + activeShares.map((share) => Number(share.worker_id)).filter((workerId) => workerId > 0), + ), + ] + const sharingPartial = workOrder.sharing_enabled === true && joinedQuantity > 0 + let nextRewardAmount = toPositiveInteger(input.rewardAmount, 0) + let nextSharingUnitReward = Number(workOrder.sharing_unit_reward || 0) + + if (workOrder.sharing_enabled === true) { + const totalQuantity = toPositiveInteger(workOrder.sharing_total_quantity, 1) + if (joinedQuantity >= totalQuantity) { + return { + updated: false, + reason: 'sharing_filled', + workOrderId: Number(workOrder.id), + sharingPartial, + workerIds, + } + } + nextSharingUnitReward = toPositiveInteger(input.sharingUnitReward, 0) + const lockedShareReward = activeShares.reduce( + (sum, share) => sum + Math.max(0, Number(share.share_reward || 0)), + 0, + ) + nextRewardAmount = resolveOpenSharingSyncReward({ + totalQuantity, + joinedQuantity, + lockedShareReward, + nextUnitReward: nextSharingUnitReward, + }) + } + + const unchanged = + Number(workOrder.reward_amount || 0) === nextRewardAmount && + (!workOrder.sharing_enabled || + Number(workOrder.sharing_unit_reward || 0) === nextSharingUnitReward) + if (unchanged) { + return { + updated: false, + reason: 'price_unchanged', + workOrderId: Number(workOrder.id), + sharingPartial, + workerIds, + } + } + + await client.query( + `UPDATE work_orders + SET reward_amount = $1, + sharing_unit_reward = $2, + updated_at = $3 + WHERE id = $4`, + [nextRewardAmount, nextSharingUnitReward, input.now, workOrder.id], + ) + await client.query( + `INSERT INTO work_order_events ( + work_order_id, actor_type, actor_id, event_type, from_status, to_status, payload_json, created_at + ) VALUES ($1, 'admin', $2, 'price_synced_from_rule', $3, $3, $4::jsonb, $5)`, + [ + workOrder.id, + input.actorName, + workOrder.status, + JSON.stringify({ + productRuleId: input.productRuleId, + previousRewardAmount: Number(workOrder.reward_amount || 0), + rewardAmount: nextRewardAmount, + previousSharingUnitReward: Number(workOrder.sharing_unit_reward || 0), + sharingUnitReward: nextSharingUnitReward, + joinedQuantity, + lockedShareReward: activeShares.reduce( + (sum, share) => sum + Math.max(0, Number(share.share_reward || 0)), + 0, + ), + }), + input.now, + ], + ) + return { + updated: true, + reason: 'updated', + workOrderId: Number(workOrder.id), + sharingPartial, + workerIds, + } + }) +} + export async function deleteWorkProductRule( ruleId: number | string, ): Promise<{ deleted: boolean }> { @@ -986,19 +1163,20 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise( ` INSERT INTO work_orders ( - work_order_no, order_id, order_item_id, task_id, platform_order_id, + 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, 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::jsonb, $18::jsonb, $19, $20 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16, $17, $18::jsonb, $19::jsonb, $20, $21 ) RETURNING id `, [ input.workOrderNo, + input.productRuleId || null, input.orderId || null, input.orderItemId || null, input.taskId || null, diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 3040236f..c49f3a80 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -53,6 +53,7 @@ import { saveAdminWorkCategory, saveAdminWorkProductRuleMapping, saveAdminWorkProductRule, + syncAdminWorkProductRulePrice, testAdminKuaishouProductMatch, saveAdminWorkerLevel, submitAdminWorkOrderMaterial, @@ -186,6 +187,29 @@ router.post( }), ) +router.post( + '/worker-platform/product-rules/:ruleId/sync-price', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => + syncAdminWorkProductRulePrice( + String(req.params.ruleId || ''), + req.adminSession?.username || '', + ), + { + successMessage: '模板价格已同步', + errorMessage: '同步模板价格失败', + scope: '[admin/worker-platform/product-rules/:ruleId/sync-price]', + audit: (req, data) => ({ + action: 'work_product_rule_price_synced', + targetType: 'work_product_rule', + targetId: String(req.params.ruleId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.delete( '/worker-platform/product-rules/:ruleId', requireAdminRoles(['admin', 'operator']), diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index f6ec6468..a5dd91b4 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -36,6 +36,7 @@ import { listWorkerFinanceRequests, listWorkOrders, listWorkOrderEventsByOrderId, + listOpenWorkOrdersByProductRuleId, listWorkProductRules, listWorkProductRuleMappings, listWorkProductRuleMappingsPage, @@ -47,6 +48,7 @@ import { listWorkOrderShares, listWorkOrderSharesByOrderIds, sumPendingUnfreezeByOrderIds, + syncOpenWorkOrderPriceFromRule, resolveProblemWorkOrder, reviewWorkerFinanceRequest, reopenCancelledWorkOrder, @@ -67,6 +69,7 @@ import { type WorkOrderRow, type WorkProductMatchLogRow, type WorkProductRuleMappingRow, + type WorkProductRuleRow, type WorkerWithdrawalAccountRow, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' @@ -881,6 +884,103 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { return { rule: mapWorkProductRule(rule) } } +/** 根据工单创建时的规格,计算当前模板可同步的价格。 */ +export function resolveWorkOrderTemplateSyncPrice(input: { + rule: WorkProductRuleRow + workOrder: WorkOrderRow +}): { rewardAmount: number; sharingUnitReward?: number } | null { + const material = safeParseJson(input.workOrder.material_json) + const source = safeParseJson(material.source) + const sourceSkuQuantity = Number(source.skuQuantity || 0) + const skuQuantity = + Number.isSafeInteger(sourceSkuQuantity) && sourceSkuQuantity > 0 + ? sourceSkuQuantity + : resolveSkuNameQuantity(input.workOrder.product_name) + const pricing = resolveWorkOrderRulePricing({ + skuQuantity, + unitPriceFen: Number(input.rule.unit_price_fen || 0), + fixedRewardAmount: Number(input.rule.reward_amount || 0), + sharingEnabled: input.rule.sharing_enabled === true, + sharingAutoFromOrder: input.rule.sharing_auto_from_order === true, + sharingTotalQuantity: Number(input.rule.sharing_total_quantity || 1), + sharingUnitReward: Number(input.rule.sharing_unit_reward || 0), + }) + + if (input.workOrder.sharing_enabled === true) { + if (input.rule.sharing_enabled !== true || pricing.sharingUnitReward <= 0) { + return null + } + return { + rewardAmount: pricing.rewardAmount, + sharingUnitReward: pricing.sharingUnitReward, + } + } + return pricing.rewardAmount > 0 ? { rewardAmount: pricing.rewardAmount } : null +} + +/** 将指定模板的当前价格同步到仍在接单大厅的来源工单。 */ +export async function syncAdminWorkProductRulePrice(ruleId: number | string, actorName = '') { + const normalizedRuleId = normalizeOptionalId(ruleId) + const rule = normalizedRuleId ? await getWorkProductRuleById(normalizedRuleId) : null + if (!rule) { + throw createHttpError('接单模板不存在', { + statusCode: 404, + errorCode: 'work_product_rule_not_found', + }) + } + + const candidates = await listOpenWorkOrdersByProductRuleId(rule.id) + const result = { + candidateCount: candidates.length, + updatedCount: 0, + updatedNormalCount: 0, + updatedPartialSharingCount: 0, + skippedInvalidPricingCount: 0, + skippedUnchangedCount: 0, + skippedUnavailableCount: 0, + } + const now = nowIso() + + for (const workOrder of candidates) { + const price = resolveWorkOrderTemplateSyncPrice({ rule, workOrder }) + if (!price) { + result.skippedInvalidPricingCount += 1 + continue + } + const synced = await syncOpenWorkOrderPriceFromRule({ + workOrderId: Number(workOrder.id), + productRuleId: Number(rule.id), + rewardAmount: price.rewardAmount, + ...(price.sharingUnitReward === undefined + ? {} + : { sharingUnitReward: price.sharingUnitReward }), + actorName, + now, + }) + if (!synced.updated) { + if (synced.reason === 'price_unchanged') { + result.skippedUnchangedCount += 1 + } else { + result.skippedUnavailableCount += 1 + } + continue + } + result.updatedCount += 1 + if (synced.sharingPartial) { + result.updatedPartialSharingCount += 1 + } else { + result.updatedNormalCount += 1 + } + publishWorkOrderRealtimeChange({ + workOrderId: synced.workOrderId, + workerIds: synced.workerIds, + hallChanged: true, + }) + } + + return { rule: mapWorkProductRule(rule), ...result } +} + export async function deleteAdminWorkProductRule(ruleId: number | string) { const normalizedRuleId = normalizeOptionalId(ruleId) if (!normalizedRuleId) { @@ -2584,6 +2684,7 @@ export async function syncWorkerOrdersForSourceOrder( const materialComplete = fields.every((field) => !field.required) const workOrder = await createWorkOrder({ workOrderNo: randomId('WO'), + productRuleId: Number(rule.id), orderId: Number(order.id), orderItemId: Number(item.id), taskId: null, diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 73dea746..10ee957c 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -11,6 +11,7 @@ import type { import { resolveWorkOrderShareCancellationStatus, resolveWorkOrderShareJoinQuantity, + resolveOpenSharingSyncReward, } from '../../repositories/worker-platform/index.js' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import { @@ -30,6 +31,7 @@ import { resolveWorkerPermissions, resolveSkuNameQuantity, resolveWorkOrderRulePricing, + resolveWorkOrderTemplateSyncPrice, resolveWorkOrderSharingEnabled, validateWorkerPassword, } from './index.js' @@ -592,6 +594,47 @@ test('按件拼单手动覆盖数量和单价时,保留手工配置', () => { }) }) +test('模板同步价格按工单来源规格重新计算普通按件单', () => { + const price = resolveWorkOrderTemplateSyncPrice({ + rule: buildProductRule({ unit_price_fen: 200 }), + workOrder: buildWorkOrderRow({ + product_name: '指挥官秘钥15个', + material_json: { source: { skuQuantity: 8 } }, + }), + }) + + assert.deepEqual(price, { rewardAmount: 1600 }) +}) + +test('模板同步价格为拼单返回新的份额单价', () => { + const price = resolveWorkOrderTemplateSyncPrice({ + rule: buildProductRule({ + unit_price_fen: 200, + sharing_enabled: true, + sharing_auto_from_order: true, + }), + workOrder: buildWorkOrderRow({ + product_name: '指挥官秘钥15个', + sharing_enabled: true, + sharing_total_quantity: 15, + }), + }) + + assert.deepEqual(price, { rewardAmount: 3000, sharingUnitReward: 200 }) +}) + +test('部分拼单同步后总报酬保留已加入份额的旧价格', () => { + assert.equal( + resolveOpenSharingSyncReward({ + totalQuantity: 10, + joinedQuantity: 4, + lockedShareReward: 4000, + nextUnitReward: 1200, + }), + 11_200, + ) +}) + test('模板启用拼单但实际只有一份时生成普通工单', () => { assert.equal(resolveWorkOrderSharingEnabled(true, 1), false) }) diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index c023e336..dc27caac 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -230,6 +230,16 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { return { title: '更改了订单信息', color: 'default' } case 'sharing_config_updated': return { title: '更新了拼单配置', color: 'default' } + case 'price_synced_from_rule': { + const rewardAmount = Number(readPayload(event, 'rewardAmount') || 0) + const unitReward = Number(readPayload(event, 'sharingUnitReward') || 0) + return { + title: unitReward > 0 ? '从接单模板同步了拼单剩余份额价格' : '从接单模板同步了接单价格', + description: + rewardAmount > 0 ? `同步后预计总报酬:${(rewardAmount / 100).toFixed(2)} 元` : undefined, + color: 'default', + } + } case 'sharing_share_cancelled_by_admin': { const workerId = readPayload(event, 'workerId') const quantity = readPayload(event, 'quantity') diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index dc036d66..d93da374 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -15,6 +15,7 @@ import { Form, Input, InputNumber, + Modal, Popconfirm, Radio, Select, @@ -38,6 +39,7 @@ import { fetchAdminWorkProductRules, reprocessAdminKuaishouWorkOrderMatches, saveAdminWorkProductRule, + syncAdminWorkProductRulePrice, } from '@/services/admin' import type { CollectField, WorkProductRule, WorkProductRuleMapping } from '@/types/worker-platform' import type { AdminKuaishouIndustryShopOption } from '@/types/admin' @@ -54,6 +56,7 @@ export default function ProductRulesPanel() { const hasInitializedRuleSelection = useRef(false) const [editingRule, setEditingRule] = useState(null) + const [syncingPrice, setSyncingPrice] = useState(false) const [activeMappingId, setActiveMappingId] = useState(null) const [sourcePrefill, setSourcePrefill] = useState(null) @@ -282,6 +285,36 @@ export default function ProductRulesPanel() { } } + function confirmSyncRulePrice() { + if (!editingRule) return + Modal.confirm({ + title: '同步模板价格', + content: + '将同步接单大厅中该模板的待抢单价格。部分拼单仅调整剩余份额价格,已加入份额报酬保持不变。', + okText: '确认同步', + cancelText: '取消', + onOk: async () => { + setSyncingPrice(true) + try { + const response = await syncAdminWorkProductRulePrice(editingRule.ruleId) + const result = response.data + message.success( + `已同步 ${result.updatedCount} 单:待抢单 ${result.updatedNormalCount} 单,部分拼单 ${result.updatedPartialSharingCount} 单`, + ) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-rules'] }), + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }), + ]) + } catch (error) { + message.error(error instanceof Error ? error.message : '同步价格失败') + throw error + } finally { + setSyncingPrice(false) + } + }, + }) + } + async function toggleRuleEnabled(rule: WorkProductRule, enabled: boolean) { try { const hasManualSharingOverride = hasManualSharingInput( @@ -668,6 +701,11 @@ export default function ProductRulesPanel() { + 新建模板 ) : null} + {editingRule ? ( + + ) : null} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 2959af75..6707bb74 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -182,6 +182,18 @@ export function saveAdminWorkProductRule(payload: { return apiPost<{ rule: WorkProductRule }>('/api/v1/admin/worker-platform/product-rules', payload) } +export function syncAdminWorkProductRulePrice(ruleId: number) { + return apiPost<{ + candidateCount: number + updatedCount: number + updatedNormalCount: number + updatedPartialSharingCount: number + skippedInvalidPricingCount: number + skippedUnchangedCount: number + skippedUnavailableCount: number + }>(`/api/v1/admin/worker-platform/product-rules/${ruleId}/sync-price`) +} + export function deleteAdminWorkProductRule(ruleId: number) { return apiDelete<{ deleted: boolean }>(`/api/v1/admin/worker-platform/product-rules/${ruleId}`) }