diff --git a/apps/backend/src/routes/admin/cost-center-catalog.ts b/apps/backend/src/routes/admin/cost-center-catalog.ts index 3890e059..4e9ca43f 100644 --- a/apps/backend/src/routes/admin/cost-center-catalog.ts +++ b/apps/backend/src/routes/admin/cost-center-catalog.ts @@ -18,11 +18,16 @@ import { listAdminChannelMappings, listAdminProducts, listAdminUnmappedMappingGroups, + setAdminProductPrice, + syncAdminProductPrice, updateAdminChannelMapping, updateAdminProduct, } from '../../services/cost-center/product-catalog-service.js' import { backfillAdminAffiliateDashSkus, + importAdminAffiliateDashPricesBaseline, + setAdminAffiliateProductPrice, + syncAdminAffiliateDashCostSnapshots, syncAdminAffiliateDashProducts, } from '../../services/cost-center/affiliate-product-service.js' import { @@ -97,6 +102,58 @@ router.post( }), ) +router.post( + '/cost-center/affiliate-products/price-baseline', + requireAdminPermission('finance.config'), + createJsonHandler((req) => importAdminAffiliateDashPricesBaseline(sessionOf(req)), { + successMessage: '已从上游基线导入自动发货采购成本', + errorMessage: '导入自动发货采购成本基线失败', + scope: '[admin/cost-center/affiliate-products/price-baseline]', + audit: (_req, data) => ({ + action: 'cost_affiliate_prices_baseline_imported', + targetType: 'cost_versions', + targetId: '', + data: data as Record, + }), + }), +) + +router.post( + '/cost-center/affiliate-products/:productId/price', + requireAdminPermission('finance.config'), + createJsonHandler( + (req) => setAdminAffiliateProductPrice(idOf(req, 'productId'), bodyOf(req), sessionOf(req)), + { + successMessage: '自动发货采购成本已配置', + errorMessage: '配置自动发货采购成本失败', + scope: '[admin/cost-center/affiliate-products/:productId/price]', + audit: (req, data) => ({ + action: 'cost_affiliate_product_price_set', + targetType: 'cost_versions', + targetId: String(req.params.productId || ''), + data: data as Record, + }), + }, + ), +) + +router.post( + '/cost-center/affiliate-products/sync-snapshots', + requireAdminPermission('finance.config'), + createJsonHandler(() => syncAdminAffiliateDashCostSnapshots(), { + successMessage: '已重算自动发货预估成本快照与销售事实', + errorMessage: '重算自动发货成本快照失败', + scope: '[admin/cost-center/affiliate-products/sync-snapshots]', + audit: (_req, data) => ({ + action: 'cost_affiliate_snapshots_synced', + targetType: 'cost_snapshots', + targetId: '', + data: data as Record, + }), + }), +) + + router.post( '/cost-center/work-order-products/backfill', requireAdminPermission('finance.config'), @@ -201,6 +258,41 @@ router.patch( }), ) +router.post( + '/cost-center/products/:productId/price', + requireAdminPermission('finance.config'), + createJsonHandler( + (req) => setAdminProductPrice(idOf(req, 'productId'), bodyOf(req), sessionOf(req)), + { + successMessage: '商品标准价格已更新', + errorMessage: '更新商品标准价格失败', + scope: '[admin/cost-center/products/:productId/price]', + audit: (req, data) => ({ + action: 'cost_product_price_set', + targetType: 'cost_versions', + targetId: String(req.params.productId || ''), + data: data as Record, + }), + }, + ), +) + +router.post( + '/cost-center/products/:productId/sync-price', + requireAdminPermission('finance.config'), + createJsonHandler((req) => syncAdminProductPrice(idOf(req, 'productId'), sessionOf(req)), { + successMessage: '标准价格已同步到下游', + errorMessage: '同步标准价格失败', + scope: '[admin/cost-center/products/:productId/sync-price]', + audit: (req, data) => ({ + action: 'cost_product_price_synced', + targetType: 'product_catalog', + targetId: String(req.params.productId || ''), + data: data as Record, + }), + }), +) + router.delete( '/cost-center/products/:productId', requireAdminPermission('finance.config'), diff --git a/apps/backend/src/services/cost-center/affiliate-product-service.ts b/apps/backend/src/services/cost-center/affiliate-product-service.ts index ebae0c0f..0582060d 100644 --- a/apps/backend/src/services/cost-center/affiliate-product-service.ts +++ b/apps/backend/src/services/cost-center/affiliate-product-service.ts @@ -1,10 +1,13 @@ -/** - * 自动发货(affiliate_dash)商品同步与 SKU 回填。 - */ +import fs from 'node:fs' + import { query } 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' import { shiftDate } from '../../utils/finance-date-range.js' import { nowIso } from '../../utils/time.js' import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js' +import { syncEstimatedOrderItemCostSnapshots } from './cost-snapshot-sync-service.js' export async function syncAdminAffiliateDashProducts() { const now = nowIso() @@ -197,3 +200,269 @@ export async function backfillAdminAffiliateDashSkus() { factsRebuild, } } + +/** + * 为单个自动发货商品设置或更新采购成本版本。 + */ +export async function setAdminAffiliateProductPrice( + productIdInput: unknown, + payload: JsonObject, + session: AdminSession, +) { + const productId = Number(productIdInput) + if (!Number.isFinite(productId) || productId <= 0) { + throw createHttpError('无效的商品 ID', { statusCode: 400 }) + } + const productResult = await query<{ + id: number + canonical_sku: string + canonical_name: string + }>( + 'SELECT id, canonical_sku, canonical_name FROM product_catalog WHERE id = $1', + [productId], + ) + const product = productResult.rows[0] + if (!product) { + throw createHttpError('商品不存在', { statusCode: 404 }) + } + if (!product.canonical_sku.startsWith('aff:')) { + throw createHttpError('该商品不是自动发货商品,无法设置自动发货采购成本', { statusCode: 400 }) + } + + const amountFen = Number(payload.amountFen ?? payload.amount_fen) + if (!Number.isFinite(amountFen) || amountFen <= 0) { + throw createHttpError('采购成本必须大于 0', { statusCode: 400 }) + } + + const costItemResult = await query<{ id: number }>( + "SELECT id FROM cost_items WHERE code = 'sys_affiliate_purchase' OR cost_model = 'affiliate_purchase' ORDER BY (code = 'sys_affiliate_purchase') DESC LIMIT 1", + ) + const costItem = costItemResult.rows[0] + if (!costItem) { + throw createHttpError('未找到自动发货采购成本项(sys_affiliate_purchase)', { statusCode: 500 }) + } + + const now = nowIso() + let effectiveFrom = payload.effectiveFrom ? String(payload.effectiveFrom) : now + if (payload.retroactive === true || payload.retroactive === 'true') { + effectiveFrom = '2026-07-10T00:00:00+08:00' + } + const changeReason = String(payload.changeReason || '配置自动发货采购成本').trim() + + 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 canonical_product_id = $2 AND status = 'active' + ORDER BY effective_from DESC, id DESC LIMIT 1`, + [costItem.id, product.id], + ) + const existing = existingResult.rows[0] + + const existingTime = existing ? Date.parse(existing.effective_from) : null + const targetTime = Date.parse(effectiveFrom) + + // 1. 金额未变,如果指定了更早的生效时间,则只更新生效时间 + if (existing && Number(existing.amount_fen) === amountFen) { + if (existingTime !== null && targetTime < existingTime) { + await query( + 'UPDATE cost_versions SET effective_from = $1, updated_at = $2 WHERE id = $3', + [effectiveFrom, now, existing.id], + ) + } + return { + success: true, + product, + costVersionId: existing.id, + amountFen, + effectiveFrom, + message: '采购成本已是该金额', + } + } + + // 2. 追溯改价或相同生效时间改价:直接就地更新当前 active 版本,避免破坏 effective_to > effective_from 约束或触发唯一索引冲突 + if (existing && existingTime !== null && targetTime <= existingTime) { + await query( + `UPDATE cost_versions + SET amount_fen = $1, + effective_from = $2, + change_reason = $3, + created_by_admin_user_id = $4, + approved_by_admin_user_id = $4, + updated_at = $5 + WHERE id = $6`, + [amountFen, effectiveFrom, changeReason, session.userId, now, existing.id], + ) + return { + success: true, + product, + costVersionId: existing.id, + amountFen, + effectiveFrom, + message: '采购成本已更新', + } + } + + // 3. 新生效时间晚于老版本:按标准版本退休老版本并插入新版本 + if (existing) { + await query( + "UPDATE cost_versions SET status = 'retired', effective_to = $1, updated_at = $2 WHERE id = $3", + [effectiveFrom, now, existing.id], + ) + } + + const insertedResult = 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, 'product', '', '', $2, + NULL, $3, 0, $4, NULL, + 'manual', 'active', $5, $6, $6, $7, $7 + ) + RETURNING id + `, + [ + costItem.id, + product.id, + amountFen, + effectiveFrom, + changeReason, + session.userId, + now, + ], + ) + + return { + success: true, + product, + costVersionId: insertedResult.rows[0]?.id, + amountFen, + effectiveFrom, + message: '采购成本已配置', + } +} + +/** + * 批量从上游基线导入 38 条自动发货采购成本。 + */ +export async function importAdminAffiliateDashPricesBaseline(session: AdminSession) { + const filePath = new URL('../../../scripts/data/affiliate-dash-prices.json', import.meta.url) + const rawData = fs.readFileSync(filePath, 'utf8') + const rows = JSON.parse(rawData) as Array<{ + sku: string + name: string + category: string + costAmountFen: number + }> + + const costItemResult = await query<{ id: number }>( + "SELECT id FROM cost_items WHERE code = 'sys_affiliate_purchase' OR cost_model = 'affiliate_purchase' ORDER BY (code = 'sys_affiliate_purchase') DESC LIMIT 1", + ) + const costItem = costItemResult.rows[0] + if (!costItem) throw createHttpError('未找到自动发货采购成本项', { statusCode: 500 }) + + const now = nowIso() + const effectiveFrom = '2026-07-10T00:00:00+08:00' + let createdProductCount = 0 + let createdVersionCount = 0 + let updatedVersionCount = 0 + let skippedCount = 0 + + for (const row of rows) { + const canonicalSku = `aff:${row.sku}` + const unitName = row.name.includes('个') ? '个' : '件' + + const productInsert = await query<{ id: number; inserted: boolean }>( + ` + INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, 'active', + jsonb_build_object('source', 'affiliate_dash_price_import', 'skuCode', $5), + $6, $6 + ) + ON CONFLICT (canonical_sku) DO UPDATE + SET canonical_name = CASE WHEN product_catalog.canonical_name = '' THEN EXCLUDED.canonical_name ELSE product_catalog.canonical_name END, + category = CASE WHEN product_catalog.category = '' THEN EXCLUDED.category ELSE product_catalog.category END, + updated_at = $6 + RETURNING id, (xmax = 0) AS inserted + `, + [canonicalSku, row.name, row.category, unitName, row.sku, now], + ) + const product = productInsert.rows[0] + if (!product) continue + if (product.inserted) createdProductCount += 1 + + const existing = 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 canonical_product_id = $2 AND status = 'active' + ORDER BY effective_from DESC, id DESC LIMIT 1`, + [costItem.id, product.id], + ) + const current = existing.rows[0] + + if (current && Number(current.amount_fen) === row.costAmountFen) { + if (Date.parse(effectiveFrom) < Date.parse(current.effective_from)) { + await query( + 'UPDATE cost_versions SET effective_from = $1, updated_at = $2 WHERE id = $3', + [effectiveFrom, now, current.id], + ) + updatedVersionCount += 1 + } else { + skippedCount += 1 + } + continue + } + + if (current) { + await query( + "UPDATE cost_versions SET status = 'retired', effective_to = $1, updated_at = $2 WHERE id = $3", + [effectiveFrom, now, current.id], + ) + } + + await query( + ` + 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, 'product', '', '', $2, + NULL, $3, 0, $4, NULL, + 'upstream_sync', 'active', '从上游价格基线导入', $5, $5, $6, $6 + ) + `, + [costItem.id, product.id, row.costAmountFen, effectiveFrom, session.userId, now], + ) + createdVersionCount += 1 + } + + return { + total: rows.length, + createdProductCount, + createdVersionCount, + updatedVersionCount, + skippedCount, + } +} + +/** + * 一键重算自动发货预估成本快照并重算销售事实。 + */ +export async function syncAdminAffiliateDashCostSnapshots() { + const snapshotResult = await syncEstimatedOrderItemCostSnapshots({ + dateFrom: '2026-07-10', + dateTo: '2026-10-26', + }) + const factsResult = await rebuildFactsForRange('2026-07-10', '2026-10-26') + + return { + snapshotResult, + factsResult, + } +} 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 a2219f8e..9e37d6c3 100644 --- a/apps/backend/src/services/cost-center/cost-center-mappers.ts +++ b/apps/backend/src/services/cost-center/cost-center-mappers.ts @@ -101,6 +101,13 @@ export function mapProduct(row: ProductRow) { attributes: parseJsonObject(row.attributes_json), mappingCount: Number(row.mapping_count || 0), costVersionCount: Number(row.cost_version_count || 0), + activeCostAmountFen: + row.active_cost_amount_fen !== null && row.active_cost_amount_fen !== undefined + ? Number(row.active_cost_amount_fen) + : null, + activeCostEffectiveFrom: row.active_cost_effective_from + ? String(row.active_cost_effective_from) + : null, 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 07af9260..95986db8 100644 --- a/apps/backend/src/services/cost-center/cost-center-types.ts +++ b/apps/backend/src/services/cost-center/cost-center-types.ts @@ -24,6 +24,8 @@ export type ProductRow = { updated_at: string mapping_count?: number cost_version_count?: number + active_cost_amount_fen?: number | null + active_cost_effective_from?: string | null affiliate_task_count?: number affiliate_last_fulfilled_at?: string | null } 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 6691aa1f..aaeacacf 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -13,6 +13,11 @@ import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js' import { resolveSkuNameQuantity } from '../product-match/pricing.js' +import { + setAdminAffiliateProductPrice, + syncAdminAffiliateDashCostSnapshots, +} from './affiliate-product-service.js' +import { syncAdminWorkProductRulePrice } from '../worker-platform/admin-worker-catalog-service.js' import { AFFILIATE_TASK_AGG_JOIN, FULFILLMENT_MODES, @@ -148,6 +153,48 @@ export async function listAdminProducts(payload: JsonObject = {}) { AND p.canonical_sku LIKE 'tpl:%' AND wpr.rule_key = substring(p.canonical_sku from 5) ) AS cost_version_count, + COALESCE( + ( + SELECT v.amount_fen + FROM cost_versions v + WHERE v.status = 'active' + AND v.canonical_product_id = p.id + ORDER BY v.effective_from DESC, v.id DESC + LIMIT 1 + ), + ( + SELECT v.amount_fen + FROM cost_versions v + JOIN work_product_rules wpr ON wpr.id = v.work_product_rule_id + WHERE v.status = 'active' + AND v.scope_type = 'rule' + AND p.canonical_sku LIKE 'tpl:%' + AND wpr.rule_key = substring(p.canonical_sku from 5) + ORDER BY v.effective_from DESC, v.id DESC + LIMIT 1 + ) + ) AS active_cost_amount_fen, + COALESCE( + ( + SELECT v.effective_from + FROM cost_versions v + WHERE v.status = 'active' + AND v.canonical_product_id = p.id + ORDER BY v.effective_from DESC, v.id DESC + LIMIT 1 + ), + ( + SELECT v.effective_from + FROM cost_versions v + JOIN work_product_rules wpr ON wpr.id = v.work_product_rule_id + WHERE v.status = 'active' + AND v.scope_type = 'rule' + AND p.canonical_sku LIKE 'tpl:%' + AND wpr.rule_key = substring(p.canonical_sku from 5) + ORDER BY v.effective_from DESC, v.id DESC + LIMIT 1 + ) + ) AS active_cost_effective_from, COALESCE(aff.task_count, 0) AS affiliate_task_count, aff.last_fulfilled_at AS affiliate_last_fulfilled_at FROM product_catalog p @@ -1226,3 +1273,306 @@ async function assertMappingKeyAvailable( }) } } + +/** + * 统一商品改价接口:全系统只有商品与成本中心维护标准价格。 + * 支持自动发货商品 (aff:)、接单模板商品 (tpl:)、工单商品 (wo:)、人工商品 (man:)。 + */ +export async function setAdminProductPrice( + productIdInput: unknown, + payload: JsonObject, + session: AdminSession, +) { + const productId = Number(productIdInput) + if (!Number.isFinite(productId) || productId <= 0) { + throw createHttpError('无效的商品 ID', { statusCode: 400 }) + } + const productResult = await query<{ + id: number + canonical_sku: string + canonical_name: string + attributes_json: unknown + }>( + 'SELECT id, canonical_sku, canonical_name, attributes_json FROM product_catalog WHERE id = $1', + [productId], + ) + const product = productResult.rows[0] + if (!product) { + throw createHttpError('商品不存在', { statusCode: 404 }) + } + + // 1. 如果是自动发货商品,委托给 setAdminAffiliateProductPrice + if (product.canonical_sku.startsWith('aff:')) { + return setAdminAffiliateProductPrice(productId, payload, session) + } + + const amountFen = Number(payload.amountFen ?? payload.amount_fen) + if (!Number.isFinite(amountFen) || amountFen <= 0) { + throw createHttpError('价格必须大于 0', { statusCode: 400 }) + } + + const now = nowIso() + let effectiveFrom = payload.effectiveFrom ? String(payload.effectiveFrom) : now + if (payload.retroactive === true || payload.retroactive === 'true') { + effectiveFrom = '2026-07-10T00:00:00+08:00' + } + const changeReason = String(payload.changeReason || '在商品与成本中心统一调整价格').trim() + + // 2. 如果是接单模板商品 (tpl:) + if (product.canonical_sku.startsWith('tpl:')) { + const ruleKey = product.canonical_sku.slice(4) + const ruleResult = await query<{ + id: number + rule_key: string + product_name: string + reward_amount: number + unit_price_fen: number + }>( + 'SELECT id, rule_key, product_name, reward_amount, unit_price_fen FROM work_product_rules WHERE rule_key = $1 LIMIT 1', + [ruleKey], + ) + const rule = ruleResult.rows[0] + + 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", + ) + const costItemId = costItemResult.rows[0]?.id + 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) { + // 追溯改价或相同生效时间改价:就地更新 active 版本 + await query( + `UPDATE cost_versions + SET amount_fen = $1, + effective_from = $2, + canonical_product_id = $3, + work_product_rule_id = $4, + change_reason = $5, + created_by_admin_user_id = $6, + approved_by_admin_user_id = $6, + updated_at = $7 + WHERE id = $8`, + [ + amountFen, + effectiveFrom, + product.id, + rule?.id ?? null, + 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 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( + `UPDATE work_product_rules + SET reward_amount = $1, + unit_price_fen = CASE WHEN unit_price_fen > 0 THEN $1 ELSE 0 END, + cost_item_id = $2, + updated_at = $3 + WHERE id = $4`, + [amountFen, 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], + ) + + // 如果指定了同步工单价格(默认 true) + let syncedOrders: unknown = null + if (payload.syncOpenOrders !== false && rule) { + syncedOrders = await syncAdminWorkProductRulePrice(rule.id, session.username) + } + + return { + success: true, + product, + costVersionId, + amountFen, + effectiveFrom, + syncedOrders, + message: '接单模板结算价格已更新', + } + } + + // 3. 其他标准商品 (wo: 或 man:) + const costItemCode = product.canonical_sku.startsWith('wo:') + ? 'sys_work_order_reward' + : 'sys_manual_cost' + const costItemResult = await query<{ id: number }>( + 'SELECT id FROM cost_items WHERE code = $1 LIMIT 1', + [costItemCode], + ) + const costItemId = costItemResult.rows[0]?.id || 1 + + 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 canonical_product_id = $2 AND status = 'active' + ORDER BY effective_from DESC, id DESC LIMIT 1`, + [costItemId, 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) { + await query( + `UPDATE cost_versions + SET amount_fen = $1, + effective_from = $2, + change_reason = $3, + created_by_admin_user_id = $4, + approved_by_admin_user_id = $4, + updated_at = $5 + WHERE id = $6`, + [amountFen, effectiveFrom, 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 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, 'product', '', '', $2, + NULL, $3, 0, $4, NULL, + 'manual', 'active', $5, $6, + $6, $7, $7 + ) RETURNING id`, + [costItemId, product.id, amountFen, effectiveFrom, changeReason, session.userId, now], + ) + costVersionId = inserted.rows[0]?.id || 0 + } + + return { + success: true, + product, + costVersionId, + amountFen, + effectiveFrom, + message: '标准商品成本已更新', + } +} + +/** + * 将商品当前标准价格同步到下游业务(接单大厅工单或自动发货快照)。 + */ +export async function syncAdminProductPrice(productIdInput: unknown, session: AdminSession) { + const productId = Number(productIdInput) + if (!Number.isFinite(productId) || productId <= 0) { + throw createHttpError('无效的商品 ID', { statusCode: 400 }) + } + const productResult = await query<{ + id: number + canonical_sku: string + canonical_name: string + }>('SELECT id, canonical_sku, canonical_name FROM product_catalog WHERE id = $1', [productId]) + const product = productResult.rows[0] + if (!product) { + throw createHttpError('商品不存在', { statusCode: 404 }) + } + + if (product.canonical_sku.startsWith('tpl:')) { + const ruleKey = product.canonical_sku.slice(4) + const ruleResult = await query<{ id: number }>( + 'SELECT id FROM work_product_rules WHERE rule_key = $1 LIMIT 1', + [ruleKey], + ) + const rule = ruleResult.rows[0] + if (!rule) { + throw createHttpError('未找到关联的接单模板', { statusCode: 404 }) + } + const synced = await syncAdminWorkProductRulePrice(rule.id, session.username) + return { + success: true, + type: 'work_order_price_synced', + product, + synced, + message: `已同步 ${synced.updatedCount} 单:待抢单 ${synced.updatedNormalCount} 单,部分拼单 ${synced.updatedPartialSharingCount} 单`, + } + } + + if (product.canonical_sku.startsWith('aff:')) { + const syncRes = await syncAdminAffiliateDashCostSnapshots() + return { + success: true, + type: 'affiliate_snapshots_synced', + product, + snapshots: syncRes, + message: `已重算成本快照(生成 ${syncRes.snapshotResult.createdCount} 笔快照,消除 ${syncRes.snapshotResult.resolvedExceptionCount} 笔异常),并重算销售事实`, + } + } + + return { + success: true, + type: 'no_sync_needed', + product, + message: '该商品无需同步外部订单', + } +} + 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 2879b480..9e09b548 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 @@ -383,6 +383,29 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { payload.unitPrice === undefined && payload.unitPriceYuan === undefined ? 0 : normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 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', + }, + ) + } + } const sharingEnabled = normalizeBoolean(payload.sharingEnabled ?? payload.sharing_enabled, false) const acceptanceMode = normalizeWorkOrderAcceptanceMode( hasOwnPayload(payload, 'acceptanceMode') || hasOwnPayload(payload, 'acceptance_mode') diff --git a/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx b/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx index 08054de9..82d6d8c3 100644 --- a/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx @@ -1,18 +1,43 @@ /** - * 自动发货 SKU Tab:上游 SKU 目录与订单项回填。 + * 自动发货 SKU Tab:上游 SKU 目录、采购成本配置与订单项快照同步。 * * 自动发货只有 affiliate-dash 一个上游渠道,SKU 固定归一为 `aff:`, * 不写渠道映射,也不维护游戏分类。 + * 成本按上游商品配置采购单价(cost_versions),并在改价/初次配置后重算成本快照。 */ -import { ReloadOutlined, SyncOutlined } from '@ant-design/icons' -import { Alert, App, Button, Card, Space, Switch, Tag, Typography } from 'antd' +import { + DownloadOutlined, + EditOutlined, + ReloadOutlined, + SyncOutlined, +} from '@ant-design/icons' +import { + Alert, + App, + Button, + Card, + DatePicker, + Form, + Input, + InputNumber, + Modal, + Radio, + Space, + Switch, + Tag, + Typography, +} from 'antd' import PercentTable from '@/components/common/PercentTable' import { useQuery, useQueryClient } from '@tanstack/react-query' +import dayjs from 'dayjs' import { useState } from 'react' import { backfillAdminAffiliateDashSkus, fetchAdminProductCatalog, + importAdminAffiliateDashPricesBaseline, + setAdminAffiliateProductPrice, + syncAdminAffiliateDashCostSnapshots, syncAdminAffiliateDashProducts, updateAdminProductCatalog, } from '@/services/admin' @@ -22,8 +47,15 @@ import { formatAdminDateTime } from '@/utils/admin-time' import { useCostCenter } from './CostCenterContext' +interface PriceFormValues { + priceYuan: number + effectiveMode: 'retroactive' | 'now' | 'custom' + customEffectiveDate?: dayjs.Dayjs + changeReason?: string +} + export function AffiliateSkuPanel() { - const { message } = App.useApp() + const { message, modal } = App.useApp() const queryClient = useQueryClient() const { canConfig, refreshAll } = useCostCenter() @@ -32,6 +64,14 @@ export function AffiliateSkuPanel() { const [statusUpdatingId, setStatusUpdatingId] = useState(null) const [syncing, setSyncing] = useState(false) const [backfilling, setBackfilling] = useState(false) + const [importingBaseline, setImportingBaseline] = useState(false) + const [syncingSnapshots, setSyncingSnapshots] = useState(false) + + // 价格弹窗状态 + const [priceModalOpen, setPriceModalOpen] = useState(false) + const [selectedProduct, setSelectedProduct] = useState(null) + const [priceSubmitting, setPriceSubmitting] = useState(false) + const [priceForm] = Form.useForm() const productsQuery = useQuery({ queryKey: ['admin-cost-center-products', 'affiliate_dash', page, pageSize], @@ -84,6 +124,108 @@ export function AffiliateSkuPanel() { } } + function handleImportBaseline() { + modal.confirm({ + title: '导入上游采购成本基线', + content: + '将从系统内置的 38 条上游商品价格清单导入采购成本版本(生效时间默认追溯至 2026-07-10,与历史订单匹配)。是否继续?', + okText: '确认导入', + cancelText: '取消', + onOk: async () => { + setImportingBaseline(true) + try { + const res = await importAdminAffiliateDashPricesBaseline() + message.success( + `已成功导入上游价格基线:新增商品 ${res.data.createdProductCount} 个,新增成本版本 ${res.data.createdVersionCount} 个,更新生效时间 ${res.data.updatedVersionCount} 个`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '导入上游价格基线失败') + } finally { + setImportingBaseline(false) + } + }, + }) + } + + function handleSyncSnapshots() { + modal.confirm({ + title: '重算自动发货预估成本快照', + content: + '将为全部已归一的自动发货历史订单重新计算预估成本快照,自动消除因缺少采购价导致的 cost_missing 异常,并重算对应周期的销售事实。确定执行?', + okText: '确认重算', + cancelText: '取消', + onOk: async () => { + setSyncingSnapshots(true) + try { + const res = await syncAdminAffiliateDashCostSnapshots() + const snap = res.data.snapshotResult + message.success( + `重算完成!已生成成本快照 ${snap.createdCount} 条,已消除/解决异常 ${snap.resolvedExceptionCount} 条`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '重算成本快照失败') + } finally { + setSyncingSnapshots(false) + } + }, + }) + } + + function openPriceModal(product: ProductCatalogItem) { + setSelectedProduct(product) + const currentYuan = + product.activeCostAmountFen !== null && product.activeCostAmountFen !== undefined + ? Number((product.activeCostAmountFen / 100).toFixed(2)) + : undefined + + priceForm.setFieldsValue({ + priceYuan: currentYuan, + effectiveMode: 'retroactive', + changeReason: currentYuan ? '上游采购成本调整' : '首次配置自动发货采购成本', + }) + setPriceModalOpen(true) + } + + async function submitPrice(values: PriceFormValues) { + if (!selectedProduct) return + const amountFen = Math.round(Number(values.priceYuan) * 100) + if (!amountFen || amountFen <= 0) { + message.error('请输入有效的采购单价') + return + } + + let effectiveFrom: string | undefined + let retroactive = false + + if (values.effectiveMode === 'retroactive') { + retroactive = true + effectiveFrom = '2026-07-10T00:00:00+08:00' + } else if (values.effectiveMode === 'now') { + effectiveFrom = new Date().toISOString() + } else if (values.effectiveMode === 'custom' && values.customEffectiveDate) { + effectiveFrom = values.customEffectiveDate.startOf('day').toISOString() + } + + setPriceSubmitting(true) + try { + await setAdminAffiliateProductPrice(selectedProduct.id, { + amountFen, + effectiveFrom, + retroactive, + changeReason: values.changeReason, + }) + message.success(`「${selectedProduct.canonicalName}」采购单价已设置为 ¥${values.priceYuan}`) + setPriceModalOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '设置采购成本失败') + } finally { + setPriceSubmitting(false) + } + } + async function toggleStatus(row: ProductCatalogItem, checked: boolean) { if (!canConfig) return setStatusUpdatingId(row.id) @@ -100,126 +242,275 @@ export function AffiliateSkuPanel() { } return ( - - 自动发货 SKU - {total} - - } - extra={ - canConfig ? ( - - - - + + + + + ), + }, + ]} + /> + + + setPriceModalOpen(false)} + onOk={() => priceForm.submit()} + confirmLoading={priceSubmitting} + okText="保存并生效" + cancelText="取消" + destroyOnClose + > +
+ + + {selectedProduct?.canonicalName}({selectedProduct?.canonicalSku}) + + + + + + + + + + + + + 追溯至历史订单 (推荐) + + 自历史业务起点 (2026-07-10) 生效,重算快照后可直接消除历史 cost_missing 异常 + + + + + 自当前时间立即生效(仅对后续订单生效) + + + 指定生效日期 + - ), - }, - ]} - /> - + + + + prev.effectiveMode !== cur.effectiveMode}> + {({ getFieldValue }) => + getFieldValue('effectiveMode') === 'custom' ? ( + + + + ) : null + } + + + + + +
+
+ ) } diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx index 4cee92b3..a78965f6 100644 --- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -6,6 +6,7 @@ */ import { DeleteOutlined, + DollarOutlined, EditOutlined, FolderOpenOutlined, PlusOutlined, @@ -17,9 +18,12 @@ import { App, Button, Card, + Checkbox, Form, Input, + InputNumber, Modal, + Radio, Select, Space, Switch, @@ -27,19 +31,23 @@ import { Tag, Typography, } from 'antd' -import PercentTable from '@/components/common/PercentTable' import type { TableColumnsType } from 'antd' +import PercentTable from '@/components/common/PercentTable' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { applyAdminTemplateProductBootstrap, + backfillAdminAffiliateDashSkus, backfillAdminWorkOrderProductSkus, createAdminProductCatalog, deleteAdminProductCatalog, fetchAdminProductCatalog, normalizeAdminManualProducts, previewAdminTemplateProductBootstrap, + setAdminProductPrice, + syncAdminAffiliateDashProducts, + syncAdminProductPrice, updateAdminProductCatalog, } from '@/services/admin' import type { ProductCatalogItem } from '@/types/admin' @@ -68,18 +76,36 @@ export function ProductCatalogPanel() { const [bootstrapApplying, setBootstrapApplying] = useState(false) const [backfillingWorkOrderSkus, setBackfillingWorkOrderSkus] = useState(false) const [normalizingManualProducts, setNormalizingManualProducts] = useState(false) + const [syncingAffiliate, setSyncingAffiliate] = useState(false) + const [backfillingAffiliate, setBackfillingAffiliate] = useState(false) const [productPage, setProductPage] = useState(1) const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [productModalOpen, setProductModalOpen] = useState(false) const [editingProduct, setEditingProduct] = useState(null) + const [priceModalOpen, setPriceModalOpen] = useState(false) + const [pricingProduct, setPricingProduct] = useState(null) + const [pricingSubmitting, setPricingSubmitting] = useState(false) + const [syncingProductId, setSyncingProductId] = useState(null) + const [priceForm] = Form.useForm() + + // 游戏分类与二级目录结构仅为接单平台(work_order)独有,自动发货与人工客服不使用 + const isWorkOrder = productSourceFilter === 'work_order' + const showCategoryBar = isWorkOrder + + useEffect(() => { + if (!isWorkOrder) { + setProductRootCategoryId(null) + setProductSubCategoryId(null) + } + }, [isWorkOrder]) const productsQuery = useQuery({ queryKey: [ 'admin-cost-center-products', productSourceFilter, productKeyword, - productRootCategoryId, - productSubCategoryId, + isWorkOrder ? productRootCategoryId : null, + isWorkOrder ? productSubCategoryId : null, productPage, productPageSize, ], @@ -87,8 +113,8 @@ export function ProductCatalogPanel() { fetchAdminProductCatalog({ keyword: productKeyword || undefined, carrierType: productSourceFilter, - rootCategoryId: productRootCategoryId ?? undefined, - categoryId: productSubCategoryId ?? undefined, + rootCategoryId: isWorkOrder ? (productRootCategoryId ?? undefined) : undefined, + categoryId: isWorkOrder ? (productSubCategoryId ?? undefined) : undefined, page: productPage, pageSize: productPageSize, }), @@ -275,6 +301,44 @@ export function ProductCatalogPanel() { } } + async function syncAffiliateProducts() { + setSyncingAffiliate(true) + try { + const result = await syncAdminAffiliateDashProducts() + message.success( + `已同步 ${result.data.productCount} 个自动发货商品(新增 ${result.data.createdCount},更新 ${result.data.updatedCount})`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '同步自动发货商品失败') + } finally { + setSyncingAffiliate(false) + } + } + + async function backfillAffiliateSkus() { + setBackfillingAffiliate(true) + try { + const response = await backfillAdminAffiliateDashSkus() + const result = response.data + const factsText = result.factsRebuild + ? `;已重算 ${result.factsRebuild.fromDate} ~ ${result.factsRebuild.toDate} 的销售事实` + : '' + const remainingText = + result.remainingCount > 0 + ? `;仍有 ${result.remainingCount} 个订单项缺少可匹配的自动发货 SKU` + : '' + message.success( + `已回填 ${result.updatedCount} 个订单项,${result.skippedCount} 个已是归一状态${factsText}${remainingText}`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '回填自动发货 SKU 失败') + } finally { + setBackfillingAffiliate(false) + } + } + function openCreateProduct() { setEditingProduct(null) productForm.resetFields() @@ -318,6 +382,103 @@ export function ProductCatalogPanel() { } } + function openSetProductPrice(item: ProductCatalogItem) { + setPricingProduct(item) + priceForm.resetFields() + priceForm.setFieldsValue({ + amountYuan: + item.activeCostAmountFen !== null && item.activeCostAmountFen !== undefined + ? (item.activeCostAmountFen / 100).toFixed(2) + : undefined, + effectiveScope: 'retroactive', + changeReason: '在商品与成本中心统一调整价格', + syncOpenOrders: true, + syncSnapshots: true, + }) + setPriceModalOpen(true) + } + + async function submitProductPrice(values: { + amountYuan: number | string + effectiveScope: 'retroactive' | 'immediate' | 'custom' + effectiveFromCustom?: string + changeReason?: string + syncOpenOrders?: boolean + }) { + if (!pricingProduct) return + const yuan = Number(values.amountYuan) + if (!Number.isFinite(yuan) || yuan <= 0) { + message.error('价格必须大于 0') + return + } + const amountFen = Math.round(yuan * 100) + let effectiveFrom: string | undefined + let retroactive = false + + if (values.effectiveScope === 'retroactive') { + retroactive = true + effectiveFrom = '2026-07-10T00:00:00+08:00' + } else if (values.effectiveScope === 'custom' && values.effectiveFromCustom) { + effectiveFrom = values.effectiveFromCustom + } + + setPricingSubmitting(true) + try { + const response = await setAdminProductPrice(pricingProduct.id, { + amountFen, + effectiveFrom, + retroactive, + changeReason: values.changeReason || '在商品与成本中心统一调整价格', + syncOpenOrders: values.syncOpenOrders !== false, + }) + const result = response.data + let extraHint = '' + if (result.syncedOrders) { + extraHint = `,已同步 ${result.syncedOrders.updatedCount} 笔工单大厅待抢单` + } + message.success(`标准价格配置成功(¥${(amountFen / 100).toFixed(2)})${extraHint}`) + setPriceModalOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '配置标准价格失败') + } finally { + setPricingSubmitting(false) + } + } + + function handleSyncProductPrice(item: ProductCatalogItem) { + const isTpl = item.canonicalSku.startsWith('tpl:') + const isAff = item.canonicalSku.startsWith('aff:') + const currentPriceText = + item.activeCostAmountFen !== null && item.activeCostAmountFen !== undefined + ? `当前价格 ¥${(item.activeCostAmountFen / 100).toFixed(2)}` + : '当前配置' + const targetDesc = isTpl + ? '接单大厅所有该模板未领取的待抢单工单' + : isAff + ? '历史自动发货成本快照与销售事实' + : '下游关联业务' + + modal.confirm({ + title: '同步标准价格', + content: `确认将「${item.canonicalName}」的 ${currentPriceText} 同步至${targetDesc}?同步后将以新价格进行履约与结算。`, + okText: '确认同步', + cancelText: '取消', + onOk: async () => { + setSyncingProductId(item.id) + try { + const response = await syncAdminProductPrice(item.id) + message.success(response.data.message || '价格同步完成') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '同步价格失败') + } finally { + setSyncingProductId(null) + } + }, + }) + } + async function toggleProductStatus(row: ProductCatalogItem, checked: boolean) { if (!canConfig) return setProductStatusUpdatingId(row.id) @@ -412,30 +573,59 @@ export function ProductCatalogPanel() { title: '已绑定渠道', dataIndex: 'mappingCount', width: 130, - render: (value: number) => - value > 0 ? ( + render: (value: number, row) => { + if (row.source === 'affiliate_dash') { + return 直连无需映射 + } + if (row.source === 'manual') { + return 内部SKU无需映射 + } + return value > 0 ? ( 已匹配 {value} 条 ) : ( 未绑定 - ), + ) + }, }, { - title: '成本版本', + title: '采购/结算成本', dataIndex: 'costVersionCount', - width: 110, - render: (value: number, row) => - value > 0 ? ( - {value} 个版本 - ) : row.source === 'work_order' ? ( - 按工单实际结算 - ) : ( - 未配置 - ), + width: 140, + render: (value: number, row) => { + if (row.activeCostAmountFen !== null && row.activeCostAmountFen !== undefined) { + return ( +
canConfig && openSetProductPrice(row)} + title={canConfig ? '点击调整价格' : undefined} + > + + + ¥{(row.activeCostAmountFen / 100).toFixed(2)} + + + {value} 个版本 {canConfig ? '· 点击改价' : ''} + + +
+ ) + } + return ( + + ) + }, }, { title: '状态', dataIndex: 'status', - width: 120, + width: 110, render: (value: ProductCatalogItem['status'], row) => ( canConfig ? ( - + + + {row.canonicalSku.startsWith('tpl:') ? ( + + ) : null} - - + {productSourceFilter === 'affiliate_dash' ? ( + <> + + + + ) : productSourceFilter === 'manual' ? ( + + ) : productSourceFilter === 'work_order' ? ( + <> + + + + ) : ( + <> + + + + + )} - {productSubCategories.map((child) => ( - - ))} + items={[ + { key: 'all', label: `全部 (${productCatalogTotal})` }, + ...productCategoryTree.map((root) => ({ + key: String(root.categoryId), + label: `${root.name} (${root.total})`, + })), + ...(productUncategorizedTotal > 0 + ? [{ key: 'uncategorized', label: `未分类 (${productUncategorizedTotal})` }] + : []), + ]} + /> - + + {selectedProductRoot && productSubCategories.length > 0 ? ( +
+
+ + 子分类: +
+
+ + {productSubCategories.map((child) => ( + + ))} +
+
+ ) : null} + ) : null} @@ -677,6 +939,173 @@ export function ProductCatalogPanel() { + + {/* 配置标准价格弹窗 */} + + + + 配置标准价格 - {pricingProduct?.canonicalName || pricingProduct?.canonicalSku} + + + } + open={priceModalOpen} + onCancel={() => setPriceModalOpen(false)} + footer={null} + destroyOnClose + width={560} + > + {pricingProduct ? ( +
+
+ + 标准商品: + +
+ {pricingProduct.canonicalName}{' '} + + ({pricingProduct.canonicalSku}) + +
+
+ 当前生效价格: + + {pricingProduct.activeCostAmountFen !== null && + pricingProduct.activeCostAmountFen !== undefined + ? `¥${(pricingProduct.activeCostAmountFen / 100).toFixed(2)}` + : '未配置'} + + · + 来源: + + {pricingProduct.source === 'affiliate_dash' + ? '自动发货' + : pricingProduct.source === 'work_order' + ? '接单模板 / 工单' + : '人工客服'} + +
+
+ + + + + + + + + 追溯至历史订单(推荐) +
+ 自历史业务起点 (2026-07-10) 生效,直接更新基线并消除历史 cost_missing 异常 +
+
+ + 自当前时间立即生效(仅对后续订单生效) +
+ 保留旧版本历史,自新版本创建时刻起计算后续履约结算 +
+
+ + 指定生效日期 + +
+
+ + prev.effectiveScope !== curr.effectiveScope}> + {({ getFieldValue }) => + getFieldValue('effectiveScope') === 'custom' ? ( + + + + ) : null + } + + + + + + + {pricingProduct.canonicalSku.startsWith('tpl:') ? ( + + + 立即同步至接单大厅待抢单工单(推荐) +
+ 接单大厅中所有未领取的该模板工单将立即同步为新价格;已领取的工单保持原价不变。 +
+
+
+ ) : null} + + {pricingProduct.source === 'affiliate_dash' ? ( + + + 立即重算成本快照并回填历史(推荐) +
+ 自动重新计算历史所有发货任务的成本快照,更新财务数据。 +
+
+
+ ) : null} + +
+ + +
+
+ ) : null} + ) } diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index fdae97c1..c3444218 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -11,6 +11,7 @@ import { import { useQuery, useQueryClient } from '@tanstack/react-query' import { App, + Alert, Button, Card, Divider, @@ -925,6 +926,24 @@ export default function ProductRulesPanel() { }} onFinish={saveRule} > + {editingRule ? ( + + 接单单价由商品与成本中心维护并提供同步发单功能。在此处价格为只读展示;如需改价或同步工单价格,请前往 + + 【商品与成本中心】 + + 进行修改。 + + } + /> + ) : null} + {/* 基础信息 */}
- + ) : ( - - + + ) } diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index ea02c586..0882f669 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -117,6 +117,53 @@ export function backfillAdminAffiliateDashSkus() { }>('/api/v1/admin/cost-center/affiliate-products/backfill') } +export function importAdminAffiliateDashPricesBaseline() { + return apiPost<{ + total: number + createdProductCount: number + createdVersionCount: number + updatedVersionCount: number + skippedCount: number + }>('/api/v1/admin/cost-center/affiliate-products/price-baseline') +} + +export function setAdminAffiliateProductPrice( + productId: number, + payload: { + amountFen: number + effectiveFrom?: string + retroactive?: boolean + changeReason?: string + }, +) { + return apiPost<{ + success: boolean + costVersionId: number + amountFen: number + effectiveFrom: string + message: string + }>(`/api/v1/admin/cost-center/affiliate-products/${productId}/price`, payload) +} + +export function syncAdminAffiliateDashCostSnapshots() { + return apiPost<{ + snapshotResult: { + range: { fromDate: string; toDate: string } + createdCount: number + exceptionCreatedCount: number + resolvedExceptionCount: number + } + factsResult: { + fromDate: string + toDate: string + deletedCount: number + insertedCount: number + batchCount: number + } + }>('/api/v1/admin/cost-center/affiliate-products/sync-snapshots') +} + + export function updateAdminProductCatalog(productId: number, payload: Record) { return apiPatch<{ product: ProductCatalogItem }>( `/api/v1/admin/cost-center/products/${productId}`, @@ -417,3 +464,43 @@ export function confirmAdminManualFulfillment(payload: { payload, ) } + +export function setAdminProductPrice( + productId: number, + payload: { + amountFen: number + effectiveFrom?: string + retroactive?: boolean + changeReason?: string + syncOpenOrders?: boolean + }, +) { + return apiPost<{ + success: boolean + costVersionId: number + amountFen: number + effectiveFrom: string + syncedOrders?: { + candidateCount: number + updatedCount: number + updatedNormalCount: number + updatedPartialSharingCount: number + } + message?: string + }>(`/api/v1/admin/cost-center/products/${productId}/price`, payload) +} + +export function syncAdminProductPrice(productId: number) { + return apiPost<{ + success: boolean + type: string + synced?: { + candidateCount: number + updatedCount: number + updatedNormalCount: number + updatedPartialSharingCount: number + } + message: string + }>(`/api/v1/admin/cost-center/products/${productId}/sync-price`, {}) +} + diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index 88886e21..b7291c74 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -49,6 +49,8 @@ export interface ProductCatalogItem { attributes: Record mappingCount: number costVersionCount: number + activeCostAmountFen?: number | null + activeCostEffectiveFrom?: string | null /** 自动发货履约任务数,实时取自 fulfillment_tasks。 */ affiliateTaskCount: number affiliateLastFulfilledAt: string | null