收口接单大厅模板价格同步与本地开发容器
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user