diff --git a/server/src/common/actions/effectifs.actions.ts b/server/src/common/actions/effectifs.actions.ts index 87161eccb..1cbf84ed4 100644 --- a/server/src/common/actions/effectifs.actions.ts +++ b/server/src/common/actions/effectifs.actions.ts @@ -11,6 +11,7 @@ import { defaultValuesEffectif } from "@/common/model/effectifs.model/effectifs. import { stripEmptyFields } from "../utils/miscUtils"; import { legacySchema } from "./effectif.legacy_schema"; +import { createComputedStatutObject } from "./effectifs.statut.actions"; /** * Méthode de build d'un effectif @@ -122,19 +123,35 @@ export const lockEffectif = async (effectif: IEffectif) => { return updated.value; }; -export const addEffectifComputedFields = (organisme: IOrganisme): IEffectif["_computed"] => { - return { - organisme: { - ...(organisme.adresse?.region ? { region: organisme.adresse.region } : {}), - ...(organisme.adresse?.departement ? { departement: organisme.adresse.departement } : {}), - ...(organisme.adresse?.academie ? { academie: organisme.adresse.academie } : {}), - ...(organisme.adresse?.bassinEmploi ? { bassinEmploi: organisme.adresse.bassinEmploi } : {}), - ...(organisme.uai ? { uai: organisme.uai } : {}), - ...(organisme.siret ? { siret: organisme.siret } : {}), - ...(organisme.reseaux ? { reseaux: organisme.reseaux } : {}), - fiable: organisme.fiabilisation_statut === "FIABLE" && !organisme.ferme, - }, - }; +export const addComputedFields = ({ + organisme, + effectif, +}: { + organisme?: IOrganisme; + effectif?: IEffectif; +}): Partial => { + const computedFields: Partial = {}; + + if (organisme) { + const { adresse, uai, siret, reseaux, fiabilisation_statut, ferme } = organisme; + computedFields.organisme = { + ...(adresse?.region && { region: adresse?.region }), + ...(adresse?.departement && { departement: adresse?.departement }), + ...(adresse?.academie && { academie: adresse?.academie }), + ...(adresse?.bassinEmploi && { bassinEmploi: adresse?.bassinEmploi }), + ...(uai && { uai }), + ...(siret && { siret }), + ...(reseaux && { reseaux }), + fiable: fiabilisation_statut === "FIABLE" && !ferme, + }; + } + + if (effectif) { + const statut = createComputedStatutObject(effectif, new Date()); + computedFields.statut = statut; + } + + return computedFields; }; export async function getEffectifForm(effectifId: ObjectId): Promise { diff --git a/server/src/common/actions/effectifs.statut.actions.ts b/server/src/common/actions/effectifs.statut.actions.ts new file mode 100644 index 000000000..c0ecaaf11 --- /dev/null +++ b/server/src/common/actions/effectifs.statut.actions.ts @@ -0,0 +1,278 @@ +import { captureException } from "@sentry/node"; +import Boom from "boom"; +import { endOfMonth } from "date-fns"; +import { MongoServerError, UpdateFilter } from "mongodb"; +import { STATUT_APPRENANT, StatutApprenant } from "shared/constants"; +import { IEffectif, IEffectifApprenant, IEffectifComputedStatut } from "shared/models/data/effectifs.model"; + +import logger from "../logger"; +import { effectifsDb } from "../model/collections"; + +export async function updateEffectifStatut(effectif: IEffectif, evaluationDate: Date): Promise { + if (!shouldUpdateStatut(effectif)) { + return false; + } + + try { + const updateObj = createUpdateObject(effectif, evaluationDate); + const { modifiedCount } = await effectifsDb().updateOne({ _id: effectif._id }, updateObj); + return modifiedCount > 0; + } catch (err) { + handleUpdateError(err, effectif); + return false; + } +} + +function shouldUpdateStatut(effectif: IEffectif): boolean { + return !( + !effectif.formation?.date_entree && + (!effectif.apprenant.historique_statut || + effectif.apprenant.historique_statut.length === 0 || + !effectif.formation?.periode) + ); +} + +/** + * Génère un objet de statut pour un effectif basé sur sa date d'entrée ou son statut historique. + * + * @param {IEffectif} effectif L'effectif pour lequel générer l'objet de statut. + * @param {Date} evaluationDate La date à laquelle l'évaluation du statut est effectuée. + * @returns {IEffectifComputedStatut} L'objet de statut calculé pour l'effectif. + */ +export function createComputedStatutObject(effectif: IEffectif, evaluationDate: Date): IEffectifComputedStatut | null { + try { + const hasEntryDate = Boolean(effectif.formation?.date_entree); + + const newStatut = hasEntryDate + ? determineNewStatut(effectif, evaluationDate) + : determineNewStatutFromHistorique(effectif.apprenant.historique_statut, effectif.formation?.periode); + + const historiqueStatut = hasEntryDate + ? genererHistoriqueStatut(effectif, evaluationDate) + : genererHistoriqueStatutFromApprenant( + effectif.apprenant.historique_statut, + effectif.formation?.periode, + evaluationDate + ); + + return { + en_cours: newStatut, + historique: historiqueStatut, + }; + } catch (error) { + logger.error( + `Échec de la création de l'objet statut dans _computed: ${ + error instanceof Error ? error.message : JSON.stringify(error) + }`, + { + context: "createComputedStatutObject", + evaluationDate, + effectifId: effectif._id, + errorStack: error instanceof Error ? error.stack : undefined, + } + ); + return null; + } +} + +function createUpdateObject(effectif: IEffectif, evaluationDate: Date): UpdateFilter { + return { + $set: { + "_computed.statut": createComputedStatutObject(effectif, evaluationDate), + }, + }; +} + +function handleUpdateError(err: unknown, effectif: IEffectif) { + console.error("Erreur lors de la mise à jour de l'effectif :", err); + if ( + err instanceof MongoServerError && + err.errInfo && + err.errInfo.details && + Array.isArray(err.errInfo.details.schemaRulesNotSatisfied) + ) { + console.error( + "Erreurs de validation de schéma détaillées :", + JSON.stringify(err.errInfo.details.schemaRulesNotSatisfied, null, 2) + ); + } + logger.error( + `Erreur lors de la mise à jour de l'effectif ${effectif._id}: ${ + err instanceof Error ? err.message : JSON.stringify(err) + }` + ); + captureException(err); +} + +export function genererHistoriqueStatut(effectif: IEffectif, endDate: Date) { + if (!effectif?.formation?.date_entree) { + return []; + } + + const dateEntree = new Date(effectif.formation.date_entree); + const historiqueStatut: { mois: string; annee: string; valeur: StatutApprenant }[] = []; + + for (let date = new Date(dateEntree); date <= endDate; date.setMonth(date.getMonth() + 1)) { + const dernierJourDuMois = endOfMonth(date); + const mois = (dernierJourDuMois.getMonth() + 1).toString().padStart(2, "0"); + const annee = dernierJourDuMois.getFullYear().toString(); + const statutPourLeMois = determineNewStatut(effectif, dernierJourDuMois); + + historiqueStatut.push({ mois, annee, valeur: statutPourLeMois }); + } + + return historiqueStatut; +} + +export function determineNewStatut(effectif: IEffectif, evaluationDate?: Date): StatutApprenant { + const currentDate = evaluationDate || new Date(); + const ninetyDaysInMs = 90 * 24 * 60 * 60 * 1000; // 90 jours en millisecondes + const oneEightyDaysInMs = 180 * 24 * 60 * 60 * 1000; // 180 jours en millisecondes + + if ( + effectif.formation?.date_fin && + new Date(effectif.formation.date_fin) < currentDate && + effectif.formation.obtention_diplome + ) { + return STATUT_APPRENANT.DIPLOME; + } + + let hasCurrentContract = false; + let hasRecentRupture = false; + let ruptureDateAfterEntry = false; + + const dateEntree = effectif.formation?.date_entree ? new Date(effectif.formation.date_entree) : null; + + effectif.contrats?.forEach((contract) => { + const dateDebut = new Date(contract.date_debut); + const dateFin = contract.date_fin ? new Date(contract.date_fin) : Infinity; + const dateRupture = contract.date_rupture ? new Date(contract.date_rupture) : null; + + if (dateDebut <= currentDate && currentDate <= dateFin && (!dateRupture || currentDate < dateRupture)) { + hasCurrentContract = true; + } + + if (dateEntree && dateRupture && currentDate.getTime() - dateRupture.getTime() <= oneEightyDaysInMs) { + hasRecentRupture = true; + ruptureDateAfterEntry = dateRupture > dateEntree; + } + }); + + if (hasCurrentContract) return STATUT_APPRENANT.APPRENTI; + + if ( + hasRecentRupture && + ruptureDateAfterEntry && + dateEntree && + currentDate.getTime() - dateEntree.getTime() <= ninetyDaysInMs + ) { + return STATUT_APPRENANT.INSCRIT; + } else if (hasRecentRupture) { + return STATUT_APPRENANT.RUPTURANT; + } + + if (dateEntree && currentDate.getTime() - dateEntree.getTime() <= ninetyDaysInMs) { + return STATUT_APPRENANT.INSCRIT; + } + + if (dateEntree && currentDate.getTime() - dateEntree.getTime() > ninetyDaysInMs) { + return STATUT_APPRENANT.ABANDON; + } + + throw Boom.internal("Aucun statut trouvé pour l'apprenant", { id: effectif._id }); +} + +export function determineNewStatutFromHistorique( + historiqueStatut: IEffectifApprenant["historique_statut"], + formationPeriode: number[] | null | undefined +): StatutApprenant { + if (!historiqueStatut || historiqueStatut.length === 0) { + throw new Error("Le statut historique est vide ou indéfini"); + } + + if (!formationPeriode) { + throw new Error("La période de formation est nulle ou indéfinie"); + } + + const filteredStatut = historiqueStatut.filter((statut) => { + const statutYear = new Date(statut.date_statut).getFullYear(); + return statutYear <= formationPeriode[1]; + }); + + if (filteredStatut.length === 0) { + throw new Error("Aucune entrée de l'historique statut ne correspond à la période de formation"); + } + + const latestStatut = filteredStatut.sort( + (a, b) => new Date(b.date_statut).getTime() - new Date(a.date_statut).getTime() + )[0]; + + return mapValeurStatutToStatutApprenant(latestStatut.valeur_statut); +} + +function genererHistoriqueStatutFromApprenant( + historiqueStatut: IEffectifApprenant["historique_statut"], + formationPeriode: number[] | null | undefined, + evaluationEndDate: Date +): IEffectifComputedStatut["historique"] { + if (!formationPeriode) { + console.error("Formation period is undefined or null"); + return []; + } + const periodeEndDate = new Date(formationPeriode[1], 11, 31); + const sortedStatut = historiqueStatut.sort( + (a, b) => new Date(a.date_statut).getTime() - new Date(b.date_statut).getTime() + ); + + const startDate = + sortedStatut.length > 0 ? new Date(sortedStatut[0].date_statut) : new Date(formationPeriode[0], 0, 1); + const endDate = periodeEndDate < evaluationEndDate ? periodeEndDate : evaluationEndDate; + + let currentDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1); + const historique: IEffectifComputedStatut["historique"] = []; + let currentStatutIndex = 0; + + while (currentDate <= endDate) { + const mois = (currentDate.getMonth() + 1).toString().padStart(2, "0"); + const annee = currentDate.getFullYear().toString(); + + while ( + currentStatutIndex < sortedStatut.length - 1 && + currentDate > new Date(sortedStatut[currentStatutIndex + 1].date_statut) + ) { + currentStatutIndex++; + } + + let effectiveStatutIndex = currentStatutIndex; + if (currentStatutIndex < sortedStatut.length - 1) { + const nextStatutDate = new Date(sortedStatut[currentStatutIndex + 1].date_statut); + if ( + nextStatutDate.getMonth() === currentDate.getMonth() && + nextStatutDate.getFullYear() === currentDate.getFullYear() + ) { + effectiveStatutIndex = currentStatutIndex + 1; + } + } + + const valeur = mapValeurStatutToStatutApprenant(sortedStatut[effectiveStatutIndex]?.valeur_statut || 0); + + historique.push({ mois, annee, valeur }); + + currentDate.setMonth(currentDate.getMonth() + 1); + currentDate.setDate(1); + } + + return historique; +} + +function mapValeurStatutToStatutApprenant(valeurStatut: number): StatutApprenant { + switch (valeurStatut) { + case 0: + return STATUT_APPRENANT.ABANDON; + case 2: + return STATUT_APPRENANT.INSCRIT; + case 3: + return STATUT_APPRENANT.APPRENTI; + } + throw Boom.internal("Valeur de statut non trouvé", { valeurStatut }); +} diff --git a/server/src/db/migrations/20240325155805-effectifs-computed-types.ts b/server/src/db/migrations/20240325155805-effectifs-computed-types.ts new file mode 100644 index 000000000..b480a6384 --- /dev/null +++ b/server/src/db/migrations/20240325155805-effectifs-computed-types.ts @@ -0,0 +1,5 @@ +import { addJob } from "job-processor"; + +export const up = async () => { + await addJob({ name: "tmp:effectifs:update_computed_statut", queued: true }); +}; diff --git a/server/src/jobs/hydrate/effectifs/hydrate-effectifs-computed-types.ts b/server/src/jobs/hydrate/effectifs/hydrate-effectifs-computed-types.ts index c5c41e567..1d8570085 100644 --- a/server/src/jobs/hydrate/effectifs/hydrate-effectifs-computed-types.ts +++ b/server/src/jobs/hydrate/effectifs/hydrate-effectifs-computed-types.ts @@ -1,19 +1,22 @@ import { captureException } from "@sentry/node"; -import Boom from "boom"; -import { endOfMonth } from "date-fns"; -import { MongoServerError, UpdateFilter } from "mongodb"; -import { STATUT_APPRENANT, StatutApprenant } from "shared/constants"; -import { IEffectif, IEffectifApprenant, IEffectifComputedStatut } from "shared/models/data/effectifs.model"; +import { updateEffectifStatut } from "@/common/actions/effectifs.statut.actions"; import logger from "@/common/logger"; import { effectifsDb } from "@/common/model/collections"; -export async function hydrateEffectifsComputedTypes(evaluationDate = new Date()) { +/** + * Met à jour le statut des effectifs en fonction d'une requête donnée. + * + * @param {Object} params - Paramètres de la fonction, incluant : + * query (Requête MongoDB pour filtrer les effectifs) et + * evaluationDate (La date pour évaluer le statut des effectifs). + */ +export async function hydrateEffectifsComputedTypes({ query = {}, evaluationDate = new Date() } = {}) { let nbEffectifsMisAJour = 0; let nbEffectifsNonMisAJour = 0; try { - const cursor = effectifsDb().find(); + const cursor = effectifsDb().find(query); while (await cursor.hasNext()) { const effectif = await cursor.next(); @@ -34,243 +37,3 @@ export async function hydrateEffectifsComputedTypes(evaluationDate = new Date()) captureException(err); } } - -async function updateEffectifStatut(effectif: IEffectif, evaluationDate: Date): Promise { - if (!shouldUpdateStatut(effectif)) { - return false; - } - - try { - const updateObj = createUpdateObject(effectif, evaluationDate); - const { modifiedCount } = await effectifsDb().updateOne({ _id: effectif._id }, updateObj); - return modifiedCount > 0; - } catch (err) { - handleUpdateError(err, effectif); - return false; - } -} - -function shouldUpdateStatut(effectif: IEffectif): boolean { - return !( - !effectif.formation?.date_entree && - (!effectif.apprenant.historique_statut || - effectif.apprenant.historique_statut.length === 0 || - !effectif.formation?.periode) - ); -} - -function createUpdateObject(effectif: IEffectif, evaluationDate: Date): UpdateFilter { - const newStatut = effectif.formation?.date_entree - ? determineNewStatut(effectif, evaluationDate) - : determineNewStatutFromHistorique(effectif.apprenant.historique_statut, effectif.formation?.periode); - const historiqueStatut = effectif.formation?.date_entree - ? genererHistoriqueStatut(effectif, evaluationDate) - : genererHistoriqueStatutFromApprenant( - effectif.apprenant.historique_statut, - effectif.formation?.periode, - evaluationDate - ); - - return { - $set: { - "_computed.statut": { - en_cours: newStatut, - historique: historiqueStatut, - }, - }, - }; -} - -function handleUpdateError(err: unknown, effectif: IEffectif) { - console.error("Erreur lors de la mise à jour de l'effectif :", err); - if ( - err instanceof MongoServerError && - err.errInfo && - err.errInfo.details && - Array.isArray(err.errInfo.details.schemaRulesNotSatisfied) - ) { - console.error( - "Erreurs de validation de schéma détaillées :", - JSON.stringify(err.errInfo.details.schemaRulesNotSatisfied, null, 2) - ); - } - logger.error( - `Erreur lors de la mise à jour de l'effectif ${effectif._id}: ${ - err instanceof Error ? err.message : JSON.stringify(err) - }` - ); - captureException(err); -} - -export function genererHistoriqueStatut(effectif: IEffectif, endDate: Date) { - if (!effectif?.formation?.date_entree) { - return []; - } - - const dateEntree = new Date(effectif.formation.date_entree); - const historiqueStatut: { mois: string; annee: string; valeur: StatutApprenant }[] = []; - - for (let date = new Date(dateEntree); date <= endDate; date.setMonth(date.getMonth() + 1)) { - const dernierJourDuMois = endOfMonth(date); - const mois = (dernierJourDuMois.getMonth() + 1).toString().padStart(2, "0"); - const annee = dernierJourDuMois.getFullYear().toString(); - const statutPourLeMois = determineNewStatut(effectif, dernierJourDuMois); - - historiqueStatut.push({ mois, annee, valeur: statutPourLeMois }); - } - - return historiqueStatut; -} - -export function determineNewStatut(effectif: IEffectif, evaluationDate?: Date): StatutApprenant { - const currentDate = evaluationDate || new Date(); - const ninetyDaysInMs = 90 * 24 * 60 * 60 * 1000; // 90 jours en millisecondes - const oneEightyDaysInMs = 180 * 24 * 60 * 60 * 1000; // 180 jours en millisecondes - - if ( - effectif.formation?.date_fin && - new Date(effectif.formation.date_fin) < currentDate && - effectif.formation.obtention_diplome - ) { - return STATUT_APPRENANT.DIPLOME; - } - - let hasCurrentContract = false; - let hasRecentRupture = false; - let ruptureDateAfterEntry = false; - - const dateEntree = effectif.formation?.date_entree ? new Date(effectif.formation.date_entree) : null; - - effectif.contrats?.forEach((contract) => { - const dateDebut = new Date(contract.date_debut); - const dateFin = contract.date_fin ? new Date(contract.date_fin) : Infinity; - const dateRupture = contract.date_rupture ? new Date(contract.date_rupture) : null; - - if (dateDebut <= currentDate && currentDate <= dateFin && (!dateRupture || currentDate < dateRupture)) { - hasCurrentContract = true; - } - - if (dateEntree && dateRupture && currentDate.getTime() - dateRupture.getTime() <= oneEightyDaysInMs) { - hasRecentRupture = true; - ruptureDateAfterEntry = dateRupture > dateEntree; - } - }); - - if (hasCurrentContract) return STATUT_APPRENANT.APPRENTI; - - if ( - hasRecentRupture && - ruptureDateAfterEntry && - dateEntree && - currentDate.getTime() - dateEntree.getTime() <= ninetyDaysInMs - ) { - return STATUT_APPRENANT.INSCRIT; - } else if (hasRecentRupture) { - return STATUT_APPRENANT.RUPTURANT; - } - - if (dateEntree && currentDate.getTime() - dateEntree.getTime() <= ninetyDaysInMs) { - return STATUT_APPRENANT.INSCRIT; - } - - if (dateEntree && currentDate.getTime() - dateEntree.getTime() > ninetyDaysInMs) { - return STATUT_APPRENANT.ABANDON; - } - - throw Boom.internal("No status found for the learner", { id: effectif._id }); -} - -export function determineNewStatutFromHistorique( - historiqueStatut: IEffectifApprenant["historique_statut"], - formationPeriode: number[] | null | undefined -): StatutApprenant { - if (!historiqueStatut || historiqueStatut.length === 0) { - throw new Error("Historique statut is empty or undefined"); - } - - if (!formationPeriode) { - throw new Error("Formation period is null or undefined"); - } - - const filteredStatut = historiqueStatut.filter((statut) => { - const statutYear = new Date(statut.date_statut).getFullYear(); - return statutYear <= formationPeriode[1]; - }); - - if (filteredStatut.length === 0) { - throw new Error("No historique statut entries match the formation period"); - } - - const latestStatut = filteredStatut.sort( - (a, b) => new Date(b.date_statut).getTime() - new Date(a.date_statut).getTime() - )[0]; - - return mapValeurStatutToStatutApprenant(latestStatut.valeur_statut); -} - -function genererHistoriqueStatutFromApprenant( - historiqueStatut: IEffectifApprenant["historique_statut"], - formationPeriode: number[] | null | undefined, - evaluationEndDate: Date -): IEffectifComputedStatut["historique"] { - if (!formationPeriode) { - console.error("Formation period is undefined or null"); - return []; - } - const periodeEndDate = new Date(formationPeriode[1], 11, 31); - const sortedStatut = historiqueStatut.sort( - (a, b) => new Date(a.date_statut).getTime() - new Date(b.date_statut).getTime() - ); - - const startDate = - sortedStatut.length > 0 ? new Date(sortedStatut[0].date_statut) : new Date(formationPeriode[0], 0, 1); - const endDate = periodeEndDate < evaluationEndDate ? periodeEndDate : evaluationEndDate; - - let currentDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1); - const historique: IEffectifComputedStatut["historique"] = []; - let currentStatutIndex = 0; - - while (currentDate <= endDate) { - const mois = (currentDate.getMonth() + 1).toString().padStart(2, "0"); - const annee = currentDate.getFullYear().toString(); - - while ( - currentStatutIndex < sortedStatut.length - 1 && - currentDate > new Date(sortedStatut[currentStatutIndex + 1].date_statut) - ) { - currentStatutIndex++; - } - - let effectiveStatutIndex = currentStatutIndex; - if (currentStatutIndex < sortedStatut.length - 1) { - const nextStatutDate = new Date(sortedStatut[currentStatutIndex + 1].date_statut); - if ( - nextStatutDate.getMonth() === currentDate.getMonth() && - nextStatutDate.getFullYear() === currentDate.getFullYear() - ) { - effectiveStatutIndex = currentStatutIndex + 1; - } - } - - const valeur = mapValeurStatutToStatutApprenant(sortedStatut[effectiveStatutIndex]?.valeur_statut || 0); - - historique.push({ mois, annee, valeur }); - - currentDate.setMonth(currentDate.getMonth() + 1); - currentDate.setDate(1); - } - - return historique; -} - -function mapValeurStatutToStatutApprenant(valeurStatut: number): StatutApprenant { - switch (valeurStatut) { - case 0: - return STATUT_APPRENANT.ABANDON; - case 2: - return STATUT_APPRENANT.INSCRIT; - case 3: - return STATUT_APPRENANT.APPRENTI; - } - throw Boom.internal("Valeur de statut non trouvé", { valeurStatut }); -} diff --git a/server/src/jobs/ingestion/process-ingestion.ts b/server/src/jobs/ingestion/process-ingestion.ts index 0bb84f162..fe0a8f1fc 100644 --- a/server/src/jobs/ingestion/process-ingestion.ts +++ b/server/src/jobs/ingestion/process-ingestion.ts @@ -8,7 +8,7 @@ import { IEffectifQueue } from "shared/models/data/effectifsQueue.model"; import { IOrganisme } from "shared/models/data/organismes.model"; import { NEVER, SafeParseReturnType, ZodIssueCode } from "zod"; -import { lockEffectif, addEffectifComputedFields, mergeEffectifWithDefaults } from "@/common/actions/effectifs.actions"; +import { lockEffectif, addComputedFields, mergeEffectifWithDefaults } from "@/common/actions/effectifs.actions"; import { buildNewHistoriqueStatutApprenant, mapEffectifQueueToEffectif, @@ -38,8 +38,6 @@ import dossierApprenantSchemaV3, { DossierApprenantSchemaV3ZodType, } from "@/common/validation/dossierApprenantSchemaV3"; -import { determineNewStatut, genererHistoriqueStatut } from "../hydrate/effectifs/hydrate-effectifs-computed-types"; - const logger = parentLogger.child({ module: "processor", }); @@ -384,7 +382,7 @@ async function transformEffectifQueueV3ToEffectif(rawEffectifQueued: IEffectifQu organisme_id: organismeLieu?._id, organisme_formateur_id: organismeFormateur?._id, organisme_responsable_id: organismeResponsable?._id, - _computed: addEffectifComputedFields(organismeLieu), + _computed: addComputedFields({ organisme: organismeLieu, effectif }), }, organisme: organismeLieu, }; @@ -456,7 +454,7 @@ async function transformEffectifQueueV1V2ToEffectif(rawEffectifQueued: IEffectif effectif: { ...effectif, organisme_id: organisme?._id, - _computed: addEffectifComputedFields(organisme), + _computed: addComputedFields({ organisme, effectif }), }, organisme: organisme, }; @@ -485,7 +483,7 @@ async function transformEffectifQueueToEffectif( * Fonctionnement : * - Le statut d'historique est construit grâce à une fonction dédiée. * - On préfèrera toujours les valeurs non vides (null, undefined ou "") quelles que soient leur provenance. - * - On préfèrera toujours les tableaux de newObject aux tableaux de previousObject (pas de fusion de tableau) + * - On préfèrera toujours les tableaux de newObject aux tablea ux de previousObject (pas de fusion de tableau) */ export function mergeEffectif(effectifDb: IEffectif, effectif: IEffectif): IEffectif { return { @@ -515,13 +513,6 @@ const createOrUpdateEffectif = async ( ...effectif, _computed: { ...effectif._computed, - statut: - effectif.formation && effectif.formation.date_entree - ? { - en_cours: determineNewStatut(effectif), - historique: genererHistoriqueStatut(effectif, new Date()), - } - : null, }, }; const itemProcessingInfos: ItemProcessingInfos = {}; diff --git a/server/src/jobs/jobs.ts b/server/src/jobs/jobs.ts index 64d2d9ca9..0875f969e 100644 --- a/server/src/jobs/jobs.ts +++ b/server/src/jobs/jobs.ts @@ -1,4 +1,5 @@ import { addJob, initJobProcessor } from "job-processor"; +import { getAnneesScolaireListFromDate } from "shared/utils"; import logger from "@/common/logger"; import { getDatabase } from "@/common/mongodb"; @@ -134,6 +135,14 @@ export async function setupJobProcessor() { }, }, + "Mettre à jour les statuts d'effectifs le 1er de chaque mois à 00h45": { + cron_string: "45 0 1 * *", + handler: async () => { + await addJob({ name: "hydrate:effectifs:update_computed_statut", queued: true }); + return 0; + }, + }, + // TODO : Checker si coté métier l'archivage est toujours prévu ? // "Run archive dossiers apprenants & effectifs job each first day of month at 12h45": { // cron_string: "45 12 1 * *", @@ -252,6 +261,13 @@ export async function setupJobProcessor() { return hydrateDeca(job.payload as any); }, }, + "hydrate:effectifs:update_computed_statut": { + handler: async () => { + return hydrateEffectifsComputedTypes({ + query: { annee_scolaire: { $in: getAnneesScolaireListFromDate(new Date()) } }, + }); + }, + }, "dev:generate-open-api": { handler: async () => { return hydrateOpenApi(); diff --git a/server/tests/data/randomizedSample.ts b/server/tests/data/randomizedSample.ts index 9b325b168..29f7c7c96 100644 --- a/server/tests/data/randomizedSample.ts +++ b/server/tests/data/randomizedSample.ts @@ -7,7 +7,7 @@ import { IEffectif } from "shared/models/data/effectifs.model"; import { IOrganisme } from "shared/models/data/organismes.model"; import type { PartialDeep } from "type-fest"; -import { addEffectifComputedFields } from "@/common/actions/effectifs.actions"; +import { addComputedFields } from "@/common/actions/effectifs.actions"; import { DossierApprenantSchemaV1V2ZodType } from "@/common/validation/dossierApprenantSchemaV1V2"; import sampleEtablissements, { SampleEtablissement } from "./sampleEtablissements"; @@ -91,30 +91,37 @@ export const createSampleEffectif = ({ const annee_scolaire = getRandomAnneeScolaire(); const formation = createRandomFormation(annee_scolaire); - return merge( - { - apprenant: { - ine: getRandomIne(), - nom: faker.person.lastName().toUpperCase(), - prenom: faker.person.firstName(), - courriel: faker.internet.email(), - date_de_naissance: getRandomDateNaissance(), - historique_statut: [], - }, - contrats: [], - formation: formation, - validation_errors: [], - created_at: new Date(), - updated_at: new Date(), - id_erp_apprenant: faker.string.uuid(), - source: faker.lorem.word(), - source_organisme_id: faker.string.uuid(), - annee_scolaire, - organisme_id: organisme?._id, - _computed: organisme ? addEffectifComputedFields(organisme as IOrganisme) : {}, + let baseEffectif: PartialDeep = { + apprenant: { + ine: getRandomIne(), + nom: faker.person.lastName().toUpperCase(), + prenom: faker.person.firstName(), + courriel: faker.internet.email(), + date_de_naissance: getRandomDateNaissance(), + historique_statut: [], }, - params - ) as WithoutId; + contrats: [], + formation: formation, + validation_errors: [], + created_at: new Date(), + updated_at: new Date(), + id_erp_apprenant: faker.string.uuid(), + source: faker.lorem.word(), + source_organisme_id: faker.string.uuid(), + annee_scolaire, + organisme_id: organisme?._id, + }; + + let computedFields = addComputedFields({ + organisme: organisme as IOrganisme, + effectif: merge({}, baseEffectif, params) as IEffectif, + }); + + let fullEffectif = merge({}, baseEffectif, params, { + _computed: computedFields, + }); + + return fullEffectif as WithoutId; }; export const createRandomDossierApprenantApiInput = ( diff --git a/server/tests/integration/common/actions/effectifs.types.actions.test.ts b/server/tests/integration/common/actions/effectifs.types.actions.test.ts index fb96f6206..af23655ba 100644 --- a/server/tests/integration/common/actions/effectifs.types.actions.test.ts +++ b/server/tests/integration/common/actions/effectifs.types.actions.test.ts @@ -44,7 +44,7 @@ describe("hydrateEffectifsComputedTypes", () => { }); const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); - await hydrateEffectifsComputedTypes(moinsDe90Jours); + await hydrateEffectifsComputedTypes({ evaluationDate: moinsDe90Jours }); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); @@ -66,7 +66,7 @@ describe("hydrateEffectifsComputedTypes", () => { }); const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); - await hydrateEffectifsComputedTypes(plusDe90Jours); + await hydrateEffectifsComputedTypes({ evaluationDate: plusDe90Jours }); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); @@ -78,7 +78,6 @@ describe("hydrateEffectifsComputedTypes", () => { }); it("doit correctement gérer un seul contrat sans rupture", async () => { - const currentDate = new Date(); const effectif = createSampleEffectif({ organisme: sampleOrganisme, contrats: [ @@ -91,7 +90,7 @@ describe("hydrateEffectifsComputedTypes", () => { }); const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); - await hydrateEffectifsComputedTypes(currentDate); + await hydrateEffectifsComputedTypes(); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); @@ -119,7 +118,7 @@ describe("hydrateEffectifsComputedTypes", () => { }); const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); - await hydrateEffectifsComputedTypes(evaluationDate); + await hydrateEffectifsComputedTypes({ evaluationDate }); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); @@ -147,7 +146,7 @@ describe("hydrateEffectifsComputedTypes", () => { }); const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); - await hydrateEffectifsComputedTypes(evaluationDate); + await hydrateEffectifsComputedTypes({ evaluationDate }); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); @@ -179,7 +178,7 @@ describe("hydrateEffectifsComputedTypes", () => { }); const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); - await hydrateEffectifsComputedTypes(evaluationDate); + await hydrateEffectifsComputedTypes({ evaluationDate }); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); @@ -215,7 +214,7 @@ describe("hydrateEffectifsComputedTypes", () => { }); const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); - await hydrateEffectifsComputedTypes(evaluationDate); + await hydrateEffectifsComputedTypes({ evaluationDate }); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); @@ -251,7 +250,7 @@ describe("hydrateEffectifsComputedTypes", () => { }); const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); - await hydrateEffectifsComputedTypes(evaluationDate); + await hydrateEffectifsComputedTypes({ evaluationDate }); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); @@ -282,7 +281,7 @@ describe("hydrateEffectifsComputedTypes", () => { const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); const evaluationDateAfterDiploma = new Date(2026, 8, 1); - await hydrateEffectifsComputedTypes(evaluationDateAfterDiploma); + await hydrateEffectifsComputedTypes({ evaluationDate: evaluationDateAfterDiploma }); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); @@ -316,7 +315,7 @@ describe("hydrateEffectifsComputedTypes", () => { }); const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); - await hydrateEffectifsComputedTypes(evaluationDate); + await hydrateEffectifsComputedTypes({ evaluationDate }); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); @@ -389,7 +388,7 @@ describe("hydrateEffectifsComputedTypes", () => { }); const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); - await hydrateEffectifsComputedTypes(new Date(2023, 6, 1)); + await hydrateEffectifsComputedTypes({ evaluationDate: new Date(2023, 6, 1) }); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); @@ -442,11 +441,11 @@ describe("hydrateEffectifsComputedTypes", () => { }); const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); - await hydrateEffectifsComputedTypes(new Date(2023, 6, 1)); + await hydrateEffectifsComputedTypes({ evaluationDate: new Date(2023, 6, 1) }); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); - expect(updatedEffectif?._computed?.statut).toBeUndefined(); + expect(updatedEffectif?._computed?.statut).toBeNull(); }); it("ne doit pas gérer de statut sans periode de formation", async () => { @@ -487,10 +486,10 @@ describe("hydrateEffectifsComputedTypes", () => { }); const { insertedId } = await effectifsDb().insertOne(effectif as IEffectif); - await hydrateEffectifsComputedTypes(new Date(2023, 6, 1)); + await hydrateEffectifsComputedTypes({ evaluationDate: new Date(2023, 6, 1) }); const updatedEffectif = await effectifsDb().findOne({ _id: insertedId }); - expect(updatedEffectif?._computed?.statut).toBeUndefined(); + expect(updatedEffectif?._computed?.statut).toBeNull(); }); }); diff --git a/server/tests/integration/jobs/ingestion/process-ingestion.test.ts b/server/tests/integration/jobs/ingestion/process-ingestion.test.ts index 02ee4f3a4..b62949e1f 100644 --- a/server/tests/integration/jobs/ingestion/process-ingestion.test.ts +++ b/server/tests/integration/jobs/ingestion/process-ingestion.test.ts @@ -254,7 +254,91 @@ describe("Processus d'ingestion", () => { uai: "0802004U", fiable: false, }, - statut: null, + statut: { + en_cours: "APPRENTI", + historique: [ + { + annee: "2022", + mois: "12", + valeur: "APPRENTI", + }, + { + annee: "2023", + mois: "01", + valeur: "APPRENTI", + }, + { + annee: "2023", + mois: "02", + valeur: "APPRENTI", + }, + { + annee: "2023", + mois: "03", + valeur: "APPRENTI", + }, + { + annee: "2023", + mois: "04", + valeur: "APPRENTI", + }, + { + annee: "2023", + mois: "05", + valeur: "APPRENTI", + }, + { + annee: "2023", + mois: "06", + valeur: "APPRENTI", + }, + { + annee: "2023", + mois: "07", + valeur: "APPRENTI", + }, + { + annee: "2023", + mois: "08", + valeur: "APPRENTI", + }, + { + annee: "2023", + mois: "09", + valeur: "APPRENTI", + }, + { + annee: "2023", + mois: "10", + valeur: "APPRENTI", + }, + { + annee: "2023", + mois: "11", + valeur: "APPRENTI", + }, + { + annee: "2023", + mois: "12", + valeur: "APPRENTI", + }, + { + annee: "2024", + mois: "01", + valeur: "APPRENTI", + }, + { + annee: "2024", + mois: "02", + valeur: "APPRENTI", + }, + { + annee: "2024", + mois: "03", + valeur: "APPRENTI", + }, + ], + }, }, }); }); diff --git a/server/tests/utils/permissions.ts b/server/tests/utils/permissions.ts index b888ae9aa..52fb2221b 100644 --- a/server/tests/utils/permissions.ts +++ b/server/tests/utils/permissions.ts @@ -5,7 +5,7 @@ import { IEffectif } from "shared/models/data/effectifs.model"; import { IOrganisationCreate, IOrganisation } from "shared/models/data/organisations.model"; import { IOrganisme } from "shared/models/data/organismes.model"; -import { addEffectifComputedFields } from "@/common/actions/effectifs.actions"; +import { addComputedFields } from "@/common/actions/effectifs.actions"; import { id } from "./testUtils"; @@ -281,7 +281,7 @@ export const userOrganisme = organismesByLabel["OF cible"]; export const commonEffectifsAttributes: Pick = { organisme_id: userOrganisme._id, - _computed: addEffectifComputedFields(userOrganisme), + _computed: addComputedFields({ organisme: userOrganisme }), }; type TestFunc = (