diff --git a/api/tests/test_declaration_flow.py b/api/tests/test_declaration_flow.py index c13a117b4..134213970 100644 --- a/api/tests/test_declaration_flow.py +++ b/api/tests/test_declaration_flow.py @@ -658,42 +658,70 @@ def test_accept_visa(self): self.assertEqual(latest_snapshot.expiration_days, 23) @authenticate - def test_visor_can_modify_comment(self): + def test_visor_can_modify_decision(self): """ - Une personne avec le rôle de visa peut modifier le commentaire à destination du pro + La viseuse peut modifier la décision de l'instructrice """ VisaRoleFactory(user=authenticate.user) - # Visa acceptée + # L'instructrice à marqué cette déclaration comme « autorisée », mais la viseuse la fera + # passer en observation declaration = OngoingVisaDeclarationFactory( post_validation_status=Declaration.DeclarationStatus.AUTHORIZED, post_validation_producer_message="À authoriser", - post_validation_expiration_days=12, ) - response = self.client.post( - reverse("api:accept_visa", kwargs={"pk": declaration.id}), {"comment": "Overriden comment"}, format="json" - ) + body = { + "comment": "overridden comment", + "proposal": "OBSERVATION", + "delayDays": 6, + "reasons": [ + "a", + "b", + ], + } + response = self.client.post(reverse("api:accept_visa", kwargs={"pk": declaration.id}), body, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) declaration.refresh_from_db() - self.assertEqual(declaration.last_administration_comment, "Overriden comment") + latest_snapshot = declaration.snapshots.latest("creation_date") + self.assertEqual(declaration.status, Declaration.DeclarationStatus.OBSERVATION) + self.assertEqual(latest_snapshot.comment, "overridden comment") + self.assertEqual(latest_snapshot.status, Declaration.DeclarationStatus.OBSERVATION) + self.assertEqual(latest_snapshot.expiration_days, 6) + self.assertEqual(latest_snapshot.blocking_reasons, ["a", "b"]) + + @authenticate + def test_visor_cant_modify_on_refuse(self): + """ + Refuser un visa n'applique pas les modifications + """ + VisaRoleFactory(user=authenticate.user) - # Visa refusée declaration = OngoingVisaDeclarationFactory( - post_validation_status=Declaration.DeclarationStatus.REJECTED, - post_validation_producer_message="À refuser", - post_validation_expiration_days=20, + post_validation_status=Declaration.DeclarationStatus.AUTHORIZED, + post_validation_producer_message="À authoriser", ) - response = self.client.post( - reverse("api:refuse_visa", kwargs={"pk": declaration.id}), - {"comment": "Overriden comment 2"}, - format="json", - ) + body = { + "comment": "overridden comment", + "proposal": "OBSERVATION", + "delayDays": 6, + "reasons": [ + "a", + "b", + ], + } + response = self.client.post(reverse("api:refuse_visa", kwargs={"pk": declaration.id}), body, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) declaration.refresh_from_db() - self.assertEqual(declaration.last_administration_comment, "Overriden comment 2") + latest_snapshot = declaration.snapshots.latest("creation_date") + self.assertEqual(declaration.status, Declaration.DeclarationStatus.AWAITING_INSTRUCTION) + self.assertEqual(latest_snapshot.comment, "À authoriser") + self.assertEqual(latest_snapshot.status, Declaration.DeclarationStatus.AWAITING_INSTRUCTION) + self.assertEqual(latest_snapshot.expiration_days, None) + self.assertEqual(latest_snapshot.blocking_reasons, None) @authenticate def accept_visa_unauthorized(self): diff --git a/api/views/declaration/declaration.py b/api/views/declaration/declaration.py index 639f68070..c6630f5d1 100644 --- a/api/views/declaration/declaration.py +++ b/api/views/declaration/declaration.py @@ -637,7 +637,7 @@ def perform_snapshot_creation(self, request, declaration): declaration.create_snapshot( user=request.user, action=self.get_snapshot_action(request, declaration), - comment=request.data.get("comment", declaration.post_validation_producer_message), + comment=declaration.post_validation_producer_message, post_validation_status=self.get_snapshot_post_validation_status(request, declaration), ) @@ -645,24 +645,42 @@ def perform_snapshot_creation(self, request, declaration): class DeclarationAcceptVisaView(VisaDecisionView): """ ONGOING_VISA -> { AUTHORIZED | REJECTED | OBJECTION | OBSERVATION } + Le ou la viseuse peut surcharger la décision de l'instructrice en envoyant + un objet dans le payload de cette forme : + { + comment: "overridden comment", + proposal: "OBSERVATION", // (ou un autre statut) + delayDays: 10, + reasons: ["a", "b"], + } """ snapshot_action = Snapshot.SnapshotActions.ACCEPT_VISA - def get_snapshot_post_validation_status(self, request, declaration): + def get_validation_status(self, request, declaration): + overridden_status = request.data.get("proposal") + if overridden_status: + return Declaration.DeclarationStatus(overridden_status) return declaration.post_validation_status + def get_snapshot_post_validation_status(self, request, declaration): + return self.get_validation_status(request, declaration) + def perform_snapshot_creation(self, request, declaration): """ Possible de le surcharger si la création du snapshot nécessite un traitement spécial """ - declaration.create_snapshot( + overridden = request.data.get("proposal") + data = request.data + d = declaration + d.create_snapshot( user=request.user, - comment=request.data.get("comment", declaration.post_validation_producer_message), - expiration_days=declaration.post_validation_expiration_days, - action=self.get_snapshot_action(request, declaration), - post_validation_status=self.get_snapshot_post_validation_status(request, declaration), + comment=data.get("comment") if overridden else d.post_validation_producer_message, + expiration_days=data.get("delay_days") if overridden else d.post_validation_expiration_days, + action=self.get_snapshot_action(request, d), + post_validation_status=self.get_snapshot_post_validation_status(request, d), + blocking_reasons=data.get("reasons") if overridden else None, ) def get_transition(self, request, declaration): @@ -672,7 +690,7 @@ def get_transition(self, request, declaration): Declaration.DeclarationStatus.OBJECTION: "accept_visa_object", Declaration.DeclarationStatus.OBSERVATION: "accept_visa_observe", } - return transition_map.get(declaration.post_validation_status) + return transition_map.get(self.get_validation_status(request, declaration)) def get_brevo_template_id(self, request, declaration): template_map = { diff --git a/frontend/src/utils/mappings.js b/frontend/src/utils/mappings.js index 4d9ea11ac..f2c92e19b 100644 --- a/frontend/src/utils/mappings.js +++ b/frontend/src/utils/mappings.js @@ -275,3 +275,59 @@ export const populationCategoriesMapping = { export const getAuthorizationModeInFrench = (type) => { return authorizationModesMapping[type] || null } + +export const blockingReasons = [ + { + title: "Le produit ne répond pas à la définition du complément alimentaire", + items: [ + "Forme assimilable à un aliment courant", + "Recommandations d'emploi incompatibles", + "Composition (source concentrée, ...)", + "Autre raison pour laquelle le produit ne répond pas à la définition du complément alimentaire", + ], + }, + { + title: "Le produit répond à la définition du médicament", + items: ["Médicament par fonction", "Médicament par présentation", "Sevrage tabagique"], + }, + { + title: "Les procédures ne sont pas respectées", + items: [ + "Présence d'un Novel Food", + "Présence d'une forme d'apport en nutriments non autorisée", + "Demande en article 17 attendue", + "Demande en article 18 attendue", + ], + }, + { + title: "Le dossier n'est pas recevable", + items: [ + "Incohérences entre le dossier et l'étiquetage", + "Informations manquantes", + "Absence de preuve de reconnaissance mutuelle", + "Absence ou non conformité de l'étiquetage", + "Autre motif d'irrecevabilité", + ], + }, + { + title: "Le complément alimentaire n'est pas acceptable", + items: ["Existence d'un risque"], + }, +] + +export const decisionCategories = [ + { + value: "approve", + title: "J’envoie l’attestation de déclaration", + icon: "ri-checkbox-circle-fill", + description: "La déclaration est conforme et peut être transmise.", + color: "green", + }, + { + value: "modify", + title: "Des changements sont nécessaires", + icon: "ri-close-circle-fill", + description: "La déclaration ne peut pas être transmise en l'état.", + color: "red", + }, +] diff --git a/frontend/src/views/InstructionPage/DecisionTab.vue b/frontend/src/views/InstructionPage/DecisionTab.vue index e0923d40e..061a62edb 100644 --- a/frontend/src/views/InstructionPage/DecisionTab.vue +++ b/frontend/src/views/InstructionPage/DecisionTab.vue @@ -105,6 +105,7 @@ import { headers } from "@/utils/data-fetching" import useToaster from "@/composables/use-toaster" import { handleError } from "@/utils/error-handling" import ArticleInfoRow from "@/components/DeclarationSummary/ArticleInfoRow" +import { blockingReasons, decisionCategories } from "@/utils/mappings" const decisionCategory = ref(null) watch(decisionCategory, () => (proposal.value = decisionCategory.value === "approve" ? "autorisation" : null)) @@ -120,7 +121,7 @@ const rules = computed(() => { }) const declaration = defineModel() const proposal = ref(null) -const delayDays = ref(15) +const delayDays = ref() const comment = ref(declaration.value?.lastAdministrationComment || "") const reasons = ref([]) @@ -136,68 +137,12 @@ const disableDelayDays = computed(() => proposal.value === "rejection") watch(proposal, (newProposal) => { if (mandatoryVisaProposals.indexOf(newProposal) > -1) needsVisa.value = true if (newProposal === "objection") delayDays.value = 30 - else if (newProposal === "rejection") delayDays.value = null - else delayDays.value = 15 + else if (newProposal === "observation") delayDays.value = 15 + else delayDays.value = null }) const needsAnsesReferal = computed(() => declaration.value?.article === "ANSES_REFERAL") -const decisionCategories = [ - { - value: "approve", - title: "J’envoie l’attestation de déclaration", - icon: "ri-checkbox-circle-fill", - description: "La déclaration est conforme et peut être transmise.", - color: "green", - }, - { - value: "modify", - title: "Des changements sont nécessaires", - icon: "ri-close-circle-fill", - description: "La déclaration ne peut pas être transmise en l'état.", - color: "red", - }, -] - -const blockingReasons = [ - { - title: "Le produit ne répond pas à la définition du complément alimentaire", - items: [ - "Forme assimilable à un aliment courant", - "Recommandations d'emploi incompatibles", - "Composition (source concentrée, ...)", - "Autre raison pour laquelle le produit ne répond pas à la définition du complément alimentaire", - ], - }, - { - title: "Le produit répond à la définition du médicament", - items: ["Médicament par fonction", "Médicament par présentation", "Sevrage tabagique"], - }, - { - title: "Les procédures ne sont pas respectées", - items: [ - "Présence d'un Novel Food", - "Présence d'une forme d'apport en nutriments non autorisée", - "Demande en article 17 attendue", - "Demande en article 18 attendue", - ], - }, - { - title: "Le dossier n'est pas recevable", - items: [ - "Incohérences entre le dossier et l'étiquetage", - "Informations manquantes", - "Absence de preuve de reconnaissance mutuelle", - "Absence ou non conformité de l'étiquetage", - "Autre motif d'irrecevabilité", - ], - }, - { - title: "Le complément alimentaire n'est pas acceptable", - items: ["Existence d'un risque"], - }, -] - const proposalOptions = computed(() => { if (decisionCategory.value === "approve") return [{ text: "Autorisation", value: "autorisation" }] diff --git a/frontend/src/views/VisaPage/DecisionModificationModal.vue b/frontend/src/views/VisaPage/DecisionModificationModal.vue new file mode 100644 index 000000000..542c97ff5 --- /dev/null +++ b/frontend/src/views/VisaPage/DecisionModificationModal.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/frontend/src/views/VisaPage/VisaInfoLine.vue b/frontend/src/views/VisaPage/VisaInfoLine.vue index 690d5c4bc..d50251ef9 100644 --- a/frontend/src/views/VisaPage/VisaInfoLine.vue +++ b/frontend/src/views/VisaPage/VisaInfoLine.vue @@ -6,14 +6,17 @@
- {{ text }} +

