Compare commits

..

5 Commits

Author SHA1 Message Date
gitea-actions
48c5c5bb33 chore : bump version to v1.9.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 2m47s
2026-05-11 14:25:24 +00:00
1e2a1dae62 Merge pull request 'feat(custom-fields) : autocomplete des noms + corrections formule de référence auto' (#3) from feat/custom-field-name-autocomplete into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #3
2026-05-11 14:25:14 +00:00
Matthieu
2a8042ba50 fix(custom-fields) : recharge la formule depuis le BE apres save du ModelType
Sur les pages d'edition de categorie composant/piece, ajoute un
loadCategory() apres updateModelType + syncExecute pour que la formule
mise a jour par propagateCustomFieldRename soit refletee dans le form
sans avoir a recharger la page.
2026-05-11 16:22:58 +02:00
Matthieu
bc32648918 fix(custom-fields) : supporte les caracteres accentues dans les placeholders de formule
La regex \w+ ne capturait pas les caracteres accentues (ex. {Diametre}
avec 'è'), le placeholder restait litteral dans la reference auto.
Remplace par [^}]+ avec le flag u/gu cote PHP et JS pour matcher
n'importe quel caractere entre les accolades.
2026-05-11 16:22:52 +02:00
Matthieu
9027917ea2 fix(custom-fields) : propage le renommage d'un champ dans la formule de reference auto 2026-05-11 16:22:28 +02:00
10 changed files with 191 additions and 4 deletions

View File

@@ -69,3 +69,8 @@ when@test:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
public: true public: true
App\Service\SkeletonStructureService:
autowire: true
autoconfigure: true
public: true

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '1.9.30' app.version: '1.9.31'

View File

@@ -204,7 +204,7 @@ const formulaBuilderCustomFields = computed(() => {
const extractFormulaFields = (formula: string | null | undefined): string[] => { const extractFormulaFields = (formula: string | null | undefined): string[] => {
if (!formula) return [] if (!formula) return []
const matches = [...formula.matchAll(/\{(\w+)\}/g)] const matches = [...formula.matchAll(/\{([^}]+)\}/gu)]
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))] return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
} }

View File

@@ -91,7 +91,7 @@ const preview = computed(() => {
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR') fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
} }
} }
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???') return props.modelValue.replace(/\{([^}]+)\}/gu, (_, name) => fieldMap.get(name) ?? '???')
}) })
const insertField = (fieldName: string) => { const insertField = (fieldName: string) => {

View File

@@ -159,6 +159,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await updateModelType(id, enrichedPayload) await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false }) await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadComponentTypes({ force: true }) await loadComponentTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de composant mise à jour avec succès.') showSuccess('Catégorie de composant mise à jour avec succès.')
} }
} catch (error) { } catch (error) {
@@ -183,6 +184,7 @@ const handleSyncConfirm = async () => {
confirmTypeChanges: !!hasModifications, confirmTypeChanges: !!hasModifications,
}) })
await loadComponentTypes({ force: true }) await loadComponentTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de composant mise à jour avec succès.') showSuccess('Catégorie de composant mise à jour avec succès.')
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))

View File

@@ -157,6 +157,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await updateModelType(id, enrichedPayload) await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false }) await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadPieceTypes({ force: true }) await loadPieceTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de pièce mise à jour avec succès.') showSuccess('Catégorie de pièce mise à jour avec succès.')
} }
} catch (error) { } catch (error) {
@@ -181,6 +182,7 @@ const handleSyncConfirm = async () => {
confirmTypeChanges: !!hasModifications, confirmTypeChanges: !!hasModifications,
}) })
await loadPieceTypes({ force: true }) await loadPieceTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de pièce mise à jour avec succès.') showSuccess('Catégorie de pièce mise à jour avec succès.')
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))

View File

@@ -32,7 +32,7 @@ class ReferenceAutoGenerator
} }
} }
return preg_replace_callback('/\{(\w+)\}/', static function (array $matches) use ($valueMap): string { return preg_replace_callback('/\{([^}]+)\}/u', static function (array $matches) use ($valueMap): string {
return $valueMap[$matches[1]] ?? ''; return $valueMap[$matches[1]] ?? '';
}, $modelType->getReferenceFormula()); }, $modelType->getReferenceFormula());
} }

View File

