feat(cost-center): 统一接单模板成本并自动同步商品

This commit is contained in:
yml2213
2026-11-02 16:14:51 +08:00
parent 55e3c4e21b
commit 290890e848
22 changed files with 963 additions and 508 deletions
@@ -0,0 +1,55 @@
-- 接单模板商品改为事件驱动投影:新模板保存时自动建/更新商品。
-- 本迁移补齐现有模板商品,并统一状态、名称、分类和价格属性。
WITH template_projection AS (
SELECT
LEFT('tpl:' || BTRIM(wpr.rule_key), 64) AS canonical_sku,
wpr.product_name AS canonical_name,
COALESCE(wc.name, '') AS category,
CASE
WHEN wpr.product_name ~ '钻石|颗' THEN '颗'
WHEN wpr.product_name ~ '枪|把' THEN '把'
WHEN wpr.product_name ~ '套' THEN '套'
WHEN wpr.product_name ~ '密钥|秘钥' THEN '个'
ELSE '件'
END AS unit_name,
'active' AS status,
jsonb_build_object(
'source', 'work_product_rule',
'ruleId', wpr.id,
'ruleKey', wpr.rule_key,
'categoryId', wpr.category_id,
'templateEnabled', wpr.enabled,
'pricingMode',
CASE
WHEN wpr.unit_price_fen > 0 AND wpr.reward_amount = 0 THEN 'unit'
ELSE 'fixed'
END,
'rewardAmountFen', wpr.reward_amount,
'unitPriceFen', wpr.unit_price_fen,
'sharingEnabled', wpr.sharing_enabled,
'sharingTotalQuantity', wpr.sharing_total_quantity,
'sharingUnitReward', wpr.sharing_unit_reward,
'sharingAutoFromOrder', wpr.sharing_auto_from_order
) AS attributes_json
FROM work_product_rules wpr
LEFT JOIN work_categories wc ON wc.id = wpr.category_id
)
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at
)
SELECT
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, NOW(), NOW()
FROM template_projection
ON CONFLICT (canonical_sku) DO UPDATE SET
canonical_name = EXCLUDED.canonical_name,
category = EXCLUDED.category,
unit_name = EXCLUDED.unit_name,
attributes_json = COALESCE(product_catalog.attributes_json, '{}'::jsonb) || EXCLUDED.attributes_json,
updated_at = NOW()
WHERE product_catalog.canonical_sku LIKE 'tpl:%';
COMMENT ON TABLE product_catalog IS
'标准商品目录;tpl: 商品由接单模板事件自动投影,价格只来自成本中心';
@@ -1,3 +1,5 @@
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import { toPositiveInteger } from './shared.js'
import {
@@ -369,8 +371,10 @@ export async function upsertWorkProductRule(input: {
matchJson?: string
sortOrder: number
now: string
client?: Pick<PoolClient, 'query'>
}): Promise<WorkProductRuleRow | null> {
const result = await query<WorkProductRuleRow>(
const db = input.client || { query }
const result = await db.query<WorkProductRuleRow>(
`
INSERT INTO work_product_rules (
rule_key, provider, platform, shop_id, sku_code, product_name,
@@ -454,5 +458,10 @@ export async function upsertWorkProductRule(input: {
input.now,
],
)
return getWorkProductRuleByKey(result.rows[0]?.rule_key || input.ruleKey)
const ruleKey = result.rows[0]?.rule_key || input.ruleKey
const selected = await db.query<WorkProductRuleRow>(
`${WORK_PRODUCT_RULE_SELECT} WHERE wpr.rule_key = $1 LIMIT 1`,
[ruleKey],
)
return selected.rows[0] || null
}
@@ -1,6 +1,5 @@
import { Router } from 'express'
import { setHttpCache } from '../../../middleware/http-cache.js'
import { syncAdminWorkOrderTemplateCostVersions } from '../../../services/cost-center/work-order-product-service.js'
import {
deleteAdminWorkCategory,
@@ -151,25 +150,17 @@ router.get(
router.post(
'/worker-platform/product-rules',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
async (req) => {
const result = await saveAdminWorkProductRule(req.body || {})
const ruleId = Number((result as { rule?: { id?: number } })?.rule?.id || 0)
const templateCost = ruleId ? await syncAdminWorkOrderTemplateCostVersions(ruleId) : null
return { ...result, templateCost }
},
{
successMessage: '接单物品规则已保存',
errorMessage: '保存接单物品规则失败',
scope: '[admin/worker-platform/product-rules]',
audit: (_req, data) => ({
action: 'work_product_rule_saved',
targetType: 'work_product_rule',
targetId: String((data as { rule?: { ruleKey?: string } })?.rule?.ruleKey || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
createJsonHandler((req) => saveAdminWorkProductRule(req.body || {}), {
successMessage: '接单物品规则已保存',
errorMessage: '保存接单物品规则失败',
scope: '[admin/worker-platform/product-rules]',
audit: (_req, data) => ({
action: 'work_product_rule_saved',
targetType: 'work_product_rule',
targetId: String((data as { rule?: { ruleKey?: string } })?.rule?.ruleKey || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
}),
)
router.post(
@@ -112,6 +112,15 @@ export function mapProduct(row: ProductRow) {
activeCostEffectiveFrom: row.active_cost_effective_from
? String(row.active_cost_effective_from)
: null,
templatePricingMode: row.template_pricing_mode || null,
templateEnabled:
row.template_enabled === null || row.template_enabled === undefined
? null
: row.template_enabled === true,
templateSharingEnabled:
row.template_sharing_enabled === null || row.template_sharing_enabled === undefined
? null
: row.template_sharing_enabled === true,
affiliateTaskCount: Number(row.affiliate_task_count || 0),
affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null,
createdAt: row.created_at,
@@ -26,6 +26,9 @@ export type ProductRow = {
cost_version_count?: number
active_cost_amount_fen?: number | null
active_cost_effective_from?: string | null
template_pricing_mode?: string | null
template_enabled?: boolean | null
template_sharing_enabled?: boolean | null
affiliate_task_count?: number
affiliate_last_fulfilled_at?: string | null
}
@@ -275,21 +275,13 @@ test('mapping lookup follows specificity and concrete-shop priority', () => {
)
})
/**
* 2026-10-29 生产:改「接单模板」商品价格 500。
*
* PostgreSQL 报 `inconsistent types deduced for parameter $1`(integer versus bigint):
* 同一个 $1 在 `reward_amount = $1` 处按 bigint 推断,在 `CASE ... THEN $1 ELSE 0`
* 处又因为 `0` 是 integer 被推断成 integer。node-pg 不发送参数类型,必须显式转换。
*/
test('接单模板改价 SQL 的参数必须显式指定 bigint', async () => {
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(
'unit_price_fen = CASE WHEN unit_price_fen > 0 THEN $1::bigint ELSE 0::bigint END',
),
)
assert.ok(source.includes('const costVersionId = await withTransaction'))
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'))
})
test('成本模型与商品来源必须覆盖第三方卡券渠道', () => {
@@ -6,7 +6,7 @@
* 2. 渠道映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧;
* 3. 归一数量必须由回填脚本按 SKU 名称解析后写入,不预填 quantity。
*/
import { query } from '../../db/client.js'
import { query, withTransaction } from '../../db/client.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
@@ -153,6 +153,13 @@ export async function listAdminProducts(payload: JsonObject = {}) {
`
SELECT
p.*,
CASE
WHEN tpl_rule.unit_price_fen > 0 AND tpl_rule.reward_amount = 0 THEN 'unit'
WHEN tpl_rule.id IS NOT NULL THEN 'fixed'
ELSE NULL
END AS template_pricing_mode,
tpl_rule.enabled AS template_enabled,
tpl_rule.sharing_enabled AS template_sharing_enabled,
CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id,
CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name,
root.id AS root_category_id,
@@ -191,20 +198,6 @@ export async function listAdminProducts(payload: JsonObject = {}) {
AND wpr.rule_key = substring(p.canonical_sku from 5)
ORDER BY v.effective_from DESC, v.id DESC
LIMIT 1
),
-- 还没有成本版本的接单模板商品(例如刚建、还没跑「同步模板发单金额」),
-- 直接展示模板同步过来的发单金额,避免显示「未配置」让改价入口看起来不可用。
(
SELECT CASE
WHEN wpr2.unit_price_fen > 0 AND wpr2.reward_amount = 0
THEN wpr2.unit_price_fen
ELSE wpr2.reward_amount
END
FROM work_product_rules wpr2
WHERE p.canonical_sku LIKE 'tpl:%'
AND wpr2.rule_key = substring(p.canonical_sku from 5)
AND (wpr2.reward_amount > 0 OR wpr2.unit_price_fen > 0)
LIMIT 1
)
) AS active_cost_amount_fen,
COALESCE(
@@ -231,6 +224,9 @@ export async function listAdminProducts(payload: JsonObject = {}) {
${affiliateColumnsSql}
FROM product_catalog p
${PRODUCT_CATEGORY_JOINS}
LEFT JOIN work_product_rules tpl_rule
ON p.canonical_sku LIKE 'tpl:%'
AND tpl_rule.rule_key = substring(p.canonical_sku from 5)
${affiliateTaskJoinSql}
${where}
ORDER BY
@@ -1435,13 +1431,54 @@ export async function setAdminProductPrice(
id: number
rule_key: string
product_name: string
enabled: boolean
reward_amount: number
unit_price_fen: number
sharing_enabled: boolean
sharing_auto_from_order: boolean
}>(
'SELECT id, rule_key, product_name, reward_amount, unit_price_fen FROM work_product_rules WHERE rule_key = $1 LIMIT 1',
`SELECT id, rule_key, product_name, enabled, reward_amount, unit_price_fen,
sharing_enabled, sharing_auto_from_order
FROM work_product_rules
WHERE rule_key = $1
LIMIT 1`,
[ruleKey],
)
const rule = ruleResult.rows[0]
if (!rule) {
throw createHttpError('接单模板商品没有关联模板,请先修复商品目录', {
statusCode: 409,
errorCode: 'cost_work_order_template_missing',
})
}
const rawPricingMode = text(payload.pricingMode ?? payload.pricing_mode)
if (rawPricingMode && rawPricingMode !== 'fixed' && rawPricingMode !== 'unit') {
throw createHttpError('接单模板计价方式无效', {
statusCode: 400,
errorCode: 'cost_work_order_pricing_mode_invalid',
})
}
const pricingMode =
rawPricingMode ||
(Number(rule.unit_price_fen || 0) > 0 && Number(rule.reward_amount || 0) === 0
? 'unit'
: 'fixed')
if (pricingMode === 'fixed' && rule.sharing_enabled && rule.sharing_auto_from_order) {
throw createHttpError(
'拼单自动按件计价与固定金额冲突,请先在接单模板关闭拼单或改为手工拼单价',
{
statusCode: 400,
errorCode: 'cost_work_order_sharing_pricing_conflict',
},
)
}
// 模板运行价格只有一个当前值;未来生效无法与成本版本同时切换,因此暂只允许立即生效。
if (Date.parse(effectiveFrom) > Date.now() + 5_000) {
throw createHttpError('接单模板成本暂不支持未来生效,请选择立即生效', {
statusCode: 400,
errorCode: 'cost_work_order_future_price_not_supported',
})
}
const costItemResult = await query<{ id: number }>(
"SELECT id FROM cost_items WHERE code = 'sys_work_order_reward' OR cost_model = 'work_order_reward' LIMIT 1",
@@ -1450,123 +1487,138 @@ export async function setAdminProductPrice(
if (!costItemId) {
throw createHttpError('未找到工单结算成本项(sys_work_order_reward)', { statusCode: 500 })
}
// 查找已有的 active 版本 (按 rule_id 或 canonical_product_id)
const existingResult = await query<{ id: number; amount_fen: number; effective_from: string }>(
`SELECT id, amount_fen, effective_from FROM cost_versions
WHERE cost_item_id = $1 AND (work_product_rule_id = $2 OR canonical_product_id = $3) AND status = 'active'
ORDER BY effective_from DESC, id DESC LIMIT 1`,
[costItemId, rule?.id ?? null, product.id],
)
const existing = existingResult.rows[0]
const existingTime = existing ? Date.parse(existing.effective_from) : null
const targetTime = Date.parse(effectiveFrom)
let costVersionId = existing?.id || 0
if (existing && existingTime !== null && targetTime < existingTime) {
// 追溯改价:退休旧版本并新增一个从 targetTime 起生效的版本,旧行保留可追溯。
// 不能就地改金额,否则 cost_snapshots.cost_version_id 指向的版本内容会被篡改。
await query("UPDATE cost_versions SET status = 'retired', updated_at = $1 WHERE id = $2", [
now,
existing.id,
])
const inserted = await query<{ id: number }>(
`INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
source, status, change_reason, created_by_admin_user_id,
approved_by_admin_user_id, created_at, updated_at
) VALUES (
$1, 'rule', '', '', $2,
$3, $4, 0, $5, NULL,
'manual', 'active', $6, $7,
$7, $8, $8
) RETURNING id`,
[
costItemId,
product.id,
rule?.id ?? null,
amountFen,
effectiveFrom,
changeReason,
session.userId,
now,
],
const projectedRewardAmount = pricingMode === 'unit' ? 0 : amountFen
const projectedUnitPrice = pricingMode === 'unit' ? amountFen : 0
const costVersionId = await withTransaction(async (client) => {
const existingResult = await client.query<{
id: number
amount_fen: number
effective_from: string
}>(
`SELECT id, amount_fen, effective_from
FROM cost_versions
WHERE cost_item_id = $1
AND work_product_rule_id = $2
AND status = 'active'
ORDER BY effective_from DESC, id DESC
LIMIT 1`,
[costItemId, rule.id],
)
costVersionId = inserted.rows[0]?.id || 0
} else if (existing && existingTime !== null && targetTime === existingTime) {
// 相同生效时间的更正:没有可插入的时间区间,只能就地更正并保留变更原因。
await query(
`UPDATE cost_versions
SET amount_fen = $1,
change_reason = $2,
created_by_admin_user_id = $3,
approved_by_admin_user_id = $3,
updated_at = $4
WHERE id = $5`,
[amountFen, changeReason, session.userId, now, existing.id],
)
} else {
if (existing) {
await query(
"UPDATE cost_versions SET status = 'retired', effective_to = $1, updated_at = $2 WHERE id = $3",
[effectiveFrom, now, existing.id],
const existing = existingResult.rows[0]
const existingTime = existing ? Date.parse(existing.effective_from) : null
const targetTime = Date.parse(effectiveFrom)
let nextCostVersionId = existing?.id || 0
if (existing && existingTime !== null && targetTime < existingTime) {
await client.query(
"UPDATE cost_versions SET status = 'retired', updated_at = $1 WHERE id = $2",
[now, existing.id],
)
const inserted = await client.query<{ id: number }>(
`INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
source, status, change_reason, created_by_admin_user_id,
approved_by_admin_user_id, created_at, updated_at
) VALUES (
$1, 'rule', '', '', $2,
$3, $4, 0, $5, NULL,
'manual', 'active', $6, $7,
$7, $8, $8
) RETURNING id`,
[
costItemId,
product.id,
rule.id,
amountFen,
effectiveFrom,
changeReason,
session.userId,
now,
],
)
nextCostVersionId = inserted.rows[0]?.id || 0
} else if (existing && existingTime !== null && targetTime === existingTime) {
await client.query(
`UPDATE cost_versions
SET amount_fen = $1,
change_reason = $2,
created_by_admin_user_id = $3,
approved_by_admin_user_id = $3,
updated_at = $4
WHERE id = $5`,
[amountFen, changeReason, session.userId, now, existing.id],
)
} else {
if (existing) {
await client.query(
"UPDATE cost_versions SET status = 'retired', effective_to = $1, updated_at = $2 WHERE id = $3",
[effectiveFrom, now, existing.id],
)
}
const inserted = await client.query<{ id: number }>(
`INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
source, status, change_reason, created_by_admin_user_id,
approved_by_admin_user_id, created_at, updated_at
) VALUES (
$1, 'rule', '', '', $2,
$3, $4, 0, $5, NULL,
'manual', 'active', $6, $7,
$7, $8, $8
) RETURNING id`,
[
costItemId,
product.id,
rule.id,
amountFen,
effectiveFrom,
changeReason,
session.userId,
now,
],
)
nextCostVersionId = inserted.rows[0]?.id || 0
}
const inserted = await query<{ id: number }>(
`INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
source, status, change_reason, created_by_admin_user_id,
approved_by_admin_user_id, created_at, updated_at
) VALUES (
$1, 'rule', '', '', $2,
$3, $4, 0, $5, NULL,
'manual', 'active', $6, $7,
$7, $8, $8
) RETURNING id`,
[
costItemId,
product.id,
rule?.id ?? null,
amountFen,
effectiveFrom,
changeReason,
session.userId,
now,
],
)
costVersionId = inserted.rows[0]?.id || 0
}
// 同步更新 work_product_rules 保持数据一致
if (rule) {
await query(
const updatedRule = await client.query(
`UPDATE work_product_rules
SET reward_amount = $1::bigint,
unit_price_fen = CASE WHEN unit_price_fen > 0 THEN $1::bigint ELSE 0::bigint END,
cost_item_id = $2,
updated_at = $3
WHERE id = $4`,
[amountFen, costItemId, now, rule.id],
unit_price_fen = $2::bigint,
cost_item_id = $3,
updated_at = $4
WHERE id = $5
RETURNING id`,
[projectedRewardAmount, projectedUnitPrice, costItemId, now, rule.id],
)
}
// 更新 product_catalog.attributes_json
const attr =
typeof product.attributes_json === 'object' && product.attributes_json
? { ...(product.attributes_json as Record<string, unknown>) }
: {}
attr.rewardAmountFen = amountFen
await query(
'UPDATE product_catalog SET attributes_json = $1::jsonb, updated_at = $2 WHERE id = $3',
[JSON.stringify(attr), now, product.id],
)
if (updatedRule.rowCount !== 1) {
throw createHttpError('接单模板价格投影失败', { statusCode: 500 })
}
await client.query(
`UPDATE product_catalog
SET attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $1::jsonb,
updated_at = $2
WHERE id = $3`,
[
JSON.stringify({
pricingMode,
rewardAmountFen: projectedRewardAmount,
unitPriceFen: projectedUnitPrice,
}),
now,
product.id,
],
)
if (!nextCostVersionId) {
throw createHttpError('接单模板成本版本保存失败', { statusCode: 500 })
}
return nextCostVersionId
})
// 如果指定了同步工单价格(默认 true)
let syncedOrders: unknown = null
if (payload.syncOpenOrders !== false && rule) {
if (payload.syncOpenOrders !== false) {
syncedOrders = await syncAdminWorkProductRulePrice(rule.id, session.username)
}
@@ -1576,6 +1628,7 @@ export async function setAdminProductPrice(
costVersionId,
amountFen,
effectiveFrom,
pricingMode,
syncedOrders,
message: '接单模板结算价格已更新',
}
@@ -18,7 +18,12 @@ import {
backfillAdminNormalizedQuantities,
backfillAdminProductMappings,
} from './product-catalog-service.js'
import { syncAdminWorkOrderTemplateCostVersions } from './work-order-product-service.js'
import {
buildTemplateProductSku,
inferTemplateUnitName,
} from './work-order-template-product-service.js'
export { buildTemplateProductSku, inferTemplateUnitName }
export async function previewAdminTemplateProductBootstrap(payload: JsonObject = {}) {
const range = resolveBootstrapDateRange(payload)
@@ -241,8 +246,6 @@ export async function applyAdminTemplateProductBootstrap(
dateTo: range.toDate,
})
mappedOrderItemCount = mappingBackfill.updatedCount
const templateCosts = await syncAdminWorkOrderTemplateCostVersions()
return {
dateFrom: range.fromDate,
dateTo: range.toDate,
@@ -260,9 +263,6 @@ export async function applyAdminTemplateProductBootstrap(
resetOrderItemCount: resetResult.resetOrderItemCount,
mappingConflictCount: plan.conflicts.length,
mappingConflictItemCount: plan.unresolvedItemCount,
templateCostVersionCount: templateCosts.activeVersionCount,
templateCostCreatedCount: templateCosts.createdCount,
templateCostRetiredCount: templateCosts.retiredCount,
}
}
@@ -806,19 +806,6 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
}
}
export function buildTemplateProductSku(ruleKey: string) {
return `tpl:${String(ruleKey || '').trim()}`.slice(0, 64)
}
export function inferTemplateUnitName(productName: string) {
const name = String(productName || '').trim()
if (/钻石|颗/.test(name)) return '颗'
if (/枪|把/.test(name)) return '把'
if (/套/.test(name)) return '套'
if (/密钥|秘钥/.test(name)) return '个'
return '件'
}
function resolveBootstrapDateRange(payload: JsonObject) {
const range = resolveFinanceDateRange(
{ dateFrom: payload.dateFrom, dateTo: payload.dateTo },
@@ -311,7 +311,9 @@ type WorkOrderTemplateCostSyncRow = {
}
/**
* 把接单模板的发单金额同步成规则级成本版本。
* 历史迁移/灾后修复:把既有接单模板发单金额补齐为规则级成本版本。
*
* 日常成本真源是商品与成本中心;模板保存不再调用本函数,前端也不再提供按钮。
*
* 固定金额模板取 `reward_amount`;按件模板取 `unit_price_fen`,数量由订单 SKU
* 决定。全局 0 元版本只作为“按工单实际结算”的占位,不再代表模板价格。
@@ -0,0 +1,215 @@
/**
* 接单模板成本单一写入口集成测试(需要真实 PostgreSQL)。
*
* WORK_ORDER_TEMPLATE_COST_TEST_DATABASE_URL=postgres://... \
* npx tsx --test src/services/cost-center/work-order-template-cost.integration.test.ts
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const TEST_DATABASE_URL = String(
process.env.WORK_ORDER_TEMPLATE_COST_TEST_DATABASE_URL || '',
).trim()
test(
'模板自动生成商品,成本中心保存价格后才允许启用',
{ skip: !TEST_DATABASE_URL ? '未设置 WORK_ORDER_TEMPLATE_COST_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = 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 suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const productName = `集成测试接单模板-${suffix}`
let ruleId = 0
let productId = 0
try {
const admin = await query<{ id: number; username: string; display_name: string }>(
'SELECT id, username, display_name FROM admin_users ORDER BY id LIMIT 1',
)
const adminUser = admin.rows[0]
assert.ok(adminUser, '集成测试库至少需要一个后台账号')
const created = await saveAdminWorkProductRule({
productName,
itemTitle: productName,
itemTitleMatchType: 'exact',
matchType: 'exact',
enabled: true,
autoCreate: true,
})
ruleId = Number(created.rule.ruleId)
productId = Number(created.productId)
assert.ok(ruleId > 0)
assert.ok(productId > 0)
assert.equal(created.rule.enabled, false)
assert.equal(created.costConfigurationRequired, true)
const product = await query<{ canonical_sku: string; status: string }>(
'SELECT canonical_sku, status FROM product_catalog WHERE id = $1',
[productId],
)
assert.equal(product.rows[0]?.canonical_sku, `tpl:${created.rule.ruleKey}`)
assert.equal(product.rows[0]?.status, 'active')
await assert.rejects(
() =>
saveAdminWorkProductRule({
ruleId,
productName,
enabled: true,
}),
/先在商品与成本中心配置接单模板成本/,
)
await assert.rejects(
() =>
saveAdminWorkProductRule({
ruleId,
productName,
rewardAmount: 999,
}),
/价格只能由【商品与成本中心】维护/,
)
const price = await setAdminProductPrice(
productId,
{
amountFen: 1234,
pricingMode: 'fixed',
changeReason: '接单模板成本集成测试',
syncOpenOrders: false,
},
{
sessionId: `it-${suffix}`,
userId: Number(adminUser.id),
username: adminUser.username,
displayName: adminUser.display_name || adminUser.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: [],
},
)
assert.ok(price.costVersionId > 0)
const projection = await query<{
enabled: boolean
reward_amount: number
unit_price_fen: number
amount_fen: number
}>(
`
SELECT wpr.enabled, wpr.reward_amount, wpr.unit_price_fen, v.amount_fen
FROM work_product_rules wpr
JOIN cost_versions v ON v.work_product_rule_id = wpr.id AND v.status = 'active'
WHERE wpr.id = $1
ORDER BY v.effective_from DESC, v.id DESC
LIMIT 1
`,
[ruleId],
)
assert.equal(projection.rows[0]?.enabled, false)
assert.equal(Number(projection.rows[0]?.reward_amount), 1234)
assert.equal(Number(projection.rows[0]?.unit_price_fen), 0)
assert.equal(Number(projection.rows[0]?.amount_fen), 1234)
await assert.rejects(
() =>
setAdminProductPrice(
productId,
{
amountFen: 2000,
pricingMode: 'fixed',
effectiveFrom: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
syncOpenOrders: false,
},
{
sessionId: `it-${suffix}`,
userId: Number(adminUser.id),
username: adminUser.username,
displayName: adminUser.display_name || adminUser.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: [],
},
),
/暂不支持未来生效/,
)
await setAdminProductPrice(
productId,
{
amountFen: 1300,
pricingMode: 'unit',
changeReason: '接单模板按件成本集成测试',
syncOpenOrders: false,
},
{
sessionId: `it-${suffix}`,
userId: Number(adminUser.id),
username: adminUser.username,
displayName: adminUser.display_name || adminUser.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 unitProjection = await query<{
reward_amount: number
unit_price_fen: number
amount_fen: number
}>(
`
SELECT wpr.reward_amount, wpr.unit_price_fen, v.amount_fen
FROM work_product_rules wpr
JOIN cost_versions v ON v.work_product_rule_id = wpr.id AND v.status = 'active'
WHERE wpr.id = $1
ORDER BY v.effective_from DESC, v.id DESC
LIMIT 1
`,
[ruleId],
)
assert.equal(Number(unitProjection.rows[0]?.reward_amount), 0)
assert.equal(Number(unitProjection.rows[0]?.unit_price_fen), 1300)
assert.equal(Number(unitProjection.rows[0]?.amount_fen), 1300)
const enabled = await saveAdminWorkProductRule({
ruleId,
productName,
enabled: true,
})
assert.equal(enabled.rule.enabled, true)
assert.equal(enabled.costConfigurationRequired, false)
} finally {
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 internal_sku_channels 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])
}
await closeDb()
}
},
)
@@ -0,0 +1,166 @@
import type { PoolClient, QueryResult, QueryResultRow } from 'pg'
import { query } from '../../db/client.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import type { WorkProductRuleRow } from '../../repositories/worker-platform/types.js'
type DbClient = Pick<PoolClient, 'query'>
type WorkOrderTemplateProjectionRow = WorkProductRuleRow & {
category_name: string | null
}
type WorkOrderTemplateProductState = {
rule: WorkOrderTemplateProjectionRow
productId: number
hasActiveCost: boolean
status: 'active' | 'archived'
}
function runQuery<T extends QueryResultRow>(
client: DbClient | undefined,
text: string,
params: unknown[] = [],
): Promise<QueryResult<T>> {
return client ? client.query<T>(text, params) : query<T>(text, params)
}
export function buildTemplateProductSku(ruleKey: string) {
return `tpl:${String(ruleKey || '').trim()}`.slice(0, 64)
}
export function inferTemplateUnitName(productName: string) {
const name = String(productName || '').trim()
if (/钻石|颗/.test(name)) return '颗'
if (/枪|把/.test(name)) return '把'
if (/套/.test(name)) return '套'
if (/密钥|秘钥/.test(name)) return '个'
return '件'
}
async function loadWorkOrderTemplateRule(ruleId: number, client?: DbClient) {
const result = await runQuery<WorkOrderTemplateProjectionRow>(
client,
`
SELECT wpr.*, wc.name AS category_name
FROM work_product_rules wpr
LEFT JOIN work_categories wc ON wc.id = wpr.category_id
WHERE wpr.id = $1
`,
[ruleId],
)
return result.rows[0] || null
}
async function hasActiveWorkOrderCost(ruleId: number, client?: DbClient) {
const result = await runQuery<{ active: boolean }>(
client,
`
SELECT EXISTS (
SELECT 1
FROM cost_versions v
JOIN cost_items ci ON ci.id = v.cost_item_id
WHERE ci.code = 'sys_work_order_reward'
AND v.scope_type = 'rule'
AND v.work_product_rule_id = $1
AND v.status = 'active'
AND v.amount_fen > 0
AND v.effective_from <= NOW()
AND (v.effective_to IS NULL OR v.effective_to > NOW())
) AS active
`,
[ruleId],
)
return result.rows[0]?.active === true
}
/**
* 接单模板的商品投影。
*
* 模板负责名称、分类和匹配配置;商品状态仍由运营在成本中心显式启停。
* 这里只写商品身份和模板属性,不修改成本版本金额,避免模板入口成为价格真源。
*/
export async function syncWorkOrderTemplateProduct(
ruleId: number,
client?: DbClient,
): Promise<WorkOrderTemplateProductState> {
const rule = await loadWorkOrderTemplateRule(ruleId, client)
if (!rule) {
throw createHttpError(`接单模板不存在: ${ruleId}`, { statusCode: 404 })
}
const hasActiveCost = await hasActiveWorkOrderCost(ruleId, client)
const now = nowIso()
const attributes = {
source: 'work_product_rule',
ruleId: Number(rule.id),
ruleKey: rule.rule_key,
categoryId: rule.category_id ? Number(rule.category_id) : null,
templateEnabled: rule.enabled === true,
pricingMode:
Number(rule.unit_price_fen || 0) > 0 && Number(rule.reward_amount || 0) === 0
? 'unit'
: 'fixed',
rewardAmountFen: Number(rule.reward_amount || 0),
unitPriceFen: Number(rule.unit_price_fen || 0),
sharingEnabled: rule.sharing_enabled === true,
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
sharingAutoFromOrder: rule.sharing_auto_from_order === true,
}
const result = await runQuery<{ id: number; status: string }>(
client,
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, 'active', $5::jsonb, $6, $6)
ON CONFLICT (canonical_sku) DO UPDATE SET
canonical_name = EXCLUDED.canonical_name,
category = EXCLUDED.category,
unit_name = EXCLUDED.unit_name,
attributes_json = COALESCE(product_catalog.attributes_json, '{}'::jsonb) || EXCLUDED.attributes_json,
updated_at = EXCLUDED.updated_at
RETURNING id, status
`,
[
buildTemplateProductSku(rule.rule_key),
rule.product_name,
rule.category_name || '',
inferTemplateUnitName(rule.product_name),
JSON.stringify(attributes),
now,
],
)
return {
rule,
productId: Number(result.rows[0]?.id || 0),
hasActiveCost,
status: result.rows[0]?.status === 'archived' ? 'archived' : 'active',
}
}
export async function assertWorkOrderTemplateCanEnable(ruleId: number, client?: DbClient) {
if (!(await hasActiveWorkOrderCost(ruleId, client))) {
throw createHttpError('请先在商品与成本中心配置接单模板成本,再启用模板', {
statusCode: 400,
errorCode: 'work_order_template_cost_required',
})
}
}
export async function archiveDeletedWorkOrderTemplateProduct(ruleKey: string, client?: DbClient) {
await runQuery(
client,
`UPDATE product_catalog
SET status = 'archived',
attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $1::jsonb,
updated_at = $2
WHERE canonical_sku = $3 AND status = 'active'`,
[
JSON.stringify({ templateEnabled: false, templateDeleted: true }),
nowIso(),
buildTemplateProductSku(ruleKey),
],
)
}
@@ -110,12 +110,13 @@ test('P1:资金/积分写命令支持 requestId 幂等', async () => {
assert.ok(migration.includes('uq_platform_credit_entries_account_request'))
})
test('P1:成本版本追溯改价退休旧版本而不是覆盖金额', async () => {
test('P1:成本改价退休旧版本且模板运行价格由成本中心同步', async () => {
const [product, affiliate] = await Promise.all([
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('新增一个从 targetTime 起生效的版本'))
assert.ok(product.includes('const costVersionId = await withTransaction'))
assert.ok(product.includes('UPDATE work_product_rules'))
assert.ok(product.includes("status = 'retired'"))
assert.ok(affiliate.includes('新增追溯版本'))
assert.ok(affiliate.includes("status = 'retired'"))
@@ -27,7 +27,12 @@ import {
upsertWorkProductRule,
} from '../../repositories/worker-platform/work-product-rule-repo.js'
import type { WorkOrderRow, WorkProductRuleRow } from '../../repositories/worker-platform/types.js'
import { syncAdminWorkOrderTemplateCostVersions } from '../cost-center/work-order-product-service.js'
import { withTransaction } from '../../db/client.js'
import {
archiveDeletedWorkOrderTemplateProduct,
assertWorkOrderTemplateCanEnable,
syncWorkOrderTemplateProduct,
} from '../cost-center/work-order-template-product-service.js'
import {
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
@@ -379,35 +384,38 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
})
}
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
const unitPriceFen =
payload.unitPrice === undefined && payload.unitPriceYuan === undefined
? 0
: normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0)
const hasRewardPayload =
payload.rewardAmount !== undefined || payload.rewardAmountYuan !== undefined
const hasUnitPricePayload = payload.unitPrice !== undefined || payload.unitPriceYuan !== undefined
const incomingRewardAmount = normalizeAmountFen(
payload.rewardAmount ?? payload.rewardAmountYuan,
0,
)
const incomingUnitPriceFen = normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0)
const existingRewardAmount = Number(existingRule?.reward_amount || 0)
const existingUnitPriceFen = Number(existingRule?.unit_price_fen || 0)
// 全系统只有【商品与成本中心】是标准价格,接单模板价格禁止在其他地方修改
if (existingRule && !payload.internalAllowPriceChange) {
const existingRewardAmount = Number(existingRule.reward_amount || 0)
const existingUnitPrice = Number(existingRule.unit_price_fen || 0)
const hasPricePayload =
payload.rewardAmount !== undefined ||
payload.rewardAmountYuan !== undefined ||
payload.unitPrice !== undefined ||
payload.unitPriceYuan !== undefined
if (
hasPricePayload &&
(rewardAmount !== existingRewardAmount || unitPriceFen !== existingUnitPrice)
) {
throw createHttpError(
'接单模板价格已由【商品与成本中心】统一定价,价格更新只能走商品与成本中心。请前往成本中心修改标准价格',
{
statusCode: 400,
errorCode: 'price_update_restricted_to_cost_center',
},
)
}
// 模板保存只维护业务配置。价格只能由商品与成本中心的统一命令写入。
if (
(hasRewardPayload && incomingRewardAmount !== existingRewardAmount) ||
(hasUnitPricePayload && incomingUnitPriceFen !== existingUnitPriceFen) ||
(!existingRule && (incomingRewardAmount > 0 || incomingUnitPriceFen > 0))
) {
throw createHttpError(
'接单模板价格只能由【商品与成本中心】维护。请先保存模板,再到成本中心配置价格',
{
statusCode: 400,
errorCode: 'price_update_restricted_to_cost_center',
},
)
}
const sharingEnabled = normalizeBoolean(payload.sharingEnabled ?? payload.sharing_enabled, false)
const rewardAmount = existingRewardAmount
const unitPriceFen = existingUnitPriceFen
const sharingEnabled = hasOwnPayload(payload, 'sharingEnabled')
? normalizeBoolean(payload.sharingEnabled, false)
: hasOwnPayload(payload, 'sharing_enabled')
? normalizeBoolean(payload.sharing_enabled, false)
: existingRule?.sharing_enabled === true
const acceptanceMode = normalizeWorkOrderAcceptanceMode(
hasOwnPayload(payload, 'acceptanceMode') || hasOwnPayload(payload, 'acceptance_mode')
? payload.acceptanceMode || payload.acceptance_mode
@@ -423,11 +431,12 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
hasConfiguredSharingValue(payload.sharingTotalQuantity ?? payload.sharing_total_quantity) ||
hasConfiguredSharingValue(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan) ||
hasConfiguredSharingValue(payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan)
const sharingAutoFromOrder =
sharingEnabled &&
unitPriceFen > 0 &&
(normalizeBoolean(payload.sharingAutoFromOrder ?? payload.sharing_auto_from_order, false) ||
!hasManualSharingConfig)
const sharingAutoFromOrder = hasOwnPayload(payload, 'sharingAutoFromOrder')
? normalizeBoolean(payload.sharingAutoFromOrder, false)
: hasOwnPayload(payload, 'sharing_auto_from_order')
? normalizeBoolean(payload.sharing_auto_from_order, false)
: (existingRule?.sharing_auto_from_order === true && !hasManualSharingConfig) ||
(!existingRule && sharingEnabled && unitPriceFen > 0 && !hasManualSharingConfig)
const hasManualSharingQuantity = hasConfiguredSharingValue(
payload.sharingTotalQuantity ?? payload.sharing_total_quantity,
)
@@ -445,17 +454,19 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
errorCode: 'work_product_rule_sharing_override_incomplete',
})
}
const sharingUnitReward = normalizeAmountFen(
payload.sharingUnitReward ?? payload.sharingUnitRewardYuan,
0,
)
const sharingUnitReward =
payload.sharingUnitReward !== undefined || payload.sharingUnitRewardYuan !== undefined
? normalizeAmountFen(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan, 0)
: Number(existingRule?.sharing_unit_reward || 0)
const sharingTotalAmount = normalizeAmountFen(
payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan,
0,
)
const sharingTotalQuantity = resolveSharingQuantity(
sharingTotalAmount,
normalizePositiveInteger(payload.sharingTotalQuantity ?? payload.sharing_total_quantity, 1),
payload.sharingTotalQuantity !== undefined || payload.sharing_total_quantity !== undefined
? normalizePositiveInteger(payload.sharingTotalQuantity ?? payload.sharing_total_quantity, 1)
: normalizePositiveInteger(existingRule?.sharing_total_quantity, 1),
sharingUnitReward,
)
const resolvedSharingTotalAmount =
@@ -473,77 +484,89 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
? 0
: resolvedSharingTotalAmount
: rewardAmount
const requestedEnabled = hasOwnPayload(payload, 'enabled')
? normalizeBoolean(payload.enabled, false)
: existingRule?.enabled === true
const enabled = existingRule ? requestedEnabled : false
if (existingRule && enabled) {
await assertWorkOrderTemplateCanEnable(existingRule.id)
}
const fields = normalizeRequirementFieldsFromPayload(payload)
const ruleKey = existingRule?.rule_key || randomId('rule-')
const rule = await upsertWorkProductRule({
ruleKey,
provider: hasOwnPayload(payload, 'provider')
? String(payload.provider || '').trim()
: String(existingRule?.provider || '').trim(),
platform: hasOwnPayload(payload, 'platform')
? String(payload.platform || '').trim()
: String(existingRule?.platform || '').trim(),
shopId:
hasOwnPayload(payload, 'shopId') || hasOwnPayload(payload, 'shop_id')
? String(payload.shopId || payload.shop_id || '').trim()
: String(existingRule?.shop_id || '').trim(),
skuCode:
hasOwnPayload(payload, 'skuCode') || hasOwnPayload(payload, 'sku_code')
? skuCode
: String(existingRule?.sku_code || '').trim(),
productName: productName || matchedProductName,
matchType:
hasOwnPayload(payload, 'matchType') || hasOwnPayload(payload, 'match_type')
? normalizeMatchType(payload.matchType || payload.match_type)
: normalizeMatchType(existingRule?.match_type),
categoryId:
normalizeOptionalId(payload.categoryId || payload.category_id) ||
defaults.category?.id ||
null,
enabled: normalizeBoolean(payload.enabled, true),
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, true),
rewardAmount: finalRewardAmount,
unitPriceFen,
// 商品规则不配置押金;工单创建后由运营按需手工填写。
requiredDepositAmount: 0,
depositThresholdAmount: 0,
sharingEnabled,
sharingTotalQuantity,
sharingUnitReward,
sharingAutoFromOrder,
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
acceptanceMode,
giftCooldownHours:
hasOwnPayload(payload, 'giftCooldownHours') || hasOwnPayload(payload, 'gift_cooldown_hours')
? normalizeWorkOrderGiftCooldownHours(
payload.giftCooldownHours ?? payload.gift_cooldown_hours,
)
: normalizeWorkOrderGiftCooldownHours(existingRule?.gift_cooldown_hours),
giftCooldownMinutes:
hasOwnPayload(payload, 'giftCooldownMinutes') ||
hasOwnPayload(payload, 'gift_cooldown_minutes')
? normalizeWorkOrderGiftCooldownMinutes(
payload.giftCooldownMinutes ?? payload.gift_cooldown_minutes,
)
: normalizeWorkOrderGiftCooldownMinutes(existingRule?.gift_cooldown_minutes),
requirementJson: JSON.stringify({ fields }),
matchJson: JSON.stringify(match),
sortOrder: normalizeInteger(payload.sortOrder, 100),
now: nowIso(),
const hasCategoryPayload =
hasOwnPayload(payload, 'categoryId') || hasOwnPayload(payload, 'category_id')
const categoryId = hasCategoryPayload
? normalizeOptionalId(payload.categoryId ?? payload.category_id)
: existingRule?.category_id || defaults.category?.id || null
const projection = await withTransaction(async (client) => {
const savedRule = await upsertWorkProductRule({
ruleKey,
provider: hasOwnPayload(payload, 'provider')
? String(payload.provider || '').trim()
: String(existingRule?.provider || '').trim(),
platform: hasOwnPayload(payload, 'platform')
? String(payload.platform || '').trim()
: String(existingRule?.platform || '').trim(),
shopId:
hasOwnPayload(payload, 'shopId') || hasOwnPayload(payload, 'shop_id')
? String(payload.shopId || payload.shop_id || '').trim()
: String(existingRule?.shop_id || '').trim(),
skuCode:
hasOwnPayload(payload, 'skuCode') || hasOwnPayload(payload, 'sku_code')
? skuCode
: String(existingRule?.sku_code || '').trim(),
productName: productName || matchedProductName,
matchType:
hasOwnPayload(payload, 'matchType') || hasOwnPayload(payload, 'match_type')
? normalizeMatchType(payload.matchType || payload.match_type)
: normalizeMatchType(existingRule?.match_type),
categoryId,
enabled,
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, true),
rewardAmount: finalRewardAmount,
unitPriceFen,
// 商品规则不配置押金;工单创建后由运营按需手工填写。
requiredDepositAmount: 0,
depositThresholdAmount: 0,
sharingEnabled,
sharingTotalQuantity,
sharingUnitReward,
sharingAutoFromOrder,
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
acceptanceMode,
giftCooldownHours:
hasOwnPayload(payload, 'giftCooldownHours') || hasOwnPayload(payload, 'gift_cooldown_hours')
? normalizeWorkOrderGiftCooldownHours(
payload.giftCooldownHours ?? payload.gift_cooldown_hours,
)
: normalizeWorkOrderGiftCooldownHours(existingRule?.gift_cooldown_hours),
giftCooldownMinutes:
hasOwnPayload(payload, 'giftCooldownMinutes') ||
hasOwnPayload(payload, 'gift_cooldown_minutes')
? normalizeWorkOrderGiftCooldownMinutes(
payload.giftCooldownMinutes ?? payload.gift_cooldown_minutes,
)
: normalizeWorkOrderGiftCooldownMinutes(existingRule?.gift_cooldown_minutes),
requirementJson: JSON.stringify({ fields }),
matchJson: JSON.stringify(match),
sortOrder: normalizeInteger(payload.sortOrder, 100),
now: nowIso(),
client,
})
if (!savedRule) {
throw createHttpError('接单模板保存失败', { statusCode: 500 })
}
const productProjection = await syncWorkOrderTemplateProduct(savedRule.id, client)
return { rule: savedRule, productProjection }
})
// 模板价格变更即同步成本版本(事件驱动);失败不阻塞模板保存,
// 夜间巡检与后台「同步发单金额」按钮仍可修复。
if (rule) {
try {
await syncAdminWorkOrderTemplateCostVersions(rule.id)
} catch {
// 成本版本同步失败不允许影响模板本身;由巡检补齐。
}
return {
rule: mapWorkProductRule(projection.rule),
productId: projection.productProjection.productId,
productStatus: projection.productProjection.status,
costConfigurationRequired: !projection.productProjection.hasActiveCost,
}
return { rule: mapWorkProductRule(rule) }
}
/** 根据工单创建时的规格,计算当前模板可同步的价格。 */
@@ -669,5 +692,6 @@ export async function deleteAdminWorkProductRule(ruleId: number | string) {
errorCode: 'work_product_rule_not_found',
})
}
await archiveDeletedWorkOrderTemplateProduct(rule.rule_key)
return { deleted, retiredCostVersionCount: detached.updated }
}