feat(cost-center): 统一商品成本中心为全系统唯一标准价格源并闭环改价与工单同步
- 核心架构:将商品与成本中心确立为全系统唯一标准价格源,接单模板价格在接单平台禁止编辑并提示收敛至成本中心,后端 saveAdminWorkProductRule 严格拦截价格篡改 - 改价与同步:标准商品目录与自动发货专属面板提供统一改价弹窗,支持追溯/立即生效/指定时间;改价后自动或手动将新价格同步至接单大厅待抢单工单,保持下游业务一致 - 缺陷修复:修复 cost_versions 追溯改价时因同生效时间违反 chk_cost_versions_period 检查约束及 uq_cost_versions_scope 唯一索引冲突的问题,采用 active 版本就地更新机制 - 分类树优化:工单二级分类树限定仅在工单来源展示,自动发货与人工客服彻底隐藏分类树并重置过滤条件 - 验证方式:通过后端 784 项全量测试、前后端 tsc 类型检查及前端生产构建打包
This commit is contained in:
@@ -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<string, unknown>,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
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<string, unknown>,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
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<string, unknown>,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
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<string, unknown>,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
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<string, unknown>,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.delete(
|
||||
'/cost-center/products/:productId',
|
||||
requireAdminPermission('finance.config'),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) }
|
||||
: {}
|
||||
attr.rewardAmountFen = amountFen
|
||||
await query(
|
||||
'UPDATE product_catalog SET attributes_json = $1::jsonb, updated_at = $2 WHERE id = $3',
|
||||
[JSON.stringify(attr), now, product.id],
|
||||
)
|
||||
|
||||
// 如果指定了同步工单价格(默认 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: '该商品无需同步外部订单',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
/**
|
||||
* 自动发货 SKU Tab:上游 SKU 目录与订单项回填。
|
||||
* 自动发货 SKU Tab:上游 SKU 目录、采购成本配置与订单项快照同步。
|
||||
*
|
||||
* 自动发货只有 affiliate-dash 一个上游渠道,SKU 固定归一为 `aff:<skuCode>`,
|
||||
* 不写渠道映射,也不维护游戏分类。
|
||||
* 成本按上游商品配置采购单价(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<number | null>(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<ProductCatalogItem | null>(null)
|
||||
const [priceSubmitting, setPriceSubmitting] = useState(false)
|
||||
const [priceForm] = Form.useForm<PriceFormValues>()
|
||||
|
||||
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 (
|
||||
<Card
|
||||
title={
|
||||
<Space size={6} wrap>
|
||||
<Typography.Text strong>自动发货 SKU</Typography.Text>
|
||||
<Tag color="green">{total}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
canConfig ? (
|
||||
<Space>
|
||||
<Button icon={<SyncOutlined />} loading={syncing} onClick={() => void syncProducts()}>
|
||||
同步自动发货商品
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={backfilling}
|
||||
onClick={() => void backfillSkus()}
|
||||
>
|
||||
回填自动发货 SKU
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={productsQuery.isFetching}
|
||||
onClick={() => void productsQuery.refetch()}
|
||||
/>
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<Space size={6} wrap>
|
||||
<Typography.Text strong>自动发货 SKU</Typography.Text>
|
||||
<Tag color="green">{total}</Tag>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="自动发货只按上游货源 SKU 配采购成本:订单项直接按 aff:<skuCode> 归一,不走渠道映射;这里不维护游戏分类和商品上下架,那些只属于接单平台。"
|
||||
/>
|
||||
<PercentTable
|
||||
tableLayout="fixed"
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={products}
|
||||
loading={productsQuery.isLoading}
|
||||
scroll={{ x: 1080 }}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showTotal: (count) => `共 ${count} 个 SKU`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
columns={[
|
||||
{
|
||||
title: 'SKU 名称',
|
||||
dataIndex: 'canonicalName',
|
||||
width: 320,
|
||||
ellipsis: true,
|
||||
render: (value: string, row) => (
|
||||
<Space direction="vertical" size={0} style={{ width: '100%' }}>
|
||||
<Typography.Text strong ellipsis={{ tooltip: value }}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12 }}
|
||||
ellipsis={{
|
||||
tooltip: String(row.attributes?.skuCode || row.canonicalSku),
|
||||
}}
|
||||
>
|
||||
{String(row.attributes?.skuCode || row.canonicalSku)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '履约任务',
|
||||
width: 110,
|
||||
render: (_, row) => `${row.affiliateTaskCount} 单`,
|
||||
},
|
||||
{
|
||||
title: '最近履约',
|
||||
width: 180,
|
||||
render: (_, row) =>
|
||||
row.affiliateLastFulfilledAt
|
||||
? formatAdminDateTime(row.affiliateLastFulfilledAt)
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
title: '成本版本',
|
||||
dataIndex: 'costVersionCount',
|
||||
width: 110,
|
||||
render: (value: number) => (value > 0 ? `${value} 个` : <Tag>未配置</Tag>),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 120,
|
||||
render: (value: string, row) => (
|
||||
<Space size={6}>
|
||||
<Switch
|
||||
}
|
||||
extra={
|
||||
canConfig ? (
|
||||
<Space wrap>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
loading={importingBaseline}
|
||||
onClick={handleImportBaseline}
|
||||
>
|
||||
导入基线价格
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncingSnapshots}
|
||||
onClick={handleSyncSnapshots}
|
||||
>
|
||||
重算成本快照
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncing}
|
||||
onClick={() => void syncProducts()}
|
||||
>
|
||||
同步自动发货商品
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={backfilling}
|
||||
onClick={() => void backfillSkus()}
|
||||
>
|
||||
回填自动发货 SKU
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={productsQuery.isFetching}
|
||||
onClick={() => void productsQuery.refetch()}
|
||||
/>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="自动发货订单直接按 aff:<skuCode> 归一,不走渠道映射。点击右侧「配置成本」可录入采购单价;已配置的商品可通过「重算成本快照」消除历史缺失异常。"
|
||||
/>
|
||||
<PercentTable
|
||||
tableLayout="fixed"
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={products}
|
||||
loading={productsQuery.isLoading}
|
||||
scroll={{ x: 1180 }}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showTotal: (count) => `共 ${count} 个 SKU`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
columns={[
|
||||
{
|
||||
title: 'SKU 名称 / 编码',
|
||||
dataIndex: 'canonicalName',
|
||||
width: 300,
|
||||
ellipsis: true,
|
||||
render: (value: string, row) => (
|
||||
<Space direction="vertical" size={0} style={{ width: '100%' }}>
|
||||
<Typography.Text strong ellipsis={{ tooltip: value }}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12 }}
|
||||
ellipsis={{
|
||||
tooltip: String(row.attributes?.skuCode || row.canonicalSku),
|
||||
}}
|
||||
>
|
||||
{String(row.attributes?.skuCode || row.canonicalSku)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '采购单价 (元)',
|
||||
width: 140,
|
||||
render: (_, row) => {
|
||||
if (row.activeCostAmountFen !== null && row.activeCostAmountFen !== undefined) {
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong style={{ color: '#cf1322', fontSize: 14 }}>
|
||||
¥{(row.activeCostAmountFen / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
{row.activeCostEffectiveFrom ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
生效: {String(row.activeCostEffectiveFrom).slice(0, 10)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
return <Tag color="error">未配置</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '履约任务',
|
||||
width: 110,
|
||||
render: (_, row) => `${row.affiliateTaskCount} 单`,
|
||||
},
|
||||
{
|
||||
title: '最近履约',
|
||||
width: 170,
|
||||
render: (_, row) =>
|
||||
row.affiliateLastFulfilledAt
|
||||
? formatAdminDateTime(row.affiliateLastFulfilledAt)
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
title: '成本版本',
|
||||
dataIndex: 'costVersionCount',
|
||||
width: 100,
|
||||
render: (value: number) =>
|
||||
value > 0 ? (
|
||||
<Typography.Text type="success">{value} 个版本</Typography.Text>
|
||||
) : (
|
||||
<Tag>未配置</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (value: string, row) => (
|
||||
<Space size={6}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={value === 'active'}
|
||||
loading={statusUpdatingId === row.id}
|
||||
disabled={
|
||||
!canConfig || (statusUpdatingId !== null && statusUpdatingId !== row.id)
|
||||
}
|
||||
onChange={(checked) => void toggleStatus(row, checked)}
|
||||
/>
|
||||
<Typography.Text
|
||||
type={value === 'active' ? 'success' : 'secondary'}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{value === 'active' ? '启用' : '归档'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 110,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
checked={value === 'active'}
|
||||
loading={statusUpdatingId === row.id}
|
||||
disabled={
|
||||
!canConfig || (statusUpdatingId !== null && statusUpdatingId !== row.id)
|
||||
}
|
||||
onChange={(checked) => void toggleStatus(row, checked)}
|
||||
/>
|
||||
<Typography.Text
|
||||
type={value === 'active' ? 'success' : 'secondary'}
|
||||
style={{ fontSize: 12 }}
|
||||
icon={<EditOutlined />}
|
||||
disabled={!canConfig}
|
||||
onClick={() => openPriceModal(row)}
|
||||
>
|
||||
{value === 'active' ? '启用' : '归档'}
|
||||
</Typography.Text>
|
||||
{row.activeCostAmountFen !== null && row.activeCostAmountFen !== undefined
|
||||
? '改价'
|
||||
: '配置成本'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={`配置自动发货采购成本 - ${selectedProduct?.canonicalName || ''}`}
|
||||
open={priceModalOpen}
|
||||
onCancel={() => setPriceModalOpen(false)}
|
||||
onOk={() => priceForm.submit()}
|
||||
confirmLoading={priceSubmitting}
|
||||
okText="保存并生效"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={priceForm} layout="vertical" onFinish={submitPrice}>
|
||||
<Form.Item label="标准商品">
|
||||
<Typography.Text strong>
|
||||
{selectedProduct?.canonicalName}({selectedProduct?.canonicalSku})
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="priceYuan"
|
||||
label="采购单价 (元)"
|
||||
rules={[
|
||||
{ required: true, message: '请输入采购单价' },
|
||||
{
|
||||
type: 'number',
|
||||
min: 0.01,
|
||||
message: '采购单价必须大于 0',
|
||||
},
|
||||
]}
|
||||
extra="上游每发货 1 件扣除的采购成本金额"
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
precision={2}
|
||||
min={0.01}
|
||||
step={1}
|
||||
addonAfter="元 / 件"
|
||||
placeholder="例如: 15.00"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="effectiveMode"
|
||||
label="生效时间范围"
|
||||
rules={[{ required: true, message: '请选择生效时间' }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Space direction="vertical">
|
||||
<Radio value="retroactive">
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>追溯至历史订单 (推荐)</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
自历史业务起点 (2026-07-10) 生效,重算快照后可直接消除历史 cost_missing 异常
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Radio>
|
||||
<Radio value="now">
|
||||
<span>自当前时间立即生效(仅对后续订单生效)</span>
|
||||
</Radio>
|
||||
<Radio value="custom">
|
||||
<span>指定生效日期</span>
|
||||
</Radio>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.effectiveMode !== cur.effectiveMode}>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('effectiveMode') === 'custom' ? (
|
||||
<Form.Item
|
||||
name="customEffectiveDate"
|
||||
label="指定生效日期"
|
||||
rules={[{ required: true, message: '请选择生效日期' }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择生效日期" />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="changeReason" label="变更说明 / 备注">
|
||||
<Input placeholder="例如:上游供货商调价" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ProductCatalogItem | null>(null)
|
||||
const [priceModalOpen, setPriceModalOpen] = useState(false)
|
||||
const [pricingProduct, setPricingProduct] = useState<ProductCatalogItem | null>(null)
|
||||
const [pricingSubmitting, setPricingSubmitting] = useState(false)
|
||||
const [syncingProductId, setSyncingProductId] = useState<number | null>(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 <Tag color="default">直连无需映射</Tag>
|
||||
}
|
||||
if (row.source === 'manual') {
|
||||
return <Tag color="default">内部SKU无需映射</Tag>
|
||||
}
|
||||
return value > 0 ? (
|
||||
<Tag color="green">已匹配 {value} 条</Tag>
|
||||
) : (
|
||||
<Tag style={{ color: '#94a3b8' }}>未绑定</Tag>
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '成本版本',
|
||||
title: '采购/结算成本',
|
||||
dataIndex: 'costVersionCount',
|
||||
width: 110,
|
||||
render: (value: number, row) =>
|
||||
value > 0 ? (
|
||||
<Typography.Text type="success">{value} 个版本</Typography.Text>
|
||||
) : row.source === 'work_order' ? (
|
||||
<Tag color="blue">按工单实际结算</Tag>
|
||||
) : (
|
||||
<Typography.Text type="secondary">未配置</Typography.Text>
|
||||
),
|
||||
width: 140,
|
||||
render: (value: number, row) => {
|
||||
if (row.activeCostAmountFen !== null && row.activeCostAmountFen !== undefined) {
|
||||
return (
|
||||
<div
|
||||
style={{ cursor: canConfig ? 'pointer' : 'default' }}
|
||||
onClick={() => canConfig && openSetProductPrice(row)}
|
||||
title={canConfig ? '点击调整价格' : undefined}
|
||||
>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
¥{(row.activeCostAmountFen / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{value} 个版本 {canConfig ? '· 点击改价' : ''}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => canConfig && openSetProductPrice(row)}
|
||||
>
|
||||
{value > 0 ? `${value} 个版本 · 改价` : row.source === 'work_order' ? '配置结算成本' : '未配置 · 立即配置'}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 120,
|
||||
width: 110,
|
||||
render: (value: ProductCatalogItem['status'], row) => (
|
||||
<Space size={6}>
|
||||
<Switch
|
||||
@@ -458,11 +648,33 @@ export function ProductCatalogPanel() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_, row) =>
|
||||
canConfig ? (
|
||||
<Space size={2}>
|
||||
<Space size={2} wrap>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<DollarOutlined />}
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={() => openSetProductPrice(row)}
|
||||
>
|
||||
{row.activeCostAmountFen ? '改价' : '配置成本'}
|
||||
</Button>
|
||||
{row.canonicalSku.startsWith('tpl:') ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<SyncOutlined spin={syncingProductId === row.id} />}
|
||||
style={{ paddingInline: 4, color: '#1677ff' }}
|
||||
title="同步此模板在接单大厅的待抢单工单价格"
|
||||
loading={syncingProductId === row.id}
|
||||
onClick={() => handleSyncProductPrice(row)}
|
||||
>
|
||||
同步工单
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
@@ -497,27 +709,73 @@ export function ProductCatalogPanel() {
|
||||
extra={
|
||||
canConfig ? (
|
||||
<Space size={6} wrap>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={bootstrapPreviewing || bootstrapApplying}
|
||||
onClick={() => void runTemplateBootstrap()}
|
||||
>
|
||||
从接单模板初始化
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={backfillingWorkOrderSkus}
|
||||
onClick={() => void backfillWorkOrderProducts()}
|
||||
>
|
||||
按工单回填商品
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={normalizingManualProducts}
|
||||
onClick={() => void normalizeManualProductCatalog()}
|
||||
>
|
||||
归一人工商品
|
||||
</Button>
|
||||
{productSourceFilter === 'affiliate_dash' ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncingAffiliate}
|
||||
onClick={() => void syncAffiliateProducts()}
|
||||
>
|
||||
同步自动发货商品
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={backfillingAffiliate}
|
||||
onClick={() => void backfillAffiliateSkus()}
|
||||
>
|
||||
回填自动发货 SKU
|
||||
</Button>
|
||||
</>
|
||||
) : productSourceFilter === 'manual' ? (
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={normalizingManualProducts}
|
||||
onClick={() => void normalizeManualProductCatalog()}
|
||||
>
|
||||
归一人工商品
|
||||
</Button>
|
||||
) : productSourceFilter === 'work_order' ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={bootstrapPreviewing || bootstrapApplying}
|
||||
onClick={() => void runTemplateBootstrap()}
|
||||
>
|
||||
从接单模板初始化
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={backfillingWorkOrderSkus}
|
||||
onClick={() => void backfillWorkOrderProducts()}
|
||||
>
|
||||
按工单回填商品
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={bootstrapPreviewing || bootstrapApplying}
|
||||
onClick={() => void runTemplateBootstrap()}
|
||||
>
|
||||
从接单模板初始化
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={backfillingWorkOrderSkus}
|
||||
onClick={() => void backfillWorkOrderProducts()}
|
||||
>
|
||||
按工单回填商品
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={normalizingManualProducts}
|
||||
onClick={() => void normalizeManualProductCatalog()}
|
||||
>
|
||||
归一人工商品
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={productsQuery.isFetching}
|
||||
@@ -530,75 +788,79 @@ export function ProductCatalogPanel() {
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="product-rules-category-tabs-bar">
|
||||
<Tabs
|
||||
activeKey={
|
||||
productRootCategoryId === null
|
||||
? 'all'
|
||||
: productRootCategoryId === -1
|
||||
? 'uncategorized'
|
||||
: String(productRootCategoryId)
|
||||
}
|
||||
size="small"
|
||||
onChange={(key) => {
|
||||
if (key === 'all') {
|
||||
setProductRootCategoryId(null)
|
||||
setProductSubCategoryId(null)
|
||||
} else if (key === 'uncategorized') {
|
||||
setProductRootCategoryId(-1)
|
||||
setProductSubCategoryId(null)
|
||||
} else {
|
||||
setProductRootCategoryId(Number(key))
|
||||
setProductSubCategoryId(null)
|
||||
}
|
||||
setProductPage(1)
|
||||
}}
|
||||
items={[
|
||||
{ key: 'all', label: `全部 (${productCatalogTotal})` },
|
||||
...productCategoryTree.map((root) => ({
|
||||
key: String(root.categoryId),
|
||||
label: `${root.name} (${root.total})`,
|
||||
})),
|
||||
...(productUncategorizedTotal > 0
|
||||
? [{ key: 'uncategorized', label: `未分类 (${productUncategorizedTotal})` }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedProductRoot && productSubCategories.length > 0 ? (
|
||||
<div className="product-rules-sub-category-bar">
|
||||
<div className="sub-category-bar-label">
|
||||
<FolderOpenOutlined style={{ color: '#1677ff', fontSize: 13 }} />
|
||||
<span>子分类:</span>
|
||||
</div>
|
||||
<div className="sub-category-pills">
|
||||
<button
|
||||
type="button"
|
||||
className={`sub-category-pill ${productSubCategoryId === null ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setProductSubCategoryId(null)
|
||||
{showCategoryBar ? (
|
||||
<>
|
||||
<div className="product-rules-category-tabs-bar">
|
||||
<Tabs
|
||||
activeKey={
|
||||
productRootCategoryId === null
|
||||
? 'all'
|
||||
: productRootCategoryId === -1
|
||||
? 'uncategorized'
|
||||
: String(productRootCategoryId)
|
||||
}
|
||||
size="small"
|
||||
onChange={(key) => {
|
||||
if (key === 'all') {
|
||||
setProductRootCategoryId(null)
|
||||
setProductSubCategoryId(null)
|
||||
} else if (key === 'uncategorized') {
|
||||
setProductRootCategoryId(-1)
|
||||
setProductSubCategoryId(null)
|
||||
} else {
|
||||
setProductRootCategoryId(Number(key))
|
||||
setProductSubCategoryId(null)
|
||||
}
|
||||
setProductPage(1)
|
||||
}}
|
||||
>
|
||||
全部 ({selectedProductRoot.total})
|
||||
</button>
|
||||
{productSubCategories.map((child) => (
|
||||
<button
|
||||
key={child.categoryId}
|
||||
type="button"
|
||||
className={`sub-category-pill ${productSubCategoryId === child.categoryId ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setProductSubCategoryId(child.categoryId)
|
||||
setProductPage(1)
|
||||
}}
|
||||
>
|
||||
{child.name}
|
||||
<span className="pill-count">{child.total}</span>
|
||||
</button>
|
||||
))}
|
||||
items={[
|
||||
{ key: 'all', label: `全部 (${productCatalogTotal})` },
|
||||
...productCategoryTree.map((root) => ({
|
||||
key: String(root.categoryId),
|
||||
label: `${root.name} (${root.total})`,
|
||||
})),
|
||||
...(productUncategorizedTotal > 0
|
||||
? [{ key: 'uncategorized', label: `未分类 (${productUncategorizedTotal})` }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedProductRoot && productSubCategories.length > 0 ? (
|
||||
<div className="product-rules-sub-category-bar">
|
||||
<div className="sub-category-bar-label">
|
||||
<FolderOpenOutlined style={{ color: '#1677ff', fontSize: 13 }} />
|
||||
<span>子分类:</span>
|
||||
</div>
|
||||
<div className="sub-category-pills">
|
||||
<button
|
||||
type="button"
|
||||
className={`sub-category-pill ${productSubCategoryId === null ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setProductSubCategoryId(null)
|
||||
setProductPage(1)
|
||||
}}
|
||||
>
|
||||
全部 ({selectedProductRoot.total})
|
||||
</button>
|
||||
{productSubCategories.map((child) => (
|
||||
<button
|
||||
key={child.categoryId}
|
||||
type="button"
|
||||
className={`sub-category-pill ${productSubCategoryId === child.categoryId ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setProductSubCategoryId(child.categoryId)
|
||||
setProductPage(1)
|
||||
}}
|
||||
>
|
||||
{child.name}
|
||||
<span className="pill-count">{child.total}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
@@ -677,6 +939,173 @@ export function ProductCatalogPanel() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 配置标准价格弹窗 */}
|
||||
<Modal
|
||||
title={
|
||||
<Space size={8}>
|
||||
<DollarOutlined style={{ color: '#1677ff' }} />
|
||||
<span>
|
||||
配置标准价格 - {pricingProduct?.canonicalName || pricingProduct?.canonicalSku}
|
||||
</span>
|
||||
</Space>
|
||||
}
|
||||
open={priceModalOpen}
|
||||
onCancel={() => setPriceModalOpen(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
>
|
||||
{pricingProduct ? (
|
||||
<Form
|
||||
form={priceForm}
|
||||
layout="vertical"
|
||||
onFinish={submitProductPrice}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#f8fafc',
|
||||
padding: '12px 16px',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
标准商品:
|
||||
</Typography.Text>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: '#0f172a' }}>
|
||||
{pricingProduct.canonicalName}{' '}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>
|
||||
({pricingProduct.canonicalSku})
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: '#64748b' }}>
|
||||
当前生效价格:
|
||||
<strong style={{ color: '#cf1322', marginInlineStart: 4 }}>
|
||||
{pricingProduct.activeCostAmountFen !== null &&
|
||||
pricingProduct.activeCostAmountFen !== undefined
|
||||
? `¥${(pricingProduct.activeCostAmountFen / 100).toFixed(2)}`
|
||||
: '未配置'}
|
||||
</strong>
|
||||
<span style={{ marginInline: 8 }}>·</span>
|
||||
来源:
|
||||
<Tag style={{ marginInlineStart: 4 }}>
|
||||
{pricingProduct.source === 'affiliate_dash'
|
||||
? '自动发货'
|
||||
: pricingProduct.source === 'work_order'
|
||||
? '接单模板 / 工单'
|
||||
: '人工客服'}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label={
|
||||
pricingProduct.source === 'affiliate_dash'
|
||||
? '采购单价(元)'
|
||||
: '标准结算单价(元)'
|
||||
}
|
||||
rules={[{ required: true, message: '请填写价格' }]}
|
||||
extra={
|
||||
pricingProduct.source === 'affiliate_dash'
|
||||
? '上游每发货 1 件扣除的采购成本金额'
|
||||
: '接单打手发单/验收的基础结算单价'
|
||||
}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={0.5}
|
||||
precision={2}
|
||||
addonAfter={`元 / ${pricingProduct.unitName || '件'}`}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="effectiveScope"
|
||||
label="生效时间范围"
|
||||
rules={[{ required: true, message: '请选择生效时间范围' }]}
|
||||
>
|
||||
<Radio.Group style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Radio value="retroactive">
|
||||
<strong>追溯至历史订单(推荐)</strong>
|
||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||
自历史业务起点 (2026-07-10) 生效,直接更新基线并消除历史 cost_missing 异常
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="immediate">
|
||||
<strong>自当前时间立即生效(仅对后续订单生效)</strong>
|
||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||
保留旧版本历史,自新版本创建时刻起计算后续履约结算
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="custom">
|
||||
<strong>指定生效日期</strong>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.effectiveScope !== curr.effectiveScope}>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('effectiveScope') === 'custom' ? (
|
||||
<Form.Item
|
||||
name="effectiveFromCustom"
|
||||
label="指定生效时间"
|
||||
rules={[{ required: true, message: '请填写生效时间' }]}
|
||||
>
|
||||
<Input placeholder="YYYY-MM-DDTHH:mm:ss+08:00" />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="changeReason" label="变更说明 / 备注">
|
||||
<Input placeholder="例如:成本中心统一定价调整 / 上游采购成本调整" />
|
||||
</Form.Item>
|
||||
|
||||
{pricingProduct.canonicalSku.startsWith('tpl:') ? (
|
||||
<Form.Item
|
||||
name="syncOpenOrders"
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Checkbox>
|
||||
<strong>立即同步至接单大厅待抢单工单(推荐)</strong>
|
||||
<div style={{ color: '#64748b', fontSize: 12 }}>
|
||||
接单大厅中所有未领取的该模板工单将立即同步为新价格;已领取的工单保持原价不变。
|
||||
</div>
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
{pricingProduct.source === 'affiliate_dash' ? (
|
||||
<Form.Item
|
||||
name="syncSnapshots"
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Checkbox>
|
||||
<strong>立即重算成本快照并回填历史(推荐)</strong>
|
||||
<div style={{ color: '#64748b', fontSize: 12 }}>
|
||||
自动重新计算历史所有发货任务的成本快照,更新财务数据。
|
||||
</div>
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20 }}>
|
||||
<Button onClick={() => setPriceModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={pricingSubmitting}>
|
||||
保存并生效
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="全系统标准价格已统一收敛至【商品与成本中心】"
|
||||
description={
|
||||
<span>
|
||||
接单单价由商品与成本中心维护并提供同步发单功能。在此处价格为只读展示;如需改价或同步工单价格,请前往
|
||||
<a href="#/admin/cost-center" style={{ marginLeft: 4, fontWeight: 500 }}>
|
||||
【商品与成本中心】
|
||||
</a>
|
||||
进行修改。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 基础信息 */}
|
||||
<div className="form-grid-3col">
|
||||
<Form.Item
|
||||
@@ -967,12 +986,29 @@ export default function ProductRulesPanel() {
|
||||
label="按件单价"
|
||||
name="unitPrice"
|
||||
rules={[{ required: true, message: '请填写单价' }]}
|
||||
extra={editingRule ? '标准单价由商品与成本中心统一管理' : undefined}
|
||||
>
|
||||
<InputNumber min={0.01} step={1} addonAfter="元" className="full-width" />
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
disabled={Boolean(editingRule)}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label="接单金额" name="rewardAmount">
|
||||
<InputNumber min={0} step={1} addonAfter="元" className="full-width" />
|
||||
<Form.Item
|
||||
label="接单金额"
|
||||
name="rewardAmount"
|
||||
extra={editingRule ? '标准单价由商品与成本中心统一管理' : undefined}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
disabled={Boolean(editingRule)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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`, {})
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface ProductCatalogItem {
|
||||
attributes: Record<string, unknown>
|
||||
mappingCount: number
|
||||
costVersionCount: number
|
||||
activeCostAmountFen?: number | null
|
||||
activeCostEffectiveFrom?: string | null
|
||||
/** 自动发货履约任务数,实时取自 fulfillment_tasks。 */
|
||||
affiliateTaskCount: number
|
||||
affiliateLastFulfilledAt: string | null
|
||||
|
||||
Reference in New Issue
Block a user