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 }
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 元占位 |
|
||||
|
||||
|
||||
@@ -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
@@ -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. 财务同步
|
||||
|
||||
Reference in New Issue
Block a user