refactor: adopt canonical component model structure schema

This commit is contained in:
MatthieuTD
2025-10-01 14:26:31 +02:00
parent d3f8ac3649
commit 386a1c9d1b
9 changed files with 470 additions and 146 deletions

View File

@@ -1,3 +1,11 @@
import {
createEmptyComponentModelStructure,
type ComponentModelCustomField,
type ComponentModelPiece,
type ComponentModelStructure,
type ComponentModelStructureNode,
} from './types/inventory'
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
@@ -5,24 +13,50 @@ export const isPlainObject = (value: unknown): value is Record<string, unknown>
export interface ModelStructurePreview {
customFields: number
pieces: number
subComponents: number
subcomponents: number
}
export const defaultStructure = () => ({
customFields: [],
pieces: [],
subComponents: [],
export const defaultStructure = (): ComponentModelStructure => ({
...createEmptyComponentModelStructure(),
})
export const cloneStructure = (input: any) => {
const ensureStructureShape = (input: any): ComponentModelStructure => {
const base = createEmptyComponentModelStructure()
if (!isPlainObject(input)) {
return base
}
const clone: ComponentModelStructure = {
...base,
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
pieces: Array.isArray((input as any).pieces) ? (input as any).pieces : [],
subcomponents: Array.isArray((input as any).subcomponents)
? (input as any).subcomponents
: Array.isArray((input as any).subComponents)
? (input as any).subComponents
: [],
typeComposantId: typeof (input as any).typeComposantId === 'string' ? (input as any).typeComposantId : undefined,
typeComposantLabel: typeof (input as any).typeComposantLabel === 'string'
? (input as any).typeComposantLabel
: undefined,
modelId: typeof (input as any).modelId === 'string' ? (input as any).modelId : undefined,
familyCode: typeof (input as any).familyCode === 'string' ? (input as any).familyCode : undefined,
alias: typeof (input as any).alias === 'string' ? (input as any).alias : undefined,
}
return clone
}
export const cloneStructure = (input: any): ComponentModelStructure => {
try {
return JSON.parse(JSON.stringify(input ?? defaultStructure()))
const cloned = JSON.parse(JSON.stringify(input ?? defaultStructure()))
return ensureStructureShape(cloned)
} catch (error) {
return defaultStructure()
}
}
const sanitizeCustomFields = (fields: any[]): any[] => {
const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
if (!Array.isArray(fields)) {
return []
}
@@ -51,16 +85,16 @@ const sanitizeCustomFields = (fields: any[]): any[] => {
options = parsed.length > 0 ? parsed : undefined
}
const result: Record<string, unknown> = { name, type, required }
const result: ComponentModelCustomField = { name, type, required }
if (options) {
result.options = options
}
return result
})
.filter(Boolean)
.filter((field): field is ComponentModelCustomField => !!field)
}
const sanitizePieces = (pieces: any[]): any[] => {
const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) {
return []
}
@@ -81,26 +115,18 @@ const sanitizePieces = (pieces: any[]): any[] => {
: ''
const typePieceLabel = rawTypePieceLabel.length > 0 ? rawTypePieceLabel : undefined
const rawName = typeof piece?.name === 'string' ? piece.name.trim() : ''
const name = rawName || typePieceLabel || ''
if (!name) {
return null
}
const reference = typeof piece?.reference === 'string' && piece.reference.trim().length > 0
? piece.reference.trim()
: undefined
const quantity = Number(piece?.quantity)
const normalizedQuantity = Number.isFinite(quantity) && quantity > 0 ? quantity : undefined
if (!typePieceId && !typePieceLabel && !reference) {
return null
}
const result: Record<string, unknown> = { name }
const result: ComponentModelPiece = {}
if (reference !== undefined) {
result.reference = reference
}
if (normalizedQuantity !== undefined) {
result.quantity = normalizedQuantity
}
if (typePieceId) {
result.typePieceId = typePieceId
}
@@ -109,10 +135,10 @@ const sanitizePieces = (pieces: any[]): any[] => {
}
return result
})
.filter(Boolean)
.filter((piece): piece is ComponentModelPiece => !!piece)
}
const sanitizeSubComponents = (components: any[]): any[] => {
const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
}
@@ -126,63 +152,68 @@ const sanitizeSubComponents = (components: any[]): any[] => {
: ''
const typeComposantId = rawTypeComposantId.length > 0 ? rawTypeComposantId : undefined
const rawTypeComposantLabel = typeof component?.typeComposantLabel === 'string'
const modelId = typeof component?.modelId === 'string' && component.modelId.trim().length > 0
? component.modelId.trim()
: undefined
const familyCode = typeof component?.familyCode === 'string' && component.familyCode.trim().length > 0
? component.familyCode.trim()
: undefined
const alias = typeof component?.alias === 'string' && component.alias.trim().length > 0
? component.alias.trim()
: undefined
if (!typeComposantId && !modelId && !familyCode) {
return null
}
const result: ComponentModelStructureNode = {
subcomponents: sanitizeSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}
if (typeComposantId) {
result.typeComposantId = typeComposantId
}
const typeComposantLabel = typeof component?.typeComposantLabel === 'string'
? component.typeComposantLabel.trim()
: typeof component?.typeComposant?.name === 'string'
? component.typeComposant.name.trim()
: ''
const typeComposantLabel = rawTypeComposantLabel.length > 0 ? rawTypeComposantLabel : undefined
const rawName = typeof component?.name === 'string' ? component.name.trim() : ''
const name = rawName || typeComposantLabel || ''
if (!name) {
return null
}
const description = typeof component?.description === 'string' && component.description.trim().length > 0
? component.description.trim()
: undefined
const quantity = Number(component?.quantity)
const normalizedQuantity = Number.isFinite(quantity) && quantity > 0 ? quantity : undefined
const result: Record<string, unknown> = {
name,
}
if (description !== undefined) {
result.description = description
}
if (normalizedQuantity !== undefined) {
result.quantity = normalizedQuantity
}
if (typeComposantId) {
result.typeComposantId = typeComposantId
}
if (typeComposantLabel) {
result.typeComposantLabel = typeComposantLabel
}
const nestedSubComponents = sanitizeSubComponents(component?.subComponents)
if (nestedSubComponents.length > 0) {
result.subComponents = nestedSubComponents
} else {
result.subComponents = []
if (modelId) {
result.modelId = modelId
}
if (familyCode) {
result.familyCode = familyCode
}
if (alias) {
result.alias = alias
}
// Sub components only carry structural info (they will be resolved via their own models)
return result
})
.filter(Boolean)
.filter((component): component is ComponentModelStructureNode => !!component)
}
export const normalizeStructureForSave = (input: any) => {
export const normalizeStructureForSave = (input: any): ComponentModelStructure => {
const source = cloneStructure(input)
return {
customFields: sanitizeCustomFields(source.customFields),
pieces: sanitizePieces(source.pieces),
subComponents: sanitizeSubComponents(source.subComponents),
subcomponents: sanitizeSubcomponents(source.subcomponents),
typeComposantId: source.typeComposantId,
typeComposantLabel: source.typeComposantLabel,
modelId: source.modelId,
familyCode: source.familyCode,
alias: source.alias,
}
}
@@ -200,43 +231,50 @@ const hydrateCustomFields = (fields: any[]): any[] => {
}))
}
const hydratePieces = (pieces: any[]): any[] => {
const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) {
return []
}
return pieces.map((piece) => ({
name: piece?.name ?? piece?.typePiece?.name ?? piece?.typePieceLabel ?? '',
reference: piece?.reference ?? '',
quantity: piece?.quantity ?? piece?.quantite ?? undefined,
typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '',
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
reference: piece?.reference ?? '',
}))
}
const hydrateSubComponents = (components: any[]): any[] => {
const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
}
return components.map((component) => ({
name: component?.name ?? component?.typeComposant?.name ?? component?.typeComposantLabel ?? '',
description: component?.description ?? '',
quantity: component?.quantity ?? component?.quantite ?? undefined,
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '',
customFields: [],
pieces: [],
subComponents: hydrateSubComponents(component?.subComponents),
modelId: component?.modelId ?? '',
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
alias: component?.alias ?? component?.name ?? '',
subcomponents: hydrateSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}))
}
export const hydrateStructureForEditor = (input: any) => {
export const hydrateStructureForEditor = (input: any): ComponentModelStructure => {
const source = cloneStructure(input)
return {
customFields: hydrateCustomFields(source.customFields),
pieces: hydratePieces(source.pieces),
subComponents: hydrateSubComponents(source.subComponents),
subcomponents: hydrateSubcomponents(
Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents,
),
typeComposantId: source.typeComposantId ?? '',
typeComposantLabel: source.typeComposantLabel ?? '',
modelId: source.modelId ?? '',
familyCode: source.familyCode ?? '',
alias: source.alias ?? '',
}
}
@@ -263,32 +301,32 @@ const mapComponentCustomFields = (fields: any[]) => {
}))
}
const mapComponentPieces = (pieces: any[]) => {
const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) {
return []
}
return pieces.map((piece) => ({
name: piece?.name ?? piece?.typePiece?.name ?? '',
reference: piece?.reference ?? '',
quantity: piece?.quantity ?? piece?.quantite ?? undefined,
typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '',
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
}))
}
const mapSubComponents = (components: any[]): any[] => {
const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
}
return components.map((component) => ({
name: component?.name ?? component?.typeComposant?.name ?? '',
description: component?.description ?? '',
quantity: component?.quantity ?? component?.quantite ?? undefined,
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '',
customFields: [],
pieces: [],
subComponents: mapSubComponents(component?.subComponents),
modelId: component?.modelId ?? '',
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
alias: component?.alias ?? component?.name ?? '',
subcomponents: mapSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}))
}
@@ -300,7 +338,16 @@ export const extractStructureFromComponent = (component: any) => {
const raw = {
customFields: mapComponentCustomFields(component.customFields),
pieces: mapComponentPieces(component.pieces),
subComponents: mapSubComponents(component.subComponents),
subcomponents: mapSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposant?.name ?? component?.typeComposantLabel ?? '',
modelId: component?.modelId ?? '',
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
alias: component?.alias ?? component?.name ?? '',
}
return normalizeStructureForSave(raw)
@@ -308,25 +355,29 @@ export const extractStructureFromComponent = (component: any) => {
export const computeStructureStats = (structure: any): ModelStructurePreview => {
if (!structure || typeof structure !== 'object') {
return { customFields: 0, pieces: 0, subComponents: 0 }
return { customFields: 0, pieces: 0, subcomponents: 0 }
}
return {
customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0,
pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0,
subComponents: Array.isArray(structure.subComponents) ? structure.subComponents.length : 0,
subcomponents: Array.isArray(structure.subcomponents)
? structure.subcomponents.length
: Array.isArray(structure.subComponents)
? structure.subComponents.length
: 0,
}
}
export const formatStructurePreview = (structure: any) => {
const stats = computeStructureStats(structure)
if (!stats.customFields && !stats.pieces && !stats.subComponents) {
if (!stats.customFields && !stats.pieces && !stats.subcomponents) {
return 'Structure vide'
}
const segments: string[] = []
if (stats.customFields) segments.push(`${stats.customFields} champ(s)`)
if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`)
if (stats.subComponents) segments.push(`${stats.subComponents} sous-composant(s)`)
if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`)
return segments.join(' • ')
}

View File

@@ -0,0 +1,221 @@
export type ComponentModelCustomFieldType = 'text' | 'number' | 'select' | 'boolean' | 'date'
export interface ComponentModelCustomField {
name: string
type: ComponentModelCustomFieldType
required: boolean
options?: string[]
}
export interface ComponentModelPiece {
typePieceId?: string
typePieceLabel?: string
reference?: string
}
export interface ComponentModelStructureNode {
typeComposantId?: string
typeComposantLabel?: string
modelId?: string
familyCode?: string
alias?: string
subcomponents: ComponentModelStructureNode[]
}
export interface ComponentModelStructure extends ComponentModelStructureNode {
customFields: ComponentModelCustomField[]
pieces: ComponentModelPiece[]
}
const FIELD_TYPES: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0
const ensureString = (value: unknown): string | undefined => {
if (!isNonEmptyString(value)) {
return undefined
}
return value.trim()
}
const validateCustomField = (
value: unknown,
issues: string[],
path: string,
): ComponentModelCustomField | null => {
if (!isPlainObject(value)) {
issues.push(`${path}: expected object`)
return null
}
const name = ensureString(value.name)
if (!name) {
issues.push(`${path}.name: valeur manquante`)
return null
}
const type = isNonEmptyString(value.type) && FIELD_TYPES.includes(value.type as ComponentModelCustomFieldType)
? (value.type as ComponentModelCustomFieldType)
: 'text'
const required = !!value.required
let options: string[] | undefined
if (value.options !== undefined) {
if (!Array.isArray(value.options)) {
issues.push(`${path}.options: attendu tableau`)
} else {
const parsed = value.options.map(ensureString).filter((option): option is string => !!option)
if (parsed.length) {
options = parsed
}
}
}
return { name, type, required, ...(options ? { options } : {}) }
}
const validatePiece = (
value: unknown,
issues: string[],
path: string,
): ComponentModelPiece | null => {
if (!isPlainObject(value)) {
issues.push(`${path}: expected object`)
return null
}
const typePieceId = ensureString(value.typePieceId)
const typePieceLabel = ensureString(value.typePieceLabel)
const reference = ensureString(value.reference)
if (!typePieceId && !typePieceLabel && !reference) {
issues.push(`${path}: au moins un identifiant ou libellé de pièce est requis`)
return null
}
return {
...(typePieceId ? { typePieceId } : {}),
...(typePieceLabel ? { typePieceLabel } : {}),
...(reference ? { reference } : {}),
}
}
const validateStructureNode = (
value: unknown,
issues: string[],
path: string,
): ComponentModelStructureNode | null => {
if (!isPlainObject(value)) {
issues.push(`${path}: expected object`)
return null
}
const typeComposantId = ensureString(value.typeComposantId)
const typeComposantLabel = ensureString(value.typeComposantLabel)
const modelId = ensureString(value.modelId)
const familyCode = ensureString(value.familyCode)
const alias = ensureString(value.alias)
const rawSubcomponents = Array.isArray((value as any).subcomponents)
? (value as any).subcomponents
: []
const subcomponents: ComponentModelStructureNode[] = []
rawSubcomponents.forEach((subValue, index) => {
const parsed = validateStructureNode(subValue, issues, `${path}.subcomponents[${index}]`)
if (parsed) {
subcomponents.push(parsed)
}
})
return {
...(typeComposantId ? { typeComposantId } : {}),
...(typeComposantLabel ? { typeComposantLabel } : {}),
...(modelId ? { modelId } : {}),
...(familyCode ? { familyCode } : {}),
...(alias ? { alias } : {}),
subcomponents,
}
}
export interface ComponentModelStructureValidationSuccess {
success: true
data: ComponentModelStructure
}
export interface ComponentModelStructureValidationFailure {
success: false
issues: string[]
}
export type ComponentModelStructureValidationResult =
| ComponentModelStructureValidationSuccess
| ComponentModelStructureValidationFailure
export const componentModelStructureValidator = {
safeParse(value: unknown): ComponentModelStructureValidationResult {
const issues: string[] = []
if (!isPlainObject(value)) {
issues.push('Structure invalide: attendu un objet')
return { success: false, issues }
}
const customFieldsInput = Array.isArray(value.customFields) ? value.customFields : []
const piecesInput = Array.isArray(value.pieces) ? value.pieces : []
const customFields: ComponentModelCustomField[] = []
customFieldsInput.forEach((field, index) => {
const parsed = validateCustomField(field, issues, `customFields[${index}]`)
if (parsed) {
customFields.push(parsed)
}
})
const pieces: ComponentModelPiece[] = []
piecesInput.forEach((piece, index) => {
const parsed = validatePiece(piece, issues, `pieces[${index}]`)
if (parsed) {
pieces.push(parsed)
}
})
const node = validateStructureNode(value, issues, 'structure')
if (!node) {
issues.push('Structure racine invalide')
return { success: false, issues }
}
if (issues.length > 0) {
return { success: false, issues }
}
return {
success: true,
data: {
...node,
customFields,
pieces,
},
}
},
parse(value: unknown): ComponentModelStructure {
const result = this.safeParse(value)
if (!result.success) {
throw new Error(result.issues.join('\n'))
}
return result.data
},
}
export const createEmptyComponentModelStructure = (): ComponentModelStructure => ({
customFields: [],
pieces: [],
subcomponents: [],
})