refactor: 拆分包材模板与成本规则
This commit is contained in:
parent
595b6717da
commit
0769957b0c
@ -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
|
||||
}
|
||||
|
||||
@ -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('启用前必须补齐成本')
|
||||
})
|
||||
})
|
||||
99
furniture-3d-system/src/lib/packaging-materials/pricing.ts
Normal file
99
furniture-3d-system/src/lib/packaging-materials/pricing.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user