8 Commits

Author SHA1 Message Date
Matthieu
9fef009610 feat(skeleton) : remove skeleton JSON field references — use structure API field directly
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:11:07 +01:00
Matthieu
4a3bceffa1 feat(machine) : afficher quantité pièces + pièces incluses des composants
- MachinePiecesCard : passer isEditMode au PieceItem + forward event update
- useMachineHierarchy : mapper quantity depuis le backend + construire
  les pièces de structure du composant en lecture seule
- useMachineDetailUpdates : PATCH MachinePieceLink.quantity + fix reference null
- ComponentItem : séparer pièces liées / pièces incluses par défaut
- useEntityDocuments : skip chargement documents pour pièces de structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:22:20 +01:00
Matthieu
50d8dde6d5 fix(piece) : include structure in composant edit PATCH payload for quantity persistence 2026-03-12 15:02:09 +01:00
Matthieu
9b40f9f2c7 feat(piece) : add quantity display and input to composant edit page 2026-03-12 14:40:55 +01:00
Matthieu
721963449b feat(piece) : display and edit quantity on machine piece items 2026-03-12 14:32:50 +01:00
Matthieu
22ba9a8d05 feat(piece) : add quantity input to composant structure editor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:11:58 +01:00
Matthieu
695d56a6d3 feat(piece) : add quantity field to piece types, sanitization and hydration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:08:43 +01:00
Matthieu
5c31045e83 fix(machine) : fix fournisseur display overflow in MachineInfoCard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:56:17 +01:00
22 changed files with 187 additions and 110 deletions

View File

@@ -215,14 +215,14 @@
/> />
</div> </div>
<!-- Component Pieces --> <!-- Component Pieces (real MachinePieceLinks) -->
<div v-if="component.pieces && component.pieces.length > 0" class="space-y-2"> <div v-if="linkedPieces.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide"> <p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces du composant Pièces du composant
</p> </p>
<div class="space-y-2"> <div class="space-y-2">
<PieceItem <PieceItem
v-for="piece in component.pieces" v-for="piece in linkedPieces"
:key="piece.id" :key="piece.id"
:piece="piece" :piece="piece"
:is-edit-mode="isEditMode" :is-edit-mode="isEditMode"
@@ -233,6 +233,21 @@
</div> </div>
</div> </div>
<!-- Structure pieces (read-only, from composant definition) -->
<div v-if="structurePieces.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces incluses par défaut
</p>
<div class="space-y-2">
<PieceItem
v-for="piece in structurePieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="false"
/>
</div>
</div>
<!-- Sub Components --> <!-- Sub Components -->
<div v-if="childComponents.length > 0" class="space-y-2"> <div v-if="childComponents.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide"> <p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
@@ -341,6 +356,14 @@ const childComponents = computed(() => {
return Array.isArray(list) ? list : [] return Array.isArray(list) ? list : []
}) })
// --- Pieces split: real links vs structure definitions ---
const allPieces = computed(() => {
const list = props.component.pieces
return Array.isArray(list) ? list : []
})
const linkedPieces = computed(() => allPieces.value.filter((p) => !p._structurePiece))
const structurePieces = computed(() => allPieces.value.filter((p) => p._structurePiece))
// --- Constructeurs --- // --- Constructeurs ---
const { constructeurs } = useConstructeurs() const { constructeurs } = useConstructeurs()

View File

