From 290890e848f712a4dd138984f4ed856282361a69 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 2 Nov 2026 16:14:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(cost-center):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=8E=A5=E5=8D=95=E6=A8=A1=E6=9D=BF=E6=88=90=E6=9C=AC=E5=B9=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=90=8C=E6=AD=A5=E5=95=86=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...work_order_template_product_projection.sql | 55 ++++ .../worker-platform/work-product-rule-repo.ts | 13 +- .../routes/admin/worker-platform/config.ts | 31 +- .../cost-center/cost-center-mappers.ts | 9 + .../services/cost-center/cost-center-types.ts | 3 + .../services/cost-center/cost-center.test.ts | 18 +- .../cost-center/product-catalog-service.ts | 299 +++++++++++------- .../cost-center/template-bootstrap-service.ts | 25 +- .../cost-center/work-order-product-service.ts | 4 +- ...rk-order-template-cost.integration.test.ts | 215 +++++++++++++ .../work-order-template-product-service.ts | 166 ++++++++++ .../services/finance/finance-controls.test.ts | 5 +- .../admin-worker-catalog-service.ts | 232 ++++++++------ .../admin/cost-center/ProductCatalogPanel.tsx | 76 +++-- .../admin/cost-center/WorkOrderPricePanel.tsx | 46 +-- .../pages/admin/panels/ProductRulesPanel.tsx | 203 +++++------- .../src/services/admin/cost-center.ts | 13 +- .../src/services/admin/worker-platform.ts | 9 +- apps/frontend/src/types/admin/cost-center.ts | 6 +- docs/成本中心与履约模型.md | 33 +- docs/生产部署核对清单-2026-11-02.md | 6 +- docs/部署启动与数据库重建.md | 4 +- 22 files changed, 963 insertions(+), 508 deletions(-) create mode 100644 apps/backend/src/db/migrations/122_work_order_template_product_projection.sql create mode 100644 apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts create mode 100644 apps/backend/src/services/cost-center/work-order-template-product-service.ts diff --git a/apps/backend/src/db/migrations/122_work_order_template_product_projection.sql b/apps/backend/src/db/migrations/122_work_order_template_product_projection.sql new file mode 100644 index 00000000..e56af19d --- /dev/null +++ b/apps/backend/src/db/migrations/122_work_order_template_product_projection.sql @@ -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: 商品由接单模板事件自动投影,价格只来自成本中心'; diff --git a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts index d5e25077..8bb1f58d 100644 --- a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts @@ -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 }): Promise { - const result = await query( + const db = input.client || { query } + const result = await db.query( ` 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( + `${WORK_PRODUCT_RULE_SELECT} WHERE wpr.rule_key = $1 LIMIT 1`, + [ruleKey], + ) + return selected.rows[0] || null } diff --git a/apps/backend/src/routes/admin/worker-platform/config.ts b/apps/backend/src/routes/admin/worker-platform/config.ts index 0020091f..5776962a 100644 --- a/apps/backend/src/routes/admin/worker-platform/config.ts +++ b/apps/backend/src/routes/admin/worker-platform/config.ts @@ -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) : {}, - }), - }, - ), + 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) : {}, + }), + }), ) router.post( diff --git a/apps/backend/src/services/cost-center/cost-center-mappers.ts b/apps/backend/src/services/cost-center/cost-center-mappers.ts index 5fee88fa..210c1847 100644 --- a/apps/backend/src/services/cost-center/cost-center-mappers.ts +++ b/apps/backend/src/services/cost-center/cost-center-mappers.ts @@ -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, diff --git a/apps/backend/src/services/cost-center/cost-center-types.ts b/apps/backend/src/services/cost-center/cost-center-types.ts index bb29e52c..16bf35f7 100644 --- a/apps/backend/src/services/cost-center/cost-center-types.ts +++ b/apps/backend/src/services/cost-center/cost-center-types.ts @@ -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 } diff --git a/apps/backend/src/services/cost-center/cost-center.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts index cef18d66..a3f63456 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -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('成本模型与商品来源必须覆盖第三方卡券渠道', () => { diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index bf64fc57..ce900ae8 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -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) } - : {} - 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: '接单模板结算价格已更新', } diff --git a/apps/backend/src/services/cost-center/template-bootstrap-service.ts b/apps/backend/src/services/cost-center/template-bootstrap-service.ts index 65781afa..08554a08 100644 --- a/apps/backend/src/services/cost-center/template-bootstrap-service.ts +++ b/apps/backend/src/services/cost-center/template-bootstrap-service.ts @@ -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 }, diff --git a/apps/backend/src/services/cost-center/work-order-product-service.ts b/apps/backend/src/services/cost-center/work-order-product-service.ts index 8c36dae7..f0a9ed4a 100644 --- a/apps/backend/src/services/cost-center/work-order-product-service.ts +++ b/apps/backend/src/services/cost-center/work-order-product-service.ts @@ -311,7 +311,9 @@ type WorkOrderTemplateCostSyncRow = { } /** - * 把接单模板的发单金额同步成规则级成本版本。 + * 历史迁移/灾后修复:把既有接单模板发单金额补齐为规则级成本版本。 + * + * 日常成本真源是商品与成本中心;模板保存不再调用本函数,前端也不再提供按钮。 * * 固定金额模板取 `reward_amount`;按件模板取 `unit_price_fen`,数量由订单 SKU * 决定。全局 0 元版本只作为“按工单实际结算”的占位,不再代表模板价格。 diff --git a/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts b/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts new file mode 100644 index 00000000..ecec92e0 --- /dev/null +++ b/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts @@ -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() + } + }, +) diff --git a/apps/backend/src/services/cost-center/work-order-template-product-service.ts b/apps/backend/src/services/cost-center/work-order-template-product-service.ts new file mode 100644 index 00000000..0b4ebe67 --- /dev/null +++ b/apps/backend/src/services/cost-center/work-order-template-product-service.ts @@ -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 + +type WorkOrderTemplateProjectionRow = WorkProductRuleRow & { + category_name: string | null +} + +type WorkOrderTemplateProductState = { + rule: WorkOrderTemplateProjectionRow + productId: number + hasActiveCost: boolean + status: 'active' | 'archived' +} + +function runQuery( + client: DbClient | undefined, + text: string, + params: unknown[] = [], +): Promise> { + return client ? client.query(text, params) : query(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( + 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 { + 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), + ], + ) +} diff --git a/apps/backend/src/services/finance/finance-controls.test.ts b/apps/backend/src/services/finance/finance-controls.test.ts index 384df527..5a0a2156 100644 --- a/apps/backend/src/services/finance/finance-controls.test.ts +++ b/apps/backend/src/services/finance/finance-controls.test.ts @@ -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'")) diff --git a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts index e019df3f..8947969d 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts @@ -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 } } diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx index d374aa30..831e02db 100644 --- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -95,7 +95,7 @@ export function ProductCatalogPanel() { const queryClient = useQueryClient() const { canConfig, productSourceFilter, refreshAll } = useCostCenter() const [productForm] = Form.useForm() - // 初始化/回填只处理最近 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: ( @@ -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() { 单位:{row.unitName || '件'} + {row.canonicalSku.startsWith('tpl:') ? ( + + {row.templateEnabled ? '模板已启用' : '模板待启用'} + + ) : null} + {row.templatePricingMode ? ( + + {row.templatePricingMode === 'unit' ? '按件计价' : '固定金额'} + + ) : null} ), @@ -720,7 +738,7 @@ export function ProductCatalogPanel() { loading={bootstrapPreviewing || bootstrapApplying} onClick={() => void runTemplateBootstrap()} > - 从接单模板初始化 + 修复历史商品/映射 ) : null} @@ -949,16 +967,38 @@ export function ProductCatalogPanel() { + {pricingProduct.canonicalSku.startsWith('tpl:') ? ( + + + 固定金额 + 按件计价 + + + ) : null} + - + 指定生效时间(不能早于当前时间) + {pricingProduct.canonicalSku.startsWith('tpl:') ? ( +
+ 接单模板当前只支持立即生效,避免模板价格与成本版本不同步 +
+ ) : null}
@@ -1010,17 +1055,6 @@ export function ProductCatalogPanel() { - {pricingProduct.canonicalSku.startsWith('tpl:') ? ( - - - 立即同步至接单大厅待抢单工单(推荐) -
- 接单大厅中所有未领取的该模板工单将立即同步为新价格;已领取的工单保持原价不变。 -
-
-
- ) : null} - {pricingProduct.source === 'affiliate_dash' ? ( diff --git a/apps/frontend/src/pages/admin/cost-center/WorkOrderPricePanel.tsx b/apps/frontend/src/pages/admin/cost-center/WorkOrderPricePanel.tsx index 4f8d7753..dbaa9b85 100644 --- a/apps/frontend/src/pages/admin/cost-center/WorkOrderPricePanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/WorkOrderPricePanel.tsx @@ -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 = [ { title: '接单规则 / 模板', @@ -176,24 +158,14 @@ export function WorkOrderPricePanel() { {total} 条 } - extra={ - canConfig ? ( - - ) : null - } + extra={canConfig ? 自动同步 : null} > (null) - const [syncingPrice, setSyncingPrice] = useState(false) const [exportingRules, setExportingRules] = useState(false) const [activeMappingId, setActiveMappingId] = useState(null) const [sourcePrefill, setSourcePrefill] = useState(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) => (
- - {row.sharing?.autoFromOrder ? '随订单计算' : formatMoney(row.rewardAmount)} - - {row.unitPriceFen > 0 ? ( - - 按件: {formatMoney(row.unitPriceFen)} - - ) : null} + {row.rewardAmount > 0 || row.unitPriceFen > 0 ? ( + <> + + {row.sharing?.autoFromOrder ? '随订单计算' : formatMoney(row.rewardAmount)} + + {row.unitPriceFen > 0 ? ( + + 按件: {formatMoney(row.unitPriceFen)} + + ) : null} + + ) : ( + + 待成本配置 + + )}
), }, @@ -891,11 +870,6 @@ export default function ProductRulesPanel() { + 新建模板 ) : null} - {editingRule ? ( - - ) : null} @@ -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 ? ( - - 接单单价由商品与成本中心维护并提供同步发单功能。在此处价格为只读展示;如需改价或同步工单价格,请前往 - - 【商品与成本中心】 - - 进行修改。 - - } - /> - ) : null} + + 固定金额、按件单价和价格同步统一由 + + 商品与成本中心 + + 管理。新模板默认停用,配置成本并启用模板后自动出现在商品目录。 + + } + /> {/* 基础信息 */}
@@ -965,55 +935,8 @@ export default function ProductRulesPanel() {
- {/* Row 2: 计价方式 + 单价/金额 + 任务时限 */} + {/* Row 2: 任务时限 */}
- - - - - - {({ getFieldValue }) => - getFieldValue('pricingMode') === 'unit' ? ( - - - - ) : ( - - - - ) - } - - @@ -1124,13 +1047,23 @@ export default function ProductRulesPanel() { {({ 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 (
- - + + @@ -1139,9 +1072,15 @@ export default function ProductRulesPanel() { label="启用拼单" name="sharingEnabled" valuePropName="checked" - tooltip={friendGiftMode ? '好友赠送模式不支持拼单' : undefined} + tooltip={ + friendGiftMode + ? '好友赠送模式不支持拼单' + : !hasConfiguredPrice + ? '请先在商品与成本中心配置成本' + : undefined + } > - + {sharingEnabled ? ( diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index ad92e8f4..3671398a 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -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 diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index ab9cc411..9b6fca9f 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -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) { diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index 8928ca1f..3f4c9542 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -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 { diff --git a/docs/成本中心与履约模型.md b/docs/成本中心与履约模型.md index 233357dc..3bac7074 100644 --- a/docs/成本中心与履约模型.md +++ b/docs/成本中心与履约模型.md @@ -151,23 +151,30 @@ normalized_sku_series -- 剥离数量并归一同义字后的名称系列 **`normalized_quantity` 不预填。** 预填会把"5 个"类 SKU 永久锁死成 1,必须由回填脚本按 SKU 名称解析后写入。`quantity` 长期几乎恒为 1,禁止直接拿它当销量。 -### 2.4 接单模板批量初始化 +### 2.4 接单模板商品与成本自动投影 -接单模板不允许逐个手工建商品。后台「从接单模板全量同步商品与映射」按以下规则批量生成: +日常流程不再依赖「从接单模板初始化」: -1. **商品全量同步,不依赖订单**:所有启用模板都建/刷新标准商品,内部编码 `tpl:`;模板改名、改分类、改发单金额都会刷新到商品上。新模板还没有工单也能在「商品与成本中心」直接定价。停用模板默认不建商品,勾选后可一并建成「归档」商品。 -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:` 标准商品;模板改名、改分类和启停都会同步投影。新模板默认停用,但商品立即出现在成本中心等待配置价格。 +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 元占位 | diff --git a/docs/生产部署核对清单-2026-11-02.md b/docs/生产部署核对清单-2026-11-02.md index d4984963..16148048 100644 --- a/docs/生产部署核对清单-2026-11-02.md +++ b/docs/生产部署核对清单-2026-11-02.md @@ -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 的唯一主渠道索引会阻止旧代码写入并列主渠道。 diff --git a/docs/部署启动与数据库重建.md b/docs/部署启动与数据库重建.md index da3fd8da..7964893b 100644 --- a/docs/部署启动与数据库重建.md +++ b/docs/部署启动与数据库重建.md @@ -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. 财务同步