refacto(F1.1): extract utility modules from machine/[id].vue

Extract ~1300 LOC of reusable logic into dedicated modules:
- shared/utils/customFieldUtils.ts: field normalization, merge, dedup, display
- shared/utils/productDisplayUtils.ts: product resolution and display helpers
- composables/useMachineHierarchy.ts: hierarchy tree builder from links
- composables/useMachinePrint.ts: print selection and execution logic

These extractions prepare the ground for wiring [id].vue to import
from these modules instead of inlining all logic.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-02-03 17:34:33 +01:00
parent 3705b8daed
commit 649f8ca9cc
4 changed files with 1234 additions and 0 deletions

View File

@@ -0,0 +1,292 @@
/**
* Builds a component/piece hierarchy tree from flat machine link arrays.
*
* Extracted from pages/machine/[id].vue to keep the page orchestrator lean.
*/
import { resolveIdentifier, resolveProductReference, getProductDisplay } from '~/shared/utils/productDisplayUtils'
import { resolveConstructeurs, uniqueConstructeurIds } from '~/shared/constructeurUtils'
type AnyRecord = Record<string, unknown>
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const collectConstructeurs = (
allConstructeurs: AnyRecord[],
...sources: unknown[]
): AnyRecord[] => {
const ids = uniqueConstructeurIds(...sources)
if (!ids.length) return []
const pools = sources
.flatMap((source) => {
if (Array.isArray(source)) return [source]
if (source && typeof source === 'object' && (source as AnyRecord).id) return [[source]]
return []
})
.filter(Boolean) as AnyRecord[][]
return resolveConstructeurs(ids, ...pools, allConstructeurs)
}
// ---------------------------------------------------------------------------
// Link array resolution
// ---------------------------------------------------------------------------
export const resolveLinkArray = (source: unknown, keys: string[]): unknown[] | null => {
if (!source || typeof source !== 'object') return null
for (const key of keys) {
const value = (source as AnyRecord)[key]
if (Array.isArray(value)) return value
}
return null
}
// ---------------------------------------------------------------------------
// Merge trees (for incremental updates)
// ---------------------------------------------------------------------------
export function mergePieceLists(existing: AnyRecord[] = [], updates: AnyRecord[] = []): AnyRecord[] {
if (!existing.length) {
return updates.map((piece) => ({ ...piece, constructeurs: piece.constructeurs || [] }))
}
if (!updates.length) {
return existing.map((piece) => ({ ...piece, constructeurs: piece.constructeurs || [] }))
}
const updateMap = new Map(
updates.map((piece) => [piece.id, { ...piece, constructeurs: piece.constructeurs || [] }]),
)
const merged = existing.map((piece) => {
const update = updateMap.get(piece.id)
if (!update) return piece
return { ...piece, ...update, customFields: update.customFields ?? piece.customFields }
})
updates.forEach((update) => {
if (!existing.some((piece) => piece.id === update.id)) merged.push(update)
})
return merged
}
export function mergeComponentTrees(existing: AnyRecord[] = [], updates: AnyRecord[] = []): AnyRecord[] {
if (!existing.length) {
return updates.map((component) => ({
...component,
constructeurs: component.constructeurs || [],
pieces: ((component.pieces || []) as AnyRecord[]).map((piece) => ({
...piece,
constructeurs: piece.constructeurs || [],
})),
subComponents: mergeComponentTrees([], (component.subComponents || []) as AnyRecord[]),
}))
}
if (!updates.length) return existing
const updateMap = new Map(
updates.map((component) => [
component.id,
{
...component,
constructeurs: component.constructeurs || [],
pieces: ((component.pieces || []) as AnyRecord[]).map((piece) => ({
...piece,
constructeurs: piece.constructeurs || [],
})),
subComponents: mergeComponentTrees([], (component.subComponents || []) as AnyRecord[]),
},
]),
)
const merged = existing.map((component) => {
const update = updateMap.get(component.id)
if (!update) {
return {
...component,
constructeurs: component.constructeurs || [],
pieces: mergePieceLists((component.pieces || []) as AnyRecord[], []),
subComponents: mergeComponentTrees((component.subComponents || []) as AnyRecord[], []),
}
}
return {
...component,
...update,
customFields: update.customFields ?? component.customFields,
pieces: mergePieceLists((component.pieces || []) as AnyRecord[], (update.pieces || []) as AnyRecord[]),
subComponents: mergeComponentTrees(
(component.subComponents || []) as AnyRecord[],
(update.subComponents || []) as AnyRecord[],
),
}
})
updates.forEach((update) => {
if (!existing.some((component) => component.id === update.id)) merged.push(update)
})
return merged
}
// ---------------------------------------------------------------------------
// Build hierarchy from links
// ---------------------------------------------------------------------------
export const buildMachineHierarchyFromLinks = (
componentLinks: AnyRecord[] = [],
pieceLinks: AnyRecord[] = [],
findProductById: (id: string) => AnyRecord | null,
allConstructeurs: AnyRecord[] = [],
): { components: AnyRecord[]; machinePieces: AnyRecord[] } => {
const normalizeComponentLinkId = (link: AnyRecord) =>
resolveIdentifier(link?.id, link?.linkId, link?.machineComponentLinkId)
const normalizePieceLinkId = (link: AnyRecord) =>
resolveIdentifier(link?.id, link?.linkId, link?.machinePieceLinkId)
const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => {
if (!link || typeof link !== 'object') return null
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
const requirement = (link.typeMachinePieceRequirement || appliedPiece.typeMachinePieceRequirement || originalPiece?.typeMachinePieceRequirement || null) as AnyRecord | null
const machinePieceLinkId = normalizePieceLinkId(link)
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
const basePiece: AnyRecord = {
...appliedPiece,
id: appliedPiece.id || pieceId || machinePieceLinkId || `piece-${machinePieceLinkId}`,
pieceId,
name: link.overrides?.name || appliedPiece.name || (appliedPiece.definition as AnyRecord)?.name || (appliedPiece.definition as AnyRecord)?.role || originalPiece?.name || 'Pièce',
reference: link.overrides?.reference || appliedPiece.reference || (appliedPiece.definition as AnyRecord)?.reference || originalPiece?.reference || null,
prix: link.overrides?.prix ?? appliedPiece.prix ?? originalPiece?.prix ?? null,
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null,
constructeurId: appliedPiece.constructeurId || (appliedPiece.constructeur as AnyRecord)?.id || originalPiece?.constructeurId || null,
documents: Array.isArray(appliedPiece.documents) ? appliedPiece.documents : Array.isArray(originalPiece?.documents) ? originalPiece!.documents : [],
typePiece: appliedPiece.typePiece || requirement?.typePiece || null,
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
typeMachinePieceRequirement: requirement,
typeMachinePieceRequirementId: requirement?.id || null,
requirementId: requirement?.id || null,
overrides: link.overrides || null,
originalPiece,
machinePieceLink: link,
machinePieceLinkId,
linkId: machinePieceLinkId,
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedPiece.parentComponentLinkId),
parentComponentId: resolveIdentifier(appliedPiece.parentComponentId, link.parentComponentId),
parentComponentName,
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
parentMachineComponentRequirementId: resolveIdentifier(appliedPiece.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
parentMachinePieceRequirementId: resolveIdentifier(appliedPiece.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
definition: appliedPiece.definition || originalPiece?.definition || {},
customFields: appliedPiece.customFields || [],
skeletonOnly: !pieceId,
}
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
const resolvedProduct = (appliedPiece.product || link.product || originalPiece?.product || (resolvedProductId ? findProductById(resolvedProductId) : null) || null) as AnyRecord | null
const constructeurs = collectConstructeurs(allConstructeurs, appliedPiece.constructeurs, appliedPiece.constructeur, appliedPiece.constructeurIds, appliedPiece.constructeurId, originalPiece?.constructeurs, originalPiece?.constructeur, originalPiece?.constructeurIds, originalPiece?.constructeurId)
return {
...basePiece,
constructeurs,
constructeur: constructeurs[0] || basePiece.constructeur || null,
constructeurId: (constructeurs[0] as AnyRecord)?.id || basePiece.constructeurId || null,
productId: resolvedProductId || appliedPiece.productId || null,
product: resolvedProduct || appliedPiece.product || null,
__productDisplay: getProductDisplay({ product: resolvedProduct || appliedPiece.product || null, productId: resolvedProductId || appliedPiece.productId || null } as AnyRecord, findProductById),
}
}
const createComponentNode = (link: AnyRecord): AnyRecord | null => {
if (!link || typeof link !== 'object') return null
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null
const requirement = (link.typeMachineComponentRequirement || appliedComponent.typeMachineComponentRequirement || originalComponent?.typeMachineComponentRequirement || null) as AnyRecord | null
const machineComponentLinkId = normalizeComponentLinkId(link)
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
const componentName = (link.overrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string
const pieces = Array.isArray(link.pieceLinks)
? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[]
: []
const subComponents = Array.isArray(link.childLinks)
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
: []
const resolvedProductId = resolveIdentifier(appliedComponent.productId, (appliedComponent.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalComponent?.productId, (originalComponent?.product as AnyRecord)?.id)
const resolvedProduct = (appliedComponent.product || link.product || originalComponent?.product || (resolvedProductId ? findProductById(resolvedProductId) : null) || null) as AnyRecord | null
const baseComponent: AnyRecord = {
...appliedComponent,
id: appliedComponent.id || composantId || machineComponentLinkId || `component-${machineComponentLinkId}`,
composantId,
name: componentName,
reference: link.overrides?.reference || appliedComponent.reference || (appliedComponent.definition as AnyRecord)?.reference || originalComponent?.reference || null,
prix: link.overrides?.prix ?? appliedComponent.prix ?? originalComponent?.prix ?? null,
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null,
constructeurId: appliedComponent.constructeurId || (appliedComponent.constructeur as AnyRecord)?.id || originalComponent?.constructeurId || null,
documents: Array.isArray(appliedComponent.documents) ? appliedComponent.documents : Array.isArray(originalComponent?.documents) ? originalComponent!.documents : [],
typeComposant: appliedComponent.typeComposant || requirement?.typeComposant || null,
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
typeMachineComponentRequirement: requirement,
typeMachineComponentRequirementId: requirement?.id || null,
requirementId: requirement?.id || null,
overrides: link.overrides || null,
machineComponentLinkOverrides: link.overrides || null,
definitionOverrides: link.overrides || null,
originalComposant: originalComponent,
machineComponentLink: link,
machineComponentLinkId,
componentLinkId: machineComponentLinkId,
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
parentRequirementId: resolveIdentifier(appliedComponent.parentRequirementId, link.parentRequirementId),
parentMachineComponentRequirementId: resolveIdentifier(appliedComponent.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
parentMachinePieceRequirementId: resolveIdentifier(appliedComponent.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
definition: appliedComponent.definition || originalComponent?.definition || {},
customFields: appliedComponent.customFields || [],
pieces,
subComponents,
subcomponents: subComponents,
sousComposants: subComponents,
skeletonOnly: !composantId,
}
const constructeurs = collectConstructeurs(allConstructeurs, appliedComponent.constructeurs, appliedComponent.constructeur, appliedComponent.constructeurIds, appliedComponent.constructeurId, originalComponent?.constructeurs, originalComponent?.constructeur, originalComponent?.constructeurIds, originalComponent?.constructeurId)
return {
...baseComponent,
constructeurs,
constructeur: constructeurs[0] || baseComponent.constructeur || null,
constructeurId: (constructeurs[0] as AnyRecord)?.id || baseComponent.constructeurId || null,
productId: resolvedProductId || appliedComponent.productId || null,
product: resolvedProduct || appliedComponent.product || null,
__productDisplay: getProductDisplay({ product: resolvedProduct || appliedComponent.product || null, productId: resolvedProductId || appliedComponent.productId || null } as AnyRecord, findProductById),
}
}
const rootComponents = (Array.isArray(componentLinks) ? componentLinks : [])
.filter((link) => !resolveIdentifier(link?.parentComponentLinkId, link?.parentLinkId, link?.parentMachineComponentLinkId))
.map(createComponentNode)
.filter(Boolean) as AnyRecord[]
const machinePieces = (Array.isArray(pieceLinks) ? pieceLinks : [])
.filter((link) => !resolveIdentifier(link?.parentComponentLinkId, link?.parentLinkId, link?.parentMachineComponentLinkId))
.map((link) => createPieceNode(link, null))
.filter(Boolean) as AnyRecord[]
return { components: rootComponents, machinePieces }
}

View File

@@ -0,0 +1,163 @@
/**
* Machine print selection and execution logic.
*
* Extracted from pages/machine/[id].vue.
*/
import { ref, reactive, nextTick } from 'vue'
import { buildMachinePrintContext, buildMachinePrintHtml } from '~/utils/printTemplates/machineReport'
import { resolveIdentifier } from '~/shared/utils/productDisplayUtils'
type AnyRecord = Record<string, unknown>
export interface PrintSelection {
machine: { info: boolean; customFields: boolean; documents: boolean }
components: Record<string, boolean>
pieces: Record<string, boolean>
}
export function useMachinePrint() {
const printModalOpen = ref(false)
const printSelection = reactive<PrintSelection>({
machine: { info: true, customFields: true, documents: true },
components: {},
pieces: {},
})
const ensurePrintSelectionEntries = (
components: AnyRecord[],
machinePieces: AnyRecord[],
) => {
printSelection.machine.info ??= true
printSelection.machine.customFields ??= true
printSelection.machine.documents ??= true
const ensureComponent = (component: AnyRecord) => {
if (component?.id !== undefined && printSelection.components[component.id as string] === undefined) {
printSelection.components[component.id as string] = true
}
;((component.pieces || []) as AnyRecord[]).forEach((piece) => {
if (piece?.id !== undefined && printSelection.pieces[piece.id as string] === undefined) {
printSelection.pieces[piece.id as string] = true
}
})
;((component.subComponents || []) as AnyRecord[]).forEach(ensureComponent)
}
components.forEach(ensureComponent)
machinePieces.forEach((piece) => {
if (piece?.id !== undefined && printSelection.pieces[piece.id as string] === undefined) {
printSelection.pieces[piece.id as string] = true
}
})
}
const setAllPrintSelection = (
value: boolean,
components: AnyRecord[],
machinePieces: AnyRecord[],
) => {
ensurePrintSelectionEntries(components, machinePieces)
printSelection.machine.info = value
printSelection.machine.customFields = value
printSelection.machine.documents = value
Object.keys(printSelection.components).forEach((key) => {
printSelection.components[key] = value
})
Object.keys(printSelection.pieces).forEach((key) => {
printSelection.pieces[key] = value
})
}
const openPrintModal = (components: AnyRecord[], machinePieces: AnyRecord[]) => {
ensurePrintSelectionEntries(components, machinePieces)
printModalOpen.value = true
}
const closePrintModal = () => {
printModalOpen.value = false
}
const printMachine = (
machine: AnyRecord,
machineName: string,
machineReference: string,
machinePieces: AnyRecord[],
components: AnyRecord[],
currentSelection: PrintSelection = printSelection,
) => {
if (typeof window === 'undefined') return
const context = buildMachinePrintContext({
machine,
machineName,
machineReference,
machinePieces,
components,
selection: currentSelection,
})
const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style'))
.map((node) => node.outerHTML)
.join('')
const htmlContent = buildMachinePrintHtml(context, styles)
const iframe = document.createElement('iframe')
iframe.style.position = 'fixed'
iframe.style.right = '0'
iframe.style.bottom = '0'
iframe.style.width = '0'
iframe.style.height = '0'
iframe.style.border = '0'
iframe.setAttribute('title', 'print-frame')
document.body.appendChild(iframe)
const iframeWindow = iframe.contentWindow
const iframeDocument = iframe.contentDocument || iframeWindow?.document
if (!iframeDocument || !iframeWindow) {
iframe.remove()
return
}
iframeDocument.open()
iframeDocument.write(htmlContent)
iframeDocument.close()
const triggerPrint = () => {
iframeWindow.focus()
iframeWindow.print()
setTimeout(() => {
iframe.remove()
}, 1000)
}
if (iframeDocument.readyState === 'complete') {
setTimeout(triggerPrint, 50)
} else {
iframe.onload = () => setTimeout(triggerPrint, 50)
}
}
const handlePrintConfirm = async (
machine: AnyRecord,
machineName: string,
machineReference: string,
machinePieces: AnyRecord[],
components: AnyRecord[],
) => {
closePrintModal()
await nextTick()
printMachine(machine, machineName, machineReference, machinePieces, components, printSelection)
}
return {
printModalOpen,
printSelection,
ensurePrintSelectionEntries,
setAllPrintSelection,
openPrintModal,
closePrintModal,
printMachine,
handlePrintConfirm,
}
}

View File

@@ -0,0 +1,426 @@
/**
* Custom field normalization, merging and display utilities.
*
* Extracted from pages/machine/[id].vue to be reusable across
* machine detail, component, piece and product views.
*/
// ---------------------------------------------------------------------------
// Primitive helpers
// ---------------------------------------------------------------------------
export const coerceValueForType = (type: string, rawValue: unknown): string => {
if (rawValue === undefined || rawValue === null || rawValue === '') {
return ''
}
if (type === 'boolean') {
const normalized = String(rawValue).toLowerCase()
if (normalized === 'true' || normalized === '1') return 'true'
if (normalized === 'false' || normalized === '0') return 'false'
return ''
}
return String(rawValue)
}
export const formatCustomFieldValue = (field: Record<string, unknown> | null | undefined): string => {
if (!field) return 'Non défini'
const value = (field.value ?? field.defaultValue ?? '') as string
if (value === '' || value === null || value === undefined) return 'Non défini'
if (field.type === 'boolean') {
const normalized = String(value).toLowerCase()
if (normalized === 'true' || normalized === '1') return 'Oui'
if (normalized === 'false' || normalized === '0') return 'Non'
}
return String(value)
}
export const shouldDisplayCustomField = (field: Record<string, unknown> | null | undefined): boolean => {
if (!field) return false
if (field.readOnly) return true
if (field.type === 'boolean') return field.value !== undefined && field.value !== null
const value = field.value
if (value === null || value === undefined) return false
if (typeof value === 'string') return value.trim().length > 0
return true
}
// ---------------------------------------------------------------------------
// Definition extraction helpers
// ---------------------------------------------------------------------------
export const extractDefinitionName = (definition: Record<string, unknown> = {}): string => {
if (typeof definition?.name === 'string' && (definition.name as string).trim()) {
return (definition.name as string).trim()
}
if (typeof definition?.key === 'string' && (definition.key as string).trim()) {
return (definition.key as string).trim()
}
if (typeof definition?.label === 'string' && (definition.label as string).trim()) {
return (definition.label as string).trim()
}
return ''
}
export const extractDefinitionType = (
definition: Record<string, unknown> = {},
fallback = 'text',
): string => {
const allowed = ['text', 'number', 'select', 'boolean', 'date']
const rawType =
typeof definition?.type === 'string'
? definition.type
: typeof (definition?.value as Record<string, unknown>)?.type === 'string'
? (definition.value as Record<string, unknown>).type as string
: typeof fallback === 'string'
? fallback
: 'text'
const normalized = (rawType as string).toLowerCase()
return allowed.includes(normalized) ? normalized : 'text'
}
export const extractDefinitionRequired = (
definition: Record<string, unknown> = {},
fallback = false,
): boolean => {
if (typeof definition?.required === 'boolean') return definition.required
const nested = (definition?.value as Record<string, unknown>)?.required
if (typeof nested === 'boolean') return nested
if (typeof nested === 'string') {
const normalized = nested.toLowerCase()
if (normalized === 'true' || normalized === '1') return true
if (normalized === 'false' || normalized === '0') return false
}
return !!fallback
}
const extractOptionList = (input: unknown): string[] | undefined => {
if (!Array.isArray(input)) return undefined
const mapped = input
.map((option) => {
if (option === null || option === undefined) return ''
if (typeof option === 'string') return option.trim()
if (typeof option === 'object') {
const record = (option || {}) as Record<string, unknown>
for (const key of ['value', 'label', 'name']) {
const candidate = record[key]
if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim()
}
}
const fallback = String(option).trim()
return fallback === '[object Object]' ? '' : fallback
})
.filter((option) => option.length > 0)
return mapped.length ? mapped : undefined
}
export const extractDefinitionOptions = (definition: Record<string, unknown> = {}): string[] => {
const sources = [
definition?.options,
(definition?.value as Record<string, unknown>)?.options,
(definition?.value as Record<string, unknown>)?.choices,
]
for (const source of sources) {
const list = extractOptionList(source)
if (list) return list
}
return []
}
export const extractDefinitionDefaultValue = (definition: Record<string, unknown> = {}): unknown => {
const candidates = [
definition?.defaultValue,
(definition?.value as Record<string, unknown>)?.defaultValue,
(definition?.value as Record<string, unknown>)?.value,
definition?.value,
definition?.default,
]
for (const candidate of candidates) {
if (candidate === undefined || candidate === null || candidate === '') continue
if (typeof candidate === 'object') {
if (candidate === null) continue
const nestedDefault =
(candidate as Record<string, unknown>).defaultValue !== undefined &&
(candidate as Record<string, unknown>).defaultValue !== null
? (candidate as Record<string, unknown>).defaultValue
: (candidate as Record<string, unknown>).value
if (nestedDefault !== undefined && nestedDefault !== null && nestedDefault !== '') {
return nestedDefault
}
continue
}
return candidate
}
return undefined
}
// ---------------------------------------------------------------------------
// Normalization
// ---------------------------------------------------------------------------
export interface NormalizedCustomFieldDefinition {
id?: string
customFieldId?: string
name: string
type: string
required: boolean
options: string[]
defaultValue?: unknown
readOnly: boolean
orderIndex: number
}
export const normalizeCustomFieldDefinitionEntry = (
definition: Record<string, unknown> = {},
fallbackIndex = 0,
): NormalizedCustomFieldDefinition | null => {
const name = extractDefinitionName(definition)
if (!name) return null
const type = extractDefinitionType(definition)
const required = extractDefinitionRequired(definition)
const options = extractDefinitionOptions(definition)
const defaultValue = extractDefinitionDefaultValue(definition)
const id = typeof definition?.id === 'string' ? definition.id : undefined
const customFieldId = typeof definition?.customFieldId === 'string' ? definition.customFieldId : id
const orderIndex = typeof definition?.orderIndex === 'number' ? definition.orderIndex : fallbackIndex
return { id, customFieldId, name, type, required, options, defaultValue, readOnly: !!definition?.readOnly, orderIndex }
}
export const normalizeExistingCustomFieldDefinitions = (
fields: unknown,
): NormalizedCustomFieldDefinition[] => {
if (!Array.isArray(fields)) return []
return fields
.map((field, index) => normalizeCustomFieldDefinitionEntry(field as Record<string, unknown>, index))
.filter((d): d is NormalizedCustomFieldDefinition => d !== null)
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
}
// ---------------------------------------------------------------------------
// Custom field value normalization
// ---------------------------------------------------------------------------
export const normalizeCustomFieldValueEntry = (entry: Record<string, unknown> = {}): Record<string, unknown> | null => {
if (!entry || typeof entry !== 'object') return null
const normalizedDefinition = normalizeCustomFieldDefinitionEntry(entry)
if (!normalizedDefinition) return null
const value = coerceValueForType(
normalizedDefinition.type,
(entry?.value ?? entry?.defaultValue ?? normalizedDefinition.defaultValue ?? '') as string,
)
return {
id: (entry?.customFieldValueId ?? entry?.id ?? null) as string | null,
customFieldId:
(entry?.customFieldId ?? normalizedDefinition.customFieldId ?? normalizedDefinition.id ?? null) as string | null,
customField: {
id: normalizedDefinition.id ?? normalizedDefinition.customFieldId ?? null,
name: normalizedDefinition.name,
type: normalizedDefinition.type,
required: normalizedDefinition.required,
options: normalizedDefinition.options,
defaultValue: normalizedDefinition.defaultValue ?? '',
},
value,
}
}
// ---------------------------------------------------------------------------
// Merge & dedup
// ---------------------------------------------------------------------------
export const mergeCustomFieldValuesWithDefinitions = (
valueEntries: Record<string, unknown>[] = [],
...definitionSources: unknown[][]
): Record<string, unknown>[] => {
const normalizedValues = (Array.isArray(valueEntries) ? valueEntries : [])
.map((entry) => {
if (!entry || typeof entry !== 'object') return null
const normalizedDefinition = normalizeCustomFieldDefinitionEntry(
((entry as Record<string, unknown>).customField || entry) as Record<string, unknown>,
)
if (!normalizedDefinition) return null
const value = coerceValueForType(
normalizedDefinition.type,
((entry as Record<string, unknown>)?.value ??
(entry as Record<string, unknown>)?.defaultValue ??
normalizedDefinition.defaultValue ??
'') as string,
)
return {
customFieldValueId: (entry as Record<string, unknown>)?.id ?? (entry as Record<string, unknown>)?.customFieldValueId ?? null,
id: normalizedDefinition.id,
customFieldId: normalizedDefinition.customFieldId ?? normalizedDefinition.id ?? null,
name: normalizedDefinition.name,
type: normalizedDefinition.type,
required: normalizedDefinition.required,
options: normalizedDefinition.options,
optionsText: normalizedDefinition.options?.length ? normalizedDefinition.options.join('\n') : '',
defaultValue: normalizedDefinition.defaultValue ?? '',
value,
readOnly: !!(entry as Record<string, unknown>)?.readOnly,
}
})
.filter((entry): entry is Record<string, unknown> => entry !== null)
const result = [...normalizedValues]
const keyFor = (item: Record<string, unknown>) => (item?.id as string) ?? `${item?.name ?? ''}::${item?.type ?? ''}`
const existingMap = new Map<string, Record<string, unknown>>()
result.forEach((item) => {
const key = keyFor(item)
if (key) existingMap.set(key, item)
const fallbackKey = item?.name ? `${item.name}::${item.type ?? ''}` : null
if (fallbackKey) existingMap.set(fallbackKey, item)
})
const definitions = definitionSources
.flatMap((source) => (Array.isArray(source) ? source : []))
.map((definition) => normalizeCustomFieldDefinitionEntry(definition as Record<string, unknown>))
.filter((d): d is NormalizedCustomFieldDefinition => d !== null)
definitions.forEach((normalizedDefinition) => {
const key = normalizedDefinition.id ?? `${normalizedDefinition.name}::${normalizedDefinition.type}`
if (!key) return
if (normalizedDefinition.id) {
const fallbackKey = `${normalizedDefinition.name}::${normalizedDefinition.type}`
if (existingMap.has(fallbackKey)) {
const existingFallback = existingMap.get(fallbackKey)
if (existingFallback) {
existingFallback.id = existingFallback.id || normalizedDefinition.id
existingFallback.customFieldId = normalizedDefinition.id
existingFallback.readOnly = (existingFallback.readOnly as boolean) && normalizedDefinition.readOnly
existingMap.delete(fallbackKey)
existingMap.set(normalizedDefinition.id, existingFallback)
existingMap.set(fallbackKey, existingFallback)
return
}
}
}
const existing =
existingMap.get(key) ||
(normalizedDefinition.name ? existingMap.get(`${normalizedDefinition.name}::${normalizedDefinition.type}`) : null)
if (existing) {
existing.name = existing.name || normalizedDefinition.name
existing.type = existing.type || normalizedDefinition.type
existing.required = (existing.required as boolean) || normalizedDefinition.required
if (!(existing.options as string[])?.length && normalizedDefinition.options?.length) {
existing.options = normalizedDefinition.options
}
if (!existing.defaultValue && normalizedDefinition.defaultValue) {
existing.defaultValue = String(normalizedDefinition.defaultValue)
if (!existing.value) {
existing.value = coerceValueForType(existing.type as string, normalizedDefinition.defaultValue)
}
}
existing.customFieldId = existing.customFieldId || normalizedDefinition.id
existing.readOnly = (existing.readOnly as boolean) && normalizedDefinition.readOnly
if (!existing.optionsText && normalizedDefinition.options?.length) {
existing.optionsText = normalizedDefinition.options.join('\n')
}
if (normalizedDefinition.id) existingMap.set(normalizedDefinition.id, existing)
if (normalizedDefinition.name) {
existingMap.set(`${normalizedDefinition.name}::${normalizedDefinition.type}`, existing)
}
return
}
const entry: Record<string, unknown> = {
customFieldValueId: null,
id: normalizedDefinition.id,
customFieldId: normalizedDefinition.id,
name: normalizedDefinition.name,
type: normalizedDefinition.type,
required: normalizedDefinition.required,
options: normalizedDefinition.options,
optionsText: normalizedDefinition.options?.length ? normalizedDefinition.options.join('\n') : '',
defaultValue: normalizedDefinition.defaultValue ?? '',
value: coerceValueForType(normalizedDefinition.type, (normalizedDefinition.defaultValue ?? '') as string),
readOnly: false,
}
result.push(entry)
existingMap.set(key, entry)
const fallbackKey = entry.name ? `${entry.name}::${entry.type}` : null
if (fallbackKey) existingMap.set(fallbackKey, entry)
})
return result
}
export const dedupeCustomFieldEntries = (fields: Record<string, unknown>[]): Record<string, unknown>[] => {
if (!Array.isArray(fields) || fields.length <= 1) {
return Array.isArray(fields) ? fields : []
}
const seen = new Set<string>()
const result: Record<string, unknown>[] = []
for (const field of fields) {
if (!field) continue
field.type = field.type || 'text'
let normalizedName = typeof field.name === 'string' ? (field.name as string).trim() : ''
if (!normalizedName && (field.customField as Record<string, unknown>)?.name) {
normalizedName = String((field.customField as Record<string, unknown>).name).trim()
field.name = normalizedName
} else if (typeof field.name === 'string') {
field.name = normalizedName
}
const key =
(field.customFieldId as string) ||
(field.id as string) ||
(normalizedName ? `${normalizedName}::${field.type || 'text'}` : null)
if (!key && !normalizedName) continue
if (key && seen.has(key)) continue
if (!normalizedName) continue
if (key) seen.add(key)
if (normalizedName) seen.add(`${normalizedName}::${field.type || 'text'}`)
result.push(field)
}
return result
}
// ---------------------------------------------------------------------------
// Summarize for display
// ---------------------------------------------------------------------------
export const summarizeCustomFields = (
fields: Record<string, unknown>[] = [],
): { key: string; label: string; value: string }[] => {
const seen = new Set<string>()
return fields
.slice()
.sort((a, b) => {
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
return (left as number) - (right as number)
})
.filter(shouldDisplayCustomField)
.filter((field) => {
const key = (field.customFieldId || field.id || field.name) as string
if (!key) return true
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((field, index) => ({
key: ((field.customFieldId || field.id || field.name) as string) || `custom-field-${index}`,
label: (field.name as string) || 'Champ',
value: formatCustomFieldValue(field),
}))
}

View File

@@ -0,0 +1,353 @@
/**
* Product resolution and display utilities.
*
* Extracted from pages/machine/[id].vue these functions resolve product
* references from deeply nested API payloads and build display objects.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ProductDisplay {
name: string
reference: string | null
category: string | null
suppliers: string | null
price: string | null
}
type AnyRecord = Record<string, unknown>
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const isPlainObject = (value: unknown): value is AnyRecord =>
Object.prototype.toString.call(value) === '[object Object]'
export const resolveIdentifier = (...candidates: unknown[]): string | null => {
for (const candidate of candidates) {
if (candidate !== undefined && candidate !== null && candidate !== '') {
return candidate as string
}
}
return null
}
// ---------------------------------------------------------------------------
// Supplier / price labels
// ---------------------------------------------------------------------------
export const getProductSuppliersLabel = (product: AnyRecord | null): string | null => {
if (!product) return null
const suppliers = Array.isArray(product.constructeurs)
? (product.constructeurs as AnyRecord[]).map((c) => c?.name as string).filter(Boolean)
: []
return suppliers.length > 0 ? suppliers.join(', ') : null
}
export const getProductPriceLabel = (product: AnyRecord | null): string | null => {
if (!product) return null
const priceValue =
(product.supplierPrice ?? product.prix ?? product.price ?? null) as string | number | null
if (priceValue === undefined || priceValue === null) return null
const numeric = Number(priceValue)
if (Number.isNaN(numeric)) return null
return `${numeric.toFixed(2)}`
}
// ---------------------------------------------------------------------------
// resolveProductReference
// ---------------------------------------------------------------------------
export const resolveProductReference = (
source: AnyRecord | null | undefined,
findProductById: (id: string) => AnyRecord | null,
): { product: AnyRecord | null; productId: string | null } => {
if (!source || typeof source !== 'object') {
return { product: null, productId: null }
}
const candidateKeys: (string | null)[] = [
null,
'productLink',
'machinePieceLink',
'machineComponentLink',
'machineProductLink',
'originalPiece',
'originalComposant',
'link',
'overrides',
'machineComponentLinkOverrides',
'requirement',
'selection',
'entry',
]
let product: AnyRecord | null = null
let productId: string | null = null
const inspect = (container: unknown) => {
if (!container || typeof container !== 'object') return
const c = container as AnyRecord
if (!product && c.product && typeof c.product === 'object') {
product = c.product as AnyRecord
}
if (!productId) {
const candidate =
(c.productId as string) ||
(c.product && typeof c.product === 'object'
? ((c.product as AnyRecord).id as string) || ((c.product as AnyRecord).productId as string)
: null) ||
null
if (candidate) productId = candidate
}
}
candidateKeys.forEach((key) => {
if (key === null) inspect(source)
else inspect((source as AnyRecord)[key])
})
if (!product && productId) {
product = findProductById(productId) || null
}
if (!product && !productId && source.productName) {
const suppliersLabel =
typeof source.constructeursLabel === 'string'
? source.constructeursLabel
: typeof source.productSuppliers === 'string'
? source.productSuppliers
: null
return {
product: {
name: source.productName,
reference: source.productReference || null,
typeProduct: source.productCategory ? { name: source.productCategory } : null,
constructeurs: suppliersLabel
? (suppliersLabel as string)
.split(',')
.map((name: string) => name.trim())
.filter((name: string) => name.length > 0)
.map((name: string) => ({ name }))
: undefined,
supplierPrice: source.productPrice ?? source.productPriceLabel ?? source.price ?? null,
} as AnyRecord,
productId: null,
}
}
if (productId && product && product.id && product.id !== productId) {
const resolved = findProductById(productId)
if (resolved) product = resolved
}
return { product: product || null, productId: productId || null }
}
// ---------------------------------------------------------------------------
// getProductDisplay
// ---------------------------------------------------------------------------
export const getProductDisplay = (
source: AnyRecord | null | undefined,
findProductById: (id: string) => AnyRecord | null,
): ProductDisplay | null => {
if (!source || typeof source !== 'object') return null
const { product, productId } = resolveProductReference(source, findProductById)
if (product) {
return {
name: (product.name as string) || (product.reference as string) || 'Produit catalogue',
reference: (product.reference as string) || null,
category: (product.typeProduct as AnyRecord)?.name as string || null,
suppliers: getProductSuppliersLabel(product),
price: getProductPriceLabel(product),
}
}
let fallbackName =
(source.productName ||
source.productLabel ||
source.typeProductLabel ||
(source.typeProduct as AnyRecord)?.name ||
(productId ? `Produit ${productId}` : null)) as string | null
let fallbackReference = (source.productReference || source.reference || null) as string | null
let fallbackCategory =
(source.productCategory ||
source.typeProductLabel ||
(source.typeProduct as AnyRecord)?.name ||
null) as string | null
let fallbackSuppliers =
(source.productSuppliers ||
source.constructeursLabel ||
source.supplierLabel ||
null) as string | null
let fallbackPrice =
(source.productPriceLabel ||
source.productPrice ||
source.priceLabel ||
source.price ||
null) as string | number | null
const structuralCandidates = [
source.products,
source.productSkeleton,
(source.definition as AnyRecord)?.products,
(source.definition as AnyRecord)?.productSkeleton,
((source.definition as AnyRecord)?.structure as AnyRecord)?.products,
((source.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.structure as AnyRecord)?.products,
(source.structure as AnyRecord)?.productSkeleton,
(source.requirement as AnyRecord)?.products,
(source.requirement as AnyRecord)?.productSkeleton,
((source.requirement as AnyRecord)?.structure as AnyRecord)?.products,
((source.requirement as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
((source.requirement as AnyRecord)?.componentSkeleton as AnyRecord)?.products,
(source.typeMachineComponentRequirement as AnyRecord)?.products,
(source.typeMachineComponentRequirement as AnyRecord)?.productSkeleton,
((source.typeMachineComponentRequirement as AnyRecord)?.structure as AnyRecord)?.products,
((source.typeMachineComponentRequirement as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
((source.typeMachineComponentRequirement as AnyRecord)?.componentSkeleton as AnyRecord)?.products,
(source.typeComposant as AnyRecord)?.products,
(source.typeComposant as AnyRecord)?.productSkeleton,
((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.products,
((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.originalComposant as AnyRecord)?.products,
(source.originalComposant as AnyRecord)?.productSkeleton,
((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.products,
((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.productSkeleton,
(((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.products,
(((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.originalComponent as AnyRecord)?.products,
(source.originalComponent as AnyRecord)?.productSkeleton,
((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.products,
((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.productSkeleton,
(((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.products,
(((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
]
const structuralProducts = structuralCandidates
.flatMap((candidate) => {
if (Array.isArray(candidate)) return candidate
if (candidate && typeof candidate === 'object' && Array.isArray((candidate as AnyRecord).products)) {
return (candidate as AnyRecord).products as unknown[]
}
return []
})
.filter((entry) => entry && typeof entry === 'object')
const structuralProduct = structuralProducts.length ? (structuralProducts[0] as AnyRecord) : null
const structuralFamilyCode =
(structuralProduct && typeof structuralProduct.familyCode === 'string'
? structuralProduct.familyCode
: null) ||
(typeof source.familyCode === 'string' ? source.familyCode : null)
if (!fallbackName && structuralProduct) {
fallbackName =
(structuralProduct.typeProductLabel as string) ||
((structuralProduct.typeProduct as AnyRecord)?.name as string) ||
(structuralProduct.reference as string) ||
(structuralFamilyCode ? `Famille ${structuralFamilyCode}` : null) ||
null
}
if (!fallbackReference && structuralProduct?.reference) {
fallbackReference = structuralProduct.reference as string
}
if (!fallbackCategory) {
fallbackCategory =
(structuralProduct?.typeProductLabel as string) ||
((structuralProduct?.typeProduct as AnyRecord)?.name as string) ||
(structuralFamilyCode ? `Famille ${structuralFamilyCode}` : null) ||
null
}
if (!fallbackSuppliers && structuralProduct?.supplierLabel) {
fallbackSuppliers = structuralProduct.supplierLabel as string
}
if (!fallbackSuppliers && Array.isArray(structuralProduct?.constructeurs)) {
const supplierNames = (structuralProduct!.constructeurs as AnyRecord[])
.map((c) => c?.name as string)
.filter((name) => typeof name === 'string' && name.trim().length > 0)
if (supplierNames.length) fallbackSuppliers = supplierNames.join(', ')
}
if (!fallbackPrice && structuralProduct?.priceLabel) fallbackPrice = structuralProduct.priceLabel as string
if (!fallbackPrice && structuralProduct?.price) fallbackPrice = structuralProduct.price as string | number
if (fallbackName || fallbackReference || fallbackCategory || fallbackSuppliers || fallbackPrice) {
return {
name: fallbackName || 'Produit catalogue',
reference: fallbackReference,
category: fallbackCategory,
suppliers: fallbackSuppliers,
price:
typeof fallbackPrice === 'number'
? `${fallbackPrice.toFixed(2)}`
: (fallbackPrice as string) || null,
}
}
return null
}
// ---------------------------------------------------------------------------
// Parent link identifiers
// ---------------------------------------------------------------------------
export const extractParentLinkIdentifiers = (source: AnyRecord | null | undefined): AnyRecord => {
if (!source || typeof source !== 'object') return {}
const identifiers: AnyRecord = {}
const idKeys = [
'parentRequirementId',
'parentComponentRequirementId',
'parentPieceRequirementId',
'parentMachineComponentRequirementId',
'parentMachinePieceRequirementId',
'parentLinkId',
'parentComponentLinkId',
'parentPieceLinkId',
'parentComponentId',
'parentPieceId',
]
idKeys.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const value = source[key]
if (value !== undefined && value !== null && value !== '') {
identifiers[key] = value
}
}
})
const objectKeys = [
'parentRequirement',
'parentComponentRequirement',
'parentPieceRequirement',
'parentMachineComponentRequirement',
'parentMachinePieceRequirement',
]
objectKeys.forEach((key) => {
const value = source[key]
if (isPlainObject(value) && value.id !== undefined && value.id !== null && value.id !== '') {
const idKey = `${key}Id`
if (!Object.prototype.hasOwnProperty.call(identifiers, idKey)) {
identifiers[idKey] = value.id
}
}
})
return identifiers
}