@@ -24,6 +24,12 @@
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold"> <h3 class="text-lg font-semibold">
{{ pieceData.name }} {{ pieceData.name }}
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"
>
×{{ displayQuantity }}
</span>
</h3> </h3>
<div class="flex flex-wrap gap-2 mt-2"> <div class="flex flex-wrap gap-2 mt-2">
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm"> <span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
@@ -63,6 +69,19 @@
<div v-show="!isCollapsed" class="space-y-4"> <div v-show="!isCollapsed" class="space-y-4">
<div class="p-4 bg-base-100 border border-base-200 rounded-lg"> <div class="p-4 bg-base-100 border border-base-200 rounded-lg">
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div v-if="!piece.parentComponentLinkId && isEditMode" class="form-control">
<label class="label">
<span class="label-text text-sm">Quantité</span>
</label>
<input
v-model.number="pieceData.quantity"
type="number"
min="1"
step="1"
class="input input-bordered input-sm md:input-md w-24"
@blur="updatePiece"
/>
</div>
<div> <div>
<span class="font-medium">Référence:</span> <span class="font-medium">Référence:</span>
<input <input
@@ -272,6 +291,11 @@ const pieceData = reactive({
reference: props.piece.reference || '', reference: props.piece.reference || '',
prix: props.piece.prix || '', prix: props.piece.prix || '',
productId: props.piece.product?.id || props.piece.productId || null, productId: props.piece.product?.id || props.piece.productId || null,
quantity: props.piece.quantity ?? 1,
})
const displayQuantity = computed(() => {
return pieceData.quantity ?? 1
}) })
// --- Products --- // --- Products ---
@@ -439,6 +463,7 @@ const updatePiece = () => {
...props.piece, ...props.piece,
...pieceData, ...pieceData,
prix: parsedPrice, prix: parsedPrice,
quantity: pieceData.quantity ?? 1,
productId: pieceData.productId || null, productId: pieceData.productId || null,
product, product,
constructeurIds: pieceConstructeurIds.value, constructeurIds: pieceConstructeurIds.value,
@@ -478,11 +503,12 @@ watch(
) )
watch( watch(
() => [props.piece.name, props.piece.reference, props.piece.prix], () => [props.piece.name, props.piece.reference, props.piece.prix, props.piece.quantity],
() => { () => {
pieceData.name = props.piece.name || '' pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || '' pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || '' pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
}, },
) )
@@ -490,6 +516,7 @@ onMounted(() => {
pieceData.name = props.piece.name || '' pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || '' pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || '' pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
loadProducts().catch(() => {}) loadProducts().catch(() => {})
if (pieceData.productId) ensureProductLoaded(pieceData.productId) if (pieceData.productId) ensureProductLoaded(pieceData.productId)
if (!props.piece.documents?.length) refreshDocuments() if (!props.piece.documents?.length) refreshDocuments()

View File

@@ -280,6 +280,18 @@
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }} {{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
</p> </p>
</div> </div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">Quantité</span></label>
<input
v-model.number="piece.quantity"
type="number"
:min="1"
step="1"
placeholder="Qté"
class="input input-bordered input-sm md:input-md w-20"
@input="piece.quantity = Math.max(1, piece.quantity || 1)"
/>
</div>
</div> </div>
<button v-if="!isPieceLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)"> <button v-if="!isPieceLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" /> <IconLucideTrash class="w-4 h-4" aria-hidden="true" />

View File

@@ -72,23 +72,23 @@
placeholder="Rechercher un ou plusieurs fournisseurs..." placeholder="Rechercher un ou plusieurs fournisseurs..."
@update:modelValue="$emit('update:constructeur-ids', $event)" @update:modelValue="$emit('update:constructeur-ids', $event)"
/> />
<div v-else class="input input-bordered bg-base-200"> <div v-else class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
<div v-if="machineConstructeursDisplay.length" class="space-y-1"> <div v-if="machineConstructeursDisplay.length" class="flex flex-wrap gap-2">
<div <span
v-for="constructeur in machineConstructeursDisplay" v-for="constructeur in machineConstructeursDisplay"
:key="constructeur.id" :key="constructeur.id"
class="flex flex-col" class="badge badge-ghost gap-1"
> >
<span class="font-medium">{{ constructeur.name }}</span> {{ constructeur.name }}
<span <span
v-if="formatConstructeurContactSummary(constructeur)" v-if="formatConstructeurContactSummary(constructeur)"
class="text-xs text-base-content/50" class="text-xs opacity-60"
> >
{{ formatConstructeurContactSummary(constructeur) }} · {{ formatConstructeurContactSummary(constructeur) }}
</span> </span>
</div> </span>
</div> </div>
<span v-else class="font-medium">Non défini</span> <span v-else class="text-base-content/50">Non défini</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -28,10 +28,11 @@
<div v-for="piece in pieces" :key="piece.id"> <div v-for="piece in pieces" :key="piece.id">
<PieceItem <PieceItem
:piece="piece" :piece="piece"
:is-edit-mode="false" :is-edit-mode="isEditMode"
:show-delete="isEditMode" :show-delete="isEditMode"
:collapse-all="collapsed" :collapse-all="collapsed"
:toggle-token="collapseToggleToken" :toggle-token="collapseToggleToken"
@update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)" @edit="$emit('edit-piece', $event)"
@delete="$emit('remove-piece', piece.linkId || piece.id)" @delete="$emit('remove-piece', piece.linkId || piece.id)"
/> />
@@ -62,6 +63,7 @@ defineProps<{
defineEmits<{ defineEmits<{
'toggle-collapse': [] 'toggle-collapse': []
'update-piece': [piece: any]
'edit-piece': [piece: any] 'edit-piece': [piece: any]
'add-piece': [] 'add-piece': []
'remove-piece': [linkId: string] 'remove-piece': [linkId: string]

View File

@@ -299,6 +299,10 @@ export function useComponentEdit(componentId: string) {
payload.prix = null payload.prix = null
} }
if (component.value.structure) {
payload.structure = component.value.structure
}
saving.value = true saving.value = true
try { try {
const result = await updateComposant(component.value.id, payload) const result = await updateComposant(component.value.id, payload)

View File

@@ -56,7 +56,7 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
// CRUD operations // CRUD operations
const refreshDocuments = async () => { const refreshDocuments = async () => {
const e = entity() const e = entity()
if (!e?.id) return if (!e?.id || e._structurePiece) return
loadingDocuments.value = true loadingDocuments.value = true
try { try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false }) const result: any = await loadDocumentsFn(e.id, { updateStore: false })

View File

@@ -42,7 +42,7 @@ export function useMachineDetailData(machineId: string) {
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { upsertCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue } = useCustomFields()
const { get } = useApi() const { get, patch: apiPatch } = useApi()
const toast = useToast() const toast = useToast()
const { constructeurs, loadConstructeurs } = useConstructeurs() const { constructeurs, loadConstructeurs } = useConstructeurs()
const { sites, loadSites } = useSites() const { sites, loadSites } = useSites()
@@ -274,6 +274,7 @@ export function useMachineDetailData(machineId: string) {
updateMachineApi, updateMachineApi,
updateComposantApi: updateComposantApi, updateComposantApi: updateComposantApi,
updatePieceApi, updatePieceApi,
apiPatch,
toast, toast,
}) })

View File

@@ -33,6 +33,7 @@ export interface UseMachineDetailUpdatesDeps {
updateMachineApi: (id: string, data: any) => Promise<unknown> updateMachineApi: (id: string, data: any) => Promise<unknown>
updateComposantApi: (id: string, data: any) => Promise<unknown> updateComposantApi: (id: string, data: any) => Promise<unknown>
updatePieceApi: (id: string, data: any) => Promise<unknown> updatePieceApi: (id: string, data: any) => Promise<unknown>
apiPatch: (endpoint: string, data?: unknown) => Promise<any>
toast: { showInfo: (msg: string) => void } toast: { showInfo: (msg: string) => void }
} }
@@ -53,6 +54,7 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
updateMachineApi, updateMachineApi,
updateComposantApi, updateComposantApi,
updatePieceApi, updatePieceApi,
apiPatch,
toast, toast,
} = deps } = deps
@@ -147,7 +149,7 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const result: any = await updatePieceApi(updatedPiece.id as string, { const result: any = await updatePieceApi(updatedPiece.id as string, {
name: updatedPiece.name, name: updatedPiece.name,
reference: updatedPiece.reference, reference: updatedPiece.reference || null,
constructeurIds: cIds, constructeurIds: cIds,
prix: Number.isNaN(prix) ? null : prix, prix: Number.isNaN(prix) ? null : prix,
productId, productId,
@@ -187,6 +189,13 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const updatePieceInfo = async (updatedPiece: AnyRecord) => { const updatePieceInfo = async (updatedPiece: AnyRecord) => {
try { try {
await _buildAndUpdatePiece(updatedPiece) await _buildAndUpdatePiece(updatedPiece)
// Update link quantity if this is a direct machine piece
const linkId = updatedPiece.linkId || updatedPiece.machinePieceLinkId
const quantity = typeof updatedPiece.quantity === 'number' ? Math.max(1, updatedPiece.quantity) : null
if (linkId && quantity !== null) {
await apiPatch(`/machine_piece_links/${linkId}`, { quantity })
}
} catch (error) { } catch (error) {
console.error('Erreur lors de la mise à jour de la pièce:', error) console.error('Erreur lors de la mise à jour de la pièce:', error)
} }

View File

@@ -181,6 +181,7 @@ export const buildMachineHierarchyFromLinks = (
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId), parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId), parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId), parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
quantity: typeof link.quantity === 'number' ? link.quantity : 1,
definition: appliedPiece.definition || originalPiece?.definition || {}, definition: appliedPiece.definition || originalPiece?.definition || {},
customFields: appliedPiece.customFields || [], customFields: appliedPiece.customFields || [],
} }
@@ -214,10 +215,38 @@ export const buildMachineHierarchyFromLinks = (
const componentName = (compOverrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string const componentName = (compOverrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string
const pieces = Array.isArray(link.pieceLinks) const linkedPieces = Array.isArray(link.pieceLinks)
? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[] ? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[]
: [] : []
// If no linked pieces exist, build read-only entries from the composant's structure
const structurePieceDefs = (!linkedPieces.length && appliedComponent.structure && typeof appliedComponent.structure === 'object')
? (Array.isArray((appliedComponent.structure as AnyRecord).pieces) ? (appliedComponent.structure as AnyRecord).pieces as AnyRecord[] : [])
: []
const structurePieces = structurePieceDefs.map((def, index) => {
const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord
const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null
const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1)
return {
...(resolved || {}),
id: resolved?.id || `structure-piece-${composantId}-${index}`,
pieceId: resolved?.id || null,
name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`,
reference: resolved?.reference || definition.reference || def.reference || null,
prix: resolved?.prix ?? null,
constructeurs: resolved?.constructeurs || [],
documents: [],
quantity,
typePieceId: resolved?.typePieceId || definition.typePieceId || def.typePieceId || null,
typePiece: resolved?.typePiece || null,
parentComponentLinkId: machineComponentLinkId,
parentComponentName: componentName,
_structurePiece: true,
}
}) as AnyRecord[]
const pieces = linkedPieces.length ? linkedPieces : structurePieces
const subComponents = Array.isArray(link.childLinks) const subComponents = Array.isArray(link.childLinks)
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[] ? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
: [] : []

View File

@@ -115,6 +115,7 @@ export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
reference: '', reference: '',
familyCode: '', familyCode: '',
role: '', role: '',
quantity: 1,
}) })
} }

View File

@@ -166,9 +166,20 @@
<div v-if="structureSelections.pieces.length" class="space-y-2"> <div v-if="structureSelections.pieces.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces choisies</h3> <h3 class="font-semibold text-sm text-base-content">Pièces choisies</h3>
<ul class="list-disc list-inside space-y-1 text-sm"> <ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.pieces" :key="`selected-piece-${entry.path}-${entry.id}`"> <li v-for="entry in structureSelections.pieces" :key="`selected-piece-${entry.path}-${entry.id}`" class="flex items-center gap-2">
<span class="font-medium">{{ entry.resolvedName }}</span> <span class="font-medium">{{ entry.resolvedName }}</span>
<span v-if="(entry.quantity ?? 1) > 1" class="text-sm text-base-content/60">×{{ entry.quantity }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span> <span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
<input
v-if="canEdit && entry._definition"
v-model.number="entry._definition.quantity"
type="number"
:min="1"
step="1"
placeholder="Qté"
class="input input-bordered input-xs w-16 ml-auto"
@input="entry._definition.quantity = Math.max(1, entry._definition.quantity || 1)"
/>
</li> </li>
</ul> </ul>
</div> </div>

View File

@@ -390,7 +390,7 @@ const loadProductType = async () => {
// Try using the expanded typeProduct from entity response first // Try using the expanded typeProduct from entity response first
const embedded = product.value?.typeProduct const embedded = product.value?.typeProduct
if (embedded && typeof embedded === 'object' && embedded.id) { if (embedded && typeof embedded === 'object' && embedded.id) {
const embeddedStructure = embedded.structure ?? embedded.productSkeleton ?? null const embeddedStructure = embedded.structure ?? null
if (embeddedStructure) { if (embeddedStructure) {
productType.value = embedded productType.value = embedded
structure.value = normalizeProductStructureForSave(embeddedStructure) structure.value = normalizeProductStructureForSave(embeddedStructure)
@@ -406,7 +406,7 @@ const loadProductType = async () => {
try { try {
const type = await getModelType(product.value.typeProductId) const type = await getModelType(product.value.typeProductId)
productType.value = type productType.value = type
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null) structure.value = normalizeProductStructureForSave(type?.structure ?? null)
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement du type de produit:', error) console.error('Erreur lors du chargement du type de produit:', error)
productType.value = embedded ?? null productType.value = embedded ?? null

View File

@@ -46,9 +46,6 @@ export interface ModelType extends BaseModelTypePayload {
updatedAt: string; updatedAt: string;
category: ModelCategory; category: ModelCategory;
structure: ModelTypeStructure; structure: ModelTypeStructure;
componentSkeleton?: ComponentModelStructure | null;
pieceSkeleton?: PieceModelStructure | null;
productSkeleton?: ProductModelStructure | null;
} }
export interface ModelTypeListParams { export interface ModelTypeListParams {
@@ -86,42 +83,9 @@ const normalizeModelType = (item: any): ModelType => {
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
return item as ModelType; return item as ModelType;
} }
if (!item.structure) {
if (item.category === 'COMPONENT' && item.componentSkeleton) {
item.structure = item.componentSkeleton;
} else if (item.category === 'PIECE' && item.pieceSkeleton) {
item.structure = item.pieceSkeleton;
} else if (item.category === 'PRODUCT' && item.productSkeleton) {
item.structure = item.productSkeleton;
}
}
return item as ModelType; return item as ModelType;
}; };
const mapStructureToSkeleton = <T extends Record<string, any>>(payload: T): T => {
if (!payload || typeof payload !== 'object') {
return payload;
}
if (!('structure' in payload)) {
return payload;
}
const structure = (payload as any).structure;
if (!structure) {
return payload;
}
const category = (payload as any).category;
const next = { ...payload } as Record<string, any>;
if (category === 'COMPONENT') {
next.componentSkeleton = structure;
} else if (category === 'PIECE') {
next.pieceSkeleton = structure;
} else if (category === 'PRODUCT') {
next.productSkeleton = structure;
}
delete next.structure;
return next as T;
};
export async function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) { export async function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch(); const requestFetch = useRequestFetch();
const query: Record<string, string | number> = {}; const query: Record<string, string | number> = {};
@@ -178,28 +142,26 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
export function createModelType(payload: ModelTypePayload, opts: { signal?: AbortSignal } = {}) { export function createModelType(payload: ModelTypePayload, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch(); const requestFetch = useRequestFetch();
const mappedPayload = mapStructureToSkeleton(payload);
return requestFetch<ModelType>(ENDPOINT, createOptions({ return requestFetch<ModelType>(ENDPOINT, createOptions({
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/ld+json', 'Content-Type': 'application/ld+json',
Accept: 'application/ld+json', Accept: 'application/ld+json',
}, },
body: mappedPayload, body: payload,
signal: opts.signal, signal: opts.signal,
})).then(normalizeModelType); })).then(normalizeModelType);
} }
export function updateModelType(id: string, payload: Partial<ModelTypePayload>, opts: { signal?: AbortSignal } = {}) { export function updateModelType(id: string, payload: Partial<ModelTypePayload>, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch(); const requestFetch = useRequestFetch();
const mappedPayload = mapStructureToSkeleton(payload);
return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({ return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/merge-patch+json', 'Content-Type': 'application/merge-patch+json',
Accept: 'application/ld+json', Accept: 'application/ld+json',
}, },
body: mappedPayload, body: payload,
signal: opts.signal, signal: opts.signal,
})).then(normalizeModelType); })).then(normalizeModelType);
} }

View File

@@ -175,6 +175,9 @@ export const normalizeStructureForSave = (input: any): any => {
if (piece.reference) { if (piece.reference) {
payload.reference = piece.reference payload.reference = piece.reference
} }
if ((piece as any).quantity !== undefined && (piece as any).quantity >= 1) {
payload.quantity = (piece as any).quantity
}
return payload return payload
}) as any }) as any

View File

@@ -103,6 +103,7 @@ export const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
reference: piece?.reference ?? '', reference: piece?.reference ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '', familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '', role: piece?.role ?? '',
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
})) }))
} }
@@ -175,6 +176,7 @@ export const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '', typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '', familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '', role: piece?.role ?? '',
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
})) }))
} }

View File

@@ -162,6 +162,8 @@ export const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
const rawRole = typeof piece?.role === 'string' ? piece.role.trim() : '' const rawRole = typeof piece?.role === 'string' ? piece.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined const role = rawRole.length > 0 ? rawRole : undefined
const quantity = typeof piece?.quantity === 'number' && piece.quantity >= 1 ? piece.quantity : undefined
if (!typePieceId && !typePieceLabel && !reference && !familyCode) { if (!typePieceId && !typePieceLabel && !reference && !familyCode) {
return null return null
} }
@@ -182,6 +184,9 @@ export const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
if (typePieceLabel) { if (typePieceLabel) {
result.typePieceLabel = typePieceLabel result.typePieceLabel = typePieceLabel
} }
if (quantity !== undefined) {
result.quantity = quantity
}
return result return result
}) })
.filter((piece): piece is ComponentModelPiece => !!piece) .filter((piece): piece is ComponentModelPiece => !!piece)

View File

@@ -21,6 +21,7 @@ export interface ComponentModelPiece {
reference?: string reference?: string
familyCode?: string familyCode?: string
role?: string role?: string
quantity?: number
} }
export interface ComponentModelProduct { export interface ComponentModelProduct {
@@ -156,6 +157,7 @@ const validatePiece = (
const reference = ensureString(value.reference) const reference = ensureString(value.reference)
const familyCode = ensureString(value.familyCode) const familyCode = ensureString(value.familyCode)
const role = ensureString(value.role) const role = ensureString(value.role)
const quantity = typeof value.quantity === 'number' && value.quantity >= 1 ? value.quantity : undefined
if (!typePieceId && !typePieceLabel && !reference && !familyCode) { if (!typePieceId && !typePieceLabel && !reference && !familyCode) {
issues.push(`${path}: au moins un identifiant, une famille ou une référence de pièce est requis`) issues.push(`${path}: au moins un identifiant, une famille ou une référence de pièce est requis`)
@@ -168,6 +170,7 @@ const validatePiece = (
...(reference ? { reference } : {}), ...(reference ? { reference } : {}),
...(familyCode ? { familyCode } : {}), ...(familyCode ? { familyCode } : {}),
...(role ? { role } : {}), ...(role ? { role } : {}),
...(quantity ? { quantity } : {}),
} }
} }

View File

@@ -196,34 +196,19 @@ export const getProductDisplay = (
const structuralCandidates = [ const structuralCandidates = [
source.products, source.products,
source.productSkeleton,
(source.definition as AnyRecord)?.products, (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)?.products,
((source.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.structure as AnyRecord)?.products, (source.structure as AnyRecord)?.products,
(source.structure as AnyRecord)?.productSkeleton,
(source.requirement as AnyRecord)?.products, (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)?.products,
((source.requirement as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
((source.requirement as AnyRecord)?.componentSkeleton as AnyRecord)?.products,
(source.typeComposant 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)?.products,
((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.originalComposant as AnyRecord)?.products, (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)?.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)?.products,
(((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.originalComponent as AnyRecord)?.products, (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)?.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)?.products,
(((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
] ]
const structuralProducts = structuralCandidates const structuralProducts = structuralCandidates

View File

@@ -176,6 +176,7 @@ export function sanitizePieceDefinition(definition: ComponentModelPiece) {
typePieceLabel: definition.typePieceLabel ?? null, typePieceLabel: definition.typePieceLabel ?? null,
reference: definition.reference ?? null, reference: definition.reference ?? null,
familyCode: (definition as any).familyCode ?? null, familyCode: (definition as any).familyCode ?? null,
quantity: typeof (definition as any).quantity === 'number' ? (definition as any).quantity : null,
}) })
} }

View File

@@ -3,6 +3,8 @@ export type SelectionEntry = {
path: string path: string
requirementLabel: string requirementLabel: string
resolvedName: string resolvedName: string
quantity?: number
_definition?: Record<string, any>
} }
export type StructureSelectionResult = { export type StructureSelectionResult = {
@@ -59,6 +61,8 @@ export function collectStructureSelections(
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`, path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
requirementLabel: resolvers.resolvePieceLabel(definition), requirementLabel: resolvers.resolvePieceLabel(definition),
resolvedName: catalogPiece?.name || selectedId, resolvedName: catalogPiece?.name || selectedId,
quantity: typeof definition?.quantity === 'number' ? definition.quantity : undefined,
_definition: definition,
}) })
}) })

