Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36edd11854 | |||
| 45cb5c834c | |||
| 2689b85ebe | |||
| f4bbc79550 | |||
| f057866e75 | |||
| 19fdb50cec | |||
| 368bb50ffb |
@@ -6,6 +6,7 @@ use App\Module\Commercial\CommercialModule;
|
|||||||
use App\Module\Core\CoreModule;
|
use App\Module\Core\CoreModule;
|
||||||
use App\Module\Sites\SitesModule;
|
use App\Module\Sites\SitesModule;
|
||||||
use App\Module\Technique\TechniqueModule;
|
use App\Module\Technique\TechniqueModule;
|
||||||
|
use App\Module\Transport\TransportModule;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
CoreModule::class,
|
CoreModule::class,
|
||||||
@@ -13,4 +14,5 @@ return [
|
|||||||
SitesModule::class,
|
SitesModule::class,
|
||||||
CatalogModule::class,
|
CatalogModule::class,
|
||||||
TechniqueModule::class,
|
TechniqueModule::class,
|
||||||
|
TransportModule::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,16 +8,22 @@ doctrine:
|
|||||||
default:
|
default:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
# Exclut `audit_log` de toute operation de comparaison de schema
|
# Exclut certaines tables de toute operation de comparaison de
|
||||||
# (doctrine:schema:update, schema:validate, diff de migrations...).
|
# schema (doctrine:schema:update, schema:validate, diff de
|
||||||
# Cette table n'a volontairement aucune entite mappee : elle est
|
# migrations...). Ces tables n'ont volontairement aucune entite
|
||||||
# append-only via DBAL brut (AuditLogWriter) pour eviter la
|
# mappee :
|
||||||
# recursion du listener Doctrine. Sans ce filtre, schema:update
|
# - `audit_log` : append-only via DBAL brut (AuditLogWriter) pour
|
||||||
# la considere comme "orpheline" et genere un `DROP TABLE
|
# eviter la recursion du listener Doctrine.
|
||||||
# audit_log` qui casse la base de test apres chaque
|
# - `qualimat_carrier` / `qualimat_sync_log` : referentiel
|
||||||
# `make test-db-setup`. La creation / suppression de la table
|
# transporteurs synchronise en DBAL brut (upsert `ON CONFLICT`)
|
||||||
# reste pilotee par les migrations (cf. Version20260420202749).
|
# par `app:qualimat:sync`, hors ORM.
|
||||||
schema_filter: '~^(?!audit_log$).+~'
|
# Sans ce filtre, schema:update les considere comme "orphelines" et
|
||||||
|
# genere un `DROP TABLE` qui casse la base de test apres chaque
|
||||||
|
# `make test-db-setup` (la migration les a creees, schema:update les
|
||||||
|
# supprime juste apres). Creation / suppression restent pilotees par
|
||||||
|
# les migrations (audit_log : Version20260420202749 ; qualimat :
|
||||||
|
# Version20260612150000).
|
||||||
|
schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log)$).+~'
|
||||||
audit:
|
audit:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
orm:
|
orm:
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ doctrine_migrations:
|
|||||||
migrations_paths:
|
migrations_paths:
|
||||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||||
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/Migrations'
|
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/Migrations'
|
||||||
|
'App\Module\Transport\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Transport/Infrastructure/Doctrine/Migrations'
|
||||||
enable_profiler: false
|
enable_profiler: false
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Active le composant HTTP Client (symfony/http-client) et enregistre
|
||||||
|
# l'autowiring de HttpClientInterface. Utilise par les commandes de
|
||||||
|
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
|
||||||
|
framework:
|
||||||
|
http_client:
|
||||||
|
default_options:
|
||||||
|
timeout: 30
|
||||||
|
headers:
|
||||||
|
User-Agent: 'Starseed-ERP (referentiel-sync)'
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.120'
|
app.version: '0.1.123'
|
||||||
|
|||||||
@@ -157,12 +157,16 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||||
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||||
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||||
|
bloc enregistre. -->
|
||||||
<ClientContactBlock
|
<ClientContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="contact.id ?? `new-${index}`"
|
:key="contact.id ?? `new-${index}`"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
:removable="contacts.length > 1"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -199,7 +203,7 @@
|
|||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="addresses.length > 1"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -304,7 +308,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -440,6 +444,7 @@ import {
|
|||||||
type RibFormDraft,
|
type RibFormDraft,
|
||||||
} from '~/modules/commercial/types/clientForm'
|
} from '~/modules/commercial/types/clientForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
@@ -490,10 +495,6 @@ const contacts = ref<ContactFormDraft[]>([])
|
|||||||
const addresses = ref<AddressFormDraft[]>([])
|
const addresses = ref<AddressFormDraft[]>([])
|
||||||
const ribs = ref<RibFormDraft[]>([])
|
const ribs = ref<RibFormDraft[]>([])
|
||||||
|
|
||||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
|
||||||
const removedContactIds = ref<number[]>([])
|
|
||||||
const removedAddressIds = ref<number[]>([])
|
|
||||||
const removedRibIds = ref<number[]>([])
|
|
||||||
|
|
||||||
const mainSubmitting = ref(false)
|
const mainSubmitting = ref(false)
|
||||||
const tabSubmitting = ref(false)
|
const tabSubmitting = ref(false)
|
||||||
@@ -754,32 +755,31 @@ function addContact(): void {
|
|||||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
|
||||||
|
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
|
||||||
|
// local. Echec serveur : bloc conserve + erreur remontee.
|
||||||
function askRemoveContact(index: number): void {
|
function askRemoveContact(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||||
const removed = contacts.value[index]
|
rows: contacts.value,
|
||||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
errors: contactErrors.value,
|
||||||
contacts.value.splice(index, 1)
|
index,
|
||||||
contactErrors.value.splice(index, 1)
|
endpoint: '/client_contacts',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
makeEmpty: emptyContact,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
|
* Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
|
||||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
* Strictement scope a la collection contacts (endpoints client_contact dedies). La
|
||||||
* collection contacts (endpoints client_contact dedies).
|
* suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||||
*/
|
*/
|
||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
contactErrors.value = []
|
contactErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedContactIds.value) {
|
|
||||||
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedContactIds.value = []
|
|
||||||
|
|
||||||
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
|
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||||
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
|
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
|
||||||
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
|
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
|
||||||
@@ -836,14 +836,15 @@ function addAddress(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askRemoveAddress(index: number): void {
|
function askRemoveAddress(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
|
||||||
const removed = addresses.value[index]
|
rows: addresses.value,
|
||||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
errors: addressErrors.value,
|
||||||
addresses.value.splice(index, 1)
|
index,
|
||||||
addressErrors.value.splice(index, 1)
|
endpoint: '/client_addresses',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
makeEmpty: emptyAddress,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddressDegraded(): void {
|
function onAddressDegraded(): void {
|
||||||
@@ -855,17 +856,12 @@ function onAddressDegraded(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
|
/** Valide l'onglet Adresse : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
|
||||||
async function submitAddresses(): Promise<void> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
addressErrors.value = []
|
addressErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedAddressIds.value) {
|
|
||||||
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedAddressIds.value = []
|
|
||||||
|
|
||||||
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
||||||
const hasError = await submitRows(
|
const hasError = await submitRows(
|
||||||
addresses.value,
|
addresses.value,
|
||||||
@@ -937,29 +933,32 @@ function addRib(): void {
|
|||||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
if (canAddRib.value) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
|
||||||
|
// d'une LCR (RG-1.13) -> 409 remonte via showError (message back), bloc conserve.
|
||||||
function askRemoveRib(index: number): void {
|
function askRemoveRib(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||||
const removed = ribs.value[index]
|
rows: ribs.value,
|
||||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
errors: ribErrors.value,
|
||||||
ribs.value.splice(index, 1)
|
index,
|
||||||
ribErrors.value.splice(index, 1)
|
endpoint: '/client_ribs',
|
||||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
makeEmpty: emptyRib,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||||
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
||||||
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
|
* back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
|
||||||
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
* persiste) sur le PATCH scalaires.
|
||||||
*
|
*
|
||||||
|
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
|
||||||
|
* plus de DELETE differe ici.
|
||||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
|
||||||
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
|
* sinon 403 sur tout le payload).
|
||||||
* de type de reglement. Aucun champ main/information dans le payload (mode strict
|
|
||||||
* RG-1.28 : sinon 403 sur tout le payload).
|
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
@@ -1013,14 +1012,6 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
|
||||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
|
||||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
|
||||||
for (const id of removedRibIds.value) {
|
|
||||||
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedRibIds.value = []
|
|
||||||
|
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -156,12 +156,16 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||||
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||||
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||||
|
bloc enregistre. -->
|
||||||
<ClientContactBlock
|
<ClientContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contact')"
|
:readonly="isValidated('contact')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -198,7 +202,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('address')"
|
:readonly="isValidated('address')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -303,7 +307,7 @@
|
|||||||
>
|
>
|
||||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -417,6 +421,7 @@ import {
|
|||||||
type RibFormDraft,
|
type RibFormDraft,
|
||||||
} from '~/modules/commercial/types/clientForm'
|
} from '~/modules/commercial/types/clientForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -126,12 +126,16 @@
|
|||||||
<!-- Onglet Contacts -->
|
<!-- Onglet Contacts -->
|
||||||
<template #contacts>
|
<template #contacts>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||||
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||||
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||||
|
bloc enregistre. -->
|
||||||
<SupplierContactBlock
|
<SupplierContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="contact.id ?? `new-${index}`"
|
:key="contact.id ?? `new-${index}`"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="contacts.length > 1"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -168,7 +172,7 @@
|
|||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="addresses.length > 1"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -273,7 +277,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -407,6 +411,7 @@ import {
|
|||||||
type SupplierRibFormDraft,
|
type SupplierRibFormDraft,
|
||||||
} from '~/modules/commercial/types/supplierForm'
|
} from '~/modules/commercial/types/supplierForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
@@ -456,10 +461,6 @@ const contacts = ref<SupplierContactFormDraft[]>([])
|
|||||||
const addresses = ref<SupplierAddressFormDraft[]>([])
|
const addresses = ref<SupplierAddressFormDraft[]>([])
|
||||||
const ribs = ref<SupplierRibFormDraft[]>([])
|
const ribs = ref<SupplierRibFormDraft[]>([])
|
||||||
|
|
||||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
|
||||||
const removedContactIds = ref<number[]>([])
|
|
||||||
const removedAddressIds = ref<number[]>([])
|
|
||||||
const removedRibIds = ref<number[]>([])
|
|
||||||
|
|
||||||
const mainSubmitting = ref(false)
|
const mainSubmitting = ref(false)
|
||||||
const tabSubmitting = ref(false)
|
const tabSubmitting = ref(false)
|
||||||
@@ -653,32 +654,31 @@ function addContact(): void {
|
|||||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
|
||||||
|
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
|
||||||
|
// local. Echec serveur : bloc conserve + erreur remontee.
|
||||||
function askRemoveContact(index: number): void {
|
function askRemoveContact(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||||
const removed = contacts.value[index]
|
rows: contacts.value,
|
||||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
errors: contactErrors.value,
|
||||||
contacts.value.splice(index, 1)
|
index,
|
||||||
contactErrors.value.splice(index, 1)
|
endpoint: '/supplier_contacts',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
makeEmpty: emptyContact,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
|
* Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
|
||||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
* Strictement scope a la collection contacts (endpoints supplier_contact dedies).
|
||||||
* collection contacts (endpoints supplier_contact dedies).
|
* La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||||
*/
|
*/
|
||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
contactErrors.value = []
|
contactErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedContactIds.value) {
|
|
||||||
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedContactIds.value = []
|
|
||||||
|
|
||||||
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
|
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||||
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
|
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
|
||||||
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
||||||
@@ -726,14 +726,15 @@ function addAddress(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askRemoveAddress(index: number): void {
|
function askRemoveAddress(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
|
||||||
const removed = addresses.value[index]
|
rows: addresses.value,
|
||||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
errors: addressErrors.value,
|
||||||
addresses.value.splice(index, 1)
|
index,
|
||||||
addressErrors.value.splice(index, 1)
|
endpoint: '/supplier_addresses',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
makeEmpty: emptyAddress,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddressDegraded(): void {
|
function onAddressDegraded(): void {
|
||||||
@@ -745,17 +746,12 @@ function onAddressDegraded(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */
|
/** Valide l'onglet Adresses : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
|
||||||
async function submitAddresses(): Promise<void> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
addressErrors.value = []
|
addressErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedAddressIds.value) {
|
|
||||||
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedAddressIds.value = []
|
|
||||||
|
|
||||||
const hasError = await submitRows(
|
const hasError = await submitRows(
|
||||||
addresses.value,
|
addresses.value,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
@@ -826,15 +822,18 @@ function addRib(): void {
|
|||||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
if (canAddRib.value) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
|
||||||
|
// d'une LCR (RG-2.08) -> 409 remonte via showError (message back), bloc conserve.
|
||||||
function askRemoveRib(index: number): void {
|
function askRemoveRib(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||||
const removed = ribs.value[index]
|
rows: ribs.value,
|
||||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
errors: ribErrors.value,
|
||||||
ribs.value.splice(index, 1)
|
index,
|
||||||
ribErrors.value.splice(index, 1)
|
endpoint: '/supplier_ribs',
|
||||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
makeEmpty: emptyRib,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -843,11 +842,12 @@ function askRemoveRib(index: number): void {
|
|||||||
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
||||||
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||||
*
|
*
|
||||||
|
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
|
||||||
|
* plus de DELETE differe ici.
|
||||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
|
||||||
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
|
* sinon 403 sur tout le payload).
|
||||||
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
@@ -897,14 +897,6 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
|
||||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
|
||||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
|
||||||
for (const id of removedRibIds.value) {
|
|
||||||
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedRibIds.value = []
|
|
||||||
|
|
||||||
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
|
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -121,12 +121,16 @@
|
|||||||
<!-- Onglet Contacts -->
|
<!-- Onglet Contacts -->
|
||||||
<template #contacts>
|
<template #contacts>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||||
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||||
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||||
|
bloc enregistre. -->
|
||||||
<SupplierContactBlock
|
<SupplierContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contacts')"
|
:readonly="isValidated('contacts')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -163,7 +167,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('addresses')"
|
:readonly="isValidated('addresses')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -267,7 +271,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -380,6 +384,7 @@ import {
|
|||||||
type SupplierRibFormDraft,
|
type SupplierRibFormDraft,
|
||||||
} from '~/modules/commercial/types/supplierForm'
|
} from '~/modules/commercial/types/supplierForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed, reactive, ref, type Ref } from 'vue'
|
import { computed, reactive, ref, type Ref } from 'vue'
|
||||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
|
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import {
|
import {
|
||||||
emptyProviderAccounting,
|
emptyProviderAccounting,
|
||||||
emptyProviderAddress,
|
emptyProviderAddress,
|
||||||
@@ -73,6 +74,16 @@ export function useProviderForm() {
|
|||||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
||||||
const mainErrors = useFormErrors()
|
const mainErrors = useFormErrors()
|
||||||
|
|
||||||
|
// ERP-172 : remontee d'erreur 409/422 lors d'une suppression immediate de
|
||||||
|
// sous-ressource (message back affiche en toast dedie — pas de mapping inline,
|
||||||
|
// le bloc est en cours de retrait). Ex. dernier RIB d'une LCR -> 409.
|
||||||
|
function notifyRemovalError(error: unknown): void {
|
||||||
|
toast.error({
|
||||||
|
title: t('technique.providers.toast.error'),
|
||||||
|
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('technique.providers.toast.error'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Etat du prestataire cree ────────────────────────────────────────────
|
// ── Etat du prestataire cree ────────────────────────────────────────────
|
||||||
const providerId = ref<number | null>(null)
|
const providerId = ref<number | null>(null)
|
||||||
const mainLocked = ref(false)
|
const mainLocked = ref(false)
|
||||||
@@ -317,9 +328,18 @@ export function useProviderForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeContact(index: number): void {
|
// ERP-172 : DELETE immediat du contact existant (sous-ressource) a la
|
||||||
contacts.value.splice(index, 1)
|
// confirmation de la modale. Bloc jamais persiste (id null) : retrait local.
|
||||||
contactErrors.value.splice(index, 1)
|
async function removeContact(index: number): Promise<void> {
|
||||||
|
await removeCollectionRow({
|
||||||
|
rows: contacts.value,
|
||||||
|
errors: contactErrors.value,
|
||||||
|
index,
|
||||||
|
endpoint: '/provider_contacts',
|
||||||
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
|
makeEmpty: emptyProviderContact,
|
||||||
|
onError: notifyRemovalError,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -387,9 +407,17 @@ export function useProviderForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAddress(index: number): void {
|
// ERP-172 : DELETE immediat de l'adresse existante (sous-ressource).
|
||||||
addresses.value.splice(index, 1)
|
async function removeAddress(index: number): Promise<void> {
|
||||||
addressErrors.value.splice(index, 1)
|
await removeCollectionRow({
|
||||||
|
rows: addresses.value,
|
||||||
|
errors: addressErrors.value,
|
||||||
|
index,
|
||||||
|
endpoint: '/provider_addresses',
|
||||||
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
|
makeEmpty: emptyProviderAddress,
|
||||||
|
onError: notifyRemovalError,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -479,13 +507,18 @@ export function useProviderForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRib(index: number): void {
|
// ERP-172 : DELETE immediat du RIB existant. Le back peut refuser la suppression
|
||||||
ribs.value.splice(index, 1)
|
// du dernier RIB d'une LCR -> 409 remonte via notifyRemovalError, bloc conserve.
|
||||||
ribErrors.value.splice(index, 1)
|
async function removeRib(index: number): Promise<void> {
|
||||||
// Garde au moins un bloc RIB visible (sous LCR).
|
await removeCollectionRow({
|
||||||
if (ribs.value.length === 0) {
|
rows: ribs.value,
|
||||||
ribs.value.push(emptyProviderRib())
|
errors: ribErrors.value,
|
||||||
}
|
index,
|
||||||
|
endpoint: '/provider_ribs',
|
||||||
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
|
makeEmpty: emptyProviderRib,
|
||||||
|
onError: notifyRemovalError,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,11 +62,15 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||||
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||||
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||||
|
bloc enregistre. -->
|
||||||
<ProviderContactBlock
|
<ProviderContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -102,7 +106,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -206,7 +210,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -308,6 +312,7 @@ import {
|
|||||||
emptyProviderRib,
|
emptyProviderRib,
|
||||||
} from '~/modules/technique/types/providerForm'
|
} from '~/modules/technique/types/providerForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -63,11 +63,15 @@
|
|||||||
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
|
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||||
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||||
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||||
|
bloc enregistre. -->
|
||||||
<ProviderContactBlock
|
<ProviderContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contact')"
|
:readonly="isValidated('contact')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -102,7 +106,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('address')"
|
:readonly="isValidated('address')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -206,7 +210,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -292,6 +296,7 @@ import {
|
|||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export default defineNuxtConfig({})
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { removeCollectionRow, isRowRemovable, type DeletableRow } from '../collectionRow'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de `removeCollectionRow` — suppression d'une ligne de collection
|
||||||
|
* (contact / adresse / RIB) avec DELETE immediat de la sous-ressource existante
|
||||||
|
* (ERP-172). Coeur de logique mutualise par les 3 modules (Client / Fournisseur /
|
||||||
|
* Prestataire) : un seul comportement teste ici couvre les 9 cas (3 modules x 3
|
||||||
|
* blocs).
|
||||||
|
*/
|
||||||
|
interface Row extends DeletableRow {
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEmpty(): Row {
|
||||||
|
return { id: null, label: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('removeCollectionRow', () => {
|
||||||
|
it('emet un DELETE sur la sous-ressource quand le bloc est existant (id non null)', async () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||||
|
const errors: Record<string, string>[] = [{}, {}]
|
||||||
|
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const onError = vi.fn()
|
||||||
|
|
||||||
|
const removed = await removeCollectionRow({
|
||||||
|
rows, errors, index: 0,
|
||||||
|
endpoint: '/client_contacts',
|
||||||
|
deleteRow, makeEmpty, onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(deleteRow).toHaveBeenCalledOnce()
|
||||||
|
expect(deleteRow).toHaveBeenCalledWith('/client_contacts/10')
|
||||||
|
expect(removed).toBe(true)
|
||||||
|
expect(rows).toEqual([{ id: 11, label: 'B' }])
|
||||||
|
expect(errors).toHaveLength(1)
|
||||||
|
expect(onError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }, { id: null, label: 'brouillon' }]
|
||||||
|
const errors: Record<string, string>[] = [{}, {}]
|
||||||
|
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const onError = vi.fn()
|
||||||
|
|
||||||
|
const removed = await removeCollectionRow({
|
||||||
|
rows, errors, index: 1,
|
||||||
|
endpoint: '/client_contacts',
|
||||||
|
deleteRow, makeEmpty, onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(deleteRow).not.toHaveBeenCalled()
|
||||||
|
expect(removed).toBe(true)
|
||||||
|
expect(rows).toEqual([{ id: 10, label: 'A' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||||
|
const errors: Record<string, string>[] = [{}, {}]
|
||||||
|
const error = { response: { status: 409 } }
|
||||||
|
const deleteRow = vi.fn().mockRejectedValue(error)
|
||||||
|
const onError = vi.fn()
|
||||||
|
|
||||||
|
const removed = await removeCollectionRow({
|
||||||
|
rows, errors, index: 0,
|
||||||
|
endpoint: '/client_ribs',
|
||||||
|
deleteRow, makeEmpty, onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(removed).toBe(false)
|
||||||
|
expect(onError).toHaveBeenCalledWith(error)
|
||||||
|
// Bloc NON retire : la suppression n'a pas ete confirmee par le serveur.
|
||||||
|
expect(rows).toEqual([{ id: 10, label: 'A' }, { id: 11, label: 'B' }])
|
||||||
|
expect(errors).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('garde au moins un bloc visible apres retrait du dernier (amorce vide)', async () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }]
|
||||||
|
const errors: Record<string, string>[] = [{}]
|
||||||
|
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await removeCollectionRow({
|
||||||
|
rows, errors, index: 0,
|
||||||
|
endpoint: '/client_contacts',
|
||||||
|
deleteRow, makeEmpty, onError: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rows).toEqual([{ id: null, label: '' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de `isRowRemovable` — la poubelle d'un bloc n'apparait que s'il reste un
|
||||||
|
* AUTRE bloc deja enregistre (id en base). Empeche de supprimer un bloc tant que
|
||||||
|
* rien n'est sauvegarde, et de supprimer son dernier bloc enregistre (ERP-172).
|
||||||
|
*/
|
||||||
|
describe('isRowRemovable', () => {
|
||||||
|
it('faux quand aucun autre bloc n\'est enregistre (que des brouillons)', () => {
|
||||||
|
const rows: Row[] = [{ id: null, label: 'brouillon 1' }, { id: null, label: 'brouillon 2' }]
|
||||||
|
expect(isRowRemovable(rows, 0)).toBe(false)
|
||||||
|
expect(isRowRemovable(rows, 1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux pour le seul bloc enregistre (un brouillon a cote ne compte pas)', () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'enregistre' }, { id: null, label: 'brouillon' }]
|
||||||
|
// Le bloc enregistre ne peut pas etre supprime : aucun AUTRE bloc enregistre.
|
||||||
|
expect(isRowRemovable(rows, 0)).toBe(false)
|
||||||
|
// Le brouillon peut etre jete : il reste le bloc enregistre id=10.
|
||||||
|
expect(isRowRemovable(rows, 1)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai pour chaque bloc des qu\'au moins deux sont enregistres', () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||||
|
expect(isRowRemovable(rows, 0)).toBe(true)
|
||||||
|
expect(isRowRemovable(rows, 1)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux pour un unique bloc', () => {
|
||||||
|
expect(isRowRemovable([{ id: 10, label: 'A' }], 0)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/** Ligne de collection supprimable (contact / adresse / RIB). */
|
||||||
|
export interface DeletableRow {
|
||||||
|
id?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indique si le bloc d'index `index` peut afficher sa poubelle (ERP-172).
|
||||||
|
*
|
||||||
|
* Regle metier : on ne peut supprimer un bloc QUE s'il reste au moins un AUTRE
|
||||||
|
* bloc deja enregistre (`id` non null, donc persiste en base). Consequences :
|
||||||
|
* - tant que rien n'est enregistre -> aucune poubelle (pas de suppression d'un
|
||||||
|
* simple brouillon saisi mais pas valide) ;
|
||||||
|
* - on peut jeter un brouillon non enregistre s'il reste un bloc enregistre ;
|
||||||
|
* - on ne peut jamais supprimer son dernier bloc enregistre.
|
||||||
|
*/
|
||||||
|
export function isRowRemovable<T extends DeletableRow>(rows: T[], index: number): boolean {
|
||||||
|
return rows.some((row, i) => i !== index && row.id != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options de {@link removeCollectionRow}. */
|
||||||
|
export interface RemoveCollectionRowOptions<T extends DeletableRow> {
|
||||||
|
/** Tableau reactif des brouillons (passer le `.value` de la ref). */
|
||||||
|
rows: T[]
|
||||||
|
/** Tableau reactif des erreurs par ligne, aligne sur l'index (passer le `.value`). */
|
||||||
|
errors: Record<string, string>[]
|
||||||
|
/** Index de la ligne a retirer. */
|
||||||
|
index: number
|
||||||
|
/** Endpoint de la sous-ressource SANS id (ex: '/client_contacts'). */
|
||||||
|
endpoint: string
|
||||||
|
/** Suppression serveur : DOIT rejeter en cas d'echec (ex: url => api.delete(url, {}, { toast: false })). */
|
||||||
|
deleteRow: (url: string) => Promise<unknown>
|
||||||
|
/** Fabrique d'un bloc vide pour garder au moins un bloc visible apres retrait. */
|
||||||
|
makeEmpty: () => T
|
||||||
|
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire une ligne de collection (contact / adresse / RIB) sur les ecrans de
|
||||||
|
* MODIFICATION, avec DELETE immediat de la sous-ressource (ERP-172). Comportement
|
||||||
|
* aligne sur les 3 modules (Client / Fournisseur / Prestataire) :
|
||||||
|
*
|
||||||
|
* - Bloc jamais persiste (`id` null) : simple retrait local, aucun appel reseau.
|
||||||
|
* - Bloc existant (`id` non null) : DELETE `/endpoint/{id}` AVANT le retrait du
|
||||||
|
* tableau. On ne retire le bloc QUE si le serveur a confirme — sinon le bloc
|
||||||
|
* reste affiche et l'erreur est remontee via `onError` (ex. dernier RIB d'une
|
||||||
|
* LCR -> 409 back, RG-x.08).
|
||||||
|
*
|
||||||
|
* Etat purement local : `rows`/`errors` sont les `.value` des refs (proxies
|
||||||
|
* reactifs), le `splice` declenche donc la reactivite.
|
||||||
|
*
|
||||||
|
* @returns `true` si la ligne a ete retiree (suppression confirmee ou bloc local),
|
||||||
|
* `false` si la suppression serveur a echoue (bloc conserve).
|
||||||
|
*/
|
||||||
|
export async function removeCollectionRow<T extends DeletableRow>(
|
||||||
|
options: RemoveCollectionRowOptions<T>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options
|
||||||
|
const removed = rows[index]
|
||||||
|
|
||||||
|
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
|
||||||
|
if (removed?.id != null) {
|
||||||
|
try {
|
||||||
|
await deleteRow(`${endpoint}/${removed.id}`)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
onError(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.splice(index, 1)
|
||||||
|
errors.splice(index, 1)
|
||||||
|
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||||
|
if (rows.length === 0) {
|
||||||
|
rows.push(makeEmpty())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -250,6 +250,14 @@ sync-permissions:
|
|||||||
seed-rbac:
|
seed-rbac:
|
||||||
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
|
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
|
||||||
|
|
||||||
|
# Synchronise le referentiel des transporteurs QUALIMAT (ERP-39) : upsert sur
|
||||||
|
# le SIRET + soft-delete des absents + journal. Idempotent (refresh complet),
|
||||||
|
# prevu pour un cron quotidien.
|
||||||
|
# Options : --dry-run (analyse sans ecriture), --file=<chemin.json> (source
|
||||||
|
# locale au lieu de l'API), --ppp=<n> (taille de page API, defaut 10000).
|
||||||
|
qualimat-sync:
|
||||||
|
$(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync
|
||||||
|
|
||||||
# Attention, supprime votre bdd local
|
# Attention, supprime votre bdd local
|
||||||
db-reset:
|
db-reset:
|
||||||
$(DOCKER_COMPOSE) down -v
|
$(DOCKER_COMPOSE) down -v
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Transport\Application\Qualimat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping pur d'un item brut de l'API QUALIMAT vers une ligne normalisee
|
||||||
|
* prete a l'upsert dans `qualimat_carrier`. Sans dependance (testable en
|
||||||
|
* isolation). Voir ERP-39 § 2 pour les pieges qualite de la source.
|
||||||
|
*/
|
||||||
|
final class QualimatRowMapper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mappe un lot d'items. Les items sans SIRET exploitable sont ignores et
|
||||||
|
* comptes a part (cf. `rows_skipped` du journal). Les doublons de SIRET
|
||||||
|
* (source "sale" : memes chiffres a separateurs pres) sont fusionnes,
|
||||||
|
* derniere occurrence gagnante — l'upsert ne verrait qu'une ligne de toute
|
||||||
|
* facon, et le compte `rows_upserted` reflete ainsi les transporteurs
|
||||||
|
* distincts.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
*
|
||||||
|
* @return array{rows: list<array<string, mixed>>, skipped: int}
|
||||||
|
*/
|
||||||
|
public static function mapMany(array $items): array
|
||||||
|
{
|
||||||
|
$bySiret = [];
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$row = self::mapOne($item);
|
||||||
|
|
||||||
|
if (null === $row) {
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cle = SIRET normalise : une occurrence ulterieure ecrase la
|
||||||
|
// precedente (derniere gagnante).
|
||||||
|
$bySiret[$row['siret']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['rows' => array_values($bySiret), 'skipped' => $skipped];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mappe un item unique. Retourne null si le SIRET est absent ou vide
|
||||||
|
* (ligne inexploitable : pas de cle naturelle pour l'upsert).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
*
|
||||||
|
* @return null|array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function mapOne(array $item): ?array
|
||||||
|
{
|
||||||
|
$siret = self::normalizeSiret(self::str($item['Siret'] ?? null));
|
||||||
|
|
||||||
|
if (null === $siret) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'siret' => $siret,
|
||||||
|
// Nom et Societe sont identiques a la source : une seule colonne.
|
||||||
|
'name' => self::str($item['Nom'] ?? null) ?? '',
|
||||||
|
'address' => self::str($item['Adresse'] ?? null),
|
||||||
|
'postal_code' => self::str($item['CodePostal'] ?? null),
|
||||||
|
'city' => self::str($item['Ville'] ?? null),
|
||||||
|
'phone' => self::str($item['Telephone_1'] ?? null),
|
||||||
|
'department' => self::str($item['Departement'] ?? null),
|
||||||
|
// Statut conserve brut (feed externe, valeurs non contraintes).
|
||||||
|
'status' => self::str($item['Statut'] ?? null) ?? '',
|
||||||
|
'validity_date' => self::parseDate(self::str($item['Validite'] ?? null)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise un SIRET : ne conserve que les chiffres. Null si vide.
|
||||||
|
* La source est "sale" (longueurs variables 7 a 14) : aucune contrainte
|
||||||
|
* de longueur, on stocke les chiffres tels quels.
|
||||||
|
*/
|
||||||
|
public static function normalizeSiret(?string $raw): ?string
|
||||||
|
{
|
||||||
|
if (null === $raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||||
|
|
||||||
|
return '' === $digits ? null : $digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une date "dd/mm/yyyy" en "yyyy-mm-dd". Null si le format ne
|
||||||
|
* correspond pas ou si la date n'est pas un jour calendaire valide
|
||||||
|
* (garde-fou : evite un INSERT en erreur sur une date impossible).
|
||||||
|
*/
|
||||||
|
public static function parseDate(?string $raw): ?string
|
||||||
|
{
|
||||||
|
if (null === $raw || !preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $raw, $m)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$day = (int) $m[1];
|
||||||
|
$month = (int) $m[2];
|
||||||
|
$year = (int) $m[3];
|
||||||
|
|
||||||
|
if (!checkdate($month, $day, $year)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim d'une valeur scalaire ; null si la chaine resultante est vide.
|
||||||
|
*/
|
||||||
|
private static function str(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim((string) $value);
|
||||||
|
|
||||||
|
return '' === $trimmed ? null : $trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Transport\Infrastructure\Console;
|
||||||
|
|
||||||
|
use App\Module\Transport\Application\Qualimat\QualimatRowMapper;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
use function array_slice;
|
||||||
|
use function count;
|
||||||
|
use function is_array;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-39 : synchronise le referentiel des transporteurs QUALIMAT.
|
||||||
|
*
|
||||||
|
* Recupere la liste des operateurs de transport depuis l'API publique (ou un
|
||||||
|
* fichier local), normalise chaque ligne et synchronise `qualimat_carrier` de
|
||||||
|
* facon transactionnelle : upsert sur le SIRET, soft-delete des absents,
|
||||||
|
* journal dans `qualimat_sync_log`. Idempotente (refresh complet) : prevue
|
||||||
|
* pour un cron quotidien.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:qualimat:sync',
|
||||||
|
description: 'Synchronise le referentiel des transporteurs QUALIMAT (upsert + soft-delete + journal).',
|
||||||
|
)]
|
||||||
|
final class SyncQualimatCommand extends Command
|
||||||
|
{
|
||||||
|
private const string API_URL = 'https://www.qualimat.org/wp-json/qualimat/v1/getOperateurs';
|
||||||
|
private const int DEFAULT_PPP = 10000;
|
||||||
|
|
||||||
|
// Cle arbitraire (mais stable) du verrou consultatif Postgres serialisant
|
||||||
|
// les runs de `app:qualimat:sync` entre eux. Propre a cette commande.
|
||||||
|
private const int ADVISORY_LOCK_KEY = 3_900_000_039;
|
||||||
|
|
||||||
|
// Nombre de lignes par INSERT groupe. 10 parametres/ligne, large marge sous
|
||||||
|
// la limite Postgres de 65535 parametres par requete.
|
||||||
|
private const int UPSERT_CHUNK = 1000;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un JSON local (court-circuite l'appel HTTP, utile pour tests/rejeu).")
|
||||||
|
->addOption('ppp', null, InputOption::VALUE_REQUIRED, "Taille de page demandee a l'API.", (string) self::DEFAULT_PPP)
|
||||||
|
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$ppp = max(1, (int) $input->getOption('ppp'));
|
||||||
|
$dryRun = (bool) $input->getOption('dry-run');
|
||||||
|
$file = $input->getOption('file');
|
||||||
|
|
||||||
|
// Verrou consultatif (session) : empeche deux runs de se chevaucher
|
||||||
|
// (cron qui deborde, invocation manuelle parallele). Sans lui, le run le
|
||||||
|
// plus tardif desactiverait les lignes que l'autre vient d'inserer.
|
||||||
|
if (!$this->acquireLock()) {
|
||||||
|
$io->error('Une synchronisation QUALIMAT est deja en cours (verrou non disponible).');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->doSync($io, $ppp, $dryRun, $file);
|
||||||
|
} finally {
|
||||||
|
$this->releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coeur de la synchronisation, execute sous verrou consultatif.
|
||||||
|
*/
|
||||||
|
private function doSync(SymfonyStyle $io, int $ppp, bool $dryRun, ?string $file): int
|
||||||
|
{
|
||||||
|
// 1. Recuperation des items (fichier local ou API).
|
||||||
|
try {
|
||||||
|
$items = null !== $file ? $this->readLocal($file) : $this->fetchRemote($ppp);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$io->error('Recuperation impossible : '.$e->getMessage());
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = count($items);
|
||||||
|
$io->section(sprintf('QUALIMAT — %d items recus', $total));
|
||||||
|
|
||||||
|
// Garde-fou troncature : un retour egal a ppp signale un dataset coupe.
|
||||||
|
if (null === $file && $total === $ppp) {
|
||||||
|
$io->warning(sprintf("Le nombre d'items recus (%d) egale --ppp : resultat potentiellement tronque, augmente --ppp.", $ppp));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mapping / normalisation (les items sans SIRET sont ignores, les
|
||||||
|
// doublons de SIRET sont fusionnes : derniere occurrence gagnante).
|
||||||
|
['rows' => $rows, 'skipped' => $skipped] = QualimatRowMapper::mapMany($items);
|
||||||
|
$io->writeln(sprintf('%d lignes exploitables, %d ignorees (sans SIRET).', count($rows), $skipped));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->renderPreview($io, $rows);
|
||||||
|
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde-fou « zero ligne » : une source vide (incident amont, liste []
|
||||||
|
// legitime) ne doit JAMAIS atteindre le soft-delete, qui desactiverait
|
||||||
|
// tout le referentiel. On abandonne sans rien ecrire.
|
||||||
|
if ([] === $rows) {
|
||||||
|
$io->error('Aucune ligne exploitable : synchronisation abandonnee (desactivation de masse evitee).');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
|
||||||
|
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
|
||||||
|
|
||||||
|
$this->connection->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$upserted = $this->upsertAll($rows, $run);
|
||||||
|
$deactivated = $this->deactivateMissing($run);
|
||||||
|
$this->log($run, $total, $upserted, $skipped, $deactivated);
|
||||||
|
$this->connection->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->connection->rollBack();
|
||||||
|
$io->error('Sync annulee (rollback) : '.$e->getMessage());
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf('%d upsert, %d ignore(s), %d desactive(s).', $upserted, $skipped, $deactivated));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tente de prendre le verrou consultatif de session. Retourne false si un
|
||||||
|
* autre run le detient deja (Postgres `pg_try_advisory_lock`, non bloquant).
|
||||||
|
*/
|
||||||
|
private function acquireLock(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->connection->fetchOne('SELECT pg_try_advisory_lock(:key)', ['key' => self::ADVISORY_LOCK_KEY]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relache le verrou consultatif pris par acquireLock().
|
||||||
|
*/
|
||||||
|
private function releaseLock(): void
|
||||||
|
{
|
||||||
|
$this->connection->executeStatement('SELECT pg_advisory_unlock(:key)', ['key' => self::ADVISORY_LOCK_KEY]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejoue l'appel GET de l'API QUALIMAT et retourne le tableau d'items.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function fetchRemote(int $ppp): array
|
||||||
|
{
|
||||||
|
$response = $this->httpClient->request('GET', self::API_URL, [
|
||||||
|
'query' => ['type' => 'operateur_transport', 'ppp' => $ppp],
|
||||||
|
'timeout' => 60,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// toArray() leve une exception sur un statut non-2xx ou un corps non-JSON.
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
// Un 2xx au corps inattendu (objet d'erreur, enveloppe {"data":[...]}, etc.)
|
||||||
|
// ne doit PAS etre interprete comme « 0 transporteur » : ce serait masquer
|
||||||
|
// un changement de contrat de l'API et declencher la desactivation de masse
|
||||||
|
// (cf. garde-fou « zero ligne » dans execute()). On echoue franchement.
|
||||||
|
if (!array_is_list($data)) {
|
||||||
|
throw new RuntimeException("Reponse inattendue de l'API QUALIMAT : un tableau d'items etait attendu.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit un export JSON local (tableau d'objets).
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function readLocal(string $path): array
|
||||||
|
{
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
|
||||||
|
if (false === $raw) {
|
||||||
|
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
if (!is_array($data) || !array_is_list($data)) {
|
||||||
|
throw new RuntimeException("Le JSON doit etre un tableau d'objets.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert de toutes les lignes valides (cle naturelle = siret) par paquets
|
||||||
|
* (INSERT groupe), au lieu d'un aller-retour par ligne. Marque is_active=TRUE
|
||||||
|
* et tamponne last_synced_at avec le run courant. Les lignes etant deja
|
||||||
|
* dedoublonnees par SIRET en amont, le compte retourne = transporteurs
|
||||||
|
* distincts effectivement synchronises.
|
||||||
|
*
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function upsertAll(array $rows, string $run): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach (array_chunk($rows, self::UPSERT_CHUNK) as $chunk) {
|
||||||
|
$placeholders = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
foreach ($chunk as $r) {
|
||||||
|
// 10 valeurs liees + is_active force a TRUE (litteral).
|
||||||
|
$placeholders[] = '(?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, ?)';
|
||||||
|
$params[] = $r['siret'];
|
||||||
|
$params[] = $r['name'];
|
||||||
|
$params[] = $r['address'];
|
||||||
|
$params[] = $r['postal_code'];
|
||||||
|
$params[] = $r['city'];
|
||||||
|
$params[] = $r['phone'];
|
||||||
|
$params[] = $r['department'];
|
||||||
|
$params[] = $r['status'];
|
||||||
|
$params[] = $r['validity_date'];
|
||||||
|
$params[] = $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = sprintf(
|
||||||
|
<<<'SQL'
|
||||||
|
INSERT INTO qualimat_carrier
|
||||||
|
(siret, name, address, postal_code, city, phone, department, status, validity_date, is_active, last_synced_at)
|
||||||
|
VALUES
|
||||||
|
%s
|
||||||
|
ON CONFLICT (siret) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
address = EXCLUDED.address,
|
||||||
|
postal_code = EXCLUDED.postal_code,
|
||||||
|
city = EXCLUDED.city,
|
||||||
|
phone = EXCLUDED.phone,
|
||||||
|
department = EXCLUDED.department,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
validity_date = EXCLUDED.validity_date,
|
||||||
|
is_active = TRUE,
|
||||||
|
last_synced_at = EXCLUDED.last_synced_at
|
||||||
|
SQL,
|
||||||
|
implode(",\n ", $placeholders),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->connection->executeStatement($sql, $params);
|
||||||
|
$count += count($chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete : toute ligne active non revue par ce run (tampon anterieur)
|
||||||
|
* passe a is_active=false.
|
||||||
|
*/
|
||||||
|
private function deactivateMissing(string $run): int
|
||||||
|
{
|
||||||
|
return (int) $this->connection->executeStatement(
|
||||||
|
'UPDATE qualimat_carrier SET is_active = FALSE WHERE is_active = TRUE AND last_synced_at < :run',
|
||||||
|
['run' => $run],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function log(string $run, int $total, int $upserted, int $skipped, int $deactivated): void
|
||||||
|
{
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
<<<'SQL'
|
||||||
|
INSERT INTO qualimat_sync_log (fetched_at, rows_total, rows_upserted, rows_skipped, rows_deactivated)
|
||||||
|
VALUES (:run, :total, :upserted, :skipped, :deactivated)
|
||||||
|
SQL,
|
||||||
|
[
|
||||||
|
'run' => $run,
|
||||||
|
'total' => $total,
|
||||||
|
'upserted' => $upserted,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'deactivated' => $deactivated,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function renderPreview(SymfonyStyle $io, array $rows): void
|
||||||
|
{
|
||||||
|
$io->table(
|
||||||
|
['SIRET', 'Nom', 'CP', 'Ville', 'Statut', 'Validite'],
|
||||||
|
array_map(static fn (array $r): array => [
|
||||||
|
(string) $r['siret'],
|
||||||
|
mb_strimwidth((string) $r['name'], 0, 40, '…'),
|
||||||
|
(string) ($r['postal_code'] ?? ''),
|
||||||
|
mb_strimwidth((string) ($r['city'] ?? ''), 0, 25, '…'),
|
||||||
|
(string) $r['status'],
|
||||||
|
(string) ($r['validity_date'] ?? ''),
|
||||||
|
], array_slice($rows, 0, 15)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Transport\Infrastructure\Doctrine\Migrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-39 (Module Transport) : referentiel des transporteurs agrees QUALIMAT.
|
||||||
|
*
|
||||||
|
* Tables alimentees par la commande de synchronisation `app:qualimat:sync`
|
||||||
|
* (upsert sur le SIRET + soft-delete des absents + journal). Aucune FK
|
||||||
|
* cross-module (referentiel autonome) : migration au namespace modulaire
|
||||||
|
* Transport. Tables autonomes, sans dependance d'ordre vis-a-vis des autres
|
||||||
|
* migrations, donc insensible au tri cross-namespace de Doctrine Migrations.
|
||||||
|
*/
|
||||||
|
final class Version20260612150000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-39 : tables qualimat_carrier + qualimat_sync_log (referentiel transporteurs QUALIMAT, synchro console).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE qualimat_carrier (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
siret VARCHAR(20) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
address VARCHAR(255) DEFAULT NULL,
|
||||||
|
postal_code VARCHAR(10) DEFAULT NULL,
|
||||||
|
city VARCHAR(255) DEFAULT NULL,
|
||||||
|
phone VARCHAR(32) DEFAULT NULL,
|
||||||
|
department VARCHAR(64) DEFAULT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
validity_date DATE DEFAULT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||||
|
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uq_qualimat_carrier_siret UNIQUE (siret)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_qualimat_carrier_active ON qualimat_carrier (is_active)');
|
||||||
|
|
||||||
|
$this->comment('qualimat_carrier', '_table', "Referentiel des transporteurs agrees QUALIMAT, synchronise quotidiennement depuis l'API qualimat.org (type=operateur_transport).");
|
||||||
|
$this->comment('qualimat_carrier', 'id', 'Cle technique auto-incrementee.');
|
||||||
|
$this->comment('qualimat_carrier', 'siret', 'SIRET normalise (chiffres sans espaces). Cle naturelle de synchro (unique). Source parfois incomplete (longueur variable), non contrainte a 14.');
|
||||||
|
$this->comment('qualimat_carrier', 'name', 'Raison sociale du transporteur (champs Nom = Societe de la source, identiques).');
|
||||||
|
$this->comment('qualimat_carrier', 'address', 'Adresse postale (voie). Nullable.');
|
||||||
|
$this->comment('qualimat_carrier', 'postal_code', 'Code postal. Nullable.');
|
||||||
|
$this->comment('qualimat_carrier', 'city', 'Ville. Nullable.');
|
||||||
|
$this->comment('qualimat_carrier', 'phone', 'Telephone au format source "indicatif|numero" (ex: +33|0608890316). Nullable.');
|
||||||
|
$this->comment('qualimat_carrier', 'department', 'Departement au format source "code - libelle" (ex: 65 - Hautes-Pyrenees). Nullable.');
|
||||||
|
$this->comment('qualimat_carrier', 'status', "Statut d'agrement QUALIMAT (valeurs connues : Audite, Valide, Suspendu). Valeur brute de la source, non contrainte.");
|
||||||
|
$this->comment('qualimat_carrier', 'validity_date', 'Date de fin de validite de la certification (convertie depuis dd/mm/yyyy). Nullable.');
|
||||||
|
$this->comment('qualimat_carrier', 'is_active', 'Faux = transporteur absent du dernier import (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
|
||||||
|
$this->comment('qualimat_carrier', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE qualimat_sync_log (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
fetched_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
rows_total INT NOT NULL,
|
||||||
|
rows_upserted INT NOT NULL,
|
||||||
|
rows_skipped INT NOT NULL,
|
||||||
|
rows_deactivated INT NOT NULL,
|
||||||
|
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->comment('qualimat_sync_log', '_table', 'Journal des synchronisations QUALIMAT (une ligne par run de la commande app:qualimat:sync).');
|
||||||
|
$this->comment('qualimat_sync_log', 'id', 'Cle technique auto-incrementee.');
|
||||||
|
$this->comment('qualimat_sync_log', 'fetched_at', "Horodatage de l'appel a l'API source (= run de synchro).");
|
||||||
|
$this->comment('qualimat_sync_log', 'rows_total', "Nombre d'items renvoyes par l'API.");
|
||||||
|
$this->comment('qualimat_sync_log', 'rows_upserted', 'Nombre de transporteurs inseres ou mis a jour.');
|
||||||
|
$this->comment('qualimat_sync_log', 'rows_skipped', "Nombre d'items ignores (sans SIRET exploitable).");
|
||||||
|
$this->comment('qualimat_sync_log', 'rows_deactivated', 'Nombre de transporteurs passes a is_active=false (absents de cet import).');
|
||||||
|
$this->comment('qualimat_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS qualimat_sync_log');
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS qualimat_carrier');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
|
||||||
|
* pour eviter tout echappement d'apostrophes dans les descriptions.
|
||||||
|
*/
|
||||||
|
private function comment(string $table, string $column, string $description): void
|
||||||
|
{
|
||||||
|
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||||
|
|
||||||
|
if ('_table' === $column) {
|
||||||
|
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
$quotedTable,
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
$description,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Transport\Application\Qualimat;
|
||||||
|
|
||||||
|
use App\Module\Transport\Application\Qualimat\QualimatRowMapper;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class QualimatRowMapperTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testNormalizeSiretStripsNonDigits(): void
|
||||||
|
{
|
||||||
|
self::assertSame('44415628500025', QualimatRowMapper::normalizeSiret('444 156 285 000 25'));
|
||||||
|
self::assertNull(QualimatRowMapper::normalizeSiret(null));
|
||||||
|
self::assertNull(QualimatRowMapper::normalizeSiret(' '));
|
||||||
|
self::assertNull(QualimatRowMapper::normalizeSiret(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseDate(): void
|
||||||
|
{
|
||||||
|
self::assertSame('2027-05-14', QualimatRowMapper::parseDate('14/05/2027'));
|
||||||
|
self::assertNull(QualimatRowMapper::parseDate(null));
|
||||||
|
self::assertNull(QualimatRowMapper::parseDate('2027-05-14'));
|
||||||
|
self::assertNull(QualimatRowMapper::parseDate('14-05-2027'));
|
||||||
|
// Date calendaire impossible : evite un INSERT en erreur.
|
||||||
|
self::assertNull(QualimatRowMapper::parseDate('31/02/2027'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMapOneNormalizesAndTrims(): void
|
||||||
|
{
|
||||||
|
$row = QualimatRowMapper::mapOne([
|
||||||
|
'Nom' => ' 2C TRANS ',
|
||||||
|
'Societe' => '2C TRANS',
|
||||||
|
'Adresse' => '66 Impasse Mendi',
|
||||||
|
'CodePostal' => '65500',
|
||||||
|
'Ville' => 'VIC EN BIGORRE',
|
||||||
|
'Telephone_1' => '+33|0608890316',
|
||||||
|
'Siret' => '444 156 285 000 25',
|
||||||
|
'Validite' => '14/05/2027',
|
||||||
|
'Statut' => 'Audité',
|
||||||
|
'Departement' => '65 - Hautes-Pyrénées',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertNotNull($row);
|
||||||
|
self::assertSame('44415628500025', $row['siret']);
|
||||||
|
self::assertSame('2C TRANS', $row['name']);
|
||||||
|
self::assertSame('2027-05-14', $row['validity_date']);
|
||||||
|
self::assertSame('+33|0608890316', $row['phone']);
|
||||||
|
self::assertSame('Audité', $row['status']);
|
||||||
|
self::assertSame('65 - Hautes-Pyrénées', $row['department']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMapOneReturnsNullWithoutSiret(): void
|
||||||
|
{
|
||||||
|
self::assertNull(QualimatRowMapper::mapOne(['Nom' => 'X', 'Siret' => null]));
|
||||||
|
self::assertNull(QualimatRowMapper::mapOne(['Nom' => 'X']));
|
||||||
|
self::assertNull(QualimatRowMapper::mapOne(['Nom' => 'X', 'Siret' => ' ']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMapManyCountsSkipped(): void
|
||||||
|
{
|
||||||
|
$result = QualimatRowMapper::mapMany([
|
||||||
|
['Nom' => 'A', 'Siret' => '111 111 111 00011', 'Statut' => 'Audité', 'Validite' => '01/01/2030'],
|
||||||
|
['Nom' => 'B', 'Siret' => null],
|
||||||
|
['Nom' => 'C', 'Siret' => ' '],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertCount(1, $result['rows']);
|
||||||
|
self::assertSame(2, $result['skipped']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMapManyDeduplicatesBySiretLastWins(): void
|
||||||
|
{
|
||||||
|
// Memes chiffres a separateurs pres : un seul transporteur, derniere
|
||||||
|
// occurrence gagnante (le compte ne doit pas surcompter les doublons).
|
||||||
|
$result = QualimatRowMapper::mapMany([
|
||||||
|
['Nom' => 'PREMIER', 'Siret' => '111 111 111 00011', 'Statut' => 'Audité'],
|
||||||
|
['Nom' => 'DERNIER', 'Siret' => '11111111100011', 'Statut' => 'Valide'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertCount(1, $result['rows']);
|
||||||
|
self::assertSame(0, $result['skipped']);
|
||||||
|
self::assertSame('DERNIER', $result['rows'][0]['name']);
|
||||||
|
self::assertSame('Valide', $result['rows'][0]['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyOptionalFieldsBecomeNull(): void
|
||||||
|
{
|
||||||
|
$row = QualimatRowMapper::mapOne([
|
||||||
|
'Siret' => '111 111 111 00011',
|
||||||
|
'Nom' => 'A',
|
||||||
|
'Adresse' => '',
|
||||||
|
'Ville' => ' ',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertNotNull($row);
|
||||||
|
self::assertNull($row['address']);
|
||||||
|
self::assertNull($row['city']);
|
||||||
|
self::assertNull($row['validity_date']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Transport\Infrastructure\Console;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fonctionnel de `app:qualimat:sync` via l'option --file (pas d'appel
|
||||||
|
* reseau) : verifie l'upsert normalise, le journal et le soft-delete.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class SyncQualimatCommandTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private Connection $connection;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
|
||||||
|
/** @var Connection $connection */
|
||||||
|
$connection = self::getContainer()->get('doctrine.dbal.default_connection');
|
||||||
|
$this->connection = $connection;
|
||||||
|
$this->purge();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->purge();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFirstSyncInsertsNormalizesAndLogs(): void
|
||||||
|
{
|
||||||
|
$tester = $this->runSync([
|
||||||
|
[
|
||||||
|
'Nom' => '2C TRANS',
|
||||||
|
'Societe' => '2C TRANS',
|
||||||
|
'Adresse' => '66 Impasse Mendi',
|
||||||
|
'CodePostal' => '65500',
|
||||||
|
'Ville' => 'VIC EN BIGORRE',
|
||||||
|
'Telephone_1' => '+33|0608890316',
|
||||||
|
'Siret' => '444 156 285 000 25',
|
||||||
|
'Validite' => '14/05/2027',
|
||||||
|
'Statut' => 'Audité',
|
||||||
|
'Departement' => '65 - Hautes-Pyrénées',
|
||||||
|
],
|
||||||
|
// Item sans SIRET : doit etre ignore (compte dans rows_skipped).
|
||||||
|
['Nom' => 'SANS SIRET', 'Siret' => null, 'Validite' => '01/01/2030', 'Statut' => 'Valide'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tester->assertCommandIsSuccessful();
|
||||||
|
|
||||||
|
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier'));
|
||||||
|
|
||||||
|
$row = $this->connection->fetchAssociative('SELECT * FROM qualimat_carrier');
|
||||||
|
self::assertNotFalse($row);
|
||||||
|
self::assertSame('44415628500025', $row['siret']);
|
||||||
|
self::assertSame('2C TRANS', $row['name']);
|
||||||
|
self::assertSame('2027-05-14', $row['validity_date']);
|
||||||
|
self::assertSame('+33|0608890316', $row['phone']);
|
||||||
|
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
|
||||||
|
|
||||||
|
$log = $this->connection->fetchAssociative('SELECT * FROM qualimat_sync_log ORDER BY id DESC LIMIT 1');
|
||||||
|
self::assertNotFalse($log);
|
||||||
|
self::assertSame(2, (int) $log['rows_total']);
|
||||||
|
self::assertSame(1, (int) $log['rows_upserted']);
|
||||||
|
self::assertSame(1, (int) $log['rows_skipped']);
|
||||||
|
self::assertSame(0, (int) $log['rows_deactivated']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSecondSyncUpdatesAndSoftDeletesMissing(): void
|
||||||
|
{
|
||||||
|
$a = ['Nom' => 'A', 'Siret' => '111 111 111 00011', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
|
||||||
|
$b = ['Nom' => 'B', 'Siret' => '222 222 222 00022', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
|
||||||
|
|
||||||
|
$this->runSync([$a, $b])->assertCommandIsSuccessful();
|
||||||
|
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
|
||||||
|
|
||||||
|
// 2e run sans B et avec A renomme : A est mis a jour, B est soft-delete.
|
||||||
|
$aRenamed = ['Nom' => 'A BIS', 'Siret' => '111 111 111 00011', 'Validite' => '02/02/2031', 'Statut' => 'Valide'];
|
||||||
|
$tester = $this->runSync([$aRenamed]);
|
||||||
|
$tester->assertCommandIsSuccessful();
|
||||||
|
|
||||||
|
// Toujours 2 lignes en base, mais une seule active.
|
||||||
|
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier'));
|
||||||
|
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
|
||||||
|
self::assertSame(1, $this->countRows("SELECT COUNT(*) FROM qualimat_carrier WHERE siret = '22222222200022' AND is_active = FALSE"));
|
||||||
|
|
||||||
|
// A a bien ete mis a jour (nom + statut + date).
|
||||||
|
$a = $this->connection->fetchAssociative("SELECT * FROM qualimat_carrier WHERE siret = '11111111100011'");
|
||||||
|
self::assertNotFalse($a);
|
||||||
|
self::assertSame('A BIS', $a['name']);
|
||||||
|
self::assertSame('Valide', $a['status']);
|
||||||
|
self::assertSame('2031-02-02', $a['validity_date']);
|
||||||
|
|
||||||
|
$log = $this->connection->fetchAssociative('SELECT * FROM qualimat_sync_log ORDER BY id DESC LIMIT 1');
|
||||||
|
self::assertNotFalse($log);
|
||||||
|
self::assertSame(1, (int) $log['rows_upserted']);
|
||||||
|
self::assertSame(1, (int) $log['rows_deactivated']);
|
||||||
|
self::assertSame(0, (int) $log['rows_skipped']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptySourceAbortsWithoutMassDeactivation(): void
|
||||||
|
{
|
||||||
|
// Premier run : 2 transporteurs actifs.
|
||||||
|
$a = ['Nom' => 'A', 'Siret' => '111 111 111 00011', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
|
||||||
|
$b = ['Nom' => 'B', 'Siret' => '222 222 222 00022', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
|
||||||
|
$this->runSync([$a, $b])->assertCommandIsSuccessful();
|
||||||
|
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
|
||||||
|
|
||||||
|
// Source ne contenant que des items inexploitables (zero ligne mappee) :
|
||||||
|
// la commande doit ECHOUER sans toucher le referentiel (pas de soft-delete
|
||||||
|
// de masse) et sans journaliser de run.
|
||||||
|
$logsBefore = $this->countRows('SELECT COUNT(*) FROM qualimat_sync_log');
|
||||||
|
$tester = $this->runSync([
|
||||||
|
['Nom' => 'SANS SIRET 1', 'Siret' => null],
|
||||||
|
['Nom' => 'SANS SIRET 2', 'Siret' => ' '],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||||
|
// Les 2 transporteurs restent ACTIFS (aucune desactivation de masse).
|
||||||
|
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
|
||||||
|
// Aucun journal supplementaire (abandon avant la transaction).
|
||||||
|
self::assertSame($logsBefore, $this->countRows('SELECT COUNT(*) FROM qualimat_sync_log'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
*/
|
||||||
|
private function runSync(array $items): CommandTester
|
||||||
|
{
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'qualimat_').'.json';
|
||||||
|
file_put_contents($path, json_encode($items, JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
$application = new Application(self::$kernel);
|
||||||
|
$tester = new CommandTester($application->find('app:qualimat:sync'));
|
||||||
|
$tester->execute(['--file' => $path]);
|
||||||
|
|
||||||
|
@unlink($path);
|
||||||
|
|
||||||
|
return $tester;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countRows(string $sql): int
|
||||||
|
{
|
||||||
|
return (int) $this->connection->fetchOne($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function purge(): void
|
||||||
|
{
|
||||||
|
$this->connection->executeStatement('DELETE FROM qualimat_carrier');
|
||||||
|
$this->connection->executeStatement('DELETE FROM qualimat_sync_log');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user