refactor: 拆分包材模板与成本规则

This commit is contained in:
Codex 2026-04-03 13:43:36 +08:00
parent 595b6717da
commit 0769957b0c
3 changed files with 308 additions and 30 deletions

View File

@ -1,31 +1,23 @@
/**
* 包材资料库 V1 分类常量与模板定义
* Packaging material catalog
*
* 说明:
* - 一级分类:固定大类
* - 二级分类:业务品类(AssetFamily)
* - 模板类型:包材模板定义,用于决定可扩展字段
* Notes:
* - 一级分类: 固定大类
* - 二级分类: 业务分类(AssetFamily)
* - 模板类型: 系统预设模板,用于控制字段和成本公式
*/
/* ============================================================
* 一级分类(固定,不允许随意新增)
* ============================================================ */
export const LEVEL1_CATEGORIES = [
{ key: 'outer', label: '外包装类', icon: '📦' },
{ key: 'cushion', label: '内缓冲防护类', icon: '🛡️' },
{ key: 'surface', label: '表面保护类', icon: '🧴' },
{ key: 'surface', label: '表面保护类', icon: '🧵' },
{ key: 'structural', label: '结构固定与填充类', icon: '🧱' },
{ key: 'accessory', label: '包装辅料与标识类', icon: '🏷️' },
{ key: 'other', label: '其他', icon: '🧰' },
{ key: 'other', label: '其他', icon: '📎' },
] as const
export type Level1CategoryKey = (typeof LEVEL1_CATEGORIES)[number]['key']
/* ============================================================
* 模板字段定义
* ============================================================ */
export type FieldType = 'text' | 'number' | 'boolean' | 'select'
export interface FieldTemplate {
@ -44,6 +36,8 @@ export interface Level2CategoryDefinition {
fields: FieldTemplate[]
}
// Legacy template definitions kept for backward compatibility while the new
// template-driven workflow is introduced.
export const LEVEL2_CATEGORIES: Level2CategoryDefinition[] = [
{
key: 'carton',
@ -149,9 +143,7 @@ export const LEVEL2_CATEGORIES: Level2CategoryDefinition[] = [
key: 'tissuePaper',
label: '拷贝纸',
level1: 'surface',
fields: [
{ key: 'gramWeight', label: '克重', type: 'number', unit: 'g/m²' },
],
fields: [{ key: 'gramWeight', label: '克重', type: 'number', unit: 'g/m²' }],
},
{
key: 'feltPad',
@ -250,10 +242,6 @@ export const LEVEL2_BY_LEVEL1: Record<Level1CategoryKey, Level2CategoryDefinitio
{} as Record<Level1CategoryKey, Level2CategoryDefinition[]>,
)
/* ============================================================
* 通用规格字段
* ============================================================ */
export const COMMON_SPEC_FIELDS: FieldTemplate[] = [
{ key: 'length', label: '长', type: 'number', unit: 'mm' },
{ key: 'width', label: '宽', type: 'number', unit: 'mm' },
@ -263,10 +251,6 @@ export const COMMON_SPEC_FIELDS: FieldTemplate[] = [
{ key: 'materialType', label: '材质', type: 'text' },
]
/* ============================================================
* 基础下拉项
* ============================================================ */
export const UNIT_OPTIONS = [
{ value: 'piece', label: '个' },
{ value: 'set', label: '套' },
@ -283,10 +267,6 @@ export const MATERIAL_STATUS_OPTIONS = [
{ value: 'DISABLED', label: '停用中', tone: 'warning' as const },
]
/* ============================================================
* 工具函数
* ============================================================ */
export function getLevel1FromFamily(
familyMetadata: Record<string, unknown> | null,
): Level1CategoryKey | null {
@ -316,3 +296,148 @@ export function getLevel2Label(key: string | null): string {
if (!key) return '未分类'
return LEVEL2_MAP.get(key)?.label ?? key
}
export type PackagingTemplateKey =
| 'carton'
| 'epeProfile'
| 'epeSheet'
| 'paperCorner'
| 'peBag'
| 'stretchFilm'
| 'strappingBand'
| 'adhesiveTape'
export type PricingMethodKey =
| 'by_area'
| 'by_volume'
| 'by_length'
| 'by_piece'
| 'by_roll'
export interface PackagingTemplateDefinition {
key: PackagingTemplateKey
label: string
level1: Level1CategoryKey
pricingMethod: PricingMethodKey
pricingFormula: string
fields: FieldTemplate[]
}
export const PACKAGING_TEMPLATES: PackagingTemplateDefinition[] = [
{
key: 'carton',
label: '纸箱',
level1: 'outer',
pricingMethod: 'by_area',
pricingFormula: '计价长 × 计价宽 ÷ 1000000 × 平方米单价',
fields: [
{ key: 'pricingLengthMm', label: '展开计价长', type: 'number', unit: 'mm', required: true },
{ key: 'pricingWidthMm', label: '展开计价宽', type: 'number', unit: 'mm', required: true },
{ key: 'squareMeterPrice', label: '平方米单价', type: 'number', required: true },
{ key: 'fluteType', label: '楞型', type: 'select', options: ['A', 'B', 'C', 'E', 'AB', 'BC'] },
{ key: 'layerCount', label: '层数', type: 'number', required: true },
],
},
{
key: 'epeProfile',
label: 'EPE异形泡沫',
level1: 'cushion',
pricingMethod: 'by_piece',
pricingFormula: '最终成本 = 单件采购价',
fields: [
{ key: 'unitPrice', label: '单件采购价', type: 'number', required: true },
{ key: 'density', label: '密度', type: 'number' },
{ key: 'isPaired', label: '是否成套', type: 'boolean' },
{ key: 'leftRightNote', label: '左右件说明', type: 'text' },
],
},
{
key: 'epeSheet',
label: 'EPE板 / 片材',
level1: 'cushion',
pricingMethod: 'by_volume',
pricingFormula: '长 × 宽 × 厚度 ÷ 1000000000 × 立方单价',
fields: [
{ key: 'lengthMm', label: '长', type: 'number', unit: 'mm', required: true },
{ key: 'widthMm', label: '宽', type: 'number', unit: 'mm', required: true },
{ key: 'thicknessMm', label: '厚度', type: 'number', unit: 'mm', required: true },
{ key: 'pricePerCubicMeter', label: '立方单价', type: 'number', required: true },
{ key: 'density', label: '密度', type: 'number' },
],
},
{
key: 'paperCorner',
label: '纸护角',
level1: 'structural',
pricingMethod: 'by_length',
pricingFormula: '长度 ÷ 1000 × 每米单价',
fields: [
{ key: 'cornerLengthMm', label: '单支长度', type: 'number', unit: 'mm', required: true },
{ key: 'edgeWidthMm', label: '边宽', type: 'number', unit: 'mm', required: true },
{ key: 'cornerThicknessMm', label: '厚度', type: 'number', unit: 'mm', required: true },
{ key: 'pricePerMeter', label: '每米单价', type: 'number', required: true },
],
},
{
key: 'peBag',
label: '套袋 / PE袋',
level1: 'outer',
pricingMethod: 'by_piece',
pricingFormula: '最终成本 = 单个单价',
fields: [
{ key: 'unitPrice', label: '单个单价', type: 'number', required: true },
{ key: 'sealMethod', label: '封口方式', type: 'text' },
{ key: 'printNote', label: '印刷说明', type: 'text' },
],
},
{
key: 'stretchFilm',
label: '缠绕膜',
level1: 'outer',
pricingMethod: 'by_roll',
pricingFormula: '最终成本 = 单卷单价',
fields: [
{ key: 'rollPrice', label: '单卷单价', type: 'number', required: true },
{ key: 'filmWidthMm', label: '膜宽', type: 'number', unit: 'mm' },
{ key: 'filmThicknessUm', label: '膜厚', type: 'number', unit: 'um' },
{ key: 'rollLengthM', label: '单卷长度', type: 'number', unit: 'm' },
],
},
{
key: 'strappingBand',
label: '打包带',
level1: 'structural',
pricingMethod: 'by_length',
pricingFormula: '长度 ÷ 1000 × 每米单价',
fields: [
{ key: 'usageLengthMm', label: '计价长度', type: 'number', unit: 'mm', required: true },
{ key: 'bandWidthMm', label: '带宽', type: 'number', unit: 'mm', required: true },
{ key: 'pricePerMeter', label: '每米单价', type: 'number', required: true },
{ key: 'bandMaterial', label: '材质', type: 'text' },
],
},
{
key: 'adhesiveTape',
label: '胶带',
level1: 'accessory',
pricingMethod: 'by_roll',
pricingFormula: '最终成本 = 单卷单价',
fields: [
{ key: 'rollPrice', label: '单卷单价', type: 'number', required: true },
{ key: 'tapeWidthMm', label: '宽度', type: 'number', unit: 'mm', required: true },
{ key: 'rollLengthM', label: '单卷长度', type: 'number', unit: 'm', required: true },
{ key: 'tapeType', label: '胶带类型', type: 'text' },
],
},
]
export const PACKAGING_TEMPLATE_MAP = new Map(
PACKAGING_TEMPLATES.map((template) => [template.key, template]),
)
export function isTemplateAllowedForLevel1(
level1: Level1CategoryKey,
templateType: PackagingTemplateKey,
): boolean {
return PACKAGING_TEMPLATE_MAP.get(templateType)?.level1 === level1
}

View File

@ -0,0 +1,54 @@
import { computePackagingMaterialCost, validatePackagingMaterialDraft } from './pricing'
describe('packaging material pricing', () => {
it('computes carton cost from area pricing fields', () => {
expect(
computePackagingMaterialCost('carton', {
pricingLengthMm: '2000',
pricingWidthMm: '1000',
squareMeterPrice: '3',
}),
).toBe(6)
})
it('computes paper corner cost from length pricing fields', () => {
expect(
computePackagingMaterialCost('paperCorner', {
cornerLengthMm: '1800',
pricePerMeter: '1.5',
}),
).toBe(2.7)
})
it('rejects available materials when template does not match level1 category', () => {
const result = validatePackagingMaterialDraft({
code: 'FOAM-001',
name: '顶部泡沫',
level1: 'outer',
templateType: 'epeProfile',
status: 'AVAILABLE',
dimensions: {},
templateFields: {
unitPrice: '5.2',
},
costPrice: '',
})
expect(result.errors).toContain('模板类型与一级分类不匹配')
})
it('requires a cost before a material can be enabled', () => {
const result = validatePackagingMaterialDraft({
code: 'CTN-001',
name: '纸箱 001',
level1: 'outer',
templateType: 'carton',
status: 'AVAILABLE',
dimensions: {},
templateFields: {},
costPrice: '',
})
expect(result.errors).toContain('启用前必须补齐成本')
})
})

View File

@ -0,0 +1,99 @@
import {
isTemplateAllowedForLevel1,
type Level1CategoryKey,
type PackagingTemplateKey,
} from './catalog'
function readPositive(value: string | undefined): number | null {
if (!value?.trim()) return null
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
export function computePackagingMaterialCost(
templateType: PackagingTemplateKey,
templateFields: Record<string, string>,
): number | null {
switch (templateType) {
case 'carton': {
const length = readPositive(templateFields.pricingLengthMm)
const width = readPositive(templateFields.pricingWidthMm)
const price = readPositive(templateFields.squareMeterPrice)
return length && width && price ? (length * width * price) / 1_000_000 : null
}
case 'epeSheet': {
const length = readPositive(templateFields.lengthMm)
const width = readPositive(templateFields.widthMm)
const thickness = readPositive(templateFields.thicknessMm)
const price = readPositive(templateFields.pricePerCubicMeter)
return length && width && thickness && price
? (length * width * thickness * price) / 1_000_000_000
: null
}
case 'paperCorner': {
const length = readPositive(templateFields.cornerLengthMm)
const price = readPositive(templateFields.pricePerMeter)
return length && price ? (length / 1000) * price : null
}
case 'strappingBand': {
const length = readPositive(templateFields.usageLengthMm)
const price = readPositive(templateFields.pricePerMeter)
return length && price ? (length / 1000) * price : null
}
case 'epeProfile':
case 'peBag':
return readPositive(templateFields.unitPrice)
case 'stretchFilm':
case 'adhesiveTape':
return readPositive(templateFields.rollPrice)
default:
return null
}
}
export function validatePackagingMaterialDraft(args: {
code: string
name: string
level1: Level1CategoryKey
templateType: PackagingTemplateKey | ''
status: string
dimensions: Record<string, string>
templateFields: Record<string, string>
costPrice: string
}): { errors: string[]; computedCost: number | null } {
const errors: string[] = []
const computedCost = args.templateType
? computePackagingMaterialCost(args.templateType, args.templateFields)
: null
if (!args.code.trim()) {
errors.push('包材编码不能为空')
}
if (!args.name.trim()) {
errors.push('包材名称不能为空')
}
if (!args.templateType) {
errors.push('请选择模板类型')
}
if (args.templateType && !isTemplateAllowedForLevel1(args.level1, args.templateType)) {
errors.push('模板类型与一级分类不匹配')
}
if (args.status === 'AVAILABLE' && computedCost == null && !readPositive(args.costPrice)) {
errors.push('启用前必须补齐成本')
}
return {
errors,
computedCost,
}
}