feat(cost-center): 统一接单模板成本并自动同步商品
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user