Compare commits

..

7 Commits

Author SHA1 Message Date
tristan 2292f2123f feat(front) : consultation + modification prestataire 2026-06-15 10:07:36 +02:00
tristan 754a0125dd feat(front) : onglet comptabilite prestataire 2026-06-15 09:51:56 +02:00
tristan a3e64f0f9e feat(front) : onglet adresse prestataire 2026-06-15 09:37:14 +02:00
tristan 8e24405423 feat(front) : onglet contact prestataire 2026-06-15 09:22:38 +02:00
tristan 845c295754 feat(front) : page ajout prestataire + formulaire principal 2026-06-15 09:08:43 +02:00
tristan d1236f1c88 style(front) : icone de la section technique dans la sidebar
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m43s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m24s
2026-06-15 09:08:15 +02:00
tristan 178662f072 feat(front) : page repertoire prestataires
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m17s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m24s
2026-06-15 08:26:16 +02:00
19 changed files with 86 additions and 398 deletions
-2
View File
@@ -6,7 +6,6 @@ use App\Module\Commercial\CommercialModule;
use App\Module\Core\CoreModule;
use App\Module\Sites\SitesModule;
use App\Module\Technique\TechniqueModule;
use App\Module\Transport\TransportModule;
return [
CoreModule::class,
@@ -14,5 +13,4 @@ return [
SitesModule::class,
CatalogModule::class,
TechniqueModule::class,
TransportModule::class,
];
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.121'
app.version: '0.1.114'
+2 -2
View File
@@ -406,6 +406,8 @@
"back": "Retour au répertoire",
"loading": "Chargement…",
"notFound": "Prestataire introuvable.",
"emptyContacts": "Aucun contact.",
"emptyAddresses": "Aucune adresse.",
"confirmArchive": "Archiver ce prestataire ? Il n'apparaîtra plus dans le répertoire actif.",
"confirmRestore": "Restaurer ce prestataire ? Il réapparaîtra dans le répertoire actif."
},
@@ -427,7 +429,6 @@
"sites": "Site"
},
"errors": {
"nameRequired": "Le nom du prestataire est obligatoire.",
"siteRequired": "Sélectionnez au moins un site.",
"categoryRequired": "Sélectionnez au moins une catégorie."
},
@@ -484,7 +485,6 @@
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
"createSuccess": "Prestataire créé avec succès",
"updateSuccess": "Prestataire mis à jour avec succès",
"addComplete": "Prestataire ajouté",
"archiveSuccess": "Prestataire archivé avec succès",
"restoreSuccess": "Prestataire restauré avec succès"
}
@@ -69,14 +69,14 @@ describe('useProviderForm', () => {
permState.accountingManage = false
})
it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => {
it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => {
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
const created = await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
expect(form.mainLocked.value).toBe(false)
@@ -122,17 +122,18 @@ describe('useProviderForm', () => {
expect(form.unlockedIndex.value).toBe(0)
})
it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => {
it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => {
mockPost.mockResolvedValueOnce({ id: 1, companyName: null })
const form = useProviderForm()
form.main.companyName = ' '
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
const body = (mockPost.mock.calls[0] ?? [])[1] as Record<string, unknown>
expect(body).not.toHaveProperty('companyName')
expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] })
})
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
@@ -20,7 +20,6 @@ import {
import {
buildProviderContactPayload,
isProviderContactBlank,
isProviderContactNamed,
} from '~/modules/technique/utils/forms/providerContact'
import {
buildProviderAddressPayload,
@@ -112,10 +111,6 @@ export function useProviderForm() {
*/
function validateMainFront(): boolean {
let valid = true
if (!main.companyName?.trim()) {
mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired'))
valid = false
}
if (main.siteIris.length === 0) {
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
valid = false
@@ -304,11 +299,10 @@ export function useProviderForm() {
// Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows.
const contactErrors = ref<Record<string, string>[]>([])
// « + Nouveau contact » desactive tant que le dernier bloc n'a pas de nom OU
// prenom (RG-3.04, aligne M1/M2 — fonction/tel/email seuls ne suffisent pas).
// « + Nouveau contact » desactive tant que le dernier bloc est vide (RG-3.04).
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && isProviderContactNamed(last)
return last !== undefined && !isProviderContactBlank(last)
})
function addContact(): void {
@@ -333,7 +333,6 @@ const { provider, loading, error, load } = useProvider(providerId)
const {
main,
providerId: formProviderId,
mainErrors,
mainSubmitting,
tabSubmitting,
@@ -390,9 +389,6 @@ function prefill(): void {
const d = provider.value
if (!d) return
// Indispensable : pilote les URLs des PATCH/POST par onglet (sinon les submits no-op).
formProviderId.value = d.id
main.companyName = d.companyName ?? null
main.categoryIris = irisOf(d.categories)
main.siteIris = irisOf(d.sites)
@@ -78,6 +78,9 @@
:model-value="contact"
readonly
/>
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('technique.providers.consultation.emptyContacts') }}
</p>
</div>
</template>
@@ -94,6 +97,9 @@
:country-options="countryOptionsFor(view.draft.country)"
readonly
/>
<p v-if="addressViews.length === 0" class="text-center text-black/60">
{{ t('technique.providers.consultation.emptyAddresses') }}
</p>
</div>
</template>
@@ -176,7 +182,6 @@ import {
siteOptionsOf,
} from '~/modules/technique/utils/forms/providerDetail'
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
const { t } = useI18n()
const route = useRoute()
@@ -217,26 +222,16 @@ const mainSiteIris = computed(() => irisOf(provider.value?.sites))
const mainCategoryOptions = computed(() => categoryOptionsOf(provider.value?.categories))
const mainSiteOptions = computed(() => siteOptionsOf(provider.value?.sites))
// Au moins un bloc affiche meme sans donnee (bloc vide en lecture seule, comme
// l'onglet Comptabilite et les autres modules — pas de message « Aucun … »).
const contacts = computed(() => {
const list = (provider.value?.contacts ?? []).map(mapContactToDraft)
return list.length > 0 ? list : [emptyProviderContact()]
})
const contacts = computed(() => (provider.value?.contacts ?? []).map(mapContactToDraft))
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
// Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
const addressViews = computed(() => {
const views = (provider.value?.addresses ?? []).map(address => ({
draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
}))
return views.length > 0
? views
: [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
})
const addressViews = computed(() => (provider.value?.addresses ?? []).map(address => ({
draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
})))
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
function countryOptionsFor(country: string): { value: string, label: string }[] {
@@ -85,7 +85,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting || providerId === null"
:disabled="tabSubmitting"
@click="onSubmitContacts"
/>
</div>
@@ -121,7 +121,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting || providerId === null"
:disabled="tabSubmitting"
@click="onSubmitAddresses"
/>
</div>
@@ -251,7 +251,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting || providerId === null"
:disabled="tabSubmitting"
@click="onSubmitAccounting"
/>
</div>
@@ -314,7 +314,6 @@ const referentials = useProviderReferentials()
const {
main,
providerId,
mainLocked,
mainSubmitting,
mainErrors,
@@ -363,33 +362,15 @@ function apiErrorMessage(error: unknown): string {
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
}
// Dernier onglet REMPLISSABLE par le role : tabKeys exclut deja la Comptabilite
// si l'user n'a pas accounting.view. Sa validation cloture l'ajout (redirection).
const lastFillableTab = computed(() => tabKeys.value[tabKeys.value.length - 1])
/**
* Apres validation d'un onglet (creation) : si c'est le dernier onglet du role,
* l'ajout est termine -> toast final + retour au repertoire (miroir M1/M2) ; sinon
* toast de mise a jour (l'onglet suivant a deja ete deverrouille par completeTab).
*/
function onTabSaved(key: string): void {
if (key === lastFillableTab.value) {
toast.success({ title: t('technique.providers.toast.addComplete') })
router.push('/providers')
return
}
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
// ── Onglet Contact ──────────────────────────────────────────────────────────
/** Valide l'onglet Contact ; redirige si c'est le dernier onglet du role. */
/** Valide l'onglet Contact ; toast de succes si l'onglet a ete finalise. */
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
onTabSaved('contact')
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
}
@@ -432,14 +413,14 @@ function onAddressDegraded(): void {
})
}
/** Valide l'onglet Adresse ; redirige si c'est le dernier onglet du role. */
/** Valide l'onglet Adresse ; toast de succes si l'onglet a ete finalise. */
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
onTabSaved('address')
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
}
@@ -469,7 +450,7 @@ function askRemoveRib(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
}
/** Valide l'onglet Comptabilite ; redirige si c'est le dernier onglet du role. */
/** Valide l'onglet Comptabilite ; toast de succes si l'onglet a ete finalise. */
async function onSubmitAccounting(): Promise<void> {
const ok = await submitAccounting(
isBankRequired.value,
@@ -480,7 +461,7 @@ async function onSubmitAccounting(): Promise<void> {
}),
)
if (ok) {
onTabSaved('accounting')
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
}
@@ -3,7 +3,6 @@ import {
buildProviderContactPayload,
hasAtLeastOneFilledContact,
isProviderContactBlank,
isProviderContactNamed,
} from '../providerContact'
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
@@ -35,28 +34,15 @@ describe('providerContact helpers', () => {
})
})
describe('isProviderContactNamed (RG-3.04 — prenom OU nom)', () => {
it('vrai avec un prenom seul ou un nom seul', () => {
expect(isProviderContactNamed({ ...emptyProviderContact(), firstName: 'Jean' })).toBe(true)
expect(isProviderContactNamed({ ...emptyProviderContact(), lastName: 'Dupont' })).toBe(true)
describe('hasAtLeastOneFilledContact (RG-3.12)', () => {
it('false si tous les blocs sont vides', () => {
expect(hasAtLeastOneFilledContact([emptyProviderContact(), emptyProviderContact()])).toBe(false)
})
it('faux si seuls fonction / telephone / email sont remplis (ne suffit pas)', () => {
expect(isProviderContactNamed({ ...emptyProviderContact(), jobTitle: 'Directeur' })).toBe(false)
expect(isProviderContactNamed({ ...emptyProviderContact(), email: 'a@b.fr' })).toBe(false)
expect(isProviderContactNamed({ ...emptyProviderContact(), phonePrimary: '0102030405' })).toBe(false)
})
})
describe('hasAtLeastOneFilledContact (RG-3.12 — au moins un contact nomme)', () => {
it('false si aucun bloc n\'est nomme', () => {
expect(hasAtLeastOneFilledContact([emptyProviderContact(), { ...emptyProviderContact(), email: 'a@b.fr' }])).toBe(false)
})
it('true des qu\'un bloc porte un nom ou prenom', () => {
it('true des qu\'un bloc porte une donnee', () => {
expect(hasAtLeastOneFilledContact([
emptyProviderContact(),
{ ...emptyProviderContact(), lastName: 'Dupont' },
{ ...emptyProviderContact(), email: 'a@b.fr' },
])).toBe(true)
})
})
@@ -32,21 +32,12 @@ export function isProviderContactBlank(contact: ProviderContactFormDraft): boole
].some(isFilled)
}
/**
* RG-3.04 : un contact est « nomme » (valide) des qu'il porte un prenom OU un nom
* — aligne sur le M1/M2. Sert le gating « + Nouveau contact » et la notion de
* contact valide (la fonction / le telephone / l'email seuls ne suffisent pas).
*/
export function isProviderContactNamed(contact: ProviderContactFormDraft): boolean {
return isFilled(contact.firstName) || isFilled(contact.lastName)
}
/**
* RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
* contact nomme (prenom ou nom).
* bloc non vide (au moins un contact valide).
*/
export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean {
return contacts.some(isProviderContactNamed)
return contacts.some(contact => !isProviderContactBlank(contact))
}
/**
@@ -1 +0,0 @@
export default defineNuxtConfig({})
-50
View File
@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* RG-3.04 (correctif) — aligne la regle de validite d'un contact prestataire sur
* le M1/M2 : au moins le PRENOM OU le NOM (et non plus « un champ quelconque parmi
* prenom/nom/fonction/telephone/email »). Remplace le CHECK chk_provider_contact_name
* et met a jour les commentaires de colonnes. La garde applicative
* (ProviderContactProcessor::validateName) est alignee dans le meme commit.
*
* Placee au namespace racine DoctrineMigrations (et non en modulaire Technique) :
* elle ALTERE une table creee par une migration racine (Version20260612100000) ;
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
* (cf. CLAUDE.md regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
*/
final class Version20260615120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'RG-3.04 : contact prestataire valide si prenom OU nom (alignement M1/M2) — CHECK chk_provider_contact_name.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).$_$');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)');
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
}
}
@@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Technique\Domain\Entity\Provider;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier (spec-front M3 § Onglet Comptabilite — jumeau de
* SupplierAccountingCompletenessValidator M2) : a la soumission complete de
* l'onglet Comptabilite, les six champs scalaires obligatoires doivent etre
* renseignes (SIREN, Numero de compte, Mode de TVA, N de TVA, Delai de reglement,
* Type de reglement). La banque reste conditionnelle (RG-3.07) et les RIB aussi
* (RG-3.08) : ils ne sont pas couverts ici (Assert\Callback sur l'entite Provider
* — validatePaymentTypeConsistency).
*
* Parti pris (miroir M1/M2) : colonnes nullable en base + validateur contextuel,
* plutot qu'un Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet
* principal, lequel n'envoie aucun champ comptable).
*
* Invoque par le ProviderProcessor uniquement quand le payload porte les six
* champs (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform (mapping inline
* front via useFormErrors, ERP-101).
*/
final class ProviderAccountingCompletenessValidator
{
public function validate(Provider $provider): void
{
// Map champ -> valeur courante des champs obligatoires de l'onglet.
$fields = [
'siren' => $provider->getSiren(),
'accountNumber' => $provider->getAccountNumber(),
'tvaMode' => $provider->getTvaMode(),
'nTva' => $provider->getNTva(),
'paymentDelay' => $provider->getPaymentDelay(),
'paymentType' => $provider->getPaymentType(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
'Ce champ est obligatoire.',
null,
[],
$provider,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
* references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que
* lorsqu'elles valent null.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -119,18 +119,23 @@ final class ProviderContactProcessor implements ProcessorInterface
}
/**
* RG-3.04 : un bloc Contact exige au moins le prenom OU le nom (aligne sur le
* M1/M2 — un contact se materialise par son nom ; fonction / telephone / email
* seuls ne suffisent pas). Double garde avec le CHECK BDD chk_provider_contact_name
* — leve une 422 propre rattachee au champ `firstName` plutot qu'une 500 SQL.
* Joue apres normalisation (les chaines vides sont deja ramenees a null).
* RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
* nom / fonction / telephone principal / email est renseigne (double garde avec
* le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
* ramenees a null et ne suffisent pas a valider le bloc.
*/
private function validateName(ProviderContact $contact): void
{
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
if (null === $contact->getFirstName()
&& null === $contact->getLastName()
&& null === $contact->getJobTitle()
&& null === $contact->getPhonePrimary()
&& null === $contact->getEmail()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Le prénom ou le nom du contact est obligatoire.',
'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).',
null,
[],
$contact,
@@ -9,7 +9,6 @@ use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use DateTimeImmutable;
@@ -76,15 +75,6 @@ final class ProviderProcessor implements ProcessorInterface
'paymentType', 'bank',
];
/**
* Champs comptables obligatoires a la validation complete de l'onglet
* (spec-front M3 § Onglet Comptabilite — miroir M1/M2). bank est exclu :
* conditionnel (RG-3.07).
*/
private const array ACCOUNTING_REQUIRED_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
];
/** Champ d'archivage (groupe provider:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
@@ -112,7 +102,6 @@ final class ProviderProcessor implements ProcessorInterface
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
private readonly ProviderAccountingCompletenessValidator $accountingValidator,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -139,10 +128,6 @@ final class ProviderProcessor implements ProcessorInterface
// deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
// Completude de l'onglet Comptabilite (apres normalize : les chaines vides
// sont deja ramenees a null). Joue uniquement sur une soumission d'onglet.
$this->validateAccountingCompleteness($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
@@ -511,21 +496,6 @@ final class ProviderProcessor implements ProcessorInterface
*
* @return list<string>
*/
/**
* Completude de l'onglet Comptabilite (miroir SupplierProcessor) : ne se
* declenche que si TOUS les champs requis sont presents dans le payload
* (= soumission d'onglet, pas un PATCH partiel cible). Delegue au validateur
* qui leve une 422 listant chaque champ manquant (mapping inline ERP-101).
*/
private function validateAccountingCompleteness(Provider $data): void
{
if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
return;
}
$this->accountingValidator->validate($data);
}
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
-29
View File
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport;
final class TransportModule
{
public const string ID = 'transport';
public const string LABEL = 'Transport';
public const bool REQUIRED = false;
/**
* Liste declarative des permissions RBAC exposees par le module Transport.
*
* Vide a ce stade : le module ne porte que des referentiels externes
* synchronises par commandes console (codes IDTF - ERP-149, transporteurs
* QUALIMAT - ERP-39), sans ecran ni action protegee. Les permissions seront
* ajoutees quand une page de consultation sera exposee.
*
* Consommee par `app:sync-permissions` (un tableau vide est valide).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [];
}
}
@@ -395,12 +395,12 @@ final class ColumnCommentsCatalog
],
'provider_contact' => [
'_table' => 'Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).',
'_table' => 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).',
'id' => 'Identifiant interne auto-incremente.',
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.',
'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).',
'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).',
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
'email' => 'Email du contact (lowercase serveur).',
@@ -9,9 +9,9 @@ namespace App\Tests\Module\Technique\Api;
* de l'entite Provider (M3, RG-3.07 / RG-3.08), via le PATCH de l'onglet
* Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le
* propertyPath de la violation (consommable par extractApiViolations cote front,
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), completude de l'onglet
* INCLUSE : a la validation complete de l'onglet, les six scalaires comptables
* sont obligatoires (spec-front M3 § Onglet Comptabilite — aligne M1/M2).
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), sans le bloc « completude de
* l'onglet » : le prestataire est minimal et n'impose pas les six scalaires
* comptables (spec M3 § 3.1).
*
* @internal
*/
@@ -81,58 +81,5 @@ final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase
self::assertResponseStatusCodeSame(200);
}
// === Completude de l'onglet Comptabilite (six scalaires obligatoires) ===
/**
* spec-front M3 § Onglet Comptabilite : a la validation COMPLETE de l'onglet
* (les six champs requis presents dans le payload), chacun vide doit renvoyer
* une 422 sur son propre propertyPath (mapping inline front, ERP-101). Miroir
* M1/M2 (ProviderAccountingCompletenessValidator).
*/
public function testIncompleteAccountingTabReturns422OnEachField(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Accounting Incomplete');
$response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => [
'siren' => null,
'accountNumber' => null,
'tvaMode' => null,
'nTva' => null,
'paymentDelay' => null,
'paymentType' => null,
],
]);
self::assertResponseStatusCodeSame(422);
$paths = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('siren', $paths);
self::assertArrayHasKey('accountNumber', $paths);
self::assertArrayHasKey('tvaMode', $paths);
self::assertArrayHasKey('nTva', $paths);
self::assertArrayHasKey('paymentDelay', $paths);
self::assertArrayHasKey('paymentType', $paths);
}
/**
* Un PATCH ciblant un sous-ensemble de champs comptables n'est PAS une
* validation d'onglet : la completude ne se declenche pas (edition ponctuelle
* preservee, cf. validateAccountingCompleteness).
*/
public function testPartialAccountingPatchSkipsCompleteness(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Accounting Partial');
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['nTva' => 'FR12345678901'],
]);
self::assertResponseStatusCodeSame(200);
}
// violationsByPath() : helper mutualise dans AbstractProviderApiTestCase.
}
@@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider;
/**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
* (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
* (au moins le prenom OU le nom — aligne M1/M2), RG-3.05 (>= 1 site sur
* (au moins un champ parmi prenom/nom/fonction/telephone/email), RG-3.05 (>= 1 site sur
* l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse),
* le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`),
* RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de
@@ -53,58 +53,41 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
}
/**
* RG-3.04 (aligne M1/M2) : un bloc Contact exige le prenom OU le nom. Une
* Fonction seule (sans nom ni prenom) ne suffit plus -> 422 rattachee a firstName.
* RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
*/
public function testPostContactWithOnlyJobTitleReturns422(): void
public function testPostContactWithOnlyJobTitleReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact JobTitle Only');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => 'Directeur'],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
}
/**
* RG-3.04 : un bloc Contact sans prenom NI nom (meme avec d'autres champs ou
* apres normalisation des chaines vides) est rejete avant la base -> 422
* rattachee a firstName (double garde CHECK chk_provider_contact_name).
*/
public function testPostContactWithoutNameReturns422OnFirstNamePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact No Name');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
// Email + telephone fournis mais ni prenom ni nom -> invalide (RG-3.04).
'json' => ['email' => 'contact@acme.fr', 'phonePrimary' => '0612345678'],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
}
/**
* RG-3.04 : le prenom SEUL (sans nom) suffit a valider le contact -> 201.
*/
public function testPostContactWithOnlyFirstNameReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact FirstName Only');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Jean'],
'json' => ['jobTitle' => 'Directeur'],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Jean', $data['firstName']);
self::assertSame('Directeur', $data['jobTitle']);
}
/**
* RG-3.04 : un bloc Contact TOTALEMENT vide (aucun champ du CHECK
* chk_provider_contact_name) est rejete avant la base -> 422 rattachee a
* firstName. Une Fonction vide (apres normalisation) ne suffit pas a valider.
*/
public function testPostContactCompletelyEmptyReturns422OnFirstNamePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact No Field');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => ' '],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
}
public function testPostContactOnMissingProviderReturns404(): void