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 }
}
@@ -95,7 +95,7 @@ export function ProductCatalogPanel() {
const queryClient = useQueryClient()
const { canConfig, productSourceFilter, refreshAll } = useCostCenter()
const [productForm] = Form.useForm<ProductFormValues>()
// 初始化/回填只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
// 历史修复只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
const [backfillRange, setBackfillRange] = useState<[Dayjs, Dayjs]>([
dayjs().subtract(29, 'day').startOf('day'),
dayjs().endOf('day'),
@@ -177,7 +177,7 @@ export function ProductCatalogPanel() {
const data = response.data
bootstrapSyncOptionsRef.current.includeDisabledTemplates = false
modal.confirm({
title: '从接单模板全量同步商品与映射',
title: '修复接单模板历史商品与映射',
width: 720,
content: (
<Space direction="vertical" size={8}>
@@ -276,7 +276,7 @@ export function ProductCatalogPanel() {
? `(含停用模板 ${applied.disabledTemplateCount} 个,已建为归档商品)`
: ''
message.success(
`已同步 ${applied.templateCount} 个模板商品:新建 ${applied.createdProductCount} 个、刷新 ${applied.updatedProductCount} 个、复用 ${applied.reusedProductCount} 个${disabledText};新增 ${applied.createdMappingCount} 条映射,同步 ${applied.templateCostVersionCount} 条模板发单金额,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`,
`已同步 ${applied.templateCount} 个模板商品:新建 ${applied.createdProductCount} 个、刷新 ${applied.updatedProductCount} 个、复用 ${applied.reusedProductCount} 个${disabledText};新增 ${applied.createdMappingCount} 条映射,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`,
)
refreshAll()
} catch (error) {
@@ -338,6 +338,7 @@ export function ProductCatalogPanel() {
}
function openSetProductPrice(item: ProductCatalogItem) {
const isTemplateProduct = item.canonicalSku.startsWith('tpl:')
setPricingProduct(item)
priceForm.resetFields()
priceForm.setFieldsValue({
@@ -345,9 +346,9 @@ export function ProductCatalogPanel() {
item.activeCostAmountFen !== null && item.activeCostAmountFen !== undefined
? (item.activeCostAmountFen / 100).toFixed(2)
: undefined,
pricingMode: isTemplateProduct ? item.templatePricingMode || 'fixed' : undefined,
effectiveScope: 'immediate',
changeReason: '在商品与成本中心统一调整价格',
syncOpenOrders: true,
syncSnapshots: true,
})
setPriceModalOpen(true)
@@ -355,10 +356,10 @@ export function ProductCatalogPanel() {
async function submitProductPrice(values: {
amountYuan: number | string
pricingMode?: 'fixed' | 'unit'
effectiveScope: 'immediate' | 'custom'
effectiveFromCustom?: string
changeReason?: string
syncOpenOrders?: boolean
}) {
if (!pricingProduct) return
const yuan = Number(values.amountYuan)
@@ -376,9 +377,10 @@ export function ProductCatalogPanel() {
try {
const response = await setAdminProductPrice(pricingProduct.id, {
amountFen,
pricingMode: values.pricingMode,
effectiveFrom,
changeReason: values.changeReason || '在商品与成本中心统一调整价格',
syncOpenOrders: values.syncOpenOrders !== false,
syncOpenOrders: true,
})
const result = response.data
let extraHint = ''
@@ -386,6 +388,12 @@ export function ProductCatalogPanel() {
extraHint = `,已同步 ${result.syncedOrders.updatedCount} 笔工单大厅待抢单`
}
message.success(`标准价格配置成功(¥${(amountFen / 100).toFixed(2)})${extraHint}`)
if (pricingProduct.canonicalSku.startsWith('tpl:') && !pricingProduct.templateEnabled) {
message.info('成本已配置;请回接单模板启用该模板后开始自动接单')
}
void queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-product-rules'],
})
setPriceModalOpen(false)
refreshAll()
} catch (error) {
@@ -536,6 +544,16 @@ export function ProductCatalogPanel() {
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
单位:{row.unitName || '件'}
</Typography.Text>
{row.canonicalSku.startsWith('tpl:') ? (
<Tag color={row.templateEnabled ? 'green' : 'orange'} style={{ marginInlineEnd: 0 }}>
{row.templateEnabled ? '模板已启用' : '模板待启用'}
</Tag>
) : null}
{row.templatePricingMode ? (
<Tag color="geekblue" style={{ marginInlineEnd: 0 }}>
{row.templatePricingMode === 'unit' ? '按件计价' : '固定金额'}
</Tag>
) : null}
</Space>
</div>
),
@@ -720,7 +738,7 @@ export function ProductCatalogPanel() {
loading={bootstrapPreviewing || bootstrapApplying}
onClick={() => void runTemplateBootstrap()}
>
从接单模板初始化
修复历史商品/映射
</Button>
</>
) : null}
@@ -949,16 +967,38 @@ export function ProductCatalogPanel() {
</div>
</div>
{pricingProduct.canonicalSku.startsWith('tpl:') ? (
<Form.Item
name="pricingMode"
label="接单计价方式"
rules={[{ required: true, message: '请选择计价方式' }]}
extra="保存后会同步到接单模板;固定金额按整单,按件单价按 SKU 数量计算"
>
<Radio.Group optionType="button" buttonStyle="solid">
<Radio.Button value="fixed">固定金额</Radio.Button>
<Radio.Button value="unit">按件计价</Radio.Button>
</Radio.Group>
</Form.Item>
) : null}
<Form.Item
name="amountYuan"
label={
pricingProduct.source === 'affiliate_dash' ? '采购单价(元)' : '标准结算单价(元)'
pricingProduct.source === 'affiliate_dash'
? '采购单价(元)'
: pricingProduct.canonicalSku.startsWith('tpl:')
? pricingProduct.templateSharingEnabled
? '拼单总成本(元)'
: '接单成本(元)'
: '标准结算单价(元)'
}
rules={[{ required: true, message: '请填写价格' }]}
extra={
pricingProduct.source === 'affiliate_dash'
? '上游每发货 1 件扣除的采购成本金额'
: '接单打手发单/验收的基础结算单价'
: pricingProduct.canonicalSku.startsWith('tpl:')
? '固定金额按整单计;按件计价时填写每个 SKU 的接单成本'
: '接单打手发单/验收的基础结算单价'
}
>
<InputNumber
@@ -983,8 +1023,13 @@ export function ProductCatalogPanel() {
只影响之后的新订单;历史订单的成本与成交价已固化,不会被改价覆盖
</div>
</Radio>
<Radio value="custom">
<Radio value="custom" disabled={pricingProduct.canonicalSku.startsWith('tpl:')}>
<strong>指定生效时间(不能早于当前时间)</strong>
{pricingProduct.canonicalSku.startsWith('tpl:') ? (
<div style={{ color: '#cf1322', fontSize: 12, marginTop: 2 }}>
接单模板当前只支持立即生效,避免模板价格与成本版本不同步
</div>
) : null}
</Radio>
</Radio.Group>
</Form.Item>
@@ -1010,17 +1055,6 @@ export function ProductCatalogPanel() {
<Input placeholder="例如:成本中心统一定价调整 / 上游采购成本调整" />
</Form.Item>
{pricingProduct.canonicalSku.startsWith('tpl:') ? (
<Form.Item name="syncOpenOrders" valuePropName="checked" style={{ marginBottom: 12 }}>
<Checkbox>
<strong>立即同步至接单大厅待抢单工单(推荐)</strong>
<div style={{ color: '#64748b', fontSize: 12 }}>
接单大厅中所有未领取的该模板工单将立即同步为新价格;已领取的工单保持原价不变。
</div>
</Checkbox>
</Form.Item>
) : null}
{pricingProduct.source === 'affiliate_dash' ? (
<Form.Item name="syncSnapshots" valuePropName="checked" style={{ marginBottom: 12 }}>
<Checkbox>
@@ -1,18 +1,18 @@
/**
* 发单价格 Tab:只读展示「接单模板 → 发单金额」的当前生效版本。
* 发单价格 Tab:展示由标准商品成本自动投影出的接单模板当前生效版本。
*
* 接单平台的成本以打手报酬为主,金额由接单模板(work_product_rules)决定,
* 这里只做核对视图;改价请改模板后点「同步模板发单金额」。
* 这里只做核对视图;改价在「标准商品」中完成并自动同步。
* 人工与自动发货的成本分别在各自 SKU 配置里维护,不在这里重复配置。
*/
import { SearchOutlined, SyncOutlined } from '@ant-design/icons'
import { Alert, App, Button, Card, Input, Select, Space, Tag, Typography } from 'antd'
import { SearchOutlined } from '@ant-design/icons'
import { Alert, Card, Input, Select, Space, Tag, Typography } from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { fetchAdminCostVersions, syncAdminWorkOrderTemplateCostVersions } from '@/services/admin'
import { fetchAdminCostVersions } from '@/services/admin'
import type { CostVersion } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
@@ -22,14 +22,12 @@ import { useCostCenter } from './CostCenterContext'
import { formatRate } from './shared'
export function WorkOrderPricePanel() {
const { message } = App.useApp()
const { canConfig, refreshAll } = useCostCenter()
const { canConfig } = useCostCenter()
const [keyword, setKeyword] = useState('')
const [status, setStatus] = useState('active')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [syncing, setSyncing] = useState(false)
const versionsQuery = useQuery({
queryKey: ['admin-cost-center-cost-versions', 'work_order', keyword, status, page, pageSize],
@@ -46,22 +44,6 @@ export function WorkOrderPricePanel() {
const versions = versionsQuery.data?.data.items || []
const total = versionsQuery.data?.data.pagination.total || 0
async function syncTemplateCosts() {
setSyncing(true)
try {
const response = await syncAdminWorkOrderTemplateCostVersions()
const result = response.data
message.success(
`已同步 ${result.activeVersionCount} 条模板发单金额(新建 ${result.createdCount},停用 ${result.retiredCount}),关联 ${result.linkedSnapshotCount} 条成本快照`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '同步接单模板发单金额失败')
} finally {
setSyncing(false)
}
}
const columns: TableColumnsType<CostVersion> = [
{
title: '接单规则 / 模板',
@@ -176,24 +158,14 @@ export function WorkOrderPricePanel() {
<Tag color="blue">{total} 条</Tag>
</Space>
}
extra={
canConfig ? (
<Button
icon={<SyncOutlined />}
loading={syncing}
onClick={() => void syncTemplateCosts()}
>
同步模板发单金额
</Button>
) : null
}
extra={canConfig ? <Tag color="green">自动同步</Tag> : null}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="接单平台的成本只看这张表;改价请改接单模板,再点「同步模板发单金额」"
description="人工客服的成本来自「人工 SKU 配置」里的消耗积分,自动发货的成本在「自动发货 SKU」里维护,二者都不在这里配置;系统内置成本项只有人工履约、自动发货采购、接单平台报酬 3 条。"
message="接单平台成本的唯一写入口是【标准商品 → 配置成本】"
description="改价保存后会自动更新成本版本、接单模板运行价格和本表;未领取的大厅工单同步新价,已领取工单保持原价。人工客服和自动发货成本分别在各自 SKU 配置中维护。"
/>
<Space style={{ marginBottom: 16 }} wrap>
<Input
@@ -18,7 +18,6 @@ import {
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Radio,
Select,
@@ -42,7 +41,6 @@ import {
fetchAdminWorkProductRules,
reprocessAdminKuaishouWorkOrderMatches,
saveAdminWorkProductRule,
syncAdminWorkProductRulePrice,
} from '@/services/admin'
import type {
CollectField,
@@ -73,7 +71,6 @@ export default function ProductRulesPanel() {
const hasInitializedRuleSelection = useRef(false)
const [editingRule, setEditingRule] = useState<WorkProductRule | null>(null)
const [syncingPrice, setSyncingPrice] = useState(false)
const [exportingRules, setExportingRules] = useState(false)
const [activeMappingId, setActiveMappingId] = useState<number | 'new' | null>(null)
const [sourcePrefill, setSourcePrefill] = useState<SourcePrefill | null>(null)
@@ -296,9 +293,6 @@ export default function ProductRulesPanel() {
itemTitleMatchType: rule.match.itemTitleMatchType,
skuNickMatchType: rule.match.skuNickMatchType,
categoryId: rule.categoryId || undefined,
pricingMode: rule.unitPriceFen > 0 ? 'unit' : 'fixed',
rewardAmount: rule.rewardAmount / 100,
unitPrice: rule.unitPriceFen / 100,
sharingEnabled: rule.sharing?.enabled || false,
sharingAutoFromOrder: rule.sharing?.autoFromOrder || false,
sharingTotalQuantity: hasManualSharingOverride ? rule.sharing?.totalQuantity : undefined,
@@ -342,9 +336,6 @@ export default function ProductRulesPanel() {
itemTitleMatchType?: 'contains' | 'exact'
skuNickMatchType?: 'contains' | 'exact'
categoryId?: number
rewardAmount?: number
pricingMode?: string
unitPrice?: number
sharingEnabled?: boolean
sharingAutoFromOrder?: boolean
sharingTotalQuantity?: number
@@ -361,7 +352,6 @@ export default function ProductRulesPanel() {
sortOrder?: number
}) {
try {
const unitMode = values.pricingMode === 'unit'
const isFriendGift = values.acceptanceMode === 'friend_gift'
if (isFriendGift && values.sharingEnabled === true) {
message.error('好友赠送验收模式不支持拼单,请先关闭拼单')
@@ -372,12 +362,11 @@ export default function ProductRulesPanel() {
? Number(values.sharingTotalQuantity || 0) > 0 ||
Number(values.sharingUnitReward || 0) > 0
: hasManualSharingInput(values.sharingTotalQuantity, values.sharingUnitReward)
const { pricingMode: _pricingMode, giftCooldownUnit, giftCooldownValue, ...payload } = values
const { giftCooldownUnit, giftCooldownValue, ...payload } = values
const res = await saveAdminWorkProductRule({
...payload,
ruleId: editingRule?.ruleId,
ruleKey: editingRule?.ruleKey || '',
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
acceptanceMode: isFriendGift ? 'friend_gift' : 'standard',
giftCooldownHours:
isFriendGift && giftCooldownUnit !== 'minutes' ? Number(giftCooldownValue || 0) : 0,
@@ -385,10 +374,18 @@ export default function ProductRulesPanel() {
isFriendGift && giftCooldownUnit === 'minutes' ? Number(giftCooldownValue || 0) : 0,
sharingEnabled: isFriendGift ? false : values.sharingEnabled === true,
sharingAutoFromOrder:
unitMode && values.sharingEnabled === true && !hasManualSharingOverride,
editingRule?.unitPriceFen !== undefined &&
editingRule.unitPriceFen > 0 &&
editingRule.rewardAmount === 0 &&
values.sharingEnabled === true &&
!hasManualSharingOverride,
})
message.success(editingRule ? '接单模板已更新' : '接单模板已创建')
const savedRule = res.data?.rule
if (res.data?.costConfigurationRequired) {
message.warning('模板已保存为停用。请到【商品与成本中心】配置成本后再启用')
} else {
message.success(editingRule ? '接单模板已更新' : '接单模板已创建')
}
if (savedRule) {
editRule(savedRule)
} else {
@@ -401,42 +398,15 @@ export default function ProductRulesPanel() {
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-summary'],
}),
queryClient.invalidateQueries({
predicate: (query) => String(query.queryKey[0] || '').startsWith('admin-cost-center'),
}),
])
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
}
}
function confirmSyncRulePrice() {
if (!editingRule) return
Modal.confirm({
title: '同步模板价格',
content:
'将同步接单大厅中该模板的待抢单价格。部分拼单仅调整剩余份额价格,已加入份额报酬保持不变。',
okText: '确认同步',
cancelText: '取消',
onOk: async () => {
setSyncingPrice(true)
try {
const response = await syncAdminWorkProductRulePrice(editingRule.ruleId)
const result = response.data
message.success(
`已同步 ${result.updatedCount} 单:待抢单 ${result.updatedNormalCount} 单,部分拼单 ${result.updatedPartialSharingCount} 单`,
)
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-rules'] }),
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }),
])
} catch (error) {
message.error(error instanceof Error ? error.message : '同步价格失败')
throw error
} finally {
setSyncingPrice(false)
}
},
})
}
async function toggleRuleEnabled(rule: WorkProductRule, enabled: boolean) {
try {
const hasManualSharingOverride = hasManualSharingInput(
@@ -450,8 +420,6 @@ export default function ProductRulesPanel() {
platform: rule.platform,
productName: rule.productName,
categoryId: rule.categoryId || undefined,
rewardAmount: rule.rewardAmount / 100,
unitPrice: rule.unitPriceFen / 100,
sharingEnabled: rule.sharing?.enabled || false,
sharingAutoFromOrder:
rule.unitPriceFen > 0 && rule.sharing?.enabled === true && !hasManualSharingOverride,
@@ -473,6 +441,9 @@ export default function ProductRulesPanel() {
await queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-product-rules'],
})
await queryClient.invalidateQueries({
predicate: (query) => String(query.queryKey[0] || '').startsWith('admin-cost-center'),
})
} catch (error) {
message.error(error instanceof Error ? error.message : '修改状态失败')
}
@@ -787,14 +758,22 @@ export default function ProductRulesPanel() {
width: '20%',
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong style={{ color: '#3f8600', fontSize: 12 }}>
{row.sharing?.autoFromOrder ? '随订单计算' : formatMoney(row.rewardAmount)}
</Typography.Text>
{row.unitPriceFen > 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
按件: {formatMoney(row.unitPriceFen)}
</Typography.Text>
) : null}
{row.rewardAmount > 0 || row.unitPriceFen > 0 ? (
<>
<Typography.Text strong style={{ color: '#3f8600', fontSize: 12 }}>
{row.sharing?.autoFromOrder ? '随订单计算' : formatMoney(row.rewardAmount)}
</Typography.Text>
{row.unitPriceFen > 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
按件: {formatMoney(row.unitPriceFen)}
</Typography.Text>
) : null}
</>
) : (
<Tag color="orange" style={{ width: 'fit-content', margin: 0 }}>
待成本配置
</Tag>
)}
</div>
),
},
@@ -891,11 +870,6 @@ export default function ProductRulesPanel() {
+ 新建模板
</Button>
) : null}
{editingRule ? (
<Button loading={syncingPrice} size="middle" onClick={confirmSyncRulePrice}>
同步价格
</Button>
) : null}
<Button type="primary" size="middle" htmlType="submit" form="product-rule-form">
{editingRule ? '保存模板' : '创建模板'}
</Button>
@@ -913,9 +887,7 @@ export default function ProductRulesPanel() {
matchType: 'contains',
itemTitleMatchType: 'exact',
skuNickMatchType: 'exact',
pricingMode: 'fixed',
rewardAmount: 25,
enabled: true,
enabled: false,
autoCreate: true,
sharingEnabled: false,
acceptanceMode: 'standard',
@@ -926,23 +898,21 @@ export default function ProductRulesPanel() {
}}
onFinish={saveRule}
>
{editingRule ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="全系统标准价格已统一收敛至【商品与成本中心】"
description={
<span>
接单单价由商品与成本中心维护并提供同步发单功能。在此处价格为只读展示;如需改价或同步工单价格,请前往
<a href="#/admin/cost-center" style={{ marginLeft: 4, fontWeight: 500 }}>
【商品与成本中心】
</a>
进行修改。
</span>
}
/>
) : null}
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="接单模板只维护匹配和履约规则"
description={
<span>
固定金额、按件单价和价格同步统一由
<a href="#/admin/cost-center" style={{ margin: '0 4px', fontWeight: 500 }}>
商品与成本中心
</a>
管理。新模板默认停用,配置成本并启用模板后自动出现在商品目录。
</span>
}
/>
{/* 基础信息 */}
<div className="form-grid-3col">
@@ -965,55 +935,8 @@ export default function ProductRulesPanel() {
</Form.Item>
</div>
{/* Row 2: 计价方式 + 单价/金额 + 任务时限 */}
{/* Row 2: 任务时限 */}
<div className="form-grid-3col">
<Form.Item label="计价方式" name="pricingMode">
<Radio.Group
optionType="button"
buttonStyle="solid"
className="full-width"
options={[
{ value: 'fixed', label: '固定金额' },
{ value: 'unit', label: '按件计价' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue('pricingMode') === 'unit' ? (
<Form.Item
label="按件单价"
name="unitPrice"
rules={[{ required: true, message: '请填写单价' }]}
extra={editingRule ? '标准单价由商品与成本中心统一管理' : undefined}
>
<InputNumber
min={0.01}
step={1}
addonAfter="元"
className="full-width"
disabled={Boolean(editingRule)}
/>
</Form.Item>
) : (
<Form.Item
label="接单金额"
name="rewardAmount"
extra={editingRule ? '标准单价由商品与成本中心统一管理' : undefined}
>
<InputNumber
min={0}
step={1}
addonAfter="元"
className="full-width"
disabled={Boolean(editingRule)}
/>
</Form.Item>
)
}
</Form.Item>
<Form.Item label="任务时限" name="timeoutMinutes">
<InputNumber min={0} step={5} addonAfter="分钟" className="full-width" />
</Form.Item>
@@ -1124,13 +1047,23 @@ export default function ProductRulesPanel() {
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) => {
const sharingEnabled = getFieldValue('sharingEnabled')
const unitMode = getFieldValue('pricingMode') === 'unit'
const unitMode =
Number(editingRule?.unitPriceFen || 0) > 0 &&
Number(editingRule?.rewardAmount || 0) === 0
const friendGiftMode = getFieldValue('acceptanceMode') === 'friend_gift'
const hasConfiguredPrice =
Number(editingRule?.unitPriceFen || 0) > 0 ||
Number(editingRule?.rewardAmount || 0) > 0
return (
<div className="switches-inline-group span-2">
<Form.Item label="启用模板" name="enabled" valuePropName="checked">
<Switch size="small" />
<Form.Item
label="启用模板"
name="enabled"
valuePropName="checked"
tooltip={!hasConfiguredPrice ? '请先在商品与成本中心配置成本' : undefined}
>
<Switch size="small" disabled={!hasConfiguredPrice} />
</Form.Item>
<Form.Item label="自动建单" name="autoCreate" valuePropName="checked">
<Switch size="small" />
@@ -1139,9 +1072,15 @@ export default function ProductRulesPanel() {
label="启用拼单"
name="sharingEnabled"
valuePropName="checked"
tooltip={friendGiftMode ? '好友赠送模式不支持拼单' : undefined}
tooltip={
friendGiftMode
? '好友赠送模式不支持拼单'
: !hasConfiguredPrice
? '请先在商品与成本中心配置成本'
: undefined
}
>
<Switch size="small" disabled={friendGiftMode} />
<Switch size="small" disabled={friendGiftMode || !hasConfiguredPrice} />
</Form.Item>
{sharingEnabled ? (
@@ -64,17 +64,6 @@ export function applyAdminTemplateProductBootstrap(payload?: {
)
}
export function syncAdminWorkOrderTemplateCostVersions() {
return apiPost<{
ruleCount: number
unitPriceRuleCount: number
activeVersionCount: number
createdCount: number
retiredCount: number
linkedSnapshotCount: number
}>('/api/v1/admin/cost-center/work-order-template-costs/sync')
}
export function backfillAdminWorkOrderProductSkus(payload?: {
dateFrom?: string
dateTo?: string
@@ -493,6 +482,7 @@ export function setAdminProductPrice(
productId: number,
payload: {
amountFen: number
pricingMode?: 'fixed' | 'unit'
effectiveFrom?: string
changeReason?: string
syncOpenOrders?: boolean
@@ -503,6 +493,7 @@ export function setAdminProductPrice(
costVersionId: number
amountFen: number
effectiveFrom: string
pricingMode?: 'fixed' | 'unit'
syncedOrders?: {
candidateCount: number
updatedCount: number
@@ -290,8 +290,6 @@ export function saveAdminWorkProductRule(payload: {
categoryId?: number
enabled?: boolean
autoCreate?: boolean
rewardAmount?: number
unitPrice?: number
sharingEnabled?: boolean
sharingAutoFromOrder?: boolean
sharingTotalQuantity?: number
@@ -305,7 +303,12 @@ export function saveAdminWorkProductRule(payload: {
fieldsText?: string
sortOrder?: number
}) {
return apiPost<{ rule: WorkProductRule }>('/api/v1/admin/worker-platform/product-rules', payload)
return apiPost<{
rule: WorkProductRule
productId?: number
productStatus?: 'active' | 'archived'
costConfigurationRequired?: boolean
}>('/api/v1/admin/worker-platform/product-rules', payload)
}
export function syncAdminWorkProductRulePrice(ruleId: number) {
+3 -3
View File
@@ -51,6 +51,9 @@ export interface ProductCatalogItem {
costVersionCount: number
activeCostAmountFen?: number | null
activeCostEffectiveFrom?: string | null
templatePricingMode?: 'fixed' | 'unit' | null
templateEnabled?: boolean | null
templateSharingEnabled?: boolean | null
/** 自动发货履约任务数,实时取自 fulfillment_tasks。 */
affiliateTaskCount: number
affiliateLastFulfilledAt: string | null
@@ -231,9 +234,6 @@ export interface TemplateProductBootstrapResult {
resetOrderItemCount: number
mappingConflictCount: number
mappingConflictItemCount: number
templateCostVersionCount: number
templateCostCreatedCount: number
templateCostRetiredCount: number
}
export interface CostLine {
+20 -13
View File
@@ -151,23 +151,30 @@ normalized_sku_series -- 剥离数量并归一同义字后的名称系列
**`normalized_quantity` 不预填。** 预填会把"5 个"类 SKU 永久锁死成 1,必须由回填脚本按 SKU 名称解析后写入。`quantity` 长期几乎恒为 1,禁止直接拿它当销量。
### 2.4 接单模板批量初始化
### 2.4 接单模板商品与成本自动投影
接单模板不允许逐个手工建商品。后台「从接单模板全量同步商品与映射」按以下规则批量生成:
日常流程不再依赖「从接单模板初始化」:
1. **商品全量同步,不依赖订单**:所有启用模板都建/刷新标准商品,内部编码 `tpl:<rule_key>`;模板改名、改分类、改发单金额都会刷新到商品上。新模板还没有工单也能在「商品与成本中心」直接定价。停用模板默认不建商品,勾选后可一并建成「归档」商品。
2. **渠道映射仍只按工单证据建**:默认最近 30 天,可在页面日期筛选里调整;范围内没有工单的模板只建商品、不建映射——没有订单就没有平台 SKU 可映射。
3. **范围内的工单直接命中模板**,以 `work_orders.product_rule_id` 作为映射的第一手证据,不只看原始 `sku_code`。
4. 同一 `provider + shop_id + sku_code` 只命中一个模板时,生成 `sku_code` 精确映射。
5. 编码被多个模板复用时,继续用"编码 + 归一名称系列"和"编码 + 精确名称"消歧;只有唯一归属才生成映射。
6. 同一编码 + 同一名称仍命中多个模板时,**不选最新、不按次数猜**;跳过并列入冲突清单。
7. 来源 + 店铺内名称系列唯一时,额外生成 `sku_name_series` 兜底,永远排在编码类映射之后。
8. 数量系列(1个/2个/5个/10个,秘钥/密钥等等价写法)归为同一标准商品;数量写入 `normalized_quantity`,**不能把"5个"再写成 `quantity_multiplier=5`**,否则会重复乘算。
9. 重跑初始化只清理**所选时间范围内**由 `source='import'`、备注为"接单模板 … 自动初始化"的映射带出的订单项 `canonical_product_id`,并删除范围内使用到的导入映射;范围外的人工映射、成本版本、快照和真实订单/工单、已建商品都不删除。
1. **商品事件驱动**:接单模板保存时,在同一事务内自动创建/更新 `tpl:<rule_key>` 标准商品;模板改名、改分类和启停都会同步投影。新模板默认停用,但商品立即出现在成本中心等待配置价格。
2. **成本唯一写入口**:固定金额、按件单价和待抢单价格同步只允许从「商品与成本中心 → 标准商品 → 配置成本」写入。接单模板 API 拒绝新增或修改价格。
3. **事务一致性**:成本中心改价会在同一事务内新增/退休成本版本、更新 `work_product_rules` 运行价格和商品价格属性;事务成功后才同步未领取的大厅工单。
4. **启用前校验**:模板必须有当前生效且金额大于 0 的规则级成本版本,才允许启用。历史已领取工单保持原价。
5. **历史修复工具**:「修复历史商品/映射」只用于补历史渠道映射和修复异常商品,不再同步模板价格,也不是新模板或新成本的必经步骤。
历史渠道映射修复仍按以下规则执行:
1. **映射只按工单证据建**:默认最近 30 天,可在页面日期筛选里调整;范围内没有工单的模板不会凭空生成平台 SKU 映射。
2. **范围内的工单直接命中模板**,以 `work_orders.product_rule_id` 作为映射的第一手证据,不只看原始 `sku_code`。
3. 同一 `provider + shop_id + sku_code` 只命中一个模板时,生成 `sku_code` 精确映射。
4. 编码被多个模板复用时,继续用"编码 + 归一名称系列"和"编码 + 精确名称"消歧;只有唯一归属才生成映射。
5. 同一编码 + 同一名称仍命中多个模板时,**不选最新、不按次数猜**;跳过并列入冲突清单。
6. 来源 + 店铺内名称系列唯一时,额外生成 `sku_name_series` 兜底,永远排在编码类映射之后。
7. 数量系列(1个/2个/5个/10个,秘钥/密钥等等价写法)归为同一标准商品;数量写入 `normalized_quantity`,**不能把"5个"再写成 `quantity_multiplier=5`**,否则会重复乘算。
8. 重跑修复只清理**所选时间范围内**由 `source='import'`、备注为"接单模板 … 自动初始化"的映射带出的订单项 `canonical_product_id`,并删除范围内使用到的导入映射;范围外的人工映射、成本版本、快照和真实订单/工单、已建商品都不删除。
本地验证结果(2026-10-18):启用模板 662 个;分层映射 4,426 条(编码精确 2,302、编码 + 精确名称 440、编码 + 名称系列 160、名称系列兜底 1,524);可唯一归一 86,533 个有证据订单项,另有 25,918 个订单项属于 32 组真实冲突,保持未映射。
全量同步改版后(2026-10-29,本地库预览 09-30 ~ 10-29):模板 710 个全部启用、范围内有工单证据 388 个;商品新建 704 个 / 复用 6 个,候选映射 3,198 条。之前被跳过的 322 个无单模板,现在也会有商品(可定价),只是暂时没有渠道映射。
迁移 122 后(2026-11-02 本地核对):714 个接单模板全部有 `tpl:` 商品,无缺失;没有工单证据的模板也可以直接在成本中心定价,只是暂时没有渠道映射。
### 2.5 自动发货商品
@@ -223,7 +230,7 @@ cost_versions
| 编码 | 成本模型 | 说明 |
| --- | --- | --- |
| `sys_work_order_reward` | `work_order_reward` | 662 个接单模板各一条规则级版本;全局 0 元版本只作"按工单实际结算"的占位 |
| `sys_work_order_reward` | `work_order_reward` | 每个可启用的接单模板各一条规则级版本;全局 0 元版本只作"按工单实际结算"的占位 |
| `sys_affiliate_purchase` | `affiliate_purchase` | 上游 SKU 固定成本版本,38 个商品级 |
| `sys_manual_service` | `manual_actual` | 订单页确认履约写入 `actual` 快照;版本为 0 元占位 |
+4 -2
View File
@@ -26,6 +26,7 @@
119_fulfillment_pending_reason
120_fulfillment_routing_invariants
121_internal_sku_single_primary
122_work_order_template_product_projection
```
重点检查:
@@ -36,11 +37,12 @@
- 119:只加列和索引,风险低。
- 120:建立统一内部 SKU SQL 函数、回填人工配置、迁移可确定的旧 sku_code 规则。
- 121:建立唯一主渠道索引;若仍有并列主渠道会主动阻断迁移。
- 122:按接单模板补齐/刷新 `tpl:` 商品,只做 UPSERT,不删除商品、成本、订单或工单。
- SQL 护栏默认只保护到生产已发布的迁移 119;部署 120 后应把 `PUBLISHED_MIGRATION_MAX_VERSION` 提升为 120。
## 3. 部署顺序
1. 执行迁移 116–121;
1. 执行迁移 116–122;
2. 部署 backend;
3. 部署 frontend;
4. 启动/恢复定时任务。
@@ -161,6 +163,6 @@ npx tsx scripts/reclassify-post-cutover.ts --apply
- 代码:回退到部署前镜像/commit;
- 数据库:
- 116–121 均为新增表/列/函数/数据,不删旧表,可保留;
- 116–122 均为新增表/列/函数/投影数据,不删旧表,可保留;
- 若必须回滚,恢复到部署前备份;
- 迁移 118–121 不改旧字段语义,回滚代码后旧逻辑仍可运行;121 的唯一主渠道索引会阻止旧代码写入并列主渠道。
+2 -2
View File
@@ -244,10 +244,10 @@ bash deploy/post-deploy.sh --only=backfill # ① 收入回填 + ② 履约
1. 钱包管理 → 补建客服备用金账户 → 按需拨付;
2. 商品与成本 → 积分钱包 → 给客服发放三平台积分;
3. 商品与成本 → 标准商品 → 从接单模板初始化 → 按工单回填商品 → 归一人工商品;
3. 商品与成本 → 标准商品:迁移 122 会自动补齐接单模板商品;新模板保存时也会自动建商品。按需执行「按工单回填商品」和「归一人工商品」,不再需要从接单模板初始化;
4. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置(保存后自动回填订单项)。
> 第 3 步的商品目录/工单回填必须先于下一节 `--only=sync`。商品目录为空时同步会为每个
> 第 3 步的工单回填必须先于下一节 `--only=sync`。商品目录为空时同步会为每个
> 未归一订单项写一条 `product_mapping_missing` 异常(实测 90 天约 36 万条)。
### 3. 财务同步