@@ -226,6 +226,13 @@ class SkeletonStructureService
} }
if ($existingField) { if ($existingField) {
// Propagate rename to the parent ModelType's reference formula and required-fields list
// so existing `{oldName}` placeholders keep resolving after the field is renamed.
$oldName = $existingField->getName();
if ($oldName !== $normalized['name']) {
$this->propagateCustomFieldRename($modelType, $oldName, $normalized['name']);
}
// Update existing field // Update existing field
$existingField->setName($normalized['name']); $existingField->setName($normalized['name']);
$existingField->setType($normalized['type']); $existingField->setType($normalized['type']);
@@ -264,6 +271,38 @@ class SkeletonStructureService
} }
} }
private function propagateCustomFieldRename(ModelType $modelType, string $oldName, string $newName): void
{
$formula = $modelType->getReferenceFormula();
if (null !== $formula && '' !== $formula) {
$newFormula = preg_replace(
'/\{'.preg_quote($oldName, '/').'\}/',
'{'.$newName.'}',
$formula
);
if (null !== $newFormula && $newFormula !== $formula) {
$modelType->setReferenceFormula($newFormula);
}
}
$required = $modelType->getRequiredFieldsForReference();
if ($required) {
$changed = false;
$newRequired = [];
foreach ($required as $fieldName) {
if ($fieldName === $oldName) {
$newRequired[] = $newName;
$changed = true;
} else {
$newRequired[] = $fieldName;
}
}
if ($changed) {
$modelType->setRequiredFieldsForReference($newRequired);
}
}
}
/** /**
* Normalize frontend custom field data to a common shape. * Normalize frontend custom field data to a common shape.
* *

View File

@@ -145,6 +145,69 @@ class ReferenceAutoGeneratorTest extends AbstractApiTestCase
self::assertSame('U507', $result); self::assertSame('U507', $result);
} }
public function testGenerateWithAccentedFieldName(): void
{
$mt = $this->createModelType('Palier', 'PAL-ACCENT', ModelCategory::PIECE);
$mt->setReferenceFormula('PA-{Diamètre}-33');
$mt->setRequiredFieldsForReference(['Diamètre']);
$em = $this->getEntityManager();
$em->flush();
$cf = $this->createCustomField('Diamètre', 'number', typePiece: $mt);
$piece = $this->createPiece('Palier 70', null, $mt);
$this->createCustomFieldValue($cf, '70', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('PA-70-33', $result);
}
public function testGenerateWithNumberTypeField(): void
{
$mt = $this->createModelType('NumberField', 'NUM-001', ModelCategory::PIECE);
$mt->setReferenceFormula('R-{taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cf = $this->createCustomField('taille', 'number', typePiece: $mt);
$piece = $this->createPiece('Piece Number', null, $mt);
$this->createCustomFieldValue($cf, '42', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('R-42', $result);
}
public function testGenerateWithDecimalNumberField(): void
{
$mt = $this->createModelType('NumberDec', 'NUM-002', ModelCategory::PIECE);
$mt->setReferenceFormula('R-{taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cf = $this->createCustomField('taille', 'number', typePiece: $mt);
$piece = $this->createPiece('Piece Dec', null, $mt);
$this->createCustomFieldValue($cf, '12.5', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('R-12.5', $result);
}
public function testGenerateWithSpaceInFormula(): void public function testGenerateWithSpaceInFormula(): void
{ {
$mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE); $mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE);

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\ModelCategory;
use App\Service\SkeletonStructureService;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class SkeletonStructureServiceTest extends AbstractApiTestCase
{
public function testRenameCustomFieldPropagatesToReferenceFormulaAndRequiredFields(): void
{
$mt = $this->createModelType('Roulement', 'ROUL-RENAME', ModelCategory::PIECE);
$mt->setReferenceFormula('{material}-{size}');
$mt->setRequiredFieldsForReference(['material', 'size']);
$em = $this->getEntityManager();
$em->flush();
$cfMaterial = $this->createCustomField('material', 'text', typePiece: $mt, orderIndex: 0);
$cfSize = $this->createCustomField('size', 'text', typePiece: $mt, orderIndex: 1);
/** @var SkeletonStructureService $service */
$service = static::getContainer()->get(SkeletonStructureService::class);
// Same fields, but `material` is renamed to `materiau` (matched by customFieldId)
$service->updateSkeletonRequirements($mt, [
'customFields' => [
['customFieldId' => $cfMaterial->getId(), 'name' => 'materiau', 'type' => 'text', 'orderIndex' => 0],
['customFieldId' => $cfSize->getId(), 'name' => 'size', 'type' => 'text', 'orderIndex' => 1],
],
]);
$em->flush();
$em->refresh($mt);
$em->refresh($cfMaterial);
self::assertSame('materiau', $cfMaterial->getName());
self::assertSame('{materiau}-{size}', $mt->getReferenceFormula());
self::assertSame(['materiau', 'size'], $mt->getRequiredFieldsForReference());
}
public function testRenameLeavesFormulaUnchangedWhenFieldNotInFormula(): void
{
$mt = $this->createModelType('Roulement2', 'ROUL-RENAME2', ModelCategory::PIECE);
$mt->setReferenceFormula('{material}');
$mt->setRequiredFieldsForReference(['material']);
$em = $this->getEntityManager();
$em->flush();
$cfMaterial = $this->createCustomField('material', 'text', typePiece: $mt, orderIndex: 0);
$cfUnused = $this->createCustomField('unused', 'text', typePiece: $mt, orderIndex: 1);
/** @var SkeletonStructureService $service */
$service = static::getContainer()->get(SkeletonStructureService::class);
$service->updateSkeletonRequirements($mt, [
'customFields' => [
['customFieldId' => $cfMaterial->getId(), 'name' => 'material', 'type' => 'text', 'orderIndex' => 0],
['customFieldId' => $cfUnused->getId(), 'name' => 'renamed', 'type' => 'text', 'orderIndex' => 1],
],
]);
$em->flush();
$em->refresh($mt);
self::assertSame('{material}', $mt->getReferenceFormula());
self::assertSame(['material'], $mt->getRequiredFieldsForReference());
}
}