Compare commits

...

22 Commits

Author SHA1 Message Date
b869984609 feat(infra) : ajout maintenance mode nginx-proxy + extraction maintenance.html au deploy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:56:05 +02:00
59fae38176 feat(infra) : ajout maintenance mode dans nginx-proxy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:51:10 +02:00
a674a5f2f0 feat(infra) : add maintenance mode support to production nginx config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:46:44 +02:00
0049638e3c fix(custom-fields) : context fields display, batch save, hierarchy propagation and UniqueEntity fix
- ComponentItem/PieceItem: DaisyUI divider, emit context-field-update for batch save
- CustomFieldDisplay: support editable/emit-blur/title/show-header props
- MachineComponentsCard/MachinePiecesCard: propagate custom-field-update events
- useMachineDetailCustomFields: pendingContextFieldUpdates + saveAllContextCustomFields
- useMachineDetailData: wire context field save into submitEdition
- useMachineDetailUpdates: only PATCH changed machine fields
- useMachineHierarchy: propagate contextCustomFields/Values from link to nodes
- componentStructure: include machineContextOnly in normalizeStructureForEditor
- Machine entity: convert empty reference to null, ignoreNull on UniqueEntity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:46:39 +02:00
c54e5c33f2 fix(custom-fields) : include machineContextOnly in ModelType.serializeCustomFields
The GET response for ModelType structure was missing machineContextOnly,
so on page reload the flag was always read as false by the frontend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:24:20 +02:00
51b491097e fix(custom-fields) : preserve machineContextOnly through sanitizeCustomFields
The sanitize layer was reconstructing the field object without machineContextOnly,
causing it to always be false in the save payload regardless of checkbox state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:22:38 +02:00
7da78b3b3e fix(custom-fields) : pass machineContextOnly through structure serialize/hydrate
- componentStructure: include machineContextOnly in save payload value object
- componentStructureHydrate: read machineContextOnly in hydrate and mapComponentCustomFields
- pieceProductStructure: include machineContextOnly in sanitize and hydrate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:13:36 +02:00
a88e4a68fb fix(custom-fields) : persist machineContextOnly in structure save and clone
- SkeletonStructureService: read and write machineContextOnly on create/update
- normalizeCustomFieldData: pass machineContextOnly through both payload formats
- cloneCustomFields: copy machineContextOnly flag on machine clone

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:09:15 +02:00
61584f2f9b fix(custom-fields) : fix typecheck errors on machineContextOnly filter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:03:24 +02:00
970708ea83 test(custom-fields) : add tests for machine context custom fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:01:23 +02:00
7b674fcc0c feat(custom-fields) : display context custom fields in PieceItem
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:00:28 +02:00
99147f4e08 feat(custom-fields) : display context custom fields in ComponentItem
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:59:48 +02:00
aec33e7911 feat(custom-fields) : clone context field values on machine clone
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:52:50 +02:00
2edb748bd4 feat(custom-fields) : support link-based upsert in CustomFieldValueController
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:52:18 +02:00
da12955b52 feat(custom-fields) : expose context custom fields in machine structure response
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:51:40 +02:00
1385d7768c feat(custom-fields) : filter machineContextOnly from standalone and machine-detail views
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:51:32 +02:00
500b6b1620 feat(custom-fields) : add machineContextOnly toggle in structure editors
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:51:05 +02:00
54203db328 feat(custom-fields) : add machineContextOnly to custom field types
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:50:33 +02:00
d085c48953 test(custom-fields) : extend factories for machineContextOnly and link params
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:50:29 +02:00
4f1f643436 feat(custom-fields) : add machineContextOnly flag and link FKs for machine context
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:49:01 +02:00
1c3b566923 feat(machines) : allow category-only links on machine structure
Enable adding a component, piece, or product to a machine by selecting
only the category (ModelType) without a specific entity. The link
displays a red "À remplir" badge; clicking it reopens the modal
pre-filled with the category so the user can associate an item later.

Backend: entity FKs made nullable on the 3 link tables, modelType FK
added, controller/audit/version/MCP normalization adapted for null
entities.

Frontend: modal accepts category-only confirm, page handles fill mode,
hierarchy builder creates pending nodes, display components show
clickable badge with event propagation through the full hierarchy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:15:47 +02:00
342ae37762 chore(makefile) : add missing make targets from SIRH/Lesstime
Add migration-migrate, shell-root, logs-dev targets and include
migration-migrate in the install dependency chain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:34:25 +02:00
50 changed files with 1387 additions and 177 deletions

View File

@@ -12,6 +12,7 @@
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('delete')"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>
</div>
</div>
@@ -43,5 +44,5 @@ defineProps({
}
})
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
</script>

View File

