@@ -170,12 +181,12 @@
Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.
-
+
Aucun sous-composant défini.
+ node: EditableStructureNode
depth?: number
componentTypes?: ModelTypeOption[]
pieceTypes?: ModelTypeOption[]
@@ -281,23 +298,31 @@ const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type)
-const ensureArray = (key: 'customFields' | 'pieces' | 'subComponents') => {
- if (!Array.isArray(props.node[key])) {
- props.node[key] = []
+const ensureArray = (key: 'customFields' | 'pieces' | 'subcomponents') => {
+ if (!Array.isArray((props.node as any)[key])) {
+ if (key === 'subcomponents') {
+ props.node.subcomponents = []
+ } else {
+ (props.node as any)[key] = []
+ }
}
}
-const syncComponentType = (component: any) => {
+const syncComponentType = (component: EditableStructureNode) => {
if (!component) {
return
}
if (props.lockType && props.isRoot) {
if (props.lockedTypeLabel) {
component.typeComposantLabel = props.lockedTypeLabel
- if (!component.name || component.name === component.typeComposantLabel) {
- component.name = props.lockedTypeLabel
+ if (!component.alias || component.alias === component.typeComposantLabel) {
+ component.alias = props.lockedTypeLabel
}
}
+ if (component.typeComposantId) {
+ const option = componentTypeMap.value.get(component.typeComposantId)
+ component.familyCode = option?.code ?? component.familyCode
+ }
return
}
const id = typeof component.typeComposantId === 'string'
@@ -306,29 +331,31 @@ const syncComponentType = (component: any) => {
if (!id) {
component.typeComposantLabel = ''
- component.name = ''
+ component.familyCode = ''
return
}
const option = componentTypeMap.value.get(id)
if (!option) {
component.typeComposantLabel = ''
- component.name = ''
+ component.familyCode = ''
return
}
component.typeComposantLabel = formatModelTypeOption(option)
- component.name = option.name || component.typeComposantLabel
+ component.familyCode = option.code ?? component.familyCode
+ if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
+ component.alias = option.name || component.typeComposantLabel
+ }
}
-const updatePieceTypeLabel = (piece: any) => {
+const updatePieceTypeLabel = (piece: ComponentModelPiece & Record) => {
if (!piece) return
if (piece.typePieceId) {
const option = pieceTypeMap.value.get(piece.typePieceId)
if (option) {
piece.typePieceLabel = formatPieceTypeOption(option)
- piece.name = option.name || formatPieceTypeOption(option)
return
}
}
@@ -345,15 +372,10 @@ const updatePieceTypeLabel = (piece: any) => {
if (match) {
piece.typePieceId = match.id
piece.typePieceLabel = formatPieceTypeOption(match)
- piece.name = match.name || formatPieceTypeOption(match)
return
}
}
}
-
- if (!piece.name) {
- piece.name = piece.typePieceLabel || ''
- }
}
const syncPieceLabels = (pieces?: any[]) => {
@@ -369,25 +391,22 @@ const handleComponentTypeSelect = (component: any) => {
syncComponentType(component)
}
-const handlePieceTypeSelect = (piece: any) => {
+const handlePieceTypeSelect = (piece: ComponentModelPiece & Record) => {
if (!piece) {
return
}
const id = typeof piece.typePieceId === 'string' ? piece.typePieceId : ''
if (!id) {
piece.typePieceLabel = ''
- piece.name = ''
return
}
const option = pieceTypeMap.value.get(id)
if (!option) {
piece.typePieceId = ''
piece.typePieceLabel = ''
- piece.name = ''
return
}
piece.typePieceLabel = formatPieceTypeOption(option)
- piece.name = option.name || piece.typePieceLabel
}
const addCustomField = () => {
@@ -409,9 +428,9 @@ const removeCustomField = (index: number) => {
const addPiece = () => {
ensureArray('pieces')
props.node.pieces.push({
- name: '',
typePieceId: '',
typePieceLabel: '',
+ reference: '',
})
}
@@ -421,17 +440,20 @@ const removePiece = (index: number) => {
}
const addSubComponent = () => {
- ensureArray('subComponents')
- props.node.subComponents.push({
- name: '',
+ ensureArray('subcomponents')
+ props.node.subcomponents.push({
typeComposantId: '',
typeComposantLabel: '',
+ modelId: '',
+ familyCode: '',
+ alias: '',
+ subcomponents: [],
})
}
const removeSubComponent = (index: number) => {
- if (!Array.isArray(props.node.subComponents)) return
- props.node.subComponents.splice(index, 1)
+ if (!Array.isArray(props.node.subcomponents)) return
+ props.node.subcomponents.splice(index, 1)
}
watch(componentTypes, () => {
@@ -463,8 +485,12 @@ watch(
if (props.lockType && props.isRoot) {
const label = props.lockedTypeLabel || lockedTypeDisplay.value
props.node.typeComposantLabel = label
- if (label && (!props.node.name || props.node.name === lockedTypeDisplay.value)) {
- props.node.name = label
+ if (label && (!props.node.alias || props.node.alias === lockedTypeDisplay.value)) {
+ props.node.alias = label
+ }
+ if (props.node.typeComposantId) {
+ const option = componentTypeMap.value.get(props.node.typeComposantId)
+ props.node.familyCode = option?.code ?? props.node.familyCode
}
}
},
diff --git a/app/pages/component-catalog.vue b/app/pages/component-catalog.vue
index a678a18..8efd920 100644
--- a/app/pages/component-catalog.vue
+++ b/app/pages/component-catalog.vue
@@ -214,6 +214,7 @@ import {
cloneStructure,
normalizeStructureForSave,
} from '~/shared/modelUtils'
+import { componentModelStructureValidator } from '~/shared/types/inventory'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideLayers from '~icons/lucide/layers'
@@ -328,12 +329,19 @@ const handleSubmit = async () => {
form.submitting = true
try {
+ const normalizedStructure = normalizeStructureForSave(form.data.structure)
+ const validationResult = componentModelStructureValidator.safeParse(normalizedStructure)
+ if (!validationResult.success) {
+ showError(`Structure invalide: ${validationResult.issues.join(', ')}`)
+ return
+ }
+
if (form.mode === 'create') {
const result = await createComponentModel({
name: form.data.name.trim(),
description: form.data.description.trim() || undefined,
typeComposantId: form.data.typeComposantId,
- structure: normalizeStructureForSave(form.data.structure),
+ structure: normalizedStructure,
})
if (!result.success) {
showError(result.error || 'Impossible de créer le modèle')
@@ -345,7 +353,7 @@ const handleSubmit = async () => {
name: form.data.name.trim(),
description: form.data.description.trim() || undefined,
typeComposantId: form.data.typeComposantId,
- structure: normalizeStructureForSave(form.data.structure),
+ structure: normalizedStructure,
})
if (!result.success) {
showError(result.error || 'Impossible de mettre à jour le modèle')
diff --git a/app/shared/modelUtils.ts b/app/shared/modelUtils.ts
index 2760008..6f2dc51 100644
--- a/app/shared/modelUtils.ts
+++ b/app/shared/modelUtils.ts
@@ -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 => {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
@@ -5,24 +13,50 @@ export const isPlainObject = (value: unknown): value is Record
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 = { 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 = { 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 = {
- 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(' • ')
}
diff --git a/app/shared/types/inventory.ts b/app/shared/types/inventory.ts
new file mode 100644
index 0000000..0f5f0fe
--- /dev/null
+++ b/app/shared/types/inventory.ts
@@ -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 => {
+ 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: [],
+})