收口接单大厅模板价格同步与本地开发容器

This commit is contained in:
yml
2026-11-14 21:36:36 +08:00
parent b03599eca7
commit 9a7aaf605e
26 changed files with 1180 additions and 344 deletions
+1
View File
@@ -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",
@@ -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<WorkOrderRow>(
`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<WorkProductRuleRow>(
'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<ShareAggregate>(
`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
@@ -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
'模板工单进入接单大厅时必须携带当前有效成本版本;寄卖合同态豁免';
@@ -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
@@ -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',
@@ -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(
`
@@ -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<WorkOrderRow>(
`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 }>(
`
@@ -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',
@@ -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
? ''
@@ -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<PoolClient, 'query'>,
): Promise<WorkOrderRow[]> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow>(sql, [Number(ruleId)])
: await query<WorkOrderRow>(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<PoolClient, 'query'>,
ruleKey: string,
): Promise<WorkProductRuleRow | null> {
const result = await client.query<WorkProductRuleRow>(
`${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<SyncOpenWorkOrderPriceResult> {
return withTransaction(async (client) => {
const orderResult = await client.query<WorkOrderRow>(
`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<WorkOrderShareRow>(
`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(
@@ -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,
@@ -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'))
@@ -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: '接单模板结算价格已更新',
}
}
@@ -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<string, unknown>
}) => {
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()
}
},
)
@@ -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('新增追溯版本'))
@@ -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) {
@@ -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<PoolClient, 'query'>,
ruleId: number,
actorName: string,
now: string,
): Promise<WorkProductRulePriceSyncResult> {
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 }
}
@@ -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<boolean> {
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])
})
}
/**
@@ -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<PoolClient, 'query'>
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<WorkOrderRow>(
`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<WorkOrderShareRow>(
`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<WorkProductRuleRow | null> {
const result = await client.query<WorkProductRuleRow>(
`SELECT * FROM work_product_rules WHERE id = $1`,
[productRuleId],
)
return result.rows[0] || null
}
async function getEffectiveCostVersionId(
client: Queryable,
productRuleId: number,
now: string,
): Promise<number | null> {
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<HallPriceMaterializationResult> {
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<HallPriceMaterializationResult> {
const result = await materializeWorkOrderHallPriceWithClient(client, workOrderId, input)
if (result.reason === 'invalid_pricing') {
throw createHttpError('接单模板当前没有可用价格或有效成本版本,无法进入大厅', {
statusCode: 409,
errorCode: 'work_order_hall_price_invalid',
})
}
return result
}
@@ -220,3 +220,8 @@ export function invalidateWorkerHallListCache(
}
return evicted
}
/** 模板批量改价后,所有打手、分页和筛选条件的大厅结果都必须整体失效。 */
export function invalidateAllWorkerHallListCache(): number {
return hallListCache.clear()
}
@@ -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',