@@ -14,7 +14,7 @@
/>
<!-- Component Header -->
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
<div class="flex items-center gap-3 p-3 rounded-lg cursor-pointer" :class="component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'" @click="toggleCollapse">
<IconLucideChevronRight
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
:class="{ 'rotate-90': !isCollapsed }"
@@ -22,9 +22,18 @@
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-semibold text-base-content truncate">
<h3 class="text-sm font-semibold truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
{{ component.name }}
</h3>
<button
v-if="component.pendingEntity"
type="button"
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
title="Cliquer pour associer un item"
@click.stop="$emit('fill-entity', component.linkId, component.modelTypeId)"
>
À remplir
</button>
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}</span>
</div>
@@ -54,7 +63,7 @@
</div>
<!-- Expanded content -->
<div v-show="!isCollapsed" class="mt-3 space-y-4 pl-7">
<div v-show="!isCollapsed && !component.pendingEntity" class="mt-3 space-y-4 pl-7">
<!-- Info fields -->
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
@@ -191,9 +200,27 @@
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:columns="2"
title="Champs personnalisés item"
:editable="false"
@field-blur="updateComponentCustomField"
/>
<template v-if="mergedContextFields.length">
<div class="divider my-4 text-xs text-base-content/50">
Champs personnalisés machine
</div>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<!-- Documents -->
<div class="space-y-2">
<div class="flex items-center justify-between">
@@ -241,6 +268,7 @@
@update="updatePiece"
@edit="editPiece"
@custom-field-update="updatePieceCustomField"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>
</div>
</div>
@@ -276,6 +304,7 @@
@update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>
</div>
</div>
@@ -299,7 +328,6 @@ import {
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils'
import {
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentIcon,
@@ -308,6 +336,12 @@ import {
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
import {
mergeFieldDefinitionsWithValues,
dedupeMergedFields,
resolveCustomFieldId,
resolveFieldId,
} from '~/shared/utils/entityCustomFieldLogic'
const props = defineProps({
component: { type: Object, required: true },
@@ -317,7 +351,7 @@ const props = defineProps({
toggleToken: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
// --- Shared composables ---
const {
@@ -347,6 +381,34 @@ const {
updateCustomField: updateComponentCustomField,
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
const mergedContextFields = computed(() => {
const definitions = props.component?.contextCustomFields ?? []
const values = props.component?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return dedupeMergedFields(
mergeFieldDefinitionsWithValues(definitions, values),
)
})
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.component?.linkId
if (!linkId || !field) return
const customFieldId = resolveCustomFieldId(field)
const customFieldValueId = resolveFieldId(field)
if (!customFieldId && !customFieldValueId) return
field.value = value
emit('custom-field-update', {
entityType: 'machineComponentLink',
entityId: linkId,
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
fieldName: field.name || field.customField?.name || 'Champ contextuel',
})
}
// --- Document edit modal ---
const editingDocument = ref(null)
const editModalVisible = ref(false)

View File

@@ -14,7 +14,7 @@
/>
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
<div class="flex items-start justify-between p-4 rounded-lg" :class="piece._emptySlot ? 'bg-error/10 border border-error' : 'bg-base-200'">
<div class="flex items-start justify-between p-4 rounded-lg" :class="piece._emptySlot || piece.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'">
<div class="flex items-start gap-3 flex-1 min-w-0">
<button
type="button"
@@ -28,9 +28,18 @@
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
</button>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot }">
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
{{ pieceData.name }}
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1"> manquant</span>
<button
v-if="piece.pendingEntity"
type="button"
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors ml-1"
title="Cliquer pour associer un item"
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
>
À remplir
</button>
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"
@@ -77,7 +86,7 @@
</button>
</div>
<div v-show="!isCollapsed" class="space-y-4">
<div v-show="!isCollapsed && !piece.pendingEntity" class="space-y-4">
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
<div class="space-y-2 text-sm">
<div v-if="isEditMode" class="form-control">
@@ -232,10 +241,28 @@
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
title="Champs personnalisés item"
:editable="false"
@field-input="handleCustomFieldInput"
@field-blur="handleCustomFieldBlur"
/>
<template v-if="mergedContextFields.length">
<div class="divider my-4 text-xs text-base-content/50">
Champs personnalisés machine
</div>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
@@ -295,6 +322,9 @@ import {
import {
resolveFieldId,
resolveFieldReadOnly,
resolveCustomFieldId,
mergeFieldDefinitionsWithValues,
dedupeMergedFields,
} from '~/shared/utils/entityCustomFieldLogic'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
@@ -308,7 +338,7 @@ const props = defineProps({
toggleToken: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete', 'fill-entity'])
// --- Local reactive data for editing ---
const pieceData = reactive({
@@ -366,6 +396,34 @@ const {
updateCustomField,
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
const mergedContextFields = computed(() => {
const definitions = props.piece?.contextCustomFields ?? []
const values = props.piece?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return dedupeMergedFields(
mergeFieldDefinitionsWithValues(definitions, values),
)
})
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.piece?.linkId
if (!linkId || !field) return
const customFieldId = resolveCustomFieldId(field)
const customFieldValueId = resolveFieldId(field)
if (!customFieldId && !customFieldValueId) return
field.value = value
emit('custom-field-update', {
entityType: 'machinePieceLink',
entityId: linkId,
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
fieldName: field.name || field.customField?.name || 'Champ contextuel',
})
}
// --- Document edit modal ---
const editingDocument = ref(null)
const editModalVisible = ref(false)

View File

@@ -124,6 +124,11 @@
Obligatoire
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
Contexte machine uniquement
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"

View File

@@ -121,6 +121,10 @@
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
Obligatoire
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
Contexte machine uniquement
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"

View File

@@ -1,10 +1,10 @@
<template>
<div
v-if="fields.length"
class="mt-4 pt-4 border-t border-base-200"
:class="containerClass"
>
<h5 class="text-sm font-medium text-base-content/80 mb-3">
Champs personnalisés
<h5 v-if="showHeader" class="text-sm font-medium text-base-content/80 mb-3">
{{ title }}
</h5>
<div :class="layoutClass">
<div
@@ -23,7 +23,7 @@
</label>
<!-- Mode édition -->
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
<template v-if="isFieldEditable(field)">
<!-- Champ de type TEXT -->
<input
v-if="resolveFieldType(field) === 'text'"
@@ -142,6 +142,11 @@ const props = defineProps<{
fields: any[]
isEditMode: boolean
columns?: 1 | 2
title?: string
showHeader?: boolean
withTopBorder?: boolean
editable?: boolean
emitBlur?: boolean
}>()
const emit = defineEmits<{
@@ -155,6 +160,20 @@ const layoutClass = computed(() =>
: 'space-y-3',
)
const title = computed(() => props.title ?? 'Champs personnalisés')
const showHeader = computed(() => props.showHeader ?? true)
const containerClass = computed(() =>
props.withTopBorder === false
? ''
: 'mt-4 pt-4 border-t border-base-200',
)
const editable = computed(() => props.editable ?? true)
const emitBlur = computed(() => props.emitBlur ?? true)
function isFieldEditable(field: any) {
return props.isEditMode && editable.value && !resolveFieldReadOnly(field)
}
function onInput(field: any, value: string) {
field.value = value
emit('field-input', field, value)
@@ -164,10 +183,14 @@ function onBooleanChange(field: any, checked: boolean) {
const value = checked ? 'true' : 'false'
field.value = value
emit('field-input', field, value)
emit('field-blur', field)
if (emitBlur.value) {
emit('field-blur', field)
}
}
function onBlur(field: any) {
emit('field-blur', field)
if (emitBlur.value) {
emit('field-blur', field)
}
}
</script>

View File

@@ -49,6 +49,12 @@
/>
</div>
<div v-if="selectedTypeName && !selectedEntityId && !loadingEntities" class="bg-warning/10 border border-warning rounded-lg p-3 mb-4">
<p class="text-sm text-warning font-medium">
Aucun item sélectionné — la catégorie sera ajoutée avec le statut "À remplir".
</p>
</div>
<!-- Summary of selection -->
<div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4">
<p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p>
@@ -64,10 +70,10 @@
<button
type="button"
class="btn btn-primary"
:disabled="!selectedEntityId"
:disabled="!selectedTypeId"
@click="handleConfirm"
>
Ajouter
{{ selectedEntityId ? 'Ajouter' : 'Ajouter (catégorie seule)' }}
</button>
</div>
</div>
@@ -90,11 +96,12 @@ type EntityKind = 'component' | 'piece' | 'product'
const props = defineProps<{
open: boolean
entityKind: EntityKind
prefillTypeId?: string
}>()
const emit = defineEmits<{
close: []
confirm: [entityId: string]
confirm: [payload: { entityId?: string; modelTypeId: string; modelTypeName: string }]
}>()
const selectedTypeId = ref('')
@@ -166,6 +173,10 @@ watch(() => props.open, async (isOpen) => {
if (props.entityKind === 'component') await loadComponentTypes()
else if (props.entityKind === 'piece') await loadPieceTypes()
else await loadProductTypes()
if (props.prefillTypeId) {
selectedTypeId.value = props.prefillTypeId
}
})
// Load entities when type changes
@@ -222,8 +233,12 @@ const handleClose = () => {
}
const handleConfirm = () => {
if (!selectedEntityId.value) return
emit('confirm', selectedEntityId.value)
if (!selectedTypeId.value) return
emit('confirm', {
entityId: selectedEntityId.value || undefined,
modelTypeId: selectedTypeId.value,
modelTypeName: selectedTypeName.value,
})
resetState()
emit('close')
}

View File

@@ -28,12 +28,15 @@
<div v-for="component in components" :key="component.id">
<ComponentHierarchy
:components="[component]"
:is-edit-mode="false"
:is-edit-mode="isEditMode"
:show-delete="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@update="$emit('update-component', $event)"
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('remove-component', component.linkId || component.id)"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>
</div>
</div>
@@ -68,5 +71,6 @@ defineEmits<{
'custom-field-update': [fieldUpdate: any]
'add-component': []
'remove-component': [linkId: string]
'fill-entity': [linkId: string, modelTypeId: string]
}>()
</script>

View File

@@ -34,7 +34,9 @@
:toggle-token="collapseToggleToken"
@update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('remove-piece', piece.linkId || piece.id)"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>
</div>
</div>
@@ -65,7 +67,9 @@ defineEmits<{
'toggle-collapse': []
'update-piece': [piece: any]
'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
'add-piece': []
'remove-piece': [linkId: string]
'fill-entity': [linkId: string, modelTypeId: string]
}>()
</script>

View File

@@ -23,14 +23,24 @@
<div v-if="products.length" class="space-y-3">
<div
v-for="product in products"
:key="product.id || product.name"
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2"
:key="product.id || product.linkId || product.name"
class="rounded border p-3 text-sm space-y-2"
:class="product.pendingEntity ? 'border-error bg-error/10' : 'border-base-200 bg-base-200/60'"
>
<div class="flex items-center justify-between flex-wrap gap-2">
<p class="font-semibold text-base-content">
<p class="font-semibold" :class="product.pendingEntity ? 'text-error' : 'text-base-content'">
{{ product.name }}
</p>
<div class="flex items-center gap-2">
<button
v-if="product.pendingEntity"
type="button"
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
title="Cliquer pour associer un item"
@click="$emit('fill-entity', (product.linkId || product.id) as string, product.modelTypeId as string)"
>
À remplir
</button>
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
{{ product.groupLabel }}
</span>
@@ -141,6 +151,9 @@ defineProps<{
supplierLabel?: string | null
priceLabel?: string | null
groupLabel?: string
pendingEntity?: boolean
modelTypeId?: string | null
modelType?: string | null
documents?: Array<{
id?: string
name?: string
@@ -156,6 +169,7 @@ defineProps<{
defineEmits<{
'add-product': []
'remove-product': [linkId: string]
'fill-entity': [linkId: string, modelTypeId: string]
}>()
const previewDocument = ref<any>(null)

View File

@@ -45,7 +45,7 @@ export function useEntityCustomFields(deps: EntityCustomFieldsDeps) {
definitionSources.value,
entity().customFieldValues,
),
),
).filter((field: any) => !field.machineContextOnly && !field.customField?.machineContextOnly),
)
const candidateCustomFields = computed(() =>

View File

@@ -44,6 +44,7 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
// ---------------------------------------------------------------------------
const machineCustomFields = ref<AnyRecord[]>([])
const pendingContextFieldUpdates = ref<AnyRecord[]>([])
// ---------------------------------------------------------------------------
// Computed
@@ -140,7 +141,9 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
return {
...normalizedPiece,
customFields,
customFields: customFields.filter((f: any) => !f.machineContextOnly && !f.customField?.machineContextOnly),
contextCustomFields: piece.contextCustomFields ?? [],
contextCustomFieldValues: piece.contextCustomFieldValues ?? [],
documents: piece.documents || [],
constructeurs: constructeursList,
constructeur: constructeursList[0] || piece.constructeur || null,
@@ -240,7 +243,9 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
return {
...normalizedComponent,
customFields,
customFields: customFields.filter((f: any) => !f.machineContextOnly && !f.customField?.machineContextOnly),
contextCustomFields: component.contextCustomFields ?? [],
contextCustomFieldValues: component.contextCustomFieldValues ?? [],
pieces: piecesTransformed,
subComponents,
documents: component.documents || [],
@@ -376,6 +381,83 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
}
}
const handleCustomFieldUpdate = async (fieldUpdate: AnyRecord) => {
if (fieldUpdate?.entityType && fieldUpdate?.entityId) {
queueContextFieldUpdate(fieldUpdate)
return
}
await updatePieceCustomField(fieldUpdate)
}
const queueContextFieldUpdate = (fieldUpdate: AnyRecord) => {
const entityType = fieldUpdate.entityType as string | undefined
const entityId = fieldUpdate.entityId as string | undefined
const fieldId = fieldUpdate.fieldId as string | undefined
const customFieldValueId = fieldUpdate.customFieldValueId as string | undefined
if (!entityType || !entityId || (!fieldId && !customFieldValueId)) return
const nextUpdate = {
entityType,
entityId,
fieldId,
customFieldValueId,
value: fieldUpdate.value ?? '',
fieldName: fieldUpdate.fieldName ?? 'Champ contextuel',
}
const existingIndex = pendingContextFieldUpdates.value.findIndex(
(item) =>
item.entityType === entityType &&
item.entityId === entityId &&
item.fieldId === fieldId &&
item.customFieldValueId === customFieldValueId,
)
if (existingIndex >= 0) {
pendingContextFieldUpdates.value[existingIndex] = nextUpdate
return
}
pendingContextFieldUpdates.value.push(nextUpdate)
}
const clearPendingContextFieldUpdates = () => {
pendingContextFieldUpdates.value = []
}
const saveAllContextCustomFields = async () => {
const updates = pendingContextFieldUpdates.value.slice()
if (!updates.length) return
try {
for (const update of updates) {
if (update.customFieldValueId) {
await updateCustomFieldValueApi(update.customFieldValueId as string, {
value: update.value ?? '',
} as any)
continue
}
if (!update.fieldId) {
continue
}
await upsertCustomFieldValue(
update.fieldId as string,
update.entityType as string,
update.entityId as string,
update.value ?? '',
)
}
clearPendingContextFieldUpdates()
} catch (error) {
console.error('Erreur lors de la sauvegarde batch des champs contextuels:', error)
throw error
}
}
const saveAllMachineCustomFields = async () => {
if (!machine.value) return
@@ -431,6 +513,7 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
return {
// State
machineCustomFields,
pendingContextFieldUpdates,
// Computed
visibleMachineCustomFields,
@@ -444,6 +527,10 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
handleCustomFieldUpdate,
queueContextFieldUpdate,
clearPendingContextFieldUpdates,
saveAllMachineCustomFields,
saveAllContextCustomFields,
}
}

View File

@@ -151,13 +151,18 @@ export function useMachineDetailData(machineId: string) {
const {
machineCustomFields,
visibleMachineCustomFields,
pendingContextFieldUpdates,
transformCustomFields,
transformComponentCustomFields,
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
handleCustomFieldUpdate,
queueContextFieldUpdate,
clearPendingContextFieldUpdates,
saveAllMachineCustomFields,
saveAllContextCustomFields,
} = useMachineDetailCustomFields({
machine,
isEditMode,
@@ -193,6 +198,10 @@ export function useMachineDetailData(machineId: string) {
removePieceLink,
addProductLink,
removeProductLink,
addComponentLinkCategoryOnly,
addPieceLinkCategoryOnly,
addProductLinkCategoryOnly,
fillEntityLink,
} = hierarchy
// Keep the product links proxy in sync with the hierarchy's machineProductLinks
@@ -329,10 +338,13 @@ export function useMachineDetailData(machineId: string) {
// 2. Save all custom field values
await saveAllMachineCustomFields()
// 3. Reload machine data to get fresh state
// 3. Save contextual custom field values queued from piece/component inputs
await saveAllContextCustomFields()
// 4. Reload machine data to get fresh state
await loadMachineData()
// 4. Exit edit mode
// 5. Exit edit mode
isEditMode.value = false
toast.showSuccess('Machine mise à jour avec succès')
} catch (error) {
@@ -346,6 +358,7 @@ export function useMachineDetailData(machineId: string) {
const cancelEdition = () => {
initMachineFields()
syncMachineCustomFields()
clearPendingContextFieldUpdates()
constructeurLinks.value = originalConstructeurLinks.value.map(l => ({ ...l }))
machineConstructeurIds.value = constructeurIdsFromLinks(constructeurLinks.value)
isEditMode.value = false
@@ -482,7 +495,7 @@ export function useMachineDetailData(machineId: string) {
// UI state
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
machineCustomFields, previewDocument, previewVisible,
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
isEditMode, debug,
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
@@ -495,7 +508,8 @@ export function useMachineDetailData(machineId: string) {
findProductById, resolveProductReference, getProductDisplay,
initMachineFields, getMachineFieldId,
syncMachineCustomFields, setMachineCustomFieldValue,
updateMachineCustomField, updatePieceCustomField,
updateMachineCustomField, updatePieceCustomField, handleCustomFieldUpdate,
queueContextFieldUpdate, clearPendingContextFieldUpdates, saveAllContextCustomFields,
refreshMachineDocuments, handleMachineFilesAdded, removeMachineDocument,
openPreview, closePreview,
updateMachineInfo, updateComponent, updatePieceFromComponent,
@@ -511,6 +525,8 @@ export function useMachineDetailData(machineId: string) {
loadMachineData, loadInitialData,
addComponentLink, removeComponentLink, addPieceLink, removePieceLink,
addProductLink, removeProductLink, reloadMachineStructure,
addComponentLinkCategoryOnly, addPieceLinkCategoryOnly,
addProductLinkCategoryOnly, fillEntityLink,
// External
constructeurs, loadProducts, updateMachineStructure, toast,

View File

@@ -39,7 +39,7 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
syncMachineCustomFields,
} = deps
const { get, post: apiPost, delete: apiDel } = useApi()
const { get, post: apiPost, delete: apiDel, patch: apiPatch } = useApi()
const toast = useToast()
// ---------------------------------------------------------------------------
@@ -263,6 +263,69 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
return result
}
const addComponentLinkCategoryOnly = async (modelTypeId: string) => {
const result: any = await apiPost('/machine_component_links', {
machine: `/api/machines/${machineId}`,
modelType: `/api/model_types/${modelTypeId}`,
})
if (result.success) {
toast.showSuccess('Catégorie ajoutée — item à remplir')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout')
}
}
const addPieceLinkCategoryOnly = async (modelTypeId: string) => {
const result: any = await apiPost('/machine_piece_links', {
machine: `/api/machines/${machineId}`,
modelType: `/api/model_types/${modelTypeId}`,
})
if (result.success) {
toast.showSuccess('Catégorie ajoutée — item à remplir')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout')
}
}
const addProductLinkCategoryOnly = async (modelTypeId: string) => {
const result: any = await apiPost('/machine_product_links', {
machine: `/api/machines/${machineId}`,
modelType: `/api/model_types/${modelTypeId}`,
})
if (result.success) {
toast.showSuccess('Catégorie ajoutée — item à remplir')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout')
}
}
const fillEntityLink = async (linkId: string, entityId: string, entityKind: string) => {
let endpoint = ''
let payload: Record<string, string> = {}
if (entityKind === 'component') {
endpoint = `/machine_component_links/${linkId}`
payload = { composant: `/api/composants/${entityId}` }
} else if (entityKind === 'piece') {
endpoint = `/machine_piece_links/${linkId}`
payload = { piece: `/api/pieces/${entityId}` }
} else {
endpoint = `/machine_product_links/${linkId}`
payload = { product: `/api/products/${entityId}` }
}
const result: any = await apiPatch(endpoint, payload)
if (result.success) {
toast.showSuccess('Item associé avec succès')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'association')
}
}
const removeProductLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_product_links/${linkId}`)
if (result.success) {
@@ -301,6 +364,10 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
addPieceLink,
removePieceLink,
addProductLink,
addComponentLinkCategoryOnly,
addPieceLinkCategoryOnly,
addProductLinkCategoryOnly,
fillEntityLink,
removeProductLink,
}
}

View File

@@ -103,7 +103,7 @@ export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
return {
id: (resolved?.id as string) || productId || null,
linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null,
name: (resolved?.name as string) || 'Produit inconnu',
name: (resolved?.name as string) || (link.modelType as AnyRecord)?.name as string || 'Produit inconnu',
reference: (resolved?.reference as string) || null,
supplierLabel: resolvedConstructeurs.length
? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null
@@ -111,6 +111,9 @@ export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
priceLabel: resolved ? getProductPriceLabel(resolved) : null,
groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '',
documents: productId ? (productDocumentsMap.value.get(productId) || []) : [],
pendingEntity: (link.pendingEntity as boolean) || false,
modelTypeId: (link.modelTypeId as string) || null,
modelType: (link.modelType as string) || null,
}
})
})

View File

@@ -73,11 +73,11 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const updateMachineInfo = async () => {
if (!machine.value) return
try {
const result: any = await updateMachineApi(machine.value.id as string, {
name: machineName.value,
reference: machineReference.value,
siteId: machineSiteId.value || undefined,
} as any)
const payload: Record<string, unknown> = {}
if (machineName.value !== machine.value.name) payload.name = machineName.value
if (machineReference.value !== machine.value.reference) payload.reference = machineReference.value
if ((machineSiteId.value || undefined) !== ((machine.value.siteId as string) || (machine.value.site as any)?.id || undefined)) payload.siteId = machineSiteId.value || undefined
const result: any = await updateMachineApi(machine.value.id as string, payload as any)
if (result.success) {
const machinePayload =
result.data?.machine && typeof result.data.machine === 'object'

View File

@@ -150,6 +150,30 @@ export const buildMachineHierarchyFromLinks = (
const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => {
if (!link || typeof link !== 'object') return null
// Category-only link (no entity yet)
if (link.pendingEntity || (!link.piece && !link.pieceId)) {
const machinePieceLinkId = normalizePieceLinkId(link)
const mt = (link.modelType || null) as AnyRecord | null
return {
id: machinePieceLinkId || `pending-${link.id}`,
linkId: machinePieceLinkId,
name: mt?.name || 'Catégorie sans item',
reference: null,
prix: null,
pendingEntity: true,
modelTypeId: link.modelTypeId || mt?.id || null,
modelType: mt,
pieceId: null,
constructeurs: [],
documents: [],
customFields: [],
parentComponentLinkId: link.parentComponentLinkId || link.parentLinkId || null,
parentComponentName,
machinePieceLinkId,
quantity: 1,
}
}
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
@@ -184,6 +208,8 @@ export const buildMachineHierarchyFromLinks = (
quantity: typeof link.quantity === 'number' ? link.quantity : 1,
definition: appliedPiece.definition || originalPiece?.definition || {},
customFields: appliedPiece.customFields || [],
contextCustomFields: link.contextCustomFields || [],
contextCustomFieldValues: link.contextCustomFieldValues || [],
}
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
@@ -205,6 +231,35 @@ export const buildMachineHierarchyFromLinks = (
const createComponentNode = (link: AnyRecord): AnyRecord | null => {
if (!link || typeof link !== 'object') return null
// Category-only link (no entity yet)
if (link.pendingEntity || (!link.composant && !link.composantId)) {
const machineComponentLinkId = normalizeComponentLinkId(link)
const mt = (link.modelType || null) as AnyRecord | null
return {
id: machineComponentLinkId || `pending-${link.id}`,
linkId: machineComponentLinkId,
name: mt?.name || 'Catégorie sans item',
reference: null,
prix: null,
pendingEntity: true,
modelTypeId: link.modelTypeId || mt?.id || null,
modelType: mt,
composantId: null,
composant: null,
constructeurs: [],
documents: [],
customFields: [],
customFieldValues: [],
subComponents: [],
pieces: [],
overrides: null,
parentComponentLinkId: link.parentComponentLinkId || link.parentLinkId || null,
machineComponentLinkId,
childLinks: [],
pieceLinks: [],
}
}
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
@@ -282,6 +337,8 @@ export const buildMachineHierarchyFromLinks = (
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
definition: appliedComponent.definition || originalComponent?.definition || {},
customFields: appliedComponent.customFields || [],
contextCustomFields: link.contextCustomFields || [],
contextCustomFieldValues: link.contextCustomFieldValues || [],
pieces,
subComponents,
subcomponents: subComponents,

View File

@@ -88,6 +88,7 @@ const toEditorField = (
...(typeof input?.id === 'string' && input.id ? { id: input.id } : {}),
...(typeof input?.customFieldId === 'string' && input.customFieldId ? { customFieldId: input.customFieldId } : {}),
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
machineContextOnly: Boolean(input?.machineContextOnly),
}
}
@@ -162,6 +163,7 @@ const buildPayload = (
type,
required,
orderIndex: index,
machineContextOnly: Boolean(field.machineContextOnly),
}
if (field.id) {
@@ -286,6 +288,7 @@ export function usePieceStructureEditorLogic(deps: Deps) {
type: 'text',
required: false,
optionsText: '',
machineContextOnly: false,
orderIndex,
})

View File

@@ -56,6 +56,7 @@ export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
required: false,
optionsText: '',
options: [],
machineContextOnly: false,
orderIndex: nextIndex,
})
reindexCustomFields()

View File

@@ -100,6 +100,7 @@
:is-edit-mode="d.isEditMode.value"
@add-product="openAddModal('product')"
@remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'product', typeId)"
/>
<!-- Components Section -->
@@ -112,9 +113,10 @@
@toggle-collapse="d.toggleAllComponents"
@update-component="d.updateComponent"
@edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.updatePieceCustomField"
@custom-field-update="d.handleCustomFieldUpdate"
@add-component="openAddModal('component')"
@remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'component', typeId)"
/>
<!-- Machine Pieces Section -->
@@ -126,9 +128,10 @@
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
@update-piece="d.updatePieceInfo"
@edit-piece="d.editPiece"
@custom-field-update="d.updatePieceCustomField"
@custom-field-update="d.handleCustomFieldUpdate"
@add-piece="openAddModal('piece')"
@remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'piece', typeId)"
@toggle-collapse="d.toggleAllPieces"
/>
@@ -136,7 +139,8 @@
<AddEntityToMachineModal
:open="addModalOpen"
:entity-kind="addModalKind"
@close="addModalOpen = false"
:prefill-type-id="fillTypeId"
@close="addModalOpen = false; fillLinkId = ''; fillTypeId = ''"
@confirm="handleAddEntity"
/>
@@ -277,6 +281,8 @@ const historyFieldLabels = {
const addModalOpen = ref(false)
const addModalKind = ref('component')
const fillLinkId = ref('')
const fillTypeId = ref('')
const openAddModal = (kind) => {
addModalKind.value = kind
@@ -288,17 +294,40 @@ const handleRemoveConstructeurLink = (constructeurId) => {
d.handleMachineConstructeurChange(ids)
}
const handleAddEntity = async (entityId) => {
if (addModalKind.value === 'component') {
await d.addComponentLink(entityId)
} else if (addModalKind.value === 'piece') {
await d.addPieceLink(entityId)
const handleAddEntity = async (payload) => {
const { entityId, modelTypeId } = payload
if (fillLinkId.value) {
await d.fillEntityLink(fillLinkId.value, entityId, addModalKind.value)
fillLinkId.value = ''
fillTypeId.value = ''
} else if (entityId) {
if (addModalKind.value === 'component') {
await d.addComponentLink(entityId)
} else if (addModalKind.value === 'piece') {
await d.addPieceLink(entityId)
} else {
await d.addProductLink(entityId)
}
} else {
await d.addProductLink(entityId)
if (addModalKind.value === 'component') {
await d.addComponentLinkCategoryOnly(modelTypeId)
} else if (addModalKind.value === 'piece') {
await d.addPieceLinkCategoryOnly(modelTypeId)
} else {
await d.addProductLinkCategoryOnly(modelTypeId)
}
}
refreshVersions()
}
const handleFillEntity = (linkId, entityKind, modelTypeId) => {
fillLinkId.value = linkId
fillTypeId.value = modelTypeId
addModalKind.value = entityKind
addModalOpen.value = true
}
const machineViewTitle = computed(() => {
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
})

View File

@@ -98,6 +98,7 @@ export const normalizeStructureForEditor = (input: any): ComponentModelStructure
name: field.name,
type: field.type,
required: field.required,
machineContextOnly: !!field.machineContextOnly,
options,
defaultValue,
optionsText,
@@ -141,6 +142,7 @@ export const normalizeStructureForSave = (input: any): any => {
const value: Record<string, any> = {
type: field.type,
required: !!field.required,
machineContextOnly: !!field.machineContextOnly,
}
if (field.options && field.options.length) {
value.options = field.options

View File

@@ -78,10 +78,18 @@ export const hydrateCustomFields = (fields: any[]): any[] => {
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
const machineContextOnly =
typeof field?.machineContextOnly === 'boolean'
? field.machineContextOnly
: typeof valueObject?.machineContextOnly === 'boolean'
? valueObject.machineContextOnly
: false
return {
name,
type,
required,
machineContextOnly,
options,
optionsText,
defaultValue,
@@ -153,6 +161,7 @@ export const mapComponentCustomFields = (fields: any[]) => {
name: typeof field?.name === 'string' ? field.name : '',
type: field?.type ?? 'text',
required: !!field?.required,
machineContextOnly: !!field?.machineContextOnly,
options: Array.isArray(field?.options) ? field.options : [],
optionsText: typeof field?.optionsText === 'string' ? field.optionsText : '',
defaultValue,

View File

@@ -85,7 +85,10 @@ export const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[]
}
}
const result: ComponentModelCustomField = { name, type, required }
const machineContextOnly =
typeof valueObject?.machineContextOnly === 'boolean' ? valueObject.machineContextOnly : !!field?.machineContextOnly
const result: ComponentModelCustomField = { name, type, required, machineContextOnly }
if (options) {
result.options = options
}

View File

@@ -82,7 +82,7 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
options = parsed.length > 0 ? parsed : undefined
}
const result: PieceModelCustomField = { name, type, required }
const result: PieceModelCustomField = { name, type, required, machineContextOnly: !!field?.machineContextOnly }
if (options) {
result.options = options
}
@@ -131,6 +131,7 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
name: field?.name ?? '',
type: field?.type ?? 'text',
required: !!field?.required,
machineContextOnly: !!field?.machineContextOnly,
options: Array.isArray(field?.options) ? field.options : undefined,
optionsText: typeof field?.optionsText === 'string'
? field.optionsText

View File

@@ -10,6 +10,7 @@ export interface ComponentModelCustomField {
id?: string
customFieldId?: string
orderIndex?: number
machineContextOnly?: boolean
key?: string
value?: unknown
}
@@ -58,6 +59,7 @@ export interface PieceModelCustomField {
required: boolean
options?: string[]
orderIndex?: number
machineContextOnly?: boolean
key?: string
value?: unknown
defaultValue?: string | null

View File

@@ -63,6 +63,7 @@ RUN rm -f /etc/nginx/sites-enabled/default
# Configs
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/inventory.conf
COPY infra/prod/maintenance.html /var/www/html/public/maintenance.html
# Backend from stage 1
COPY --from=backend-build /app /var/www/html

View File

@@ -8,6 +8,9 @@ export INVENTORY_IMAGE_TAG="$TAG"
echo "==> Deploying inventory:${TAG}..."
echo "==> Enabling maintenance mode..."
touch maintenance.on
echo "==> Pulling image..."
sudo docker compose pull
@@ -17,6 +20,10 @@ sudo docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Extracting maintenance page..."
mkdir -p public
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
@@ -24,5 +31,8 @@ echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
echo "==> Disabling maintenance mode..."
rm -f maintenance.on
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}"

View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maintenance en cours</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.container {
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.10);
padding: 48px 40px;
max-width: 480px;
text-align: center;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
}
h1 {
font-size: 24px;
color: #111827;
margin: 0 0 12px;
}
p {
font-size: 16px;
color: #6b7280;
margin: 0;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">&#128736;</div>
<h1>Maintenance en cours</h1>
<p>L'application est temporairement indisponible pour mise à jour. Elle sera de retour dans quelques instants.</p>
</div>
</body>
</html>

View File

@@ -3,8 +3,25 @@ server {
listen [::]:80;
server_name inventory.malio-dev.fr;
root /var/www/inventory/public;
# Maintenance mode
if (-f /var/www/inventory/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_pass http://127.0.0.1:8082;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -2,6 +2,23 @@ server {
listen 80;
server_name _;
# Maintenance mode
if (-f /var/www/html/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
root /var/www/html/public;
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
root /var/www/html/public;
internal;
}
root /var/www/html/frontend/.output/public;
index index.html;

View File

@@ -47,7 +47,7 @@ restart: env-init
$(DOCKER_COMPOSE) down
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate
# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
reset: delete_built_dir remove_orphans build-without-cache start wait install
@@ -89,6 +89,9 @@ db-restart:
$(DOCKER_COMPOSE) down
$(DOCKER_COMPOSE) up -d
migration-migrate:
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
cache-clear:
$(SYMFONY_CONSOLE) cache:clear
@@ -105,6 +108,13 @@ copy-git-hook:
shell:
$(EXEC_PHP_INTERACTIVE) bash
shell-root:
$(EXEC_PHP_INTERACTIVE_ROOT) bash
# Suivi temps réel des logs dev
logs-dev:
$(EXEC_PHP_INTERACTIVE) sh -lc "tail -f var/log/dev.log"
# Force la version node
node-use:
bash -lc 'source "$$HOME/.nvm/nvm.sh" && nvm install && nvm use'

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260403084805 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add machineContextOnly to custom_fields + link FKs on custom_field_values';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL');
$this->addSql('ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinecomponentlinkid VARCHAR(36) DEFAULT NULL');
$this->addSql('ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinepiecelinkid VARCHAR(36) DEFAULT NULL');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_component_link') THEN
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_component_link
FOREIGN KEY (machinecomponentlinkid) REFERENCES machine_component_links(id) ON DELETE CASCADE;
END IF;
END $$;
SQL);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_piece_link') THEN
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_piece_link
FOREIGN KEY (machinepiecelinkid) REFERENCES machine_piece_links(id) ON DELETE CASCADE;
END IF;
END $$;
SQL);
$this->addSql('CREATE INDEX IF NOT EXISTS idx_cfv_machine_component_link ON custom_field_values(machinecomponentlinkid)');
$this->addSql('CREATE INDEX IF NOT EXISTS idx_cfv_machine_piece_link ON custom_field_values(machinepiecelinkid)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_machine_component_link');
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_machine_piece_link');
$this->addSql('DROP INDEX IF EXISTS idx_cfv_machine_component_link');
$this->addSql('DROP INDEX IF EXISTS idx_cfv_machine_piece_link');
$this->addSql('ALTER TABLE custom_field_values DROP COLUMN IF EXISTS machinecomponentlinkid');
$this->addSql('ALTER TABLE custom_field_values DROP COLUMN IF EXISTS machinepiecelinkid');
$this->addSql('ALTER TABLE custom_fields DROP COLUMN IF EXISTS machinecontextonly');
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260403_CategoryOnlyLinks extends AbstractMigration
{
public function getDescription(): string
{
return 'Allow category-only machine links: make entity FKs nullable, add modelType FK to link tables';
}
public function up(Schema $schema): void
{
// 1. Make entity FK columns nullable
$this->addSql('ALTER TABLE machine_component_links ALTER COLUMN composantid DROP NOT NULL');
$this->addSql('ALTER TABLE machine_piece_links ALTER COLUMN pieceid DROP NOT NULL');
$this->addSql('ALTER TABLE machine_product_links ALTER COLUMN productid DROP NOT NULL');
// 2. Add modeltypeid column to all 3 tables
$this->addSql('ALTER TABLE machine_component_links ADD COLUMN IF NOT EXISTS modeltypeid VARCHAR(36) DEFAULT NULL');
$this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS modeltypeid VARCHAR(36) DEFAULT NULL');
$this->addSql('ALTER TABLE machine_product_links ADD COLUMN IF NOT EXISTS modeltypeid VARCHAR(36) DEFAULT NULL');
// 3. Add FK constraints from modeltypeid to model_types(id) ON DELETE SET NULL
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'fk_machine_component_links_modeltype' AND table_name = 'machine_component_links'
) THEN
ALTER TABLE machine_component_links ADD CONSTRAINT fk_machine_component_links_modeltype
FOREIGN KEY (modeltypeid) REFERENCES model_types(id) ON DELETE SET NULL;
END IF;
END $$;
SQL);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'fk_machine_piece_links_modeltype' AND table_name = 'machine_piece_links'
) THEN
ALTER TABLE machine_piece_links ADD CONSTRAINT fk_machine_piece_links_modeltype
FOREIGN KEY (modeltypeid) REFERENCES model_types(id) ON DELETE SET NULL;
END IF;
END $$;
SQL);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'fk_machine_product_links_modeltype' AND table_name = 'machine_product_links'
) THEN
ALTER TABLE machine_product_links ADD CONSTRAINT fk_machine_product_links_modeltype
FOREIGN KEY (modeltypeid) REFERENCES model_types(id) ON DELETE SET NULL;
END IF;
END $$;
SQL);
// 4. Add indexes on modeltypeid
$this->addSql('CREATE INDEX IF NOT EXISTS idx_machine_component_links_modeltypeid ON machine_component_links (modeltypeid)');
$this->addSql('CREATE INDEX IF NOT EXISTS idx_machine_piece_links_modeltypeid ON machine_piece_links (modeltypeid)');
$this->addSql('CREATE INDEX IF NOT EXISTS idx_machine_product_links_modeltypeid ON machine_product_links (modeltypeid)');
}
public function down(Schema $schema): void
{
// Drop indexes
$this->addSql('DROP INDEX IF EXISTS idx_machine_component_links_modeltypeid');
$this->addSql('DROP INDEX IF EXISTS idx_machine_piece_links_modeltypeid');
$this->addSql('DROP INDEX IF EXISTS idx_machine_product_links_modeltypeid');
// Drop FK constraints
$this->addSql('ALTER TABLE machine_component_links DROP CONSTRAINT IF EXISTS fk_machine_component_links_modeltype');
$this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_machine_piece_links_modeltype');
$this->addSql('ALTER TABLE machine_product_links DROP CONSTRAINT IF EXISTS fk_machine_product_links_modeltype');
// Drop modeltypeid columns
$this->addSql('ALTER TABLE machine_component_links DROP COLUMN IF EXISTS modeltypeid');
$this->addSql('ALTER TABLE machine_piece_links DROP COLUMN IF EXISTS modeltypeid');
$this->addSql('ALTER TABLE machine_product_links DROP COLUMN IF EXISTS modeltypeid');
// Restore NOT NULL on entity FK columns
$this->addSql('ALTER TABLE machine_component_links ALTER COLUMN composantid SET NOT NULL');
$this->addSql('ALTER TABLE machine_piece_links ALTER COLUMN pieceid SET NOT NULL');
$this->addSql('ALTER TABLE machine_product_links ALTER COLUMN productid SET NOT NULL');
}
}

View File

@@ -9,6 +9,8 @@ use App\Entity\CustomFieldValue;
use App\Repository\ComposantRepository;
use App\Repository\CustomFieldRepository;
use App\Repository\CustomFieldValueRepository;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
@@ -29,6 +31,8 @@ class CustomFieldValueController extends AbstractController
private readonly ComposantRepository $composantRepository,
private readonly PieceRepository $pieceRepository,
private readonly ProductRepository $productRepository,
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
) {}
#[Route('', name: 'custom_field_values_create', methods: ['POST'])]
@@ -214,7 +218,7 @@ class CustomFieldValueController extends AbstractController
$entityId = isset($payload['entityId']) ? trim((string) $payload['entityId']) : '';
if ('' === $entityType || '' === $entityId) {
foreach (['machine', 'composant', 'piece', 'product'] as $candidate) {
foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) {
$key = $candidate.'Id';
if (!isset($payload[$key])) {
continue;
@@ -226,16 +230,20 @@ class CustomFieldValueController extends AbstractController
}
}
$entityType = strtolower($entityType);
if ('' === $entityType || '' === $entityId) {
return $this->json(['success' => false, 'error' => 'Entity target is missing.'], 400);
}
return match ($entityType) {
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
'machinecomponentlink' => $this->resolveEntity('machineComponentLink', $entityId, $this->machineComponentLinkRepository),
'machinepiecelink' => $this->resolveEntity('machinePieceLink', $entityId, $this->machinePieceLinkRepository),
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
};
}
@@ -270,6 +278,18 @@ class CustomFieldValueController extends AbstractController
case 'product':
$value->setProduct($entity);
break;
case 'machineComponentLink':
$value->setMachineComponentLink($entity);
$value->setComposant($entity->getComposant());
break;
case 'machinePieceLink':
$value->setMachinePieceLink($entity);
$value->setPiece($entity->getPiece());
break;
}
}

View File

@@ -162,6 +162,9 @@ class MachineStructureController extends AbstractController
// Copy product links
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
// Copy context field values
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
$this->entityManager->flush();
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
@@ -188,6 +191,7 @@ class MachineStructureController extends AbstractController
$newCf->setDefaultValue($cf->getDefaultValue());
$newCf->setOptions($cf->getOptions());
$newCf->setOrderIndex($cf->getOrderIndex());
$newCf->setMachineContextOnly($cf->isMachineContextOnly());
$newCf->setMachine($target);
$this->entityManager->persist($newCf);
@@ -313,6 +317,45 @@ class MachineStructureController extends AbstractController
}
}
/**
* @param array<string, MachineComponentLink> $componentLinkMap
* @param array<string, MachinePieceLink> $pieceLinkMap
*/
private function cloneContextFieldValues(
array $componentLinkMap,
array $pieceLinkMap,
): void {
foreach ($componentLinkMap as $oldLinkId => $newLink) {
$oldLink = $this->machineComponentLinkRepository->find($oldLinkId);
if (!$oldLink) {
continue;
}
foreach ($oldLink->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachineComponentLink($newLink);
$newValue->setComposant($newLink->getComposant());
$this->entityManager->persist($newValue);
}
}
foreach ($pieceLinkMap as $oldLinkId => $newLink) {
$oldLink = $this->machinePieceLinkRepository->find($oldLinkId);
if (!$oldLink) {
continue;
}
foreach ($oldLink->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachinePieceLink($newLink);
$newValue->setPiece($newLink->getPiece());
$this->entityManager->persist($newValue);
}
}
}
private function normalizePayloadList(mixed $value): array
{
if (!is_array($value)) {
@@ -606,13 +649,14 @@ class MachineStructureController extends AbstractController
continue;
}
$items[] = [
'id' => $customField->getId(),
'name' => $customField->getName(),
'type' => $customField->getType(),
'required' => $customField->isRequired(),
'options' => $customField->getOptions(),
'defaultValue' => $customField->getDefaultValue(),
'orderIndex' => $customField->getOrderIndex(),
'id' => $customField->getId(),
'name' => $customField->getName(),
'type' => $customField->getType(),
'required' => $customField->isRequired(),
'options' => $customField->getOptions(),
'defaultValue' => $customField->getDefaultValue(),
'orderIndex' => $customField->getOrderIndex(),
'machineContextOnly' => $customField->isMachineContextOnly(),
];
}
@@ -623,20 +667,27 @@ class MachineStructureController extends AbstractController
{
return array_map(function (MachineComponentLink $link): array {
$composant = $link->getComposant();
$modelType = $link->getModelType();
$parentLink = $link->getParentLink();
$type = $composant?->getTypeComposant();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant?->getId(),
'composant' => $composant ? $this->normalizeComposant($composant) : null,
'modelTypeId' => $modelType?->getId(),
'modelType' => $modelType ? $this->normalizeModelType($modelType) : null,
'pendingEntity' => null === $composant,
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()?->getId(),
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [],
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
];
}, $links);
}
@@ -645,19 +696,26 @@ class MachineStructureController extends AbstractController
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$modelType = $link->getModelType();
$parentLink = $link->getParentLink();
$type = $piece?->getTypePiece();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'quantity' => $this->resolvePieceQuantity($link),
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece?->getId(),
'piece' => $piece ? $this->normalizePiece($piece) : null,
'modelTypeId' => $modelType?->getId(),
'modelType' => $modelType ? $this->normalizeModelType($modelType) : null,
'pendingEntity' => null === $piece,
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()?->getId(),
'overrides' => $this->normalizeOverrides($link),
'quantity' => $piece ? $this->resolvePieceQuantity($link) : 1,
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [],
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
];
}, $links);
}
@@ -665,13 +723,16 @@ class MachineStructureController extends AbstractController
private function resolvePieceQuantity(MachinePieceLink $link): int
{
$parentLink = $link->getParentLink();
$piece = $link->getPiece();
if (!$parentLink) {
if (!$parentLink || !$piece) {
return $link->getQuantity();
}
$composant = $parentLink->getComposant();
$piece = $link->getPiece();
if (!$composant) {
return $link->getQuantity();
}
foreach ($composant->getPieceSlots() as $slot) {
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
@@ -685,14 +746,18 @@ class MachineStructureController extends AbstractController
private function normalizeProductLinks(array $links): array
{
return array_map(function (MachineProductLink $link): array {
$product = $link->getProduct();
$product = $link->getProduct();
$modelType = $link->getModelType();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'productId' => $product->getId(),
'product' => $this->normalizeProduct($product),
'productId' => $product?->getId(),
'product' => $product ? $this->normalizeProduct($product) : null,
'modelTypeId' => $modelType?->getId(),
'modelType' => $modelType ? $this->normalizeModelType($modelType) : null,
'pendingEntity' => null === $product,
'parentLinkId' => $link->getParentLink()?->getId(),
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
@@ -844,13 +909,14 @@ class MachineStructureController extends AbstractController
continue;
}
$items[] = [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
'machineContextOnly' => $cf->isMachineContextOnly(),
];
}
@@ -871,13 +937,14 @@ class MachineStructureController extends AbstractController
'id' => $cfv->getId(),
'value' => $cfv->getValue(),
'customField' => [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
'machineContextOnly' => $cf->isMachineContextOnly(),
],
];
}
@@ -885,6 +952,30 @@ class MachineStructureController extends AbstractController
return $items;
}
private function normalizeContextCustomFieldDefinitions(Collection $customFields): array
{
$items = [];
foreach ($customFields as $cf) {
if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) {
continue;
}
$items[] = [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
'machineContextOnly' => true,
];
}
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
return $items;
}
private function normalizeOverrides(object $link): ?array
{
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;

View File

@@ -53,6 +53,6 @@ final class MaintenanceController extends AbstractController
private function flagPath(): string
{
return $this->kernel->getProjectDir() . '/var/maintenance';
return $this->kernel->getProjectDir().'/var/maintenance';
}
}

View File

@@ -55,6 +55,10 @@ class CustomField
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private bool $required = false;
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false], name: 'machinecontextonly')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private bool $machineContextOnly = false;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'defaultValue')]
private ?string $defaultValue = null;
@@ -220,4 +224,16 @@ class CustomField
return $this;
}
public function isMachineContextOnly(): bool
{
return $this->machineContextOnly;
}
public function setMachineContextOnly(bool $machineContextOnly): static
{
$this->machineContextOnly = $machineContextOnly;
return $this;
}
}

View File

@@ -66,6 +66,14 @@ class CustomFieldValue
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Product $product = null;
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(name: 'machinecomponentlinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachineComponentLink $machineComponentLink = null;
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(name: 'machinepiecelinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachinePieceLink $machinePieceLink = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private DateTimeImmutable $createdAt;
@@ -151,4 +159,28 @@ class CustomFieldValue
return $this;
}
public function getMachineComponentLink(): ?MachineComponentLink
{
return $this->machineComponentLink;
}
public function setMachineComponentLink(?MachineComponentLink $machineComponentLink): static
{
$this->machineComponentLink = $machineComponentLink;
return $this;
}
public function getMachinePieceLink(): ?MachinePieceLink
{
return $this->machinePieceLink;
}
public function setMachinePieceLink(?MachinePieceLink $machinePieceLink): static
{
$this->machinePieceLink = $machinePieceLink;
return $this;
}
}

View File

@@ -25,7 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: MachineRepository::class)]
#[ORM\Table(name: 'machines')]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.')]
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)]
#[ApiResource(
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
operations: [
@@ -150,7 +150,7 @@ class Machine
public function setReference(?string $reference): static
{
$this->reference = $reference;
$this->reference = (null !== $reference && '' !== trim($reference)) ? $reference : null;
return $this;
}

View File

@@ -46,8 +46,12 @@ class MachineComponentLink
private Machine $machine;
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'machineLinks')]
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Composant $composant;
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Composant $composant = null;
#[ORM\ManyToOne(targetEntity: ModelType::class)]
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?ModelType $modelType = null;
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'childLinks')]
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
@@ -71,6 +75,12 @@ class MachineComponentLink
#[ORM\OneToMany(mappedBy: 'parentComponentLink', targetEntity: MachineProductLink::class)]
private Collection $productLinks;
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'machineComponentLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'nameOverride')]
private ?string $nameOverride = null;
@@ -88,11 +98,12 @@ class MachineComponentLink
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->childLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection();
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->childLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection();
$this->contextFieldValues = new ArrayCollection();
}
public function getMachine(): Machine
@@ -107,18 +118,30 @@ class MachineComponentLink
return $this;
}
public function getComposant(): Composant
public function getComposant(): ?Composant
{
return $this->composant;
}
public function setComposant(Composant $composant): static
public function setComposant(?Composant $composant): static
{
$this->composant = $composant;
return $this;
}
public function getModelType(): ?ModelType
{
return $this->modelType;
}
public function setModelType(?ModelType $modelType): static
{
$this->modelType = $modelType;
return $this;
}
public function getParentLink(): ?MachineComponentLink
{
return $this->parentLink;
@@ -166,4 +189,12 @@ class MachineComponentLink
return $this;
}
/**
* @return Collection<int, CustomFieldValue>
*/
public function getContextFieldValues(): Collection
{
return $this->contextFieldValues;
}
}

View File

@@ -47,8 +47,12 @@ class MachinePieceLink
private Machine $machine;
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'machineLinks')]
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Piece $piece;
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Piece $piece = null;
#[ORM\ManyToOne(targetEntity: ModelType::class)]
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?ModelType $modelType = null;
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'pieceLinks')]
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
@@ -60,6 +64,12 @@ class MachinePieceLink
#[ORM\OneToMany(mappedBy: 'parentPieceLink', targetEntity: MachineProductLink::class)]
private Collection $productLinks;
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'machinePieceLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'nameOverride')]
private ?string $nameOverride = null;
@@ -81,9 +91,10 @@ class MachinePieceLink
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->productLinks = new ArrayCollection();
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->productLinks = new ArrayCollection();
$this->contextFieldValues = new ArrayCollection();
}
public function getMachine(): Machine
@@ -98,18 +109,30 @@ class MachinePieceLink
return $this;
}
public function getPiece(): Piece
public function getPiece(): ?Piece
{
return $this->piece;
}
public function setPiece(Piece $piece): static
public function setPiece(?Piece $piece): static
{
$this->piece = $piece;
return $this;
}
public function getModelType(): ?ModelType
{
return $this->modelType;
}
public function setModelType(?ModelType $modelType): static
{
$this->modelType = $modelType;
return $this;
}
public function getParentLink(): ?MachineComponentLink
{
return $this->parentLink;
@@ -169,4 +192,12 @@ class MachinePieceLink
return $this;
}
/**
* @return Collection<int, CustomFieldValue>
*/
public function getContextFieldValues(): Collection
{
return $this->contextFieldValues;
}
}

View File

@@ -46,8 +46,12 @@ class MachineProductLink
private Machine $machine;
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'machineLinks')]
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Product $product;
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Product $product = null;
#[ORM\ManyToOne(targetEntity: ModelType::class)]
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?ModelType $modelType = null;
#[ORM\ManyToOne(targetEntity: MachineProductLink::class, inversedBy: 'childLinks')]
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
@@ -92,18 +96,30 @@ class MachineProductLink
return $this;
}
public function getProduct(): Product
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(Product $product): static
public function setProduct(?Product $product): static
{
$this->product = $product;
return $this;
}
public function getModelType(): ?ModelType
{
return $this->modelType;
}
public function setModelType(?ModelType $modelType): static
{
$this->modelType = $modelType;
return $this;
}
public function getParentLink(): ?MachineProductLink
{
return $this->parentLink;

View File

@@ -421,13 +421,14 @@ class ModelType
$items = [];
foreach ($fields as $cf) {
$items[] = [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
'machineContextOnly' => $cf->isMachineContextOnly(),
];
}

View File

@@ -24,14 +24,14 @@ final class MaintenanceModeListener
return;
}
$flagFile = $this->kernel->getProjectDir() . '/var/maintenance';
$flagFile = $this->kernel->getProjectDir().'/var/maintenance';
if (!file_exists($flagFile)) {
return;
}
$request = $event->getRequest();
$path = $request->getPathInfo();
$path = $request->getPathInfo();
// Always allow maintenance status endpoint and session endpoints
if (str_starts_with($path, '/api/admin/maintenance')

View File

@@ -74,18 +74,20 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
foreach ($entity->getComponentLinks() as $link) {
$componentLinks[] = [
'id' => $link->getId(),
'composantId' => $link->getComposant()->getId(),
'composantName' => $link->getComposant()->getName(),
'composantId' => $link->getComposant()?->getId(),
'composantName' => $link->getComposant()?->getName(),
'modelTypeId' => $link->getModelType()?->getId(),
];
}
$pieceLinks = [];
foreach ($entity->getPieceLinks() as $link) {
$pieceLinks[] = [
'id' => $link->getId(),
'pieceId' => $link->getPiece()->getId(),
'pieceName' => $link->getPiece()->getName(),
'quantity' => $link->getQuantity(),
'id' => $link->getId(),
'pieceId' => $link->getPiece()?->getId(),
'pieceName' => $link->getPiece()?->getName(),
'quantity' => $link->getQuantity(),
'modelTypeId' => $link->getModelType()?->getId(),
];
}
@@ -93,8 +95,9 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
foreach ($entity->getProductLinks() as $link) {
$productLinks[] = [
'id' => $link->getId(),
'productId' => $link->getProduct()->getId(),
'productName' => $link->getProduct()->getName(),
'productId' => $link->getProduct()?->getId(),
'productName' => $link->getProduct()?->getName(),
'modelTypeId' => $link->getModelType()?->getId(),
];
}
@@ -187,8 +190,8 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
'machine' => $entity->getMachine(),
'diffKey' => $action.'Component',
'diffValue' => [
'id' => $entity->getComposant()->getId(),
'name' => $entity->getComposant()->getName(),
'id' => $entity->getComposant()?->getId() ?? $entity->getModelType()?->getId(),
'name' => $entity->getComposant()?->getName() ?? $entity->getModelType()?->getName() ?? 'Catégorie seule',
],
];
}
@@ -198,8 +201,8 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
'machine' => $entity->getMachine(),
'diffKey' => $action.'Piece',
'diffValue' => [
'id' => $entity->getPiece()->getId(),
'name' => $entity->getPiece()->getName(),
'id' => $entity->getPiece()?->getId() ?? $entity->getModelType()?->getId(),
'name' => $entity->getPiece()?->getName() ?? $entity->getModelType()?->getName() ?? 'Catégorie seule',
],
];
}
@@ -209,8 +212,8 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
'machine' => $entity->getMachine(),
'diffKey' => $action.'Product',
'diffValue' => [
'id' => $entity->getProduct()->getId(),
'name' => $entity->getProduct()->getName(),
'id' => $entity->getProduct()?->getId() ?? $entity->getModelType()?->getId(),
'name' => $entity->getProduct()?->getName() ?? $entity->getModelType()?->getName() ?? 'Catégorie seule',
],
];
}

View File

@@ -164,11 +164,11 @@ class MachineStructureTool
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant),
'composantId' => $composant?->getId(),
'composant' => $composant ? $this->normalizeComposant($composant) : null,
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'parentComponentId' => $parentLink?->getComposant()?->getId(),
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
@@ -189,13 +189,13 @@ class MachineStructureTool
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece),
'pieceId' => $piece?->getId(),
'piece' => $piece ? $this->normalizePiece($piece) : null,
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'parentComponentId' => $parentLink?->getComposant()?->getId(),
'overrides' => $this->normalizeOverrides($link),
'quantity' => $this->resolvePieceQuantity($link),
'quantity' => $piece ? $this->resolvePieceQuantity($link) : 1,
];
}, $links);
}

View File

@@ -869,22 +869,25 @@ final class EntityVersionService
$snapshot['componentLinks'] = [];
foreach ($entity->getComponentLinks() as $link) {
$snapshot['componentLinks'][] = [
'id' => $link->getId(), 'composantId' => $link->getComposant()->getId(),
'composantName' => $link->getComposant()->getName(),
'id' => $link->getId(), 'composantId' => $link->getComposant()?->getId(),
'composantName' => $link->getComposant()?->getName(),
'modelTypeId' => $link->getModelType()?->getId(),
];
}
$snapshot['pieceLinks'] = [];
foreach ($entity->getPieceLinks() as $link) {
$snapshot['pieceLinks'][] = [
'id' => $link->getId(), 'pieceId' => $link->getPiece()->getId(),
'pieceName' => $link->getPiece()->getName(), 'quantity' => $link->getQuantity(),
'id' => $link->getId(), 'pieceId' => $link->getPiece()?->getId(),
'pieceName' => $link->getPiece()?->getName(), 'quantity' => $link->getQuantity(),
'modelTypeId' => $link->getModelType()?->getId(),
];
}
$snapshot['productLinks'] = [];
foreach ($entity->getProductLinks() as $link) {
$snapshot['productLinks'][] = [
'id' => $link->getId(), 'productId' => $link->getProduct()->getId(),
'productName' => $link->getProduct()->getName(),
'id' => $link->getId(), 'productId' => $link->getProduct()?->getId(),
'productName' => $link->getProduct()?->getName(),
'modelTypeId' => $link->getModelType()?->getId(),
];
}
}

View File

@@ -233,6 +233,7 @@ class SkeletonStructureService
$existingField->setOptions($normalized['options']);
$existingField->setDefaultValue($normalized['defaultValue']);
$existingField->setOrderIndex($normalized['orderIndex']);
$existingField->setMachineContextOnly($normalized['machineContextOnly']);
$processedIds[$existingField->getId()] = true;
} else {
// Create new field
@@ -243,6 +244,7 @@ class SkeletonStructureService
$cf->setOptions($normalized['options']);
$cf->setDefaultValue($normalized['defaultValue']);
$cf->setOrderIndex($normalized['orderIndex']);
$cf->setMachineContextOnly($normalized['machineContextOnly']);
match ($category) {
ModelCategory::COMPONENT => $cf->setTypeComposant($modelType),
@@ -265,7 +267,7 @@ class SkeletonStructureService
/**
* Normalize frontend custom field data to a common shape.
*
* @return array{name: string, type: string, required: bool, options: ?array, defaultValue: ?string, orderIndex: int}
* @return array{name: string, type: string, required: bool, options: ?array, defaultValue: ?string, orderIndex: int, machineContextOnly: bool}
*/
private function normalizeCustomFieldData(array $fieldData, int $index): array
{
@@ -274,23 +276,25 @@ class SkeletonStructureService
$value = $fieldData['value'];
return [
'name' => $fieldData['key'],
'type' => $value['type'] ?? 'text',
'required' => (bool) ($value['required'] ?? false),
'options' => $value['options'] ?? null,
'defaultValue' => $value['defaultValue'] ?? null,
'orderIndex' => $index,
'name' => $fieldData['key'],
'type' => $value['type'] ?? 'text',
'required' => (bool) ($value['required'] ?? false),
'options' => $value['options'] ?? null,
'defaultValue' => $value['defaultValue'] ?? null,
'orderIndex' => $index,
'machineContextOnly' => (bool) ($value['machineContextOnly'] ?? false),
];
}
// PIECE/PRODUCT format: {name, type, required, options?, orderIndex?, defaultValue?}
return [
'name' => $fieldData['name'] ?? '',
'type' => $fieldData['type'] ?? 'text',
'required' => (bool) ($fieldData['required'] ?? false),
'options' => $fieldData['options'] ?? null,
'defaultValue' => $fieldData['defaultValue'] ?? null,
'orderIndex' => $fieldData['orderIndex'] ?? $index,
'name' => $fieldData['name'] ?? '',
'type' => $fieldData['type'] ?? 'text',
'required' => (bool) ($fieldData['required'] ?? false),
'options' => $fieldData['options'] ?? null,
'defaultValue' => $fieldData['defaultValue'] ?? null,
'orderIndex' => $fieldData['orderIndex'] ?? $index,
'machineContextOnly' => (bool) ($fieldData['machineContextOnly'] ?? false),
];
}
}

View File

@@ -404,11 +404,13 @@ abstract class AbstractApiTestCase extends ApiTestCase
?ModelType $typePiece = null,
?ModelType $typeProduct = null,
int $orderIndex = 0,
bool $machineContextOnly = false,
): CustomField {
$cf = new CustomField();
$cf->setName($name);
$cf->setType($type);
$cf->setOrderIndex($orderIndex);
$cf->setMachineContextOnly($machineContextOnly);
if (null !== $machine) {
$cf->setMachine($machine);
}
@@ -436,6 +438,8 @@ abstract class AbstractApiTestCase extends ApiTestCase
?Composant $composant = null,
?Piece $piece = null,
?Product $product = null,
?MachineComponentLink $machineComponentLink = null,
?MachinePieceLink $machinePieceLink = null,
): CustomFieldValue {
$cfv = new CustomFieldValue();
$cfv->setValue($value);
@@ -452,6 +456,12 @@ abstract class AbstractApiTestCase extends ApiTestCase
if (null !== $product) {
$cfv->setProduct($product);
}
if (null !== $machineComponentLink) {
$cfv->setMachineComponentLink($machineComponentLink);
}
if (null !== $machinePieceLink) {
$cfv->setMachinePieceLink($machinePieceLink);
}
$em = $this->getEntityManager();
$em->persist($cfv);

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
class MachineContextCustomFieldTest extends AbstractApiTestCase
{
public function testStructureReturnsContextFieldsOnComponentLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site A');
$modelType = $this->createModelType('Motor', 'MOT', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Voltage',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$normalField = $this->createCustomField(
name: 'Serial',
type: 'text',
typeComposant: $modelType,
);
$machine = $this->createMachine('Machine A', $site);
$composant = $this->createComposant('Motor 1', 'MOT-001', $modelType);
$link = $this->createMachineComponentLink($machine, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '220',
composant: $composant,
machineComponentLink: $link,
);
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$componentLink = $data['componentLinks'][0];
$this->assertArrayHasKey('contextCustomFields', $componentLink);
$this->assertCount(1, $componentLink['contextCustomFields']);
$this->assertSame('Voltage', $componentLink['contextCustomFields'][0]['name']);
$this->assertTrue($componentLink['contextCustomFields'][0]['machineContextOnly']);
$this->assertArrayHasKey('contextCustomFieldValues', $componentLink);
$this->assertCount(1, $componentLink['contextCustomFieldValues']);
$this->assertSame('220', $componentLink['contextCustomFieldValues'][0]['value']);
$normalFields = array_filter(
$componentLink['composant']['customFields'],
fn (array $f) => $f['name'] === 'Serial',
);
$this->assertCount(1, $normalFields);
}
public function testStructureReturnsContextFieldsOnPieceLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site B');
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
$contextField = $this->createCustomField(
name: 'Wear Level',
type: 'select',
typePiece: $modelType,
machineContextOnly: true,
);
$contextField->setOptions(['Good', 'Fair', 'Replace']);
$this->getEntityManager()->flush();
$machine = $this->createMachine('Machine B', $site);
$piece = $this->createPiece('Bearing 1', 'BRG-001', $modelType);
$link = $this->createMachinePieceLink($machine, $piece);
$this->createCustomFieldValue(
customField: $contextField,
value: 'Fair',
piece: $piece,
machinePieceLink: $link,
);
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
$data = $response->toArray();
$pieceLink = $data['pieceLinks'][0];
$this->assertCount(1, $pieceLink['contextCustomFields']);
$this->assertSame('Wear Level', $pieceLink['contextCustomFields'][0]['name']);
$this->assertCount(1, $pieceLink['contextCustomFieldValues']);
$this->assertSame('Fair', $pieceLink['contextCustomFieldValues'][0]['value']);
}
public function testUpsertContextFieldValueViaComponentLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site C');
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Flow Rate',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$machine = $this->createMachine('Machine C', $site);
$composant = $this->createComposant('Pump 1', 'PMP-001', $modelType);
$link = $this->createMachineComponentLink($machine, $composant);
$response = $client->request('POST', '/api/custom-fields/values/upsert', [
'json' => [
'customFieldId' => $contextField->getId(),
'machineComponentLinkId' => $link->getId(),
'value' => '380',
],
]);
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertSame('380', $data['value']);
}
public function testSameComposantDifferentValuesPerMachine(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site D');
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Pressure',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$machineA = $this->createMachine('Machine A', $site);
$machineB = $this->createMachine('Machine B', $site);
$composant = $this->createComposant('Valve 1', 'VLV-001', $modelType);
$linkA = $this->createMachineComponentLink($machineA, $composant);
$linkB = $this->createMachineComponentLink($machineB, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '100',
composant: $composant,
machineComponentLink: $linkA,
);
$this->createCustomFieldValue(
customField: $contextField,
value: '200',
composant: $composant,
machineComponentLink: $linkB,
);
$dataA = $client->request('GET', '/api/machines/'.$machineA->getId().'/structure')->toArray();
$this->assertSame('100', $dataA['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
$dataB = $client->request('GET', '/api/machines/'.$machineB->getId().'/structure')->toArray();
$this->assertSame('200', $dataB['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
}
public function testMachineContextOnlyFieldSerialization(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site E');
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Calibration Date',
type: 'date',
typeComposant: $modelType,
machineContextOnly: true,
);
$response = $client->request('GET', '/api/custom_fields/'.$contextField->getId());
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertTrue($data['machineContextOnly']);
}
public function testCloneMachineCopiesContextFieldValues(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site F');
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'RPM Setting',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$source = $this->createMachine('Source Machine', $site);
$composant = $this->createComposant('Motor C', 'MOTC-001', $modelType);
$link = $this->createMachineComponentLink($source, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '3000',
composant: $composant,
machineComponentLink: $link,
);
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
'json' => [
'name' => 'Cloned Machine',
'siteId' => $site->getId(),
],
]);
$this->assertResponseStatusCodeSame(201);
$data = $response->toArray();
$clonedLink = $data['componentLinks'][0] ?? null;
$this->assertNotNull($clonedLink);
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
}
}