View File

@@ -50,60 +50,55 @@ beforeEach(() => {
// normalizeModelType (tested via getModelType which calls .then(normalizeModelType)) // normalizeModelType (tested via getModelType which calls .then(normalizeModelType))
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('normalizeModelType (via getModelType)', () => { describe('normalizeModelType (via getModelType)', () => {
it('maps componentSkeleton to structure for COMPONENT', async () => { it('returns structure as-is for COMPONENT', async () => {
const skeleton = { customFields: [{ name: 'Weight' }] } const structure = { customFields: [{ name: 'Weight' }] }
mockFetch.mockResolvedValue(fakeModelType({ mockFetch.mockResolvedValue(fakeModelType({
category: 'COMPONENT', category: 'COMPONENT',
structure: null, structure: structure as any,
componentSkeleton: skeleton as any,
})) }))
const result = await getModelType('mt-1') const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton) expect(result.structure).toEqual(structure)
}) })
it('maps pieceSkeleton to structure for PIECE', async () => { it('returns structure as-is for PIECE', async () => {
const skeleton = { customFields: [{ name: 'Size' }] } const structure = { customFields: [{ name: 'Size' }] }
mockFetch.mockResolvedValue(fakeModelType({ mockFetch.mockResolvedValue(fakeModelType({
category: 'PIECE', category: 'PIECE',
structure: null, structure: structure as any,
pieceSkeleton: skeleton as any,
})) }))
const result = await getModelType('mt-1') const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton) expect(result.structure).toEqual(structure)
}) })
it('maps productSkeleton to structure for PRODUCT', async () => { it('returns structure as-is for PRODUCT', async () => {
const skeleton = { customFields: [{ name: 'Brand' }] } const structure = { customFields: [{ name: 'Brand' }] }
mockFetch.mockResolvedValue(fakeModelType({ mockFetch.mockResolvedValue(fakeModelType({
category: 'PRODUCT', category: 'PRODUCT',
structure: null, structure: structure as any,
productSkeleton: skeleton as any,
})) }))
const result = await getModelType('mt-1') const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton) expect(result.structure).toEqual(structure)
}) })
it('does not override existing structure', async () => { it('preserves null structure', async () => {
const existing = { customFields: [{ name: 'Existing' }] }
mockFetch.mockResolvedValue(fakeModelType({ mockFetch.mockResolvedValue(fakeModelType({
category: 'COMPONENT', category: 'COMPONENT',
structure: existing as any, structure: null,
componentSkeleton: { customFields: [{ name: 'Skeleton' }] } as any,
})) }))
const result = await getModelType('mt-1') const result = await getModelType('mt-1')
expect(result.structure).toEqual(existing) expect(result.structure).toBeNull()
}) })
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// createModelType — maps structure to skeleton // createModelType — sends structure directly
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('createModelType', () => { describe('createModelType', () => {
it('sends POST with componentSkeleton for COMPONENT', async () => { it('sends POST with structure for COMPONENT', async () => {
const structure = { customFields: [] } const structure = { customFields: [] }
mockFetch.mockResolvedValue(fakeModelType()) mockFetch.mockResolvedValue(fakeModelType())
@@ -120,11 +115,10 @@ describe('createModelType', () => {
const [endpoint, options] = mockFetch.mock.calls[0] const [endpoint, options] = mockFetch.mock.calls[0]
expect(endpoint).toBe('/model_types') expect(endpoint).toBe('/model_types')
expect(options.method).toBe('POST') expect(options.method).toBe('POST')
expect(options.body.componentSkeleton).toEqual(structure) expect(options.body.structure).toEqual(structure)
expect(options.body.structure).toBeUndefined()
}) })
it('sends POST with pieceSkeleton for PIECE', async () => { it('sends POST with structure for PIECE', async () => {
const structure = { customFields: [], products: [] } const structure = { customFields: [], products: [] }
mockFetch.mockResolvedValue(fakeModelType({ category: 'PIECE' })) mockFetch.mockResolvedValue(fakeModelType({ category: 'PIECE' }))
@@ -136,11 +130,10 @@ describe('createModelType', () => {
}) })
const [, options] = mockFetch.mock.calls[0] const [, options] = mockFetch.mock.calls[0]
expect(options.body.pieceSkeleton).toEqual(structure) expect(options.body.structure).toEqual(structure)
expect(options.body.structure).toBeUndefined()
}) })
it('sends POST with productSkeleton for PRODUCT', async () => { it('sends POST with structure for PRODUCT', async () => {
const structure = { customFields: [] } const structure = { customFields: [] }
mockFetch.mockResolvedValue(fakeModelType({ category: 'PRODUCT' })) mockFetch.mockResolvedValue(fakeModelType({ category: 'PRODUCT' }))
@@ -152,15 +145,15 @@ describe('createModelType', () => {
}) })
const [, options] = mockFetch.mock.calls[0] const [, options] = mockFetch.mock.calls[0]
expect(options.body.productSkeleton).toEqual(structure) expect(options.body.structure).toEqual(structure)
}) })
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// updateModelType — maps structure to skeleton // updateModelType — sends structure directly
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('updateModelType', () => { describe('updateModelType', () => {
it('sends PATCH with correct endpoint and skeleton', async () => { it('sends PATCH with correct endpoint and structure', async () => {
const structure = { customFields: [{ name: 'Updated' }] } const structure = { customFields: [{ name: 'Updated' }] }
mockFetch.mockResolvedValue(fakeModelType()) mockFetch.mockResolvedValue(fakeModelType())
@@ -176,10 +169,10 @@ describe('updateModelType', () => {
expect(endpoint).toBe('/model_types/mt-1') expect(endpoint).toBe('/model_types/mt-1')
expect(options.method).toBe('PATCH') expect(options.method).toBe('PATCH')
expect(options.headers['Content-Type']).toBe('application/merge-patch+json') expect(options.headers['Content-Type']).toBe('application/merge-patch+json')
expect(options.body.componentSkeleton).toEqual(structure) expect(options.body.structure).toEqual(structure)
}) })
it('sends payload without skeleton when no structure', async () => { it('sends payload without structure when not provided', async () => {
mockFetch.mockResolvedValue(fakeModelType()) mockFetch.mockResolvedValue(fakeModelType())
await updateModelType('mt-1', { await updateModelType('mt-1', {
@@ -189,7 +182,7 @@ describe('updateModelType', () => {
}) })
const [, options] = mockFetch.mock.calls[0] const [, options] = mockFetch.mock.calls[0]
expect(options.body.componentSkeleton).toBeUndefined() expect(options.body.structure).toBeUndefined()
expect(options.body.name).toBe('Just Name') expect(options.body.name).toBe('Just Name')
}) })
}) })