+ {{ strikethroughText }} +

+

{{ text }}

diff --git a/frontend/src/views/VisaPage/VisaValidationTab.vue b/frontend/src/views/VisaPage/VisaValidationTab.vue index ea59848a9..59da17ce5 100644 --- a/frontend/src/views/VisaPage/VisaValidationTab.vue +++ b/frontend/src/views/VisaPage/VisaValidationTab.vue @@ -4,41 +4,45 @@

Proposition à viser / signer

- - - - + + +
+ +
-
+
{{ decision.title }}
-

+

{{ decision.description }}

@@ -64,25 +68,35 @@ import { handleError } from "@/utils/error-handling" import useToaster from "@/composables/use-toaster" import VisaInfoLine from "./VisaInfoLine.vue" import ArticleInfoRow from "@/components/DeclarationSummary/ArticleInfoRow" +import DecisionModificationModal from "./DecisionModificationModal" const $externalResults = ref({}) const emit = defineEmits(["decision-done"]) const declaration = defineModel() +const overriddenDecision = ref() +const hasOverriddenOriginalDecision = computed( + () => overriddenDecision.value && Object.keys(overriddenDecision.value).length > 0 +) + const producerMessage = ref(declaration.value.postValidationProducerMessage) -const instructorName = computed( - () => `${declaration.value?.instructor?.firstName} ${declaration.value?.instructor?.lastName}` -) -const showExpirationDays = computed( - () => - declaration.value.postValidationStatus === "OBJECTION" || declaration.value.postValidationStatus === "OBSERVATION" -) +const instructorName = computed(() => { + if (!declaration.value?.instructor) return "-" + return `${declaration.value.instructor.firstName || ""} ${declaration.value.instructor.lastName || ""}` +}) +const showExpirationDays = computed(() => { + const concernedStatuses = ["OBJECTION", "OBSERVATION"] + const validationStatus = hasOverriddenOriginalDecision.value + ? overriddenDecision.value.proposal + : declaration.value.postValidationStatus + return concernedStatuses.indexOf(validationStatus) > -1 +}) const postValidationStatus = computed(() => statusProps[declaration.value.postValidationStatus].label) const refusalUrl = computed(() => `/api/v1/declarations/${declaration.value.id}/refuse-visa/`) const acceptanceUrl = computed(() => `/api/v1/declarations/${declaration.value.id}/accept-visa/`) -const postData = computed(() => ({ comment: producerMessage.value })) +const postData = computed(() => overriddenDecision.value || {}) const { execute: refuseExecute, @@ -122,9 +136,7 @@ const decisionCategories = computed(() => { title: "Je valide cette décision", icon: "ri-checkbox-circle-fill", iconColor: "green", - description: shouldBlockApproval.value - ? "La déclaration ne peut pas être autorisée en nécessitant une saisine ANSEES." - : "Je vise cette déclaration et signe.", + description: validationHelperText.value, buttonText: "Valider", buttonHandler: acceptVisa, blockedByAnses: shouldBlockApproval.value, @@ -133,10 +145,35 @@ const decisionCategories = computed(() => { title: "Je ne suis pas d'accord", icon: "ri-close-circle-fill", iconColor: "red", - description: "Je renvoie le dossier en instruction.", + description: refusalHelperText.value, buttonText: "Refuser", buttonHandler: refuseVisa, }, ] }) + +const validationHelperText = computed(() => { + if (shouldBlockApproval.value) return "La déclaration ne peut pas être autorisée en nécessitant une saisine ANSEES." + if (hasOverriddenOriginalDecision.value) + return "Je vise cette déclaration avec les modifications effectuées et signe." + return "Je vise cette déclaration et signe." +}) + +const refusalHelperText = computed(() => { + if (hasOverriddenOriginalDecision.value) + return "Je renvoie le dossier en instruction. Les modifications effectuées ne seront pas prises en compte." + return "Je renvoie le dossier en instruction." +}) + +const declarationReasons = computed(() => declaration.value?.blockingReasons?.join(",\n")) +const overriddenReasons = computed(() => overriddenDecision.value?.reasons?.join(",\n")) + +const declarationComment = computed(() => declaration.value?.postValidationProducerMessage) +const overriddenComment = computed(() => overriddenDecision.value?.comment) + +const declarationExpirationDays = computed(() => declaration.value?.postValidationExpirationDays) +const overriddenExpirationDays = computed(() => overriddenDecision.value?.delayDays) + +const declarationProposal = computed(() => postValidationStatus) +const overriddenProposal = computed(() => statusProps[overriddenDecision.value?.proposal]?.label)