From 9a7aaf605e667c6947d65ef1e0fa27ab2b44d0c5 Mon Sep 17 00:00:00 2001 From: yml Date: Sat, 14 Nov 2026 21:36:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E6=8E=A5=E5=8D=95=E5=A4=A7?= =?UTF-8?q?=E5=8E=85=E6=A8=A1=E6=9D=BF=E4=BB=B7=E6=A0=BC=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E4=B8=8E=E6=9C=AC=E5=9C=B0=E5=BC=80=E5=8F=91=E5=AE=B9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/package.json | 1 + .../scripts/audit-work-order-hall-pricing.ts | 171 ++++++++++ .../163_work_order_hall_price_version.sql | 65 ++++ .../src/repositories/worker-platform/types.ts | 2 + .../worker-platform/work-order-cancel-repo.ts | 30 +- .../worker-platform/work-order-issue-repo.ts | 7 + .../work-order-management-repo.ts | 28 ++ .../work-order-settlement-repo.ts | 5 + .../worker-platform/work-order-share-repo.ts | 4 + .../worker-platform/work-product-rule-repo.ts | 197 ++--------- .../worker-cancel-request-repo.ts | 5 + .../services/cost-center/cost-center.test.ts | 4 +- .../cost-center/product-catalog-service.ts | 43 ++- ...ork-order-hall-pricing.integration.test.ts | 312 ++++++++++++++++++ .../services/finance/finance-controls.test.ts | 3 +- .../admin-work-order-management-service.ts | 7 - .../admin-worker-catalog-service.ts | 167 +++++----- .../consignment-match-engine-service.ts | 49 ++- .../work-order-hall-pricing-service.ts | 301 +++++++++++++++++ .../worker-platform/worker-hall-service.ts | 5 + .../worker-platform-service.test.ts | 14 +- deploy/docker/backend-dev.Dockerfile | 9 +- deploy/docker/dev-caddy.Dockerfile | 5 +- docker-compose.dev.yml | 13 +- docs/生产升级操作手册.md | 41 ++- docs/统一订单履约与财务模型-权威版.md | 36 +- 26 files changed, 1180 insertions(+), 344 deletions(-) create mode 100644 apps/backend/scripts/audit-work-order-hall-pricing.ts create mode 100644 apps/backend/src/db/migrations/163_work_order_hall_price_version.sql create mode 100644 apps/backend/src/services/cost-center/work-order-hall-pricing.integration.test.ts create mode 100644 apps/backend/src/services/worker-platform/work-order-hall-pricing-service.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 99cf09cd..240ef4a9 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -34,6 +34,7 @@ "test:industry:curl": "tsx scripts/curl-send-callback.ts", "audit:fulfillment": "tsx scripts/audit-fulfillment-state.ts", "audit:product-routing": "tsx scripts/audit-product-routing-alignment.ts", + "audit:work-order-hall-pricing": "tsx scripts/audit-work-order-hall-pricing.ts", "typecheck": "tsc -p tsconfig.json --noEmit", "check": "npm run format:check && npm run check:sql && npm run lint:check && npm run typecheck && npm test && npm run build", "start": "node dist/index.js", diff --git a/apps/backend/scripts/audit-work-order-hall-pricing.ts b/apps/backend/scripts/audit-work-order-hall-pricing.ts new file mode 100644 index 00000000..d470c665 --- /dev/null +++ b/apps/backend/scripts/audit-work-order-hall-pricing.ts @@ -0,0 +1,171 @@ +import { closeDb, query } from '../src/db/client.js' +import type { WorkOrderRow, WorkProductRuleRow } from '../src/repositories/worker-platform/types.js' +import { resolveWorkOrderTemplatePriceProjection } from '../src/services/worker-platform/work-order-hall-pricing-service.js' + +type ShareAggregate = { + work_order_id: number + joined_quantity: number + locked_share_reward: number +} + +type AuditIssue = { + severity: 'critical' | 'warning' + code: string + workOrderId: number + status: string + detail: string +} + +const rows = await query( + `SELECT * + FROM work_orders + WHERE product_rule_id IS NOT NULL + AND assigned_worker_id IS NULL + AND status IN ('pending_material', 'unassigned', 'open') + AND NOT (COALESCE(material_json, '{}'::jsonb) ? 'consignment') + ORDER BY id`, +) +const workOrders = rows.rows +const ruleIds = [...new Set(workOrders.map((row) => Number(row.product_rule_id || 0)))] +const workOrderIds = workOrders.map((row) => Number(row.id)) + +const rulesResult = + ruleIds.length > 0 + ? await query( + 'SELECT * FROM work_product_rules WHERE id = ANY($1::bigint[])', + [ruleIds], + ) + : { rows: [] as WorkProductRuleRow[] } +const versionsResult = + ruleIds.length > 0 + ? await query<{ work_product_rule_id: number; id: number }>( + `SELECT DISTINCT ON (work_product_rule_id) + work_product_rule_id, id + FROM cost_versions + WHERE work_product_rule_id = ANY($1::bigint[]) + AND status = 'active' + AND effective_from <= NOW() + AND (effective_to IS NULL OR effective_to > NOW()) + ORDER BY work_product_rule_id, effective_from DESC, id DESC`, + [ruleIds], + ) + : { rows: [] as Array<{ work_product_rule_id: number; id: number }> } +const sharesResult = + workOrderIds.length > 0 + ? await query( + `SELECT + work_order_id, + COALESCE(SUM(quantity), 0)::int AS joined_quantity, + COALESCE(SUM(share_reward), 0)::bigint AS locked_share_reward + FROM work_order_shares + WHERE work_order_id = ANY($1::bigint[]) + AND status != 'cancelled' + GROUP BY work_order_id`, + [workOrderIds], + ) + : { rows: [] as ShareAggregate[] } + +const rules = new Map(rulesResult.rows.map((rule) => [Number(rule.id), rule])) +const activeVersions = new Map( + versionsResult.rows.map((version) => [Number(version.work_product_rule_id), Number(version.id)]), +) +const shareAggregates = new Map( + sharesResult.rows.map((share) => [Number(share.work_order_id), share]), +) +const issues: AuditIssue[] = [] + +for (const workOrder of workOrders) { + const ruleId = Number(workOrder.product_rule_id || 0) + const rule = rules.get(ruleId) + const activeVersionId = activeVersions.get(ruleId) || 0 + const share = shareAggregates.get(Number(workOrder.id)) + const joinedQuantity = Number(share?.joined_quantity || 0) + const lockedShareReward = Number(share?.locked_share_reward || 0) + const severity = workOrder.status === 'open' ? 'critical' : 'warning' + + if (!rule) { + issues.push({ + severity, + code: 'template_missing', + workOrderId: Number(workOrder.id), + status: workOrder.status, + detail: `product_rule_id=${ruleId} 不存在`, + }) + continue + } + if (!activeVersionId) { + issues.push({ + severity, + code: 'active_cost_version_missing', + workOrderId: Number(workOrder.id), + status: workOrder.status, + detail: `模板 ${ruleId} 没有当前生效成本版本`, + }) + continue + } + if (Number(workOrder.hall_price_version_id || 0) !== activeVersionId) { + issues.push({ + severity, + code: 'hall_price_version_stale', + workOrderId: Number(workOrder.id), + status: workOrder.status, + detail: `当前=${Number(workOrder.hall_price_version_id || 0) || 'NULL'} 应=${activeVersionId}`, + }) + } + + const projection = resolveWorkOrderTemplatePriceProjection({ rule, workOrder }) + if (!projection) { + issues.push({ + severity, + code: 'template_price_invalid', + workOrderId: Number(workOrder.id), + status: workOrder.status, + detail: '模板当前配置无法计算工单价格', + }) + continue + } + const totalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) + const expectedReward = workOrder.sharing_enabled + ? lockedShareReward + + Math.max(0, totalQuantity - joinedQuantity) * Number(projection.sharingUnitReward || 0) + : projection.rewardAmount + if (Number(workOrder.reward_amount || 0) !== expectedReward) { + issues.push({ + severity, + code: 'reward_amount_stale', + workOrderId: Number(workOrder.id), + status: workOrder.status, + detail: `当前=${Number(workOrder.reward_amount || 0)} 应=${expectedReward}`, + }) + } + if ( + workOrder.sharing_enabled && + Number(workOrder.sharing_unit_reward || 0) !== Number(projection.sharingUnitReward || 0) + ) { + issues.push({ + severity, + code: 'sharing_unit_reward_stale', + workOrderId: Number(workOrder.id), + status: workOrder.status, + detail: `当前=${Number(workOrder.sharing_unit_reward || 0)} 应=${Number(projection.sharingUnitReward || 0)}`, + }) + } +} + +const critical = issues.filter((issue) => issue.severity === 'critical') +const warnings = issues.filter((issue) => issue.severity === 'warning') +console.log( + JSON.stringify( + { + ok: critical.length === 0, + checkedAt: new Date().toISOString(), + scannedWorkOrders: workOrders.length, + critical, + warnings, + }, + null, + 2, + ), +) +await closeDb() +if (critical.length > 0) process.exitCode = 1 diff --git a/apps/backend/src/db/migrations/163_work_order_hall_price_version.sql b/apps/backend/src/db/migrations/163_work_order_hall_price_version.sql new file mode 100644 index 00000000..05285dd3 --- /dev/null +++ b/apps/backend/src/db/migrations/163_work_order_hall_price_version.sql @@ -0,0 +1,65 @@ +-- 163_work_order_hall_price_version.sql +-- +-- 接单大厅价格必须来自当前生效的接单模板成本版本: +-- 1. hall_price_version_id 记录工单进入大厅时物化的成本版本; +-- 2. 模板工单进入 open 时,若版本缺失、失效或不属于同一模板,数据库直接拒绝; +-- 3. 正在使用寄卖现货的合同态工单豁免,避免覆盖寄卖结算金额。 +-- +-- 本迁移不重写历史工单金额,只新增版本引用、同步索引和写入门禁。 + +ALTER TABLE work_orders + ADD COLUMN IF NOT EXISTS hall_price_version_id BIGINT + REFERENCES cost_versions(id) ON DELETE RESTRICT; + +COMMENT ON COLUMN work_orders.hall_price_version_id IS + '工单最近一次按接单模板物化大厅价格时使用的 cost_versions.id;寄卖合同态可为空'; + +CREATE INDEX IF NOT EXISTS idx_work_orders_price_sync_candidates + ON work_orders (product_rule_id, status, id) + WHERE product_rule_id IS NOT NULL + AND status IN ('pending_material', 'unassigned', 'open'); + +CREATE INDEX IF NOT EXISTS idx_work_orders_hall_price_version + ON work_orders (hall_price_version_id) + WHERE hall_price_version_id IS NOT NULL; + +CREATE OR REPLACE FUNCTION assert_work_order_hall_price_version() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.status <> 'open' + OR NEW.product_rule_id IS NULL + OR COALESCE(NEW.material_json, '{}'::jsonb) ? 'consignment' + THEN + RETURN NEW; + END IF; + + IF NEW.hall_price_version_id IS NULL OR NOT EXISTS ( + SELECT 1 + FROM cost_versions version + WHERE version.id = NEW.hall_price_version_id + AND version.work_product_rule_id = NEW.product_rule_id + AND version.status = 'active' + ) THEN + RAISE EXCEPTION + 'work order % cannot enter hall with stale hall_price_version_id %', + NEW.id, + NEW.hall_price_version_id + USING ERRCODE = '23514', + HINT = 'Call the work-order hall pricing service before setting status = open.'; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_work_orders_hall_price_version ON work_orders; +CREATE TRIGGER trg_work_orders_hall_price_version +BEFORE INSERT OR UPDATE OF status, hall_price_version_id, product_rule_id +ON work_orders +FOR EACH ROW +EXECUTE FUNCTION assert_work_order_hall_price_version(); + +COMMENT ON FUNCTION assert_work_order_hall_price_version() IS + '模板工单进入接单大厅时必须携带当前有效成本版本;寄卖合同态豁免'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index dda3225c..36d066ef 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -437,6 +437,8 @@ export type WorkOrderRow = { sharing_enabled: boolean sharing_total_quantity: number sharing_unit_reward: number + /** 最近一次物化大厅价格使用的接单模板成本版本。 */ + hall_price_version_id?: number | null published_at: string | null hall_queued_at: string | null pinned_at: string | null diff --git a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts index 0324594b..13d5733f 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts @@ -8,6 +8,7 @@ import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' import { getOutstandingDepositAmountWithClient } from './work-order-deposit-repo.js' import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js' import type { WorkOrderRow } from './types.js' +import { assertWorkOrderHallPriceReadyWithClient } from '../../services/worker-platform/work-order-hall-pricing-service.js' export async function cancelWorkerWorkOrder(input: { workOrderId: number @@ -42,6 +43,14 @@ export async function cancelWorkerWorkOrder(input: { Number(wallet?.frozen_deposit_amount || 0), ) + // 寄卖现货先回库并恢复匹配前报酬,再物化大厅模板价。 + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, { + now: input.now, + reason: '打手撤单', + }) + await assertWorkOrderHallPriceReadyWithClient(client, input.workOrderId, { + now: input.now, + }) await client.query( ` UPDATE work_orders @@ -81,12 +90,6 @@ export async function cancelWorkerWorkOrder(input: { ) } - // 打手撤单:被本工单占用的寄卖现货放回在库池(否则现货永久停留在 consumed)。 - await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, { - now: input.now, - reason: '打手撤单', - }) - await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'worker', @@ -151,6 +154,15 @@ export async function unassignWorkOrder(input: { Number(wallet?.frozen_deposit_amount || 0), ) + // 退回大厅前先释放寄卖绑定并恢复原合同价,再按当前模板价重新物化。 + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, { + now: input.now, + reason: '后台退回大厅', + }) + await assertWorkOrderHallPriceReadyWithClient(client, input.workOrderId, { + now: input.now, + actorName: input.actorName || '', + }) await client.query( ` UPDATE work_orders @@ -324,12 +336,6 @@ export async function returnAssignedWorkOrderToHall(input: { ) } - // 退回大厅:寄卖现货回到在库池;若该单原本走内部现货履约,运营可重新指派。 - await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, { - now: input.now, - reason: '后台退回大厅', - }) - await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index 61ec1573..ed7bd11f 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -14,6 +14,7 @@ import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' import { getOutstandingDepositAmountWithClient } from './work-order-deposit-repo.js' import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js' import type { ProblemWorkOrderResolutionAction, WorkOrderRow, WorkOrderShareRow } from './types.js' +import { assertWorkOrderHallPriceReadyWithClient } from '../../services/worker-platform/work-order-hall-pricing-service.js' /** * 统计工单的活跃拼单份额(除已取消外都算活跃)。 @@ -211,6 +212,12 @@ export async function resolveProblemWorkOrder(input: { : `问题单处置退回大厅(${effectiveAction})`, }) } + if (targetStatus === 'open') { + await assertWorkOrderHallPriceReadyWithClient(client, input.workOrderId, { + now: input.now, + actorName: input.actorName || '', + }) + } await client.query( ` 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 f06e15fc..6635b7ee 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 @@ -3,6 +3,10 @@ import { resolveSourceOrderAmountFen, toJsonString, toPositiveInteger } from './ import { getWorkOrderById, getWorkOrderByIdWithClient } from './work-order-query-repo.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { insertWorkOrderPaymentAmountHistoryWithClient } from './work-order-payment-history-repo.js' +import { + assertWorkOrderHallPriceReadyWithClient, + materializeWorkOrderHallPriceWithClient, +} from '../../services/worker-platform/work-order-hall-pricing-service.js' import { normalizeWorkOrderAcceptanceMode, normalizeWorkOrderGiftCooldownHours, @@ -187,6 +191,26 @@ export async function updateWorkOrder( const statement = buildWorkOrderUpdateStatement(workOrderId, patch, options) // 空 patch 不产生写操作;返回当前值,保持原有调用语义。 if (!statement) return getWorkOrderById(workOrderId) + if (patch.status === 'open') { + return withTransaction(async (client) => { + const currentResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [Number(workOrderId)], + ) + const current = currentResult.rows[0] || null + if (!current) return null + if (options.expectedStatus && current.status !== options.expectedStatus) return null + await assertWorkOrderHallPriceReadyWithClient(client, Number(current.id), { + now: String(patch.updated_at || current.updated_at), + }) + const result = await client.query<{ id: number }>(statement.text, statement.values) + if (!result.rows[0]) return null + await materializeWorkOrderHallPriceWithClient(client, Number(current.id), { + now: String(patch.updated_at || current.updated_at), + }) + return getWorkOrderByIdWithClient(client, workOrderId) + }) + } const result = await query<{ id: number }>(statement.text, statement.values) if (!result.rows[0]) return null return getWorkOrderById(workOrderId) @@ -246,6 +270,10 @@ export async function publishUnassignedWorkOrder(input: { if (!current || current.status !== 'unassigned' || current.assigned_worker_id) { return { order: null, failureReason: 'work_order_not_publishable' } } + await assertWorkOrderHallPriceReadyWithClient(client, Number(current.id), { + now: input.now, + actorName: input.actorName || '', + }) const updatedResult = await client.query<{ id: number }>( ` diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index c72813e7..4f69ffe1 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -24,6 +24,7 @@ import { } from './work-order-deposit-repo.js' import { maybeUpgradeWorkerLevel } from './worker-ranking-repo.js' import type { WorkOrderRow, WorkOrderShareRow } from './types.js' +import { assertWorkOrderHallPriceReadyWithClient } from '../../services/worker-platform/work-order-hall-pricing-service.js' import { forfeitConsignmentPublishDepositByInventoryWithClient, releaseConsignmentPublishDepositBySubmissionWithClient, @@ -1344,6 +1345,10 @@ export async function cancelWorkOrderSharing(input: { [input.now, cancelledShareIds], ) if (input.action === 'return_to_hall') { + await assertWorkOrderHallPriceReadyWithClient(client, input.workOrderId, { + now: input.now, + actorName: input.actorName || '', + }) await client.query( `UPDATE work_orders SET status = 'open', diff --git a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts index 2b0e1f73..904a0d50 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts @@ -21,6 +21,7 @@ import { isWorkOrderWithinHallCapacityWithClient, } from './work-order-repo.js' import type { WorkOrderRow, WorkOrderShareRow } from './types.js' +import { assertWorkOrderHallPriceReadyWithClient } from '../../services/worker-platform/work-order-hall-pricing-service.js' export async function listWorkOrderShares( workOrderId: number | string, @@ -789,6 +790,9 @@ export async function recomputeWorkOrderSharingStatusWithClient( if (String(workOrder.status || '') === nextStatus) { return workOrder } + if (nextStatus === 'open') { + await assertWorkOrderHallPriceReadyWithClient(client, workOrderId, { now }) + } const hallQueueAssignments = options.preserveHallQueue ? '' diff --git a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts index 06318a2f..c47f4c09 100644 --- a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts @@ -1,18 +1,13 @@ import type { PoolClient } from 'pg' -import { query, withTransaction } from '../../db/client.js' +import { query } from '../../db/client.js' import { toPositiveInteger } from './shared.js' import { normalizeWorkOrderAcceptanceMode, normalizeWorkOrderGiftCooldownHours, normalizeWorkOrderGiftCooldownMinutes, } from '../../domain/work-order-acceptance-mode.js' -import type { - ProductRuleListInput, - WorkOrderRow, - WorkOrderShareRow, - WorkProductRuleRow, -} from './types.js' +import type { ProductRuleListInput, WorkOrderRow, WorkProductRuleRow } from './types.js' import { WORK_ORDER_SELECT } from './work-order-repo.js' const WORK_PRODUCT_RULE_SELECT = ` @@ -137,181 +132,33 @@ export async function getWorkProductRuleById( return result.rows[0] || null } -/** 查询仍在接单大厅、且明确来源于指定模板的工单。 */ -export async function listOpenWorkOrdersByProductRuleId( +/** 查询尚未形成不可变履约合同、需要跟随模板价格更新的工单。 */ +export async function listWorkOrdersForPriceSyncByProductRuleId( ruleId: number | string, + client?: Pick, ): Promise { - const result = await query( - `${WORK_ORDER_SELECT} + const sql = `${WORK_ORDER_SELECT} WHERE wo.product_rule_id = $1 - AND wo.status = 'open' - ORDER BY wo.id ASC`, - [Number(ruleId)], - ) + AND wo.status IN ('pending_material', 'unassigned', 'open') + ORDER BY wo.id ASC` + const result = client + ? await client.query(sql, [Number(ruleId)]) + : await query(sql, [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 lockWorkProductRuleForPriceUpdate( + client: Pick, + ruleKey: string, +): Promise { + const result = await client.query( + `${WORK_PRODUCT_RULE_SELECT} + WHERE wpr.rule_key = $1 + LIMIT 1 + FOR UPDATE OF wpr`, + [String(ruleKey || '').trim()], ) -} - -/** - * 同步大厅工单的模板价格。 - * 已加入的拼单份额报酬是合同快照,只更新尚未加入的剩余份额价格。 - */ -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, - } - }) + return result.rows[0] || null } export async function detachWorkProductRuleCostVersions( diff --git a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts index e69f7de8..f14bae97 100644 --- a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts @@ -17,6 +17,7 @@ import { getWorkOrderByIdWithClient, } from './work-order-repo.js' import type { WorkerCancelRequestRow, WorkOrderRow, WorkOrderShareRow } from './types.js' +import { assertWorkOrderHallPriceReadyWithClient } from '../../services/worker-platform/work-order-hall-pricing-service.js' const WORKER_CANCEL_REQUEST_SELECT = ` SELECT @@ -671,6 +672,10 @@ export async function reviewWorkerCancelRequest(input: { now: input.now, reason: '打手退单申请通过', }) + await assertWorkOrderHallPriceReadyWithClient(client, workOrder.id, { + now: input.now, + actorName: input.actorName || '', + }) await client.query( `UPDATE work_orders SET status = 'open', assigned_worker_id = NULL, assigned_at = NULL, deadline_at = NULL, diff --git a/apps/backend/src/services/cost-center/cost-center.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts index 012b10e8..9e0ef899 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -279,7 +279,9 @@ test('mapping lookup follows specificity and concrete-shop priority', () => { test('接单模板改价必须在同一事务中同步成本版本与模板投影', async () => { const { readFile } = await import('node:fs/promises') const source = await readFile(new URL('./product-catalog-service.ts', import.meta.url), 'utf8') - assert.ok(source.includes('const costVersionId = await withTransaction')) + assert.ok(source.includes('const priceSync = await withTransaction')) + assert.ok(source.includes('syncAdminWorkProductRulePriceWithClient')) + assert.ok(source.includes('return { costVersionId: nextCostVersionId, syncedOrders }')) assert.ok(source.includes('reward_amount = $1::bigint')) assert.ok(source.includes('unit_price_fen = $2::bigint')) assert.ok(source.includes('cost_work_order_future_price_not_supported')) diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index 7336850a..cbaec7f1 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -24,7 +24,12 @@ import { resolveOrphanCostExceptions, } from './cost-exception-service.js' import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-facts.js' -import { syncAdminWorkProductRulePrice } from '../worker-platform/admin-worker-catalog-service.js' +import { + syncAdminWorkProductRulePrice, + syncAdminWorkProductRulePriceWithClient, +} from '../worker-platform/admin-worker-catalog-service.js' +import { invalidateAllWorkerHallListCache } from '../worker-platform/worker-hall-service.js' +import { publishWorkerHallRealtimeRefresh } from '../realtime/realtime-event-service.js' import { AFFILIATE_TASK_AGG_JOIN, MAPPING_CONFIDENCES, @@ -1591,7 +1596,17 @@ export async function setAdminProductPrice( } const projectedRewardAmount = pricingMode === 'unit' ? 0 : amountFen const projectedUnitPrice = pricingMode === 'unit' ? amountFen : 0 - const costVersionId = await withTransaction(async (client) => { + const priceSync = await withTransaction(async (client) => { + const lockedRule = await client.query<{ id: number }>( + `SELECT id FROM work_product_rules WHERE id = $1 FOR UPDATE`, + [rule.id], + ) + if (!lockedRule.rows[0]) { + throw createHttpError('接单模板不存在', { + statusCode: 404, + errorCode: 'work_product_rule_not_found', + }) + } const existingResult = await client.query<{ id: number amount_fen: number @@ -1603,7 +1618,8 @@ export async function setAdminProductPrice( AND work_product_rule_id = $2 AND status = 'active' ORDER BY effective_from DESC, id DESC - LIMIT 1`, + LIMIT 1 + FOR UPDATE`, [costItemId, rule.id], ) const existing = existingResult.rows[0] @@ -1715,23 +1731,26 @@ export async function setAdminProductPrice( if (!nextCostVersionId) { throw createHttpError('接单模板成本版本保存失败', { statusCode: 500 }) } - return nextCostVersionId + const syncNow = new Date().toISOString() + const syncedOrders = await syncAdminWorkProductRulePriceWithClient( + client, + Number(rule.id), + session.username, + syncNow, + ) + return { costVersionId: nextCostVersionId, syncedOrders } }) - - // 如果指定了同步工单价格(默认 true) - let syncedOrders: unknown = null - if (payload.syncOpenOrders !== false) { - syncedOrders = await syncAdminWorkProductRulePrice(rule.id, session.username) - } + invalidateAllWorkerHallListCache() + publishWorkerHallRealtimeRefresh() return { success: true, product, - costVersionId, + costVersionId: priceSync.costVersionId, amountFen, effectiveFrom, pricingMode, - syncedOrders, + syncedOrders: priceSync.syncedOrders, message: '接单模板结算价格已更新', } } diff --git a/apps/backend/src/services/cost-center/work-order-hall-pricing.integration.test.ts b/apps/backend/src/services/cost-center/work-order-hall-pricing.integration.test.ts new file mode 100644 index 00000000..5314c58c --- /dev/null +++ b/apps/backend/src/services/cost-center/work-order-hall-pricing.integration.test.ts @@ -0,0 +1,312 @@ +/** + * 接单大厅价格版本与改价同步集成测试(仅隔离 PostgreSQL)。 + * + * WORK_ORDER_HALL_PRICING_TEST_DATABASE_URL=postgres://... \ + * npx tsx --test src/services/cost-center/work-order-hall-pricing.integration.test.ts + */ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { PoolClient } from 'pg' + +const TEST_DATABASE_URL = String(process.env.WORK_ORDER_HALL_PRICING_TEST_DATABASE_URL || '').trim() + +test( + '模板改价同步未发布工单、保留已加入拼单份额并阻止旧版本进入大厅', + { skip: !TEST_DATABASE_URL ? '未设置 WORK_ORDER_HALL_PRICING_TEST_DATABASE_URL' : false }, + async () => { + process.env.DATABASE_URL = TEST_DATABASE_URL + const { closeDb, query, withTransaction } = await import('../../db/client.js') + const { saveAdminWorkProductRule } = + await import('../worker-platform/admin-worker-catalog-service.js') + const { setAdminProductPrice } = await import('./product-catalog-service.js') + const { materializeWorkOrderHallPriceWithClient } = + await import('../worker-platform/work-order-hall-pricing-service.js') + const { publishUnassignedWorkOrder } = + await import('../../repositories/worker-platform/work-order-management-repo.js') + const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` + const productName = `大厅价格收口集成测试-${suffix}` + const createdWorkOrderIds: number[] = [] + const createdWorkerIds: number[] = [] + let ruleId = 0 + let productId = 0 + let adminId = 0 + + const insertWorkOrder = async (input: { + label: string + status: string + rewardAmount: number + sharingEnabled?: boolean + sharingTotalQuantity?: number + sharingUnitReward?: number + hallPriceVersionId?: number | null + material?: Record + }) => { + const result = await query<{ id: number }>( + `INSERT INTO work_orders ( + work_order_no, product_rule_id, product_name, status, reward_amount, + sharing_enabled, sharing_total_quantity, sharing_unit_reward, + hall_price_version_id, material_json, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, NOW(), NOW()) + RETURNING id`, + [ + `IT-HALL-${input.label}-${suffix}`, + ruleId, + productName, + input.status, + input.rewardAmount, + input.sharingEnabled === true, + input.sharingTotalQuantity || 1, + input.sharingUnitReward || 0, + input.hallPriceVersionId || null, + JSON.stringify(input.material || { source: { skuQuantity: 2 } }), + ], + ) + const id = Number(result.rows[0]!.id) + createdWorkOrderIds.push(id) + return id + } + + try { + const admin = await query<{ id: number; username: string; display_name: string }>( + `INSERT INTO admin_users (username, password_hash, role, status, created_at, updated_at) + VALUES ($1, 'x', 'admin', 'active', NOW(), NOW()) + RETURNING id, username, display_name`, + [`hall_price_admin_${suffix}`], + ) + adminId = Number(admin.rows[0]!.id) + const session = { + sessionId: `hall-price-${suffix}`, + userId: adminId, + username: admin.rows[0]!.username, + displayName: admin.rows[0]!.display_name || admin.rows[0]!.username, + role: 'admin', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + absoluteExpiresAt: new Date(Date.now() + 60_000).toISOString(), + sessionVersion: 1, + deviceId: 'integration-test', + permissions: [], + } + + const createdRule = await saveAdminWorkProductRule({ + productName, + itemTitle: productName, + itemTitleMatchType: 'exact', + matchType: 'exact', + enabled: true, + autoCreate: true, + pricingMode: 'unit', + sharingEnabled: true, + sharingAutoFromOrder: true, + }) + ruleId = Number(createdRule.rule.ruleId) + productId = Number(createdRule.productId) + + const firstPrice = await setAdminProductPrice( + productId, + { + amountFen: 1320, + changeReason: '大厅价格集成测试初始价', + syncOpenOrders: false, + }, + session, + ) + const firstVersionId = Number(firstPrice.costVersionId) + assert.ok(firstVersionId > 0) + + const pendingId = await insertWorkOrder({ + label: 'pending', + status: 'pending_material', + rewardAmount: 2640, + }) + const unassignedId = await insertWorkOrder({ + label: 'unassigned', + status: 'unassigned', + rewardAmount: 2640, + }) + const sharingId = await insertWorkOrder({ + label: 'sharing', + status: 'pending_material', + rewardAmount: 5280, + sharingEnabled: true, + sharingTotalQuantity: 4, + sharingUnitReward: 1320, + }) + const workerResult = await query<{ id: number }>( + `INSERT INTO worker_users + (username, password_hash, display_name, phone, status, invite_code, created_at, updated_at) + VALUES + ($1, 'x', '大厅价格测试打手', $2, 'active', $3, NOW(), NOW()) + RETURNING id`, + [ + `hall_price_worker_${suffix}`, + `139${String(Date.now()).slice(-8)}`, + `HP${suffix}`.slice(0, 32), + ], + ) + const workerId = Number(workerResult.rows[0]!.id) + createdWorkerIds.push(workerId) + await query( + `INSERT INTO work_order_shares ( + work_order_id, worker_id, quantity, unit_reward, share_reward, + share_deposit, status, created_at, updated_at + ) VALUES ($1, $2, 2, 1320, 2640, 0, 'joined', NOW(), NOW())`, + [sharingId, workerId], + ) + await query( + `UPDATE work_orders + SET status = 'open', hall_price_version_id = $1, published_at = NOW(), + hall_queued_at = NOW(), updated_at = NOW() + WHERE id = $2`, + [firstVersionId, sharingId], + ) + + const secondPrice = await setAdminProductPrice( + productId, + { + amountFen: 1335, + changeReason: '大厅价格集成测试新价', + }, + session, + ) + const secondVersionId = Number(secondPrice.costVersionId) + assert.notEqual(secondVersionId, firstVersionId) + assert.equal(Number(secondPrice.syncedOrders?.candidateCount || 0), 3) + + const normalRows = await query<{ + id: number + reward_amount: number + hall_price_version_id: number | null + }>( + `SELECT id, reward_amount, hall_price_version_id + FROM work_orders + WHERE id = ANY($1::bigint[]) + ORDER BY id`, + [[pendingId, unassignedId]], + ) + assert.deepEqual( + normalRows.rows.map((row) => ({ + id: Number(row.id), + reward: Number(row.reward_amount), + version: Number(row.hall_price_version_id), + })), + [ + { id: pendingId, reward: 2670, version: secondVersionId }, + { id: unassignedId, reward: 2670, version: secondVersionId }, + ], + ) + + const sharingRow = await query<{ + reward_amount: number + sharing_unit_reward: number + hall_price_version_id: number + }>( + `SELECT reward_amount, sharing_unit_reward, hall_price_version_id + FROM work_orders WHERE id = $1`, + [sharingId], + ) + assert.equal(Number(sharingRow.rows[0]?.reward_amount), 2640 + 2 * 1335) + assert.equal(Number(sharingRow.rows[0]?.sharing_unit_reward), 1335) + assert.equal(Number(sharingRow.rows[0]?.hall_price_version_id), secondVersionId) + const share = await query<{ share_reward: number; unit_reward: number }>( + `SELECT share_reward, unit_reward FROM work_order_shares WHERE work_order_id = $1`, + [sharingId], + ) + assert.equal(Number(share.rows[0]?.share_reward), 2640) + assert.equal(Number(share.rows[0]?.unit_reward), 1320) + + const published = await publishUnassignedWorkOrder({ + workOrderId: unassignedId, + now: new Date().toISOString(), + actorName: 'integration-test', + }) + assert.equal(published.order?.status, 'open') + assert.equal(Number(published.order?.reward_amount), 2670) + + const bypassId = await insertWorkOrder({ + label: 'bypass', + status: 'unassigned', + rewardAmount: 2670, + hallPriceVersionId: firstVersionId, + }) + await assert.rejects( + () => + query( + `UPDATE work_orders + SET status = 'open', published_at = NOW(), hall_queued_at = NOW(), updated_at = NOW() + WHERE id = $1`, + [bypassId], + ), + /stale hall_price_version_id/, + ) + await withTransaction(async (client: PoolClient) => { + await materializeWorkOrderHallPriceWithClient(client, bypassId, { + now: new Date().toISOString(), + }) + await client.query( + `UPDATE work_orders + SET status = 'open', published_at = NOW(), hall_queued_at = NOW(), updated_at = NOW() + WHERE id = $1`, + [bypassId], + ) + }) + + const consignmentId = await insertWorkOrder({ + label: 'consignment', + status: 'unassigned', + rewardAmount: 9000, + material: { + source: { skuQuantity: 2 }, + consignment: { inventoryId: 123, rewardAmountFen: 9000 }, + }, + }) + const preserved = await withTransaction((client) => + materializeWorkOrderHallPriceWithClient(client, consignmentId, { + now: new Date().toISOString(), + }), + ) + assert.equal(preserved.reason, 'preserved_consignment') + assert.equal(preserved.rewardAmount, 9000) + await query( + `UPDATE work_orders + SET status = 'open', published_at = NOW(), hall_queued_at = NOW(), updated_at = NOW() + WHERE id = $1`, + [consignmentId], + ) + } finally { + if (createdWorkOrderIds.length > 0) { + await query('DELETE FROM work_order_events WHERE work_order_id = ANY($1::bigint[])', [ + createdWorkOrderIds, + ]) + await query('DELETE FROM work_order_shares WHERE work_order_id = ANY($1::bigint[])', [ + createdWorkOrderIds, + ]) + await query('DELETE FROM work_orders WHERE id = ANY($1::bigint[])', [createdWorkOrderIds]) + } + if (createdWorkerIds.length > 0) { + await query('DELETE FROM worker_users WHERE id = ANY($1::bigint[])', [createdWorkerIds]) + } + if (ruleId > 0 || productId > 0) { + await query('DELETE FROM cost_snapshots WHERE canonical_product_id = $1', [productId]) + await query( + `DELETE FROM cost_versions + WHERE work_product_rule_id = $1 OR canonical_product_id = $2`, + [ruleId, productId], + ) + await query('DELETE FROM channel_product_mappings WHERE canonical_product_id = $1', [ + productId, + ]) + await query('DELETE FROM product_channel_capabilities WHERE product_id = $1', [productId]) + await query( + 'UPDATE sku_channel_routes SET canonical_product_id = NULL WHERE canonical_product_id = $1', + [productId], + ) + await query('DELETE FROM product_catalog WHERE id = $1', [productId]) + await query('DELETE FROM work_product_rules WHERE id = $1', [ruleId]) + } + if (adminId > 0) { + await query('DELETE FROM admin_users WHERE id = $1', [adminId]) + } + await closeDb() + } + }, +) diff --git a/apps/backend/src/services/finance/finance-controls.test.ts b/apps/backend/src/services/finance/finance-controls.test.ts index fe9a42bf..1d2d50d0 100644 --- a/apps/backend/src/services/finance/finance-controls.test.ts +++ b/apps/backend/src/services/finance/finance-controls.test.ts @@ -115,7 +115,8 @@ test('P1:成本改价退休旧版本且模板运行价格由成本中心同步 read(new URL('../cost-center/product-catalog-service.ts', import.meta.url)), read(new URL('../cost-center/affiliate-product-service.ts', import.meta.url)), ]) - assert.ok(product.includes('const costVersionId = await withTransaction')) + assert.ok(product.includes('const priceSync = await withTransaction')) + assert.ok(product.includes('syncAdminWorkProductRulePriceWithClient')) assert.ok(product.includes('UPDATE work_product_rules')) assert.ok(product.includes("status = 'retired'")) assert.ok(affiliate.includes('新增追溯版本')) 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 a9191881..98c74eac 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 @@ -368,13 +368,6 @@ export async function publishAdminWorkOrder( errorCode: 'work_order_publish_status_invalid', }) } - if (Number(workOrder.reward_amount || 0) <= 0) { - throw createHttpError('接单金额为 0,无法发布到大厅', { - statusCode: 409, - errorCode: 'work_order_publish_reward_invalid', - }) - } - // 自动匹配:发布到大厅前先试一次内部库存;命中后工单直接 in_progress,不进大厅。 const matchedByConsignment = await tryAutoMatchBeforeHall(Number(workOrder.id)).catch(() => false) if (matchedByConsignment) { diff --git a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts index 43a7cfbe..fa1268b4 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts @@ -1,3 +1,5 @@ +import type { PoolClient } from 'pg' + import { countWorkCategoryUsages, createWorkCategory, @@ -21,12 +23,10 @@ import { deleteWorkProductRule, detachWorkProductRuleCostVersions, getWorkProductRuleById, - listOpenWorkOrdersByProductRuleId, + listWorkOrdersForPriceSyncByProductRuleId, listWorkProductRulesPage, - syncOpenWorkOrderPriceFromRule, upsertWorkProductRule, } from '../../repositories/worker-platform/work-product-rule-repo.js' -import type { WorkOrderRow, WorkProductRuleRow } from '../../repositories/worker-platform/types.js' import { query, withTransaction } from '../../db/client.js' import { archiveDeletedWorkOrderTemplateProduct, @@ -44,7 +44,7 @@ import { createHttpError } from '../../utils/http.js' import { randomId } from '../../utils/random.js' import { nowIso } from '../../utils/time.js' import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' -import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' +import { publishWorkerHallRealtimeRefresh } from '../realtime/realtime-event-service.js' import { DEFAULT_CATEGORY_KEY, DEFAULT_LEVEL_KEY, @@ -62,11 +62,11 @@ import { normalizeRequirementFieldsFromPayload, normalizeSlugKey, normalizeWorkProductRuleMatch, - resolveSkuNameQuantity, - resolveWorkOrderRulePricing, } from './mappers.js' import { ensureWorkerPlatformDefaults } from './worker-platform-defaults.js' import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js' +import { materializeWorkOrderHallPriceWithClient } from './work-order-hall-pricing-service.js' +import { invalidateAllWorkerHallListCache } from './worker-hall-service.js' export async function listAdminWorkerLevels() { await ensureWorkerPlatformDefaults() @@ -617,42 +617,74 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { } } -/** 根据工单创建时的规格,计算当前模板可同步的价格。 */ -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), - pricingMode: input.rule.pricing_mode, - 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 type WorkProductRulePriceSyncResult = { + candidateCount: number + updatedCount: number + updatedNormalCount: number + updatedPartialSharingCount: number + skippedInvalidPricingCount: number + skippedUnchangedCount: number + skippedUnavailableCount: number } -/** 将指定模板的当前价格同步到仍在接单大厅的来源工单。 */ +/** + * 在调用方事务内同步模板价格。 + * + * pending_material / unassigned 也必须在场,否则模板在发布前改价时工单会停留在旧价。 + * 这里不发布实时事件;只有事务提交后才可以刷新大厅缓存和广播。 + */ +export async function syncAdminWorkProductRulePriceWithClient( + client: Pick, + ruleId: number, + actorName: string, + now: string, +): Promise { + const candidates = await listWorkOrdersForPriceSyncByProductRuleId(ruleId, client) + const result: WorkProductRulePriceSyncResult = { + candidateCount: candidates.length, + updatedCount: 0, + updatedNormalCount: 0, + updatedPartialSharingCount: 0, + skippedInvalidPricingCount: 0, + skippedUnchangedCount: 0, + skippedUnavailableCount: 0, + } + + for (const workOrder of candidates) { + if (workOrder.assigned_worker_id) { + result.skippedUnavailableCount += 1 + continue + } + const materialized = await materializeWorkOrderHallPriceWithClient(client, workOrder.id, { + now, + actorName, + }) + if (materialized.reason === 'invalid_pricing') { + throw createHttpError('模板价格无法物化到现有工单,已中止整批改价', { + statusCode: 409, + errorCode: 'work_order_hall_price_invalid', + }) + } + if (materialized.reason === 'no_template' || materialized.reason === 'preserved_consignment') { + result.skippedUnavailableCount += 1 + continue + } + if (!materialized.updated) { + result.skippedUnchangedCount += 1 + continue + } + result.updatedCount += 1 + if (materialized.sharingPartial) { + result.updatedPartialSharingCount += 1 + } else { + result.updatedNormalCount += 1 + } + } + + return result +} + +/** 将指定模板的当前价格同步到尚未形成履约合同的来源工单。 */ export async function syncAdminWorkProductRulePrice(ruleId: number | string, actorName = '') { const normalizedRuleId = normalizeOptionalId(ruleId) const rule = normalizedRuleId ? await getWorkProductRuleById(normalizedRuleId) : null @@ -662,56 +694,13 @@ export async function syncAdminWorkProductRulePrice(ruleId: number | string, act 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, - }) - } - + const result = await withTransaction(async (client) => { + await client.query(`SELECT id FROM work_product_rules WHERE id = $1 FOR SHARE`, [rule.id]) + return syncAdminWorkProductRulePriceWithClient(client, Number(rule.id), actorName, now) + }) + invalidateAllWorkerHallListCache() + publishWorkerHallRealtimeRefresh() return { rule: mapWorkProductRule(rule), ...result } } diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index 780338d1..49c676ab 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -27,6 +27,7 @@ import { type ConsignmentProfileConfig, type ConsignmentProfileSnapshot, } from './consignment-profile-service.js' +import { assertWorkOrderHallPriceReadyWithClient } from './work-order-hall-pricing-service.js' import { evaluateConsignmentSpecMatch, isBuiltInConsignmentSpecField, @@ -627,23 +628,37 @@ export async function fallbackConsignmentWorkOrderToHall( workOrderId: number, now = nowIso(), ): Promise { - const result = await query<{ id: number }>( - ` - UPDATE work_orders - SET status = 'open', - assigned_worker_id = NULL, - assigned_at = NULL, - deadline_at = NULL, - published_at = COALESCE(published_at, $1), - internal_match_deadline_at = NULL, - hall_visible_at = NULL, - updated_at = $1 - WHERE id = $2 AND status IN ('internal_matching', 'open', 'unassigned', 'in_progress') - RETURNING id - `, - [now, workOrderId], - ) - return Boolean(result.rows[0]) + return withTransaction(async (client) => { + const current = await client.query<{ status: string }>( + `SELECT status FROM work_orders WHERE id = $1 FOR UPDATE`, + [workOrderId], + ) + if ( + !current.rows[0] || + !['internal_matching', 'open', 'unassigned', 'in_progress'].includes(current.rows[0].status) + ) { + return false + } + await assertWorkOrderHallPriceReadyWithClient(client, workOrderId, { now }) + const result = await client.query<{ id: number }>( + ` + UPDATE work_orders + SET status = 'open', + assigned_worker_id = NULL, + assigned_at = NULL, + deadline_at = NULL, + published_at = COALESCE(published_at, $1), + hall_queued_at = $1, + internal_match_deadline_at = NULL, + hall_visible_at = NULL, + updated_at = $1 + WHERE id = $2 + RETURNING id + `, + [now, workOrderId], + ) + return Boolean(result.rows[0]) + }) } /** diff --git a/apps/backend/src/services/worker-platform/work-order-hall-pricing-service.ts b/apps/backend/src/services/worker-platform/work-order-hall-pricing-service.ts new file mode 100644 index 00000000..c71792a9 --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-hall-pricing-service.ts @@ -0,0 +1,301 @@ +import type { PoolClient } from 'pg' + +import type { + WorkOrderRow, + WorkOrderShareRow, + WorkProductRuleRow, +} from '../../repositories/worker-platform/types.js' +import { toPositiveInteger } from '../../repositories/worker-platform/shared.js' +import { createHttpError } from '../../utils/http.js' +import { safeParseJson } from '../admin/admin-query-utils.js' +import { resolveSkuNameQuantity, resolveWorkOrderRulePricing } from '../product-match/pricing.js' + +type Queryable = Pick + +export type HallPriceMaterializationReason = + | 'updated' + | 'unchanged' + | 'sharing_filled' + | 'no_template' + | 'preserved_consignment' + | 'invalid_pricing' + +export type HallPriceMaterializationResult = { + workOrderId: number + reason: HallPriceMaterializationReason + updated: boolean + rewardAmount: number + sharingUnitReward: number + hallPriceVersionId: number | null + joinedQuantity: number + lockedShareReward: number + sharingPartial: boolean + workerIds: number[] +} + +/** 按工单创建时的来源规格计算模板当前价格,供改价同步和审计共用。 */ +export function resolveWorkOrderTemplatePriceProjection(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), + pricingMode: input.rule.pricing_mode, + 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 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) + ) +} + +function hasActiveConsignmentContract(workOrder: WorkOrderRow) { + const material = safeParseJson(workOrder.material_json) + return Boolean(material.consignment && typeof material.consignment === 'object') +} + +async function lockWorkOrder(client: Queryable, workOrderId: number) { + const result = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [workOrderId], + ) + return result.rows[0] || null +} + +async function lockActiveShares(client: Queryable, workOrderId: number) { + const result = await client.query( + `SELECT * + FROM work_order_shares + WHERE work_order_id = $1 + FOR UPDATE`, + [workOrderId], + ) + return result.rows.filter((share) => share.status !== 'cancelled') +} + +async function getActiveRule( + client: Queryable, + productRuleId: number, +): Promise { + const result = await client.query( + `SELECT * FROM work_product_rules WHERE id = $1`, + [productRuleId], + ) + return result.rows[0] || null +} + +async function getEffectiveCostVersionId( + client: Queryable, + productRuleId: number, + now: string, +): Promise { + const result = await client.query<{ id: number }>( + `SELECT id + FROM cost_versions + WHERE work_product_rule_id = $1 + AND status = 'active' + AND effective_from <= $2::timestamptz + AND (effective_to IS NULL OR effective_to > $2::timestamptz) + ORDER BY effective_from DESC, id DESC + LIMIT 1 + `, + [productRuleId, now], + ) + return Number(result.rows[0]?.id || 0) || null +} + +/** + * 把接单模板当前价格物化到一张工单。 + * + * 已加入拼单份额的 share_reward 是参与时合同快照,只按当前模板价刷新尚未加入的份额。 + * 使用寄卖现货的工单是另一份真实合同,不得被模板价覆盖。 + */ +export async function materializeWorkOrderHallPriceWithClient( + client: Queryable, + workOrderId: number, + options: { + now: string + actorName?: string + eventType?: string + }, +): Promise { + const workOrder = await lockWorkOrder(client, workOrderId) + if (!workOrder) { + throw createHttpError('接单工单不存在', { + statusCode: 404, + errorCode: 'work_order_not_found', + }) + } + + const activeShares = await lockActiveShares(client, workOrder.id) + const joinedQuantity = activeShares.reduce( + (sum, share) => sum + Math.max(0, Number(share.quantity || 0)), + 0, + ) + const lockedShareReward = activeShares.reduce( + (sum, share) => sum + Math.max(0, Number(share.share_reward || 0)), + 0, + ) + const workerIds = [ + ...new Set( + activeShares.map((share) => Number(share.worker_id)).filter((workerId) => workerId > 0), + ), + ] + const current = { + workOrderId: Number(workOrder.id), + rewardAmount: Number(workOrder.reward_amount || 0), + sharingUnitReward: Number(workOrder.sharing_unit_reward || 0), + hallPriceVersionId: Number(workOrder.hall_price_version_id || 0) || null, + joinedQuantity, + lockedShareReward, + sharingPartial: workOrder.sharing_enabled === true && joinedQuantity > 0, + workerIds, + updated: false, + } + + if (hasActiveConsignmentContract(workOrder)) { + return { ...current, reason: 'preserved_consignment' } + } + + const productRuleId = Number(workOrder.product_rule_id || 0) + if (!productRuleId) { + return { ...current, reason: 'no_template' } + } + + const rule = await getActiveRule(client, productRuleId) + if (!rule) { + return { ...current, reason: 'invalid_pricing' } + } + const projection = resolveWorkOrderTemplatePriceProjection({ rule, workOrder }) + if (!projection) { + return { ...current, reason: 'invalid_pricing' } + } + + let nextRewardAmount = projection.rewardAmount + let nextSharingUnitReward = Number(workOrder.sharing_unit_reward || 0) + let nextReason: HallPriceMaterializationReason = 'updated' + if (workOrder.sharing_enabled === true) { + if (projection.sharingUnitReward === undefined) { + return { ...current, reason: 'invalid_pricing' } + } + const totalQuantity = toPositiveInteger(workOrder.sharing_total_quantity, 1) + if (joinedQuantity >= totalQuantity) { + nextReason = 'sharing_filled' + } + nextSharingUnitReward = projection.sharingUnitReward + nextRewardAmount = + lockedShareReward + Math.max(0, totalQuantity - joinedQuantity) * nextSharingUnitReward + } else if (nextRewardAmount <= 0) { + return { ...current, reason: 'invalid_pricing' } + } + + const hallPriceVersionId = await getEffectiveCostVersionId(client, productRuleId, options.now) + if (!hallPriceVersionId) { + return { ...current, reason: 'invalid_pricing' } + } + + const unchanged = + Number(workOrder.reward_amount || 0) === nextRewardAmount && + Number(workOrder.sharing_unit_reward || 0) === nextSharingUnitReward && + Number(workOrder.hall_price_version_id || 0) === hallPriceVersionId + if (unchanged) { + return { + ...current, + reason: nextReason === 'sharing_filled' ? 'sharing_filled' : 'unchanged', + rewardAmount: nextRewardAmount, + sharingUnitReward: nextSharingUnitReward, + hallPriceVersionId, + } + } + + await client.query( + `UPDATE work_orders + SET reward_amount = $1, + sharing_unit_reward = $2, + hall_price_version_id = $3, + updated_at = $4 + WHERE id = $5`, + [nextRewardAmount, nextSharingUnitReward, hallPriceVersionId, options.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, $3, $4, $4, $5::jsonb, $6)`, + [ + workOrder.id, + options.actorName || '', + options.eventType || 'price_synced_from_rule', + workOrder.status, + JSON.stringify({ + productRuleId, + previousRewardAmount: Number(workOrder.reward_amount || 0), + rewardAmount: nextRewardAmount, + previousSharingUnitReward: Number(workOrder.sharing_unit_reward || 0), + sharingUnitReward: nextSharingUnitReward, + previousHallPriceVersionId: Number(workOrder.hall_price_version_id || 0) || null, + hallPriceVersionId, + joinedQuantity, + lockedShareReward, + }), + options.now, + ], + ) + return { + ...current, + reason: nextReason, + updated: true, + rewardAmount: nextRewardAmount, + sharingUnitReward: nextSharingUnitReward, + hallPriceVersionId, + } +} + +/** 进入大厅前强制物化价格;模板工单没有有效价格时拒绝写入 open。 */ +export async function assertWorkOrderHallPriceReadyWithClient( + client: Queryable, + workOrderId: number, + input: { now: string; actorName?: string }, +): Promise { + const result = await materializeWorkOrderHallPriceWithClient(client, workOrderId, input) + if (result.reason === 'invalid_pricing') { + throw createHttpError('接单模板当前没有可用价格或有效成本版本,无法进入大厅', { + statusCode: 409, + errorCode: 'work_order_hall_price_invalid', + }) + } + return result +} diff --git a/apps/backend/src/services/worker-platform/worker-hall-service.ts b/apps/backend/src/services/worker-platform/worker-hall-service.ts index cca3614d..8a220dd3 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-service.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-service.ts @@ -220,3 +220,8 @@ export function invalidateWorkerHallListCache( } return evicted } + +/** 模板批量改价后,所有打手、分页和筛选条件的大厅结果都必须整体失效。 */ +export function invalidateAllWorkerHallListCache(): number { + return hallListCache.clear() +} 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 8453bd6b..dcbf7368 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 @@ -21,7 +21,6 @@ import { resolveWorkOrderShareDepositPlan, resolveWorkOrderSharingCancellationPlan, resolveWorkOrderShareJoinQuantity, - resolveOpenSharingSyncReward, recomputeWorkOrderSharingStatusWithClient, } from '../../repositories/worker-platform/index.js' import { resolveProblemReturnToWorkerStatus } from '../../repositories/worker-platform/work-order-issue-repo.js' @@ -51,11 +50,14 @@ import { resolveWorkerPermissions, resolveSkuNameQuantity, resolveWorkOrderRulePricing, - resolveWorkOrderTemplateSyncPrice, resolveWorkerShareStatus, resolveWorkOrderSharingEnabled, validateWorkerPassword, } from './index.js' +import { + resolveOpenSharingSyncReward, + resolveWorkOrderTemplatePriceProjection, +} from './work-order-hall-pricing-service.js' import { resolveAfterSalesRecoveryAllocation } from '../../repositories/worker-platform/after-sales-recovery.js' import { normalizeWorkerFinanceConfig } from './worker-finance-config-service.js' import { @@ -1676,7 +1678,7 @@ test('按件拼单手动覆盖数量和单价时,保留手工配置', () => { }) test('模板同步价格按工单来源规格重新计算普通按件单', () => { - const price = resolveWorkOrderTemplateSyncPrice({ + const price = resolveWorkOrderTemplatePriceProjection({ rule: buildProductRule({ unit_price_fen: 200, pricing_mode: 'unit' }), workOrder: buildWorkOrderRow({ product_name: '指挥官秘钥15个', @@ -1691,7 +1693,7 @@ test('模板同步价格以显式计价方式为准,不按两个金额字段 const workOrder = buildWorkOrderRow({ product_name: '指挥官秘钥15个' }) assert.deepEqual( - resolveWorkOrderTemplateSyncPrice({ + resolveWorkOrderTemplatePriceProjection({ rule: buildProductRule({ reward_amount: 2500, unit_price_fen: 200, @@ -1703,7 +1705,7 @@ test('模板同步价格以显式计价方式为准,不按两个金额字段 ) assert.deepEqual( - resolveWorkOrderTemplateSyncPrice({ + resolveWorkOrderTemplatePriceProjection({ rule: buildProductRule({ reward_amount: 2500, unit_price_fen: 200, @@ -1716,7 +1718,7 @@ test('模板同步价格以显式计价方式为准,不按两个金额字段 }) test('模板同步价格为拼单返回新的份额单价', () => { - const price = resolveWorkOrderTemplateSyncPrice({ + const price = resolveWorkOrderTemplatePriceProjection({ rule: buildProductRule({ unit_price_fen: 200, pricing_mode: 'unit', diff --git a/deploy/docker/backend-dev.Dockerfile b/deploy/docker/backend-dev.Dockerfile index e2b63a00..101851ff 100644 --- a/deploy/docker/backend-dev.Dockerfile +++ b/deploy/docker/backend-dev.Dockerfile @@ -1,13 +1,16 @@ # 快手轻量开发镜像:只保留 Node 运行时和基础工具。 -# 本地 dev 不写死 registry,交给 Docker Desktop 的默认 Docker Hub 与 mirrors。 -FROM node:22-bookworm-slim +# 本地 dev 固定使用官方 Docker Hub,不经过任何镜像加速器。 +FROM docker.io/library/node:22-bookworm-slim ENV DEBIAN_FRONTEND=noninteractive ENV TZ=Asia/Shanghai WORKDIR /app -RUN mkdir -p /app/data +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /app/data EXPOSE 3000 diff --git a/deploy/docker/dev-caddy.Dockerfile b/deploy/docker/dev-caddy.Dockerfile index f5424f1d..ac9de063 100644 --- a/deploy/docker/dev-caddy.Dockerfile +++ b/deploy/docker/dev-caddy.Dockerfile @@ -1,6 +1,5 @@ -# 基础镜像源可整体替换(.env 的 BASE_REGISTRY),原因见 backend.Dockerfile 顶部注释。 -ARG BASE_REGISTRY=docker.m.daocloud.io -FROM ${BASE_REGISTRY}/library/caddy:2-alpine +# 本地 dev 固定使用官方 Docker Hub,不经过任何镜像加速器。 +FROM docker.io/library/caddy:2-alpine ENV TZ=Asia/Shanghai diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 75af6b4a..d57b4cfb 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -6,7 +6,7 @@ x-json-log-rotation: &json-log-rotation services: postgres: - image: postgres:16-bookworm + image: docker.io/library/postgres:16-bookworm command: [ "postgres", @@ -52,7 +52,7 @@ services: minio: # MinIO 官方镜像已停止维护;使用固定版本的 Bitnami Legacy 构建,避免 latest 漂移。 - image: bitnamilegacy/minio:2025.7.23-debian-12-r5 + image: docker.io/bitnamilegacy/minio:2025.7.23-debian-12-r5 # 首次启动以 root 修正既有 dev volume 的权限,随后镜像脚本会降权为 minio 用户运行。 user: "0" restart: unless-stopped @@ -137,6 +137,8 @@ services: - ./apps/backend:/app - ./.prettierrc.json:/.prettierrc.json:ro - ./.prettierignore:/.prettierignore:ro + - ./deploy:/deploy:ro + - ./scripts:/scripts:ro - ./.env.server.example:/workspace/.env.server.example:ro - ./.env.mac-docker.example:/workspace/.env.mac-docker.example:ro - ./docker-compose.yml:/workspace/docker-compose.yml:ro @@ -145,7 +147,7 @@ services: - ./apps/backend/data:/app/data frontend: - image: node:22-bookworm-slim + image: docker.io/library/node:22-bookworm-slim working_dir: /app restart: unless-stopped logging: *json-log-rotation @@ -161,6 +163,7 @@ services: VITE_WORKER_SITE_URL: ${WORKER_SITE_ADDR:-http://worker.localhost} volumes: - ./apps/frontend:/app + - ./apps/backend/src:/backend/src:ro - ./.prettierrc.json:/.prettierrc.json:ro - ./.prettierignore:/.prettierignore:ro - frontend_node_modules:/app/node_modules @@ -168,7 +171,7 @@ services: # 打手端专用 dev server:同一份代码,只把「打手端 hostname」设成 IP, # 由 Caddy 的 8080 端口反代过来(前端按 hostname 判站点,见 utils/site.ts)。 frontend-worker: - image: node:22-bookworm-slim + image: docker.io/library/node:22-bookworm-slim working_dir: /app restart: unless-stopped logging: *json-log-rotation @@ -192,7 +195,7 @@ services: - frontend_worker_node_modules:/app/node_modules web: - image: caddy:2-alpine + image: docker.io/library/caddy:2-alpine restart: unless-stopped logging: *json-log-rotation depends_on: diff --git a/docs/生产升级操作手册.md b/docs/生产升级操作手册.md index 19d0b643..7badc57e 100644 --- a/docs/生产升级操作手册.md +++ b/docs/生产升级操作手册.md @@ -3,8 +3,8 @@ type: 生产运维操作手册 description: order_site 生产结构升级、数据切换、验收与回滚的唯一执行步骤。 tags: [生产, 部署, 数据库, 迁移, 备份, 回滚, 财务] status: current -version: 1.0 -updated_at: 2026-11-08 +version: 1.1 +updated_at: 2026-11-14 --- # 生产升级操作手册 @@ -24,12 +24,14 @@ updated_at: 2026-11-08 > 在发布负责人确认目标提交并完成隔离恢复演练前,只作为开发基线, > 不得按旧的 `147–150` 一条路径直接上线。 > -> 2026-11-14 增量待发布基线:在 `161` 之上新增 -> `162_fulfillment_queue_buckets.sql`,建立履约待办四桶只读投影、 -> 补载体扫描专用索引和新单优先扫描口径。该迁移不改写原始订单、履约尝试或售后事实; -> 仍须完成停写、最终备份、隔离恢复验证、迁移、健康检查和履约审计后才可恢复营业。 +> 2026-11-14 增量待发布基线:在 `161` 之上依次新增 +> `162_fulfillment_queue_buckets.sql`、`163_work_order_hall_price_version.sql`。 +> `162` 建立履约待办四桶只读投影、补载体扫描专用索引和新单优先扫描口径; +> `163` 增加接单大厅价格成本版本引用、改价同步候选索引和版本闸门,不重写历史工单金额。 +> 两项迁移都不改写原始订单、履约尝试或售后事实;仍须完成停写、最终备份、隔离恢复验证、 +> 迁移、健康检查、履约审计和大厅价格审计后才可恢复营业。 -### 1.1 迁移 `151 → 162` 增量门禁 +### 1.1 迁移 `151 → 163` 增量门禁 - 升级前必须确认生产最高迁移仍为 `151`;分支合并后重新核对迁移号,禁止回退或改写 `151` 及以前文件。 - 半自动迁移已收敛为单个 `161_semi_auto_fulfillment.sql`,不得在已应用 main `158–160` @@ -40,7 +42,10 @@ updated_at: 2026-11-08 不迁移或清理订单、退款、资金流水等 A 级事实。 - 迁移完成后至少验证 `semi_auto_credentials` 表、任务外键和索引存在,以及 `v_fulfillment_queue`、补载体部分索引存在; - 用测试库完成 `001 → 162` 空库迁移,并在隔离库验证 `151 → 162`。 +- `163` 新增 `work_orders.hall_price_version_id`、模板工单进入 `open` 的版本闸门和 + 改价同步候选索引;正在使用寄卖现货的合同态工单豁免版本闸门。 +- 用测试库完成 `001 → 163` 空库迁移,并在隔离库验证 `151 → 163`。 +- 迁移后执行 `npm run audit:work-order-hall-pricing`,`critical` 必须为 0。 - 该增量仍包含迁移,普通停机发布不能使用 `deploy/ubuntu-deploy.sh` 在旧后端运行时直接换库; 必须沿用本手册的停写、最终备份、隔离恢复验证、迁移、新后端健康检查和验收流程。 @@ -424,6 +429,26 @@ docker compose --profile tools run --rm --no-deps tools \ - 售后关闭仍进入履约队列 `24`。 - 已分配但无有效载体 `27`。 +### 6.3.1 接单大厅价格审计 + +迁移 `163` 后必须执行: + +```bash +docker compose --profile tools run --rm --no-deps tools \ + npm run audit:work-order-hall-pricing +``` + +要求: + +- `critical` 必须为 `0`。 +- `open` 模板工单的 `hall_price_version_id` 必须对应当前有效成本版本。 +- 普通工单金额和拼单剩余份额单价必须与当前模板一致。 +- 已加入拼单份额的合同金额不得因审计或改价被重写。 +- 寄卖合同态工单不参与模板价审计覆盖。 + +`warning` 可包含尚未发布的 `pending_material / unassigned` 工单价格待同步项, +但必须由发布负责人在恢复营业前执行模板同步或记录结构化待办,不得手工猜价。 + ### 6.4 财务重建 dry-run 先定义本次财务回看区间: diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index f3edc289..23a7f441 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -3,7 +3,7 @@ type: 权威架构与实施计划 description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。 tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移] status: authoritative -version: 2.18 +version: 2.19 updated_at: 2026-11-14 --- @@ -12,7 +12,7 @@ updated_at: 2026-11-14 > 状态:**唯一权威文档**。 > 最后校准:2026-11-14。 > 生产基线:迁移 `151`。 -> 开发基线:迁移 `162`(仅本地,尚未部署生产)。 +> 开发基线:迁移 `163`(仅本地,尚未部署生产)。 > > 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现, > 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。 @@ -361,6 +361,25 @@ manual fulfillment - 完成截图可选;操作人、操作时间、外部单号和成本确认必须留痕。 - 客户统一入口为 `/c/<平台订单号>`,它是入口而非安全授权凭据;高价值或全自动场景必须增加二次验证,不能仅凭订单号自动履约。 +### 3.4.3 接单大厅模板价格版本 + +接单工单进入大厅时展示的模板价格,是 `cost_versions` 当前生效版本在工单上的价格投影, +不是新的价格真源。 + +- 模板工单必须记录 `work_orders.hall_price_version_id`,指向本次价格物化使用的成本版本。 +- `pending_material`、`unassigned`、`open` 工单在模板改价时必须同事务更新;改价失败时 + 成本版本、模板价格投影和工单价格全部回滚。 +- 普通工单按模板计价方式和订单原始规格数量计算。 +- 拼单工单只刷新尚未加入的份额;已加入份额的 `unit_reward`、`share_reward` 是参与时的 + 合同快照,不得被后续模板改价重写。 +- 撤单、退单审核、超时重开、问题单退回大厅、拼单份额退回大厅等所有重新进入 `open` 的路径, + 必须先经过统一的大厅价格物化入口,再修改状态。 +- 数据库使用迁移 `163` 的版本闸门拒绝“模板工单携带空版本或已失效版本进入 `open`”。 + 正在使用寄卖现货的合同态工单显式豁免,不得用模板价覆盖寄卖结算金额。 +- 模板批量改价提交后必须整体清空接单大厅列表缓存并广播全量刷新,不能依赖逐张工单事件补齐。 +- 只读审计命令为 `npm run audit:work-order-hall-pricing`;`open` 工单的版本、金额或份额单价 + 不一致属于 critical,必须停止继续放大并修复,不自动猜测补价。 + ### 3.5 商品与财务域 标准商品目标结构: @@ -892,6 +911,8 @@ npm run audit:finance -- --strict `product_catalog.source_kind` 与 `internal_sku_id`。 - 商品创建写入口显式写入 `source_kind`,数据库约束只允许 `affiliate_dash / work_order / third_party_voucher / manual / unclassified`。 +- 迁移 `163` 将接单模板改价、未发布工单价格投影和大厅价格版本收敛到同一事务, + 所有重新进入大厅的路径统一先物化当前模板价;寄卖合同态不参与模板价覆盖。 - `product_channel_capabilities`、`manual_sku_registry`、`internal_sku_channels` 和历史 `legacy_*` 字段继续保留为审计留存,不在生产观察期前删除。 @@ -947,6 +968,9 @@ npm run audit:finance -- --strict - 同一订单项不得同时有活跃工单和活跃自动发货任务。 - 当前尝试必须是已存在且唯一的历史尝试。 - 所有重试动作必须按 issue_code 限定作用域。 +- 模板工单进入大厅时必须绑定当前有效 `hall_price_version_id`。 +- 部分拼单改价后,已加入份额金额不变,剩余份额按当前模板价重算。 +- `npm run audit:work-order-hall-pricing` 的 `critical` 必须为 0。 ### 财务 @@ -1003,17 +1027,19 @@ docs/archive/ ## 十三、当前下一步 -截至 2026-11-14,生产基线为 `151`,集成分支开发基线为 `162`。迁移 `152` 保护订单来源快照, +截至 2026-11-14,生产基线为 `151`,集成分支开发基线为 `163`。迁移 `152` 保护订单来源快照, 迁移 `158–160` 收口寄卖登记人同步、寄卖平台与自动匹配设置; 迁移 `161` 一次建立人工渠道半自动任务、内置 Connector 默认值、账号级 Cookie JSON 凭证、 任务凭证引用和最终约束;迁移 `162` 建立履约待办四桶只读投影、补载体专用索引和新单优先扫描口径。 +迁移 `163` 增加接单大厅价格成本版本引用、改价同步候选索引和旧版本进入大厅的数据库闸门; +该迁移不重写历史工单金额,寄卖合同态显式豁免。 迁移中保留的 `semi_auto.credential.export` 权限仅作兼容记录, 不再作为 CK 导出门禁。 1. 生产升级只按 [生产升级操作手册](生产升级操作手册.md) 执行。 2. 升级前备份必须完成隔离恢复验证;恢复验证未通过时禁止迁移。 -3. `147 → 153` 按生产升级手册分批执行,迁移后刷新统计并完成商品、履约、 - 财务三组严格审计;无法确定的数据进入未路由或待分类,不做猜测。 +3. `147 → 163` 按生产升级手册分批执行,迁移后刷新统计并完成商品、履约、 + 财务和大厅价格审计;无法确定的数据进入未路由或待分类,不做猜测。 4. 执行 `finance:rebuild-derived` 的 dry-run,核对 A 级数据守恒后, 再按窗口执行 `--execute --confirm=REBUILD_DERIVED`,并验证第二次重建幂等。 5. 观察一个完整财务期间,确认页面、履约、事实和总账持续对平。