diff --git a/server/src/commands.ts b/server/src/commands.ts index 5493a7dc4..f196fcdfc 100644 --- a/server/src/commands.ts +++ b/server/src/commands.ts @@ -451,6 +451,14 @@ program .option("-f, --full", "Récupère l'intégralité des données disponibles via l'API Deca", false) .action(createJobAction("hydrate:contratsDeca")); +program + .command("hydrate:contrats-deca-raw") + .description("Remplissage des contrats Deca") + .option("-q, --queued", "Run job asynchronously", false) + .option("-d, --drop", "Supprime les contrats existants avant de les recréer", false) + .option("-f, --full", "Récupère l'intégralité des données disponibles via l'API Deca", false) + .action(createJobAction("hydrate:contrats-deca-raw")); + program .command("dev:generate-open-api") .description("Création/maj du fichier open-api.json") @@ -477,6 +485,15 @@ program .option("-q, --queued", "Run job asynchronously", false) .action(createJobAction("hydrate:organismes-effectifs-count")); +/** + * Mise à jour des organismes avec le nombre d'effectifs hierarchisé + */ +program + .command("hydrate:organismes-effectifs-count-with-hierarchy") + .description("Mise à jour des organismes avec le nombre d'effectifs hierarchisé") + .option("-q, --queued", "Run job asynchronously", false) + .action(createJobAction("hydrate:organismes-effectifs-count-with-hierarchy")); + /** * Job de mise à jour des organismes en allant appeler des API externes pour remplir * - Les informations liés au SIRET (API Entreprise) diff --git a/server/src/common/actions/effectifs.actions.ts b/server/src/common/actions/effectifs.actions.ts index 1cbf84ed4..4cc395557 100644 --- a/server/src/common/actions/effectifs.actions.ts +++ b/server/src/common/actions/effectifs.actions.ts @@ -5,7 +5,7 @@ import { IEffectif } from "shared/models/data/effectifs.model"; import { IOrganisme } from "shared/models/data/organismes.model"; import type { Paths } from "type-fest"; -import { effectifsDb } from "@/common/model/collections"; +import { effectifsDECADb, effectifsDb } from "@/common/model/collections"; import { defaultValuesEffectif } from "@/common/model/effectifs.model/effectifs.model"; import { stripEmptyFields } from "../utils/miscUtils"; @@ -155,7 +155,12 @@ export const addComputedFields = ({ }; export async function getEffectifForm(effectifId: ObjectId): Promise { - const effectif = await effectifsDb().findOne({ _id: effectifId }); + let effectif = await effectifsDb().findOne({ _id: effectifId }); + + if (!effectif) { + effectif = await effectifsDECADb().findOne({ _id: effectifId }); + } + return buildEffectifResult(effectif); } @@ -260,6 +265,13 @@ export async function updateEffectifFromForm(effectifId: ObjectId, body: any): P function buildEffectifResult(effectif) { const { properties: effectifSchema } = legacySchema; + if (!effectif.is_lock) { + effectif.is_lock = { + apprenant: {}, + formation: {}, + }; + } + function customizer(objValue, srcValue) { if (objValue !== undefined) { return { diff --git a/server/src/common/actions/helpers/permissions.ts b/server/src/common/actions/helpers/permissions.ts index ffca4858c..a562a698e 100644 --- a/server/src/common/actions/helpers/permissions.ts +++ b/server/src/common/actions/helpers/permissions.ts @@ -40,3 +40,14 @@ export function findOrganismeFormateursIds(organisme: IOrganisme, withResponsabi .map((organisme) => organisme._id as ObjectId) ); } + +export async function findOrganismeResponsableIdsOfOrganisme(organismeId: ObjectId) { + const organisme = await getOrganismeById(organismeId); + return findOrganismeResponsablesIds(organisme); +} + +export function findOrganismeResponsablesIds(organisme: IOrganisme): ObjectId[] { + return (organisme.organismesResponsables ?? []) + .filter((organisme) => !!organisme._id) + .map((organisme) => organisme._id as ObjectId); +} diff --git a/server/src/common/actions/indicateurs/indicateurs-national.actions.ts b/server/src/common/actions/indicateurs/indicateurs-national.actions.ts index b6e4ac972..093d4f3b4 100644 --- a/server/src/common/actions/indicateurs/indicateurs-national.actions.ts +++ b/server/src/common/actions/indicateurs/indicateurs-national.actions.ts @@ -5,7 +5,8 @@ import { tryCachedExecution } from "@/common/utils/cacheUtils"; import { DateFilters, TerritoireFilters } from "../helpers/filters"; -import { getIndicateursEffectifsParDepartement, getIndicateursOrganismesParDepartement } from "./indicateurs.actions"; +import { getIndicateursEffectifsParDepartement } from "./indicateurs-with-deca.actions"; +import { getIndicateursOrganismesParDepartement } from "./indicateurs.actions"; const indicateursNationalCacheExpirationMs = 3600 * 1000; // 1 hour diff --git a/server/src/common/actions/indicateurs/indicateurs-with-deca.actions.ts b/server/src/common/actions/indicateurs/indicateurs-with-deca.actions.ts new file mode 100644 index 000000000..fd2b31ca9 --- /dev/null +++ b/server/src/common/actions/indicateurs/indicateurs-with-deca.actions.ts @@ -0,0 +1,127 @@ +import { ObjectId } from "mongodb"; +import { TypeEffectifNominatif } from "shared/constants/indicateurs"; +import { Acl } from "shared/constants/permissions"; + +import { effectifsDECADb, effectifsDb } from "@/common/model/collections"; +import { AuthContext } from "@/common/model/internal/AuthContext"; + +import { DateFilters, EffectifsFiltersTerritoire, FullEffectifsFilters, TerritoireFilters } from "../helpers/filters"; + +import { + getIndicateursEffectifsParDepartementGenerique, + getIndicateursEffectifsParOrganismeGenerique, + getEffectifsNominatifsGenerique, + getOrganismeIndicateursEffectifsParFormationGenerique, + getOrganismeIndicateursEffectifsGenerique, +} from "./indicateurs.actions"; + +export const buildDECAFilter = (decaMode) => (decaMode ? { is_deca_compatible: true } : {}); + +// Attention ca marche pas, il faut ensuite merger par departement et sommer les valeurs +export const getIndicateursEffectifsParDepartement = async (filters: DateFilters & TerritoireFilters, acl: Acl) => { + const indicateurs = [ + ...(await getIndicateursEffectifsParDepartementGenerique(filters, acl, effectifsDb(), false)), + ...(await getIndicateursEffectifsParDepartementGenerique(filters, acl, effectifsDECADb(), true)), + ]; + + const mapDepartement = indicateurs.reduce((acc, { departement, ...rest }) => { + return acc[departement] + ? { + ...acc, + [departement]: { + departement, + apprentis: acc[departement].apprentis + rest.apprentis, + abandons: acc[departement].abandons + rest.abandons, + inscrits: acc[departement].inscrits + rest.inscrits, + apprenants: acc[departement].apprenants + rest.apprenants, + rupturants: acc[departement].rupturants + rest.rupturants, + }, + } + : { + ...acc, + [departement]: { + departement, + ...rest, + }, + }; + }, {}); + return Object.values(mapDepartement); +}; + +export const getIndicateursEffectifsParOrganisme = async ( + ctx: AuthContext, + filters: FullEffectifsFilters, + organismeId?: ObjectId +) => [ + ...(await getIndicateursEffectifsParOrganismeGenerique(ctx, filters, effectifsDb(), false, organismeId)), + ...(await getIndicateursEffectifsParOrganismeGenerique(ctx, filters, effectifsDECADb(), true, organismeId)), +]; + +export const getEffectifsNominatifs = async ( + ctx: AuthContext, + filters: FullEffectifsFilters, + type: TypeEffectifNominatif, + organismeId?: ObjectId +) => [ + ...(await getEffectifsNominatifsGenerique(ctx, filters, type, effectifsDb(), false, organismeId)), + ...(await getEffectifsNominatifsGenerique(ctx, filters, type, effectifsDECADb(), true, organismeId)), +]; + +export const getOrganismeIndicateursEffectifs = async ( + ctx: AuthContext, + organismeId: ObjectId, + filters: EffectifsFiltersTerritoire +) => { + const eff = await getOrganismeIndicateursEffectifsGenerique(ctx, organismeId, filters, effectifsDb(), false); + const effDECA = await getOrganismeIndicateursEffectifsGenerique(ctx, organismeId, filters, effectifsDECADb(), true); + + return { + apprenants: eff.apprenants + effDECA.apprenants, + apprentis: eff.apprentis + effDECA.apprentis, + inscrits: eff.inscrits + effDECA.inscrits, + abandons: eff.abandons + effDECA.abandons, + rupturants: eff.rupturants + effDECA.rupturants, + }; +}; + +export const getOrganismeIndicateursEffectifsParFormation = async ( + ctx: AuthContext, + organismeId: ObjectId, + filters: FullEffectifsFilters +) => { + const indicateurs = [ + ...(await getOrganismeIndicateursEffectifsParFormationGenerique(ctx, organismeId, filters, effectifsDb())), + ...(await getOrganismeIndicateursEffectifsParFormationGenerique( + ctx, + organismeId, + filters, + effectifsDECADb(), + true + )), + ]; + + const mapRNCP = indicateurs.reduce((acc, { rncp_code, ...rest }) => { + const rncp = rncp_code ?? "null"; + return acc[rncp] + ? { + ...acc, + [rncp]: { + rncp_code, + apprentis: acc[rncp].apprentis + rest.apprentis, + abandons: acc[rncp].abandons + rest.abandons, + inscrits: acc[rncp].inscrits + rest.inscrits, + apprenants: acc[rncp].apprenants + rest.apprenants, + rupturants: acc[rncp].rupturants + rest.rupturants, + }, + } + : { + ...acc, + [rncp]: { + rncp_code, + ...rest, + }, + }; + }, {}); + + return Object.values(mapRNCP); +}; diff --git a/server/src/common/actions/indicateurs/indicateurs.actions.ts b/server/src/common/actions/indicateurs/indicateurs.actions.ts index 0d0080813..2e57ecdce 100644 --- a/server/src/common/actions/indicateurs/indicateurs.actions.ts +++ b/server/src/common/actions/indicateurs/indicateurs.actions.ts @@ -1,4 +1,4 @@ -import { ObjectId } from "mongodb"; +import { Collection, ObjectId } from "mongodb"; import { Acl, CODES_STATUT_APPRENANT, @@ -20,10 +20,11 @@ import { combineFilters, } from "@/common/actions/helpers/filters"; import { findOrganismesFormateursIdsOfOrganisme } from "@/common/actions/helpers/permissions"; -import { effectifsDb, organismesDb } from "@/common/model/collections"; +import { organismesDb } from "@/common/model/collections"; import { AuthContext } from "@/common/model/internal/AuthContext"; import { buildEffectifMongoFilters } from "./effectifs/effectifs-filters"; +import { buildDECAFilter } from "./indicateurs-with-deca.actions"; import { buildOrganismeMongoFilters } from "./organismes/organismes-filters"; function buildIndicateursEffectifsPipeline(groupBy: string | null, currentDate: Date) { @@ -93,17 +94,20 @@ function buildIndicateursEffectifsPipeline(groupBy: string | null, currentDate: ]; } -export async function getIndicateursEffectifsParDepartement( +export async function getIndicateursEffectifsParDepartementGenerique( filters: DateFilters & TerritoireFilters, - acl: Acl + acl: Acl, + db: Collection, + decaMode: boolean = false ): Promise { - const indicateurs = await effectifsDb() + const indicateurs = await db .aggregate([ { $match: combineFilters( { "_computed.organisme.fiable": true, // TODO : a supprimer si on permet de choisir de voir les effectifs des non fiables }, + buildDECAFilter(decaMode), ...buildEffectifMongoFilters(filters, acl.indicateursEffectifs) ), }, @@ -419,16 +423,19 @@ export async function getIndicateursOrganismesParDepartement( return indicateurs; } -export async function getIndicateursEffectifsParOrganisme( +export async function getIndicateursEffectifsParOrganismeGenerique( ctx: AuthContext, filters: FullEffectifsFilters, + db: Collection, + decaMode: boolean = false, organismeId?: ObjectId ): Promise { - const indicateurs = (await effectifsDb() + const indicateurs = (await db .aggregate([ { $match: combineFilters( await getOrganismeRestriction(organismeId), + buildDECAFilter(decaMode), ...buildEffectifMongoFilters(filters, ctx.acl.indicateursEffectifs), { "_computed.organisme.fiable": true, // TODO : a supprimer si on permet de choisir de voir les effectifs des non fiables @@ -485,16 +492,19 @@ export async function getIndicateursEffectifsParOrganisme( return indicateurs; } -export async function getOrganismeIndicateursEffectifsParFormation( +export async function getOrganismeIndicateursEffectifsParFormationGenerique( ctx: AuthContext, organismeId: ObjectId, - filters: FullEffectifsFilters + filters: FullEffectifsFilters, + db: Collection, + decaMode: boolean = false ): Promise { - const indicateurs = (await effectifsDb() + const indicateurs = (await db .aggregate([ { $match: combineFilters( await getOrganismeRestriction(organismeId), + buildDECAFilter(decaMode), ...buildEffectifMongoFilters(filters, ctx.acl.indicateursEffectifs), { "_computed.organisme.fiable": true, // TODO : a supprimer si on permet de choisir de voir les effectifs des non fiables @@ -540,17 +550,20 @@ export async function getOrganismeIndicateursEffectifsParFormation( return indicateurs; } -export async function getEffectifsNominatifs( +export async function getEffectifsNominatifsGenerique( ctx: AuthContext, filters: FullEffectifsFilters, type: TypeEffectifNominatif, + db: Collection, + decaMode: boolean = false, organismeId?: ObjectId ): Promise { - const indicateurs = (await effectifsDb() + const indicateurs = (await db .aggregate([ { $match: combineFilters( await getOrganismeRestriction(organismeId), + buildDECAFilter(decaMode), ...buildEffectifMongoFilters(filters, ctx.acl.effectifsNominatifs[type]), { "_computed.organisme.fiable": true, // TODO : a supprimer si on permet de choisir de voir les effectifs des non fiables @@ -701,16 +714,19 @@ export async function getEffectifsNominatifs( return indicateurs; } -export async function getOrganismeIndicateursEffectifs( +export async function getOrganismeIndicateursEffectifsGenerique( ctx: AuthContext, organismeId: ObjectId, - filters: EffectifsFiltersTerritoire + filters: EffectifsFiltersTerritoire, + db: Collection, + decaMode: boolean = false ): Promise { - const indicateurs = (await effectifsDb() + const indicateurs = (await db .aggregate([ { $match: combineFilters( await getOrganismeRestriction(organismeId), + buildDECAFilter(decaMode), ...buildEffectifMongoFilters(filters, ctx.acl.indicateursEffectifs) ), }, diff --git a/server/src/common/actions/organismes/organismes.actions.ts b/server/src/common/actions/organismes/organismes.actions.ts index 07d99b7e2..07a67f588 100644 --- a/server/src/common/actions/organismes/organismes.actions.ts +++ b/server/src/common/actions/organismes/organismes.actions.ts @@ -9,11 +9,19 @@ import { v4 as uuidv4 } from "uuid"; import { findOrganismesAccessiblesByOrganisationOF, findOrganismesFormateursIdsOfOrganisme, + findOrganismeFormateursIds, + findOrganismeResponsablesIds, } from "@/common/actions/helpers/permissions"; import { findDataFromSiret } from "@/common/actions/infoSiret.actions"; import { listContactsOrganisation } from "@/common/actions/organisations.actions"; import logger from "@/common/logger"; -import { organismesDb, effectifsDb, organisationsDb, usersMigrationDb } from "@/common/model/collections"; +import { + organismesDb, + effectifsDb, + organisationsDb, + usersMigrationDb, + effectifsDECADb, +} from "@/common/model/collections"; import { AuthContext } from "@/common/model/internal/AuthContext"; import { stripEmptyFields } from "@/common/utils/miscUtils"; import { cleanProjection } from "@/common/utils/mongoUtils"; @@ -428,6 +436,42 @@ export const updateEffectifsCount = async (organisme_id: ObjectId) => { ); }; +export const updateOrganismesHasTransmittedWithHierarchy = async ( + organisme: IOrganisme | null, + forceStatus: boolean = false +) => { + if (!organisme || (organisme && organisme.is_transmission_target)) { + return; + } + const organismeFormateursIds = findOrganismeFormateursIds(organisme, true); + const organismeResponsableIds = findOrganismeResponsablesIds(organisme); + const statusFromCount = + (await effectifsDb().countDocuments({ + organisme_id: { $in: [organisme._id, ...organismeFormateursIds, ...organismeResponsableIds] }, + })) > 0; + + const computedStatus = statusFromCount || forceStatus; + await organismesDb().updateMany( + { _id: { $in: [organisme._id, ...organismeFormateursIds, ...organismeResponsableIds] } }, + { + $set: { + is_transmission_target: computedStatus, + }, + }, + { bypassDocumentValidation: true } + ); + await effectifsDECADb().updateMany( + { + organisme_id: { $in: [organisme._id, ...organismeFormateursIds, ...organismeResponsableIds] }, + }, + { + $set: { + is_deca_compatible: !computedStatus, + }, + } + ); +}; + /** * Génération d'une api key s'il n'existe pas */ diff --git a/server/src/common/model/collections.ts b/server/src/common/model/collections.ts index a1860ac3c..1a15baf90 100644 --- a/server/src/common/model/collections.ts +++ b/server/src/common/model/collections.ts @@ -2,7 +2,9 @@ import { FiabilisationUaiSiret } from "shared/models/data/@types"; import auditLogsModelDescriptor, { IAuditLog } from "shared/models/data/auditLogs.model"; import bassinsEmploiDescriptor, { IBassinEmploi } from "shared/models/data/bassinsEmploi.model"; import contratsDecaModelDescriptor, { IContratDeca } from "shared/models/data/contratsDeca.model"; +import decaRawModelDescriptor, { IDecaRaw } from "shared/models/data/decaRaw.model"; import effectifsModelDescriptor, { IEffectif } from "shared/models/data/effectifs.model"; +import effectifsDECAModelDescriptor, { IEffectifDECA } from "shared/models/data/effectifsDECA.model"; import effectifsQueueModelDescriptor, { IEffectifQueue } from "shared/models/data/effectifsQueue.model"; import fiabilisationUaiSiretModelDescriptor from "shared/models/data/fiabilisationUaiSiret.model"; import formationsModelDescriptor, { IFormation } from "shared/models/data/formations.model"; @@ -32,6 +34,7 @@ export const modelDescriptors = [ jobEventsModelDescriptor, usersMigrationModelDescriptor, JwtSessionsModelDescriptor, + decaRawModelDescriptor, MaintenanceMessagesModelDescriptor, invitationsModelDescriptor, organisationsModelDescriptor, @@ -61,6 +64,7 @@ export const organismesReferentielDb = () => export const maintenanceMessageDb = () => getDbCollection(MaintenanceMessagesModelDescriptor.collectionName); export const effectifsDb = () => getDbCollection(effectifsModelDescriptor.collectionName); +export const effectifsDECADb = () => getDbCollection(effectifsDECAModelDescriptor.collectionName); export const effectifsQueueDb = () => getDbCollection(effectifsQueueModelDescriptor.collectionName); export const fiabilisationUaiSiretDb = () => getDbCollection(fiabilisationUaiSiretModelDescriptor.collectionName); @@ -71,3 +75,4 @@ export const romeDb = () => getDbCollection(romeModelDescriptor.collectio export const rncpDb = () => getDbCollection(rncpModelDescriptor.collectionName); export const contratsDecaDb = () => getDbCollection(contratsDecaModelDescriptor.collectionName); export const auditLogsDb = () => getDbCollection(auditLogsModelDescriptor.collectionName); +export const decaRawDb = () => getDbCollection(decaRawModelDescriptor.collectionName); diff --git a/server/src/common/mongodb/__snapshots__/validationSchema.test.ts.snap b/server/src/common/mongodb/__snapshots__/validationSchema.test.ts.snap index 7e33c1e31..7a1807191 100644 --- a/server/src/common/mongodb/__snapshots__/validationSchema.test.ts.snap +++ b/server/src/common/mongodb/__snapshots__/validationSchema.test.ts.snap @@ -318,6 +318,300 @@ exports[`validation-schema should create validation schema for contratsDeca: con } `; +exports[`validation-schema should create validation schema for decaRaw: decaRaw 1`] = ` +{ + "additionalProperties": false, + "bsonType": "object", + "properties": { + "_id": { + "bsonType": "objectId", + "description": "Identifiant MongoDB de l'effectif", + }, + "alternant": { + "additionalProperties": false, + "bsonType": "object", + "properties": { + "adresse": { + "additionalProperties": false, + "bsonType": "object", + "properties": { + "code_postal": { + "bsonType": "string", + }, + "numero": { + "bsonType": "string", + }, + "voie": { + "bsonType": "string", + }, + }, + "required": [ + "code_postal", + ], + }, + "courriel": { + "bsonType": "string", + }, + "date_naissance": { + "anyOf": [ + { + "bsonType": "date", + "description": "Date de naissance de l'alternant", + }, + { + "bsonType": "null", + }, + ], + "description": "Date de naissance de l'alternant", + }, + "departement_naissance": { + "bsonType": "string", + }, + "derniere_classe": { + "bsonType": "number", + }, + "handicap": { + "bsonType": "bool", + }, + "nationalite": { + "bsonType": "number", + }, + "nom": { + "bsonType": "string", + }, + "prenom": { + "bsonType": "string", + }, + "sexe": { + "bsonType": "number", + }, + "telephone": { + "bsonType": "string", + }, + }, + "required": [ + "handicap", + "nom", + "prenom", + "adresse", + "departement_naissance", + "derniere_classe", + "nationalite", + "sexe", + "telephone", + "courriel", + ], + }, + "created_at": { + "anyOf": [ + { + "bsonType": "date", + "description": "Date de création de l'enregistrement dans la base de données", + }, + { + "bsonType": "null", + }, + ], + "description": "Date de création de l'enregistrement dans la base de données", + }, + "date_debut_contrat": { + "anyOf": [ + { + "bsonType": "date", + "description": "Date de début du contrat", + }, + { + "bsonType": "null", + }, + ], + "description": "Date de début du contrat", + }, + "date_effet_rupture": { + "anyOf": [ + { + "bsonType": "date", + "description": "Date d'effet de la rupture du contrat", + }, + { + "bsonType": "null", + }, + ], + "description": "Date d'effet de la rupture du contrat", + }, + "date_fin_contrat": { + "anyOf": [ + { + "bsonType": "date", + "description": "Date de fin du contrat", + }, + { + "bsonType": "null", + }, + ], + "description": "Date de fin du contrat", + }, + "dispositif": { + "bsonType": "string", + }, + "employeur": { + "additionalProperties": false, + "bsonType": "object", + "properties": { + "adresse": { + "additionalProperties": false, + "bsonType": "object", + "properties": { + "code_postal": { + "bsonType": "string", + }, + }, + "required": [ + "code_postal", + ], + }, + "code_idcc": { + "bsonType": "string", + }, + "denomination": { + "bsonType": "string", + }, + "naf": { + "bsonType": "string", + }, + "nombre_de_salaries": { + "bsonType": "number", + }, + "siret": { + "bsonType": "string", + }, + "telephone": { + "bsonType": "string", + }, + }, + "required": [ + "adresse", + "code_idcc", + "denomination", + "naf", + "nombre_de_salaries", + "siret", + "telephone", + ], + }, + "etablissement_formation": { + "additionalProperties": true, + "bsonType": "object", + "properties": {}, + }, + "flag_correction": { + "bsonType": "bool", + }, + "formation": { + "additionalProperties": false, + "bsonType": "object", + "properties": { + "code_diplome": { + "bsonType": "string", + }, + "date_debut_formation": { + "anyOf": [ + { + "bsonType": "date", + "description": "Date de début de la formation", + }, + { + "bsonType": "null", + }, + ], + "description": "Date de début de la formation", + }, + "date_fin_formation": { + "anyOf": [ + { + "bsonType": "date", + "description": "Date de fin de la formation", + }, + { + "bsonType": "null", + }, + ], + "description": "Date de fin de la formation", + }, + "intitule_ou_qualification": { + "bsonType": "string", + }, + "rncp": { + "bsonType": "string", + }, + "type_diplome": { + "bsonType": "string", + }, + }, + "required": [ + "code_diplome", + "rncp", + "intitule_ou_qualification", + "type_diplome", + ], + }, + "no_contrat": { + "bsonType": "string", + }, + "organisme_formation": { + "additionalProperties": false, + "bsonType": "object", + "properties": { + "siret": { + "bsonType": "string", + }, + "uai_cfa": { + "bsonType": "string", + }, + }, + "required": [ + "uai_cfa", + "siret", + ], + }, + "rupture_avant_debut": { + "bsonType": "bool", + }, + "statut": { + "bsonType": "string", + }, + "type_contrat": { + "bsonType": "string", + }, + "updated_at": { + "anyOf": [ + { + "bsonType": "date", + "description": "Date de dernière mise à jour de l'enregistrement dans la base de données", + }, + { + "bsonType": "null", + }, + ], + "description": "Date de dernière mise à jour de l'enregistrement dans la base de données", + }, + }, + "required": [ + "_id", + "rupture_avant_debut", + "statut", + "alternant", + "employeur", + "flag_correction", + "formation", + "no_contrat", + "organisme_formation", + "type_contrat", + "dispositif", + "etablissement_formation", + ], +} +`; + exports[`validation-schema should create validation schema for effectifs: effectifs 1`] = ` { "additionalProperties": false, @@ -5921,6 +6215,13 @@ exports[`validation-schema should create validation schema for organismes: organ "bsonType": "array", "maxItems": 0, }, + "is_transmission_target": { + "bsonType": [ + "bool", + "null", + ], + "description": "Indique si cet organisme ( ou un de ces organismes formateur dont il est le responsable ) a été la cible ou non de transmissions d'effectif", + }, "last_transmission_date": { "bsonType": "date", "description": "Date de la dernière transmission de données", diff --git a/server/src/common/validation/dossierApprenantSchemaV3.ts b/server/src/common/validation/dossierApprenantSchemaV3.ts index 7e0db5e0e..a289d09b8 100644 --- a/server/src/common/validation/dossierApprenantSchemaV3.ts +++ b/server/src/common/validation/dossierApprenantSchemaV3.ts @@ -2,8 +2,6 @@ import { z } from "zod"; import { primitivesV1, primitivesV3 } from "@/common/validation/utils/zodPrimitives"; -import { validateContrat } from "./contratsDossierApprenantSchemaV3"; - export const stripModelAdditionalKeys = (validationSchema, data) => { const strippedData = Object.keys(validationSchema.shape).reduce((acc, curr) => { return data[curr] !== undefined @@ -110,7 +108,7 @@ export const dossierApprenantSchemaV3Base = () => type_cfa: primitivesV3.type_cfa.optional(), }); -const dossierdossierApprenantSchemaV3BaseWithApiData = () => { +const dossierApprenantSchemaV3BaseWithApiData = () => { return dossierApprenantSchemaV3Base().merge( z.object({ // These fields are hidden in documentation because they are generated by the API itself. @@ -121,12 +119,7 @@ const dossierdossierApprenantSchemaV3BaseWithApiData = () => { ); }; const dossierApprenantSchemaV3 = () => { - return dossierdossierApprenantSchemaV3BaseWithApiData().superRefine((dossier, ctx) => { - validateContrat(dossier, "", ctx); - validateContrat(dossier, "_2", ctx); - validateContrat(dossier, "_3", ctx); - validateContrat(dossier, "_4", ctx); - }); + return dossierApprenantSchemaV3BaseWithApiData(); }; export const dossierApprenantSchemaV3Input = () => { @@ -207,7 +200,7 @@ export function dossierApprenantSchemaV3WithMoreRequiredFieldsValidatingUAISiret } export type DossierApprenantSchemaV3BaseWithApiDataType = z.input< - ReturnType + ReturnType >; export type DossierApprenantSchemaV3ZodType = z.input>; diff --git a/server/src/http/middlewares/helpers.ts b/server/src/http/middlewares/helpers.ts index 8ad44161e..c78bdfb82 100644 --- a/server/src/http/middlewares/helpers.ts +++ b/server/src/http/middlewares/helpers.ts @@ -4,7 +4,7 @@ import { ObjectId } from "mongodb"; import { PermissionOrganisme } from "shared"; import { getOrganismePermission } from "@/common/actions/helpers/permissions-organisme"; -import { effectifsDb } from "@/common/model/collections"; +import { effectifsDECADb, effectifsDb } from "@/common/model/collections"; import { AuthContext } from "@/common/model/internal/AuthContext"; // catch errors and return the result of the request handler @@ -65,7 +65,12 @@ export function requireEffectifOrganismePermission { try { // On récupère l'organisme rattaché à l'effectif - const effectif = await effectifsDb().findOne({ _id: new ObjectId((req.params as any).id) }); + let effectif = await effectifsDb().findOne({ _id: new ObjectId((req.params as any).id) }); + + if (!effectif) { + effectif = await effectifsDECADb().findOne({ _id: new ObjectId((req.params as any).id) }); + } + if (!effectif) { throw Boom.notFound("effectif non trouvé"); } diff --git a/server/src/http/routes/specific.routes/organisme.routes.ts b/server/src/http/routes/specific.routes/organisme.routes.ts index 8e605f0c5..dfbd0577d 100644 --- a/server/src/http/routes/specific.routes/organisme.routes.ts +++ b/server/src/http/routes/specific.routes/organisme.routes.ts @@ -8,10 +8,13 @@ import { } from "shared"; import { isEligibleSIFA } from "@/common/actions/sifa.actions/sifa.actions"; -import { effectifsDb } from "@/common/model/collections"; +import { effectifsDECADb, effectifsDb, organismesDb } from "@/common/model/collections"; export async function getOrganismeEffectifs(organismeId: ObjectId, sifa = false) { - const effectifs = await effectifsDb() + const organisme = await organismesDb().findOne({ _id: organismeId }); + const db = organisme?.is_transmission_target ? effectifsDb() : effectifsDECADb(); + + const effectifs = await db .find({ organisme_id: organismeId, ...(sifa diff --git a/server/src/http/server.ts b/server/src/http/server.ts index f7be68668..aae490502 100644 --- a/server/src/http/server.ts +++ b/server/src/http/server.ts @@ -39,9 +39,11 @@ import { getEffectifsNominatifs, getIndicateursEffectifsParDepartement, getIndicateursEffectifsParOrganisme, - getIndicateursOrganismesParDepartement, getOrganismeIndicateursEffectifs, getOrganismeIndicateursEffectifsParFormation, +} from "@/common/actions/indicateurs/indicateurs-with-deca.actions"; +import { + getIndicateursOrganismesParDepartement, getOrganismeIndicateursOrganismes, } from "@/common/actions/indicateurs/indicateurs.actions"; import { findDataFromSiret } from "@/common/actions/infoSiret.actions"; diff --git a/server/src/jobs/hydrate/deca/hydrate-deca-raw.ts b/server/src/jobs/hydrate/deca/hydrate-deca-raw.ts new file mode 100644 index 000000000..def38e6cc --- /dev/null +++ b/server/src/jobs/hydrate/deca/hydrate-deca-raw.ts @@ -0,0 +1,165 @@ +import { normalize } from "path"; + +import { captureException } from "@sentry/node"; +import { ObjectId, WithoutId } from "mongodb"; +import { IEffectif, IOrganisme } from "shared/models"; +import { IDecaRaw } from "shared/models/data/decaRaw.model"; +import { zApprenant } from "shared/models/data/effectifs/apprenant.part"; +import { zContrat } from "shared/models/data/effectifs/contrat.part"; +import { IEffectifDECA } from "shared/models/data/effectifsDECA.model"; +import { zodOpenApi } from "shared/models/zodOpenApi"; +import { cyrb53Hash, getYearFromDate } from "shared/utils"; + +import { addComputedFields } from "@/common/actions/effectifs.actions"; +import { getOrganismeByUAIAndSIRET } from "@/common/actions/organismes/organismes.actions"; +import parentLogger from "@/common/logger"; +import { decaRawDb, effectifsDECADb } from "@/common/model/collections"; +import { __dirname } from "@/common/utils/esmUtils"; + +const logger = parentLogger.child({ module: "job:hydrate:contrats-deca-raw" }); + +export async function hydrateDecaRaw() { + let count = 0; + + try { + await effectifsDECADb().drop(); + + const cursor = decaRawDb().find({ + dispositif: "APPR", + "organisme_formation.uai_cfa": { $exists: true }, + "organisme_formation.siret": { $exists: true }, + "formation.date_debut_formation": { $exists: true }, + "formation.date_fin_formation": { $exists: true }, + }); + + for await (const document of cursor) { + try { + await updateEffectifDeca(document); + count++; + } catch (docError) { + logger.error(`Error updating document ${document._id}: ${docError}`); + } + } + + if (count === 0) { + console.log("No documents found matching the criteria."); + } + } catch (err) { + logger.error(`Échec de la mise à jour des effectifs: ${err}`); + captureException(err); + } finally { + logger.info(`Collection contratsDeca initialized successfully. Processed count: ${count}`); + } +} + +async function updateEffectifDeca(document: IDecaRaw) { + const newDocument = await transformDocument(document); + + return await effectifsDECADb().insertOne(newDocument as IEffectifDECA); +} + +async function transformDocument(document: IDecaRaw): Promise> { + const { + alternant, + formation, + employeur, + organisme_formation, + date_debut_contrat, + date_fin_contrat, + date_effet_rupture, + type_contrat, + } = document; + const { + nom, + prenom, + date_naissance, + handicap, + telephone, + sexe, + nationalite, + courriel, + adresse: adresseAlternant, + derniere_classe, + } = alternant; + const { date_debut_formation, date_fin_formation, code_diplome, rncp, intitule_ou_qualification } = formation; + const { siret, denomination, naf, adresse, nombre_de_salaries } = employeur; + const { uai_cfa, siret: orgSiret } = organisme_formation; + + const startYear = getYearFromDate(date_debut_formation); + const endYear = getYearFromDate(date_fin_formation); + + const organisme: IOrganisme = await getOrganismeByUAIAndSIRET(uai_cfa, orgSiret); + + if (!organisme) { + throw new Error("L'organisme n'a pas été trouvé dans la base de données"); + } + + if (!startYear || !endYear) { + throw new Error("L'année de début et l'année de fin doivent être définies"); + } + + if (!date_debut_contrat || !date_fin_contrat) { + throw new Error("Les dates de début et de fin de contrat sont requises"); + } + + const effectif: IEffectifDECA = { + _id: new ObjectId(), + deca_raw_id: document._id, + apprenant: { + nom, + prenom, + date_de_naissance: date_naissance, + nationalite: nationalite as zodOpenApi.TypeOf["nationalite"], + historique_statut: [], + has_nir: false, + rqth: handicap, + sexe: sexe === 1 ? "M" : sexe === 0 ? "F" : null, + telephone, + courriel, + adresse: { + numero: adresseAlternant.numero ? parseInt(adresseAlternant.numero, 10) : undefined, + voie: adresseAlternant.voie, + code_postal: adresseAlternant.code_postal, + }, + situation_avant_contrat: derniere_classe as zodOpenApi.TypeOf["situation_avant_contrat"], + }, + contrats: [ + { + siret, + denomination, + type_employeur: parseInt(type_contrat, 10) as zodOpenApi.TypeOf["type_employeur"], + naf, + adresse: { code_postal: adresse.code_postal }, + date_debut: date_debut_contrat, + date_fin: date_fin_contrat, + date_rupture: date_effet_rupture, + nombre_de_salaries, + }, + ], + formation: { + cfd: code_diplome, + rncp, + periode: [startYear, endYear], + libelle_court: intitule_ou_qualification, + libelle_long: intitule_ou_qualification, + date_inscription: date_debut_formation, + date_entree: date_debut_formation, + date_fin: date_fin_formation, + }, + organisme_id: organisme._id, + organisme_responsable_id: organisme._id, + organisme_formateur_id: organisme._id, + validation_errors: [], + created_at: new Date(), + updated_at: new Date(), + id_erp_apprenant: cyrb53Hash(normalize(prenom || "").trim() + normalize(nom || "").trim() + (date_naissance || "")), + source: "DECA", + annee_scolaire: startYear <= 2023 && endYear >= 2024 ? "2023-2024" : `${startYear}-${endYear}`, + }; + + return { + ...effectif, + _computed: addComputedFields({ organisme, effectif: effectif as IEffectif }), + is_deca_compatible: !organisme.is_transmission_target, + }; +} diff --git a/server/src/jobs/hydrate/organismes/hydrate-effectifs-count-with-hierarchy.ts b/server/src/jobs/hydrate/organismes/hydrate-effectifs-count-with-hierarchy.ts new file mode 100644 index 000000000..55c8732b9 --- /dev/null +++ b/server/src/jobs/hydrate/organismes/hydrate-effectifs-count-with-hierarchy.ts @@ -0,0 +1,22 @@ +import { captureException } from "@sentry/node"; + +import { updateOrganismesHasTransmittedWithHierarchy } from "@/common/actions/organismes/organismes.actions"; +import logger from "@/common/logger"; +import { organismesDb } from "@/common/model/collections"; + +export const hydrateOrganismesEffectifsCountWithHierarchy = async () => { + try { + logger.info(`hydrateOrganismesEffectifsCount: processing`); + const organismesCursor = organismesDb().find({}); + while (await organismesCursor.hasNext()) { + const organisme = await organismesCursor.next(); + if (organisme) { + await updateOrganismesHasTransmittedWithHierarchy(organisme); + } + } + + logger.info(`hydrateOrganismesEffectifsCount: processed`); + } catch (err) { + captureException(err); + } +}; diff --git a/server/src/jobs/ingestion/process-ingestion.ts b/server/src/jobs/ingestion/process-ingestion.ts index fe0a8f1fc..3de1817e3 100644 --- a/server/src/jobs/ingestion/process-ingestion.ts +++ b/server/src/jobs/ingestion/process-ingestion.ts @@ -19,6 +19,7 @@ import { getNiveauFormationFromLibelle } from "@/common/actions/formations.actio import { findOrganismeByUaiAndSiret, updateOrganismeTransmission, + updateOrganismesHasTransmittedWithHierarchy, } from "@/common/actions/organismes/organismes.actions"; import parentLogger from "@/common/logger"; import { @@ -26,11 +27,13 @@ import { effectifsQueueDb, fiabilisationUaiSiretDb, formationsCatalogueDb, + organismesDb, } from "@/common/model/collections"; import { sleep } from "@/common/utils/asyncUtils"; import { formatError } from "@/common/utils/errorUtils"; import { mergeIgnoringNullPreferringNewArray } from "@/common/utils/mergeIgnoringNullPreferringNewArray"; import { AddPrefix, addPrefixToProperties } from "@/common/utils/miscUtils"; +import { validateContrat } from "@/common/validation/contratsDossierApprenantSchemaV3"; import dossierApprenantSchemaV1V2, { DossierApprenantSchemaV1V2ZodType, } from "@/common/validation/dossierApprenantSchemaV1V2"; @@ -155,10 +158,9 @@ async function processEffectifQueueItem(effectifQueue: WithId): const start = Date.now(); try { // Phase de transformation d'une donnée de queue - const { result, itemProcessingInfos } = await (effectifQueue.api_version === "v3" + const { result, itemProcessingInfos, organismeTarget } = await (effectifQueue.api_version === "v3" ? transformEffectifQueueV3ToEffectif(effectifQueue) : transformEffectifQueueV1V2ToEffectif(effectifQueue)); - // ajout des informations sur le traitement au logger itemLogger = itemLogger.child({ ...itemProcessingInfos, format: effectifQueue.api_version }); @@ -197,8 +199,6 @@ async function processEffectifQueueItem(effectifQueue: WithId): ); itemLogger.info({ duration: Date.now() - start }, "processed item"); - - return true; } else { // MAJ de la queue pour indiquer que les données ont été traitées await effectifsQueueDb().updateOne( @@ -216,9 +216,9 @@ async function processEffectifQueueItem(effectifQueue: WithId): ); itemLogger.error({ duration: Date.now() - start, err: result.error }, "item validation error"); - - return false; } + await handleDECAMechanism(organismeTarget); + return result.success; } catch (err: any) { const error = Boom.internal("failed processing item", ctx); error.cause = err; @@ -262,205 +262,200 @@ type ItemProcessingInfos = { async function transformEffectifQueueV3ToEffectif(rawEffectifQueued: IEffectifQueue): Promise<{ result: SafeParseReturnType; itemProcessingInfos: ItemProcessingInfos; + organismeTarget: IOrganisme; }> { const itemProcessingInfos: ItemProcessingInfos = {}; - return { - result: await dossierApprenantSchemaV3() - .transform(async (effectifQueued, ctx) => { - const [effectif, organismeLieu, organismeFormateur, organismeResponsable, formation] = await Promise.all([ - (async () => { - return await transformEffectifQueueToEffectif(effectifQueued); - })(), - (async () => { - const { organisme, stats } = await findOrganismeWithStats( - effectifQueued?.etablissement_lieu_de_formation_uai, - effectifQueued?.etablissement_lieu_de_formation_siret - ); - Object.assign(itemProcessingInfos, addPrefixToProperties("organisme_lieu_", stats)); - return organisme; - })(), - (async () => { - const { organisme, stats } = await findOrganismeWithStats( - effectifQueued?.etablissement_formateur_uai, - effectifQueued?.etablissement_formateur_siret, - { _id: 1 } - ); - Object.assign(itemProcessingInfos, addPrefixToProperties("organisme_formateur_", stats)); - return organisme; - })(), - (async () => { - const { organisme, stats } = await findOrganismeWithStats( - effectifQueued?.etablissement_responsable_uai, - effectifQueued?.etablissement_responsable_siret, - { _id: 1 } - ); - Object.assign(itemProcessingInfos, addPrefixToProperties("organisme_responsable_", stats)); - return organisme; - })(), - (async () => { - if (!effectifQueued.formation_cfd) { - return null; - } - - const formationFromCatalogue = await formationsCatalogueDb().findOne({ cfd: effectifQueued.formation_cfd }); - itemProcessingInfos.formation_cfd = effectifQueued.formation_cfd; - itemProcessingInfos.formation_found = !!formationFromCatalogue; - return formationFromCatalogue; - })(), - ]); - - if (!organismeLieu) { - ctx.addIssue({ - code: ZodIssueCode.custom, - message: "organisme non trouvé", - path: ["etablissement_lieu_de_formation_uai", "etablissement_lieu_de_formation_siret"], - params: { - uai: effectifQueued.etablissement_lieu_de_formation_uai, - siret: effectifQueued.etablissement_lieu_de_formation_siret, - }, - }); - } - if (!organismeFormateur) { - ctx.addIssue({ - code: ZodIssueCode.custom, - message: "organisme formateur non trouvé", - path: ["etablissement_formateur_uai", "etablissement_formateur_siret"], - params: { - uai: effectifQueued.etablissement_formateur_uai, - siret: effectifQueued.etablissement_formateur_siret, - }, - }); - } - if (!organismeResponsable) { - ctx.addIssue({ - code: ZodIssueCode.custom, - message: "organisme responsable non trouvé", - path: ["etablissement_responsable_uai", "etablissement_responsable_siret"], - params: { - uai: effectifQueued.etablissement_responsable_uai, - siret: effectifQueued.etablissement_responsable_siret, - }, - }); - } - - if (!organismeLieu || !organismeFormateur || !organismeResponsable) { - return NEVER; - } - // désactivé si non bloquant - // if (!formation) { - // ctx.addIssue({ - // code: ZodIssueCode.custom, - // message: "formation non trouvée dans le catalogue", - // params: { - // cfd: effectifQueued.formation_cfd, - // }, - // }); - // } - - // Set du niveau de la formation depuis le catalogue - if (formation && effectif.formation) { - effectif.formation.niveau = getNiveauFormationFromLibelle(formation.niveau); - effectif.formation.niveau_libelle = formation.niveau; - - // Source: https://mission-apprentissage.slack.com/archives/C02FR2L1VB8/p1695295051135549 - // We compute the real duration of the formation in months, only if we have both date_entree and date_fin - if (effectif.formation.date_fin && effectif.formation.date_entree) { - effectif.formation.duree_formation_relle = Math.round( - (effectif.formation.date_fin.getTime() - effectif.formation.date_entree.getTime()) / - 1000 / - 60 / - 60 / - 24 / - 30 - ); + let organismeTarget: any; + + const result = await dossierApprenantSchemaV3() + .transform(async (effectifQueued, ctx) => { + const [effectif, organismeLieu, organismeFormateur, organismeResponsable, formation] = await Promise.all([ + (async () => { + return await transformEffectifQueueToEffectif(effectifQueued); + })(), + (async () => { + const { organisme, stats } = await findOrganismeWithStats( + effectifQueued?.etablissement_lieu_de_formation_uai, + effectifQueued?.etablissement_lieu_de_formation_siret + ); + organismeTarget = organisme; + Object.assign(itemProcessingInfos, addPrefixToProperties("organisme_lieu_", stats)); + return organisme; + })(), + (async () => { + const { organisme, stats } = await findOrganismeWithStats( + effectifQueued?.etablissement_formateur_uai, + effectifQueued?.etablissement_formateur_siret, + { _id: 1 } + ); + Object.assign(itemProcessingInfos, addPrefixToProperties("organisme_formateur_", stats)); + return organisme; + })(), + (async () => { + const { organisme, stats } = await findOrganismeWithStats( + effectifQueued?.etablissement_responsable_uai, + effectifQueued?.etablissement_responsable_siret, + { _id: 1 } + ); + Object.assign(itemProcessingInfos, addPrefixToProperties("organisme_responsable_", stats)); + return organisme; + })(), + (async () => { + if (!effectifQueued.formation_cfd) { + return null; } - } - return { - effectif: { - ...effectif, - organisme_id: organismeLieu?._id, - organisme_formateur_id: organismeFormateur?._id, - organisme_responsable_id: organismeResponsable?._id, - _computed: addComputedFields({ organisme: organismeLieu, effectif }), + const formationFromCatalogue = await formationsCatalogueDb().findOne({ cfd: effectifQueued.formation_cfd }); + itemProcessingInfos.formation_cfd = effectifQueued.formation_cfd; + itemProcessingInfos.formation_found = !!formationFromCatalogue; + return formationFromCatalogue; + })(), + ]); + + if (!organismeLieu) { + ctx.addIssue({ + code: ZodIssueCode.custom, + message: "organisme non trouvé", + path: ["etablissement_lieu_de_formation_uai", "etablissement_lieu_de_formation_siret"], + params: { + uai: effectifQueued.etablissement_lieu_de_formation_uai, + siret: effectifQueued.etablissement_lieu_de_formation_siret, + }, + }); + } + if (!organismeFormateur) { + ctx.addIssue({ + code: ZodIssueCode.custom, + message: "organisme formateur non trouvé", + path: ["etablissement_formateur_uai", "etablissement_formateur_siret"], + params: { + uai: effectifQueued.etablissement_formateur_uai, + siret: effectifQueued.etablissement_formateur_siret, + }, + }); + } + if (!organismeResponsable) { + ctx.addIssue({ + code: ZodIssueCode.custom, + message: "organisme responsable non trouvé", + path: ["etablissement_responsable_uai", "etablissement_responsable_siret"], + params: { + uai: effectifQueued.etablissement_responsable_uai, + siret: effectifQueued.etablissement_responsable_siret, }, - organisme: organismeLieu, - }; - }) - .safeParseAsync(rawEffectifQueued), + }); + } + validateContrat(effectifQueued, "", ctx); + validateContrat(effectifQueued, "_2", ctx); + validateContrat(effectifQueued, "_3", ctx); + validateContrat(effectifQueued, "_4", ctx); + + if (!organismeLieu || !organismeFormateur || !organismeResponsable) { + return NEVER; + } + + // Set du niveau de la formation depuis le catalogue + if (formation && effectif.formation) { + effectif.formation.niveau = getNiveauFormationFromLibelle(formation.niveau); + effectif.formation.niveau_libelle = formation.niveau; + + // Source: https://mission-apprentissage.slack.com/archives/C02FR2L1VB8/p1695295051135549 + // We compute the real duration of the formation in months, only if we have both date_entree and date_fin + if (effectif.formation.date_fin && effectif.formation.date_entree) { + effectif.formation.duree_formation_relle = Math.round( + (effectif.formation.date_fin.getTime() - effectif.formation.date_entree.getTime()) / + 1000 / + 60 / + 60 / + 24 / + 30 + ); + } + } + + return { + effectif: { + ...effectif, + organisme_id: organismeLieu?._id, + organisme_formateur_id: organismeFormateur?._id, + organisme_responsable_id: organismeResponsable?._id, + _computed: addComputedFields({ organisme: organismeLieu, effectif }), + }, + organisme: organismeLieu, + }; + }) + .safeParseAsync(rawEffectifQueued); + + return { + result, itemProcessingInfos, + organismeTarget, }; } async function transformEffectifQueueV1V2ToEffectif(rawEffectifQueued: IEffectifQueue): Promise<{ result: SafeParseReturnType; itemProcessingInfos: ItemProcessingInfos; + organismeTarget: any; }> { const itemProcessingInfos: ItemProcessingInfos = {}; - return { - result: await dossierApprenantSchemaV1V2() - .transform(async (effectifQueued, ctx) => { - const [effectif, organisme, formation] = await Promise.all([ - (async () => { - return await transformEffectifQueueToEffectif(effectifQueued); - })(), - (async () => { - const { organisme, stats } = await findOrganismeWithStats( - effectifQueued.uai_etablissement, - effectifQueued.siret_etablissement - ); - Object.assign(itemProcessingInfos, addPrefixToProperties("organisme_", stats)); - return organisme; - })(), - (async () => { - const formationFromCatalogue = await formationsCatalogueDb().findOne({ cfd: effectifQueued.id_formation }); - itemProcessingInfos.formation_cfd = effectifQueued.id_formation; - itemProcessingInfos.formation_found = !!formationFromCatalogue; - return formationFromCatalogue; - })(), - ]); - - if (!organisme) { - ctx.addIssue({ - code: ZodIssueCode.custom, - message: "organisme non trouvé", - path: ["uai_etablissement", "siret_etablissement"], - params: { - uai: effectifQueued.uai_etablissement, - siret: effectifQueued.siret_etablissement, - }, - }); - return NEVER; - } - - // Set du niveau de la formation depuis le catalogue - if (formation && effectif.formation) { - effectif.formation.niveau = getNiveauFormationFromLibelle(formation.niveau); - effectif.formation.niveau_libelle = formation.niveau; - } + let organismeTarget; + + const result = await dossierApprenantSchemaV1V2() + .transform(async (effectifQueued, ctx) => { + const [effectif, organisme, formation] = await Promise.all([ + (async () => { + return await transformEffectifQueueToEffectif(effectifQueued); + })(), + (async () => { + const { organisme, stats } = await findOrganismeWithStats( + effectifQueued.uai_etablissement, + effectifQueued.siret_etablissement + ); + organismeTarget = organisme; + Object.assign(itemProcessingInfos, addPrefixToProperties("organisme_", stats)); + return organisme; + })(), + (async () => { + const formationFromCatalogue = await formationsCatalogueDb().findOne({ cfd: effectifQueued.id_formation }); + itemProcessingInfos.formation_cfd = effectifQueued.id_formation; + itemProcessingInfos.formation_found = !!formationFromCatalogue; + return formationFromCatalogue; + })(), + ]); - // désactivé si non bloquant - // if (!formation) { - // ctx.addIssue({ - // code: ZodIssueCode.custom, - // message: "formation non trouvée dans le catalogue", - // params: { - // cfd: effectifQueued.id_formation, - // }, - // }); - // } - - return { - effectif: { - ...effectif, - organisme_id: organisme?._id, - _computed: addComputedFields({ organisme, effectif }), + if (!organisme) { + ctx.addIssue({ + code: ZodIssueCode.custom, + message: "organisme non trouvé", + path: ["uai_etablissement", "siret_etablissement"], + params: { + uai: effectifQueued.uai_etablissement, + siret: effectifQueued.siret_etablissement, }, - organisme: organisme, - }; - }) - .safeParseAsync(rawEffectifQueued), + }); + return NEVER; + } + + // Set du niveau de la formation depuis le catalogue + if (formation && effectif.formation) { + effectif.formation.niveau = getNiveauFormationFromLibelle(formation.niveau); + effectif.formation.niveau_libelle = formation.niveau; + } + return { + effectif: { + ...effectif, + organisme_id: organisme?._id, + _computed: addComputedFields({ organisme, effectif }), + }, + organisme: organisme, + }; + }) + .safeParseAsync(rawEffectifQueued); + return { + result, itemProcessingInfos, + organismeTarget, }; } @@ -599,3 +594,13 @@ async function findOrganismeWithStats( } return { organisme, stats }; } + +const handleDECAMechanism = async (organismeTarget) => { + if (!organismeTarget) { + logger.error("Cannot find target organisme for this transmission"); + return; + } + const orga_id = organismeTarget._id; + const orga = await organismesDb().findOne({ _id: orga_id }); + return updateOrganismesHasTransmittedWithHierarchy(orga, true); +}; diff --git a/server/src/jobs/jobs.ts b/server/src/jobs/jobs.ts index 93c0a549a..e64906c81 100644 --- a/server/src/jobs/jobs.ts +++ b/server/src/jobs/jobs.ts @@ -19,6 +19,7 @@ import { buildFiabilisationUaiSiret } from "./fiabilisation/uai-siret/build"; import { resetOrganismesFiabilisationStatut } from "./fiabilisation/uai-siret/build.utils"; import { updateOrganismesFiabilisationUaiSiret } from "./fiabilisation/uai-siret/update"; import { hydrateDeca } from "./hydrate/deca/hydrate-deca"; +import { hydrateDecaRaw } from "./hydrate/deca/hydrate-deca-raw"; import { hydrateEffectifsComputed } from "./hydrate/effectifs/hydrate-effectifs-computed"; import { hydrateEffectifsComputedTypes } from "./hydrate/effectifs/hydrate-effectifs-computed-types"; import { hydrateEffectifsFormationsNiveaux } from "./hydrate/effectifs/hydrate-effectifs-formations-niveaux"; @@ -27,6 +28,7 @@ import { hydrateOrganismesOPCOs } from "./hydrate/hydrate-organismes-opcos"; import { hydrateRNCP } from "./hydrate/hydrate-rncp"; import { hydrateROME } from "./hydrate/hydrate-rome"; import { hydrateOpenApi } from "./hydrate/open-api/hydrate-open-api"; +import { hydrateOrganismesEffectifsCountWithHierarchy } from "./hydrate/organismes/hydrate-effectifs-count-with-hierarchy"; import { hydrateOrganismesEffectifsCount } from "./hydrate/organismes/hydrate-effectifs_count"; import { hydrateOrganismesFromReferentiel } from "./hydrate/organismes/hydrate-organismes"; import { hydrateOrganismesBassinEmploi } from "./hydrate/organismes/hydrate-organismes-bassinEmploi"; @@ -267,6 +269,11 @@ export async function setupJobProcessor() { return hydrateDeca(job.payload as any); }, }, + "hydrate:contrats-deca-raw": { + handler: async () => { + return hydrateDecaRaw(); + }, + }, "hydrate:effectifs:update_computed_statut_month": { handler: async () => { return hydrateEffectifsComputedTypes({ @@ -289,6 +296,11 @@ export async function setupJobProcessor() { return hydrateOrganismesEffectifsCount(); }, }, + "hydrate:organismes-effectifs-count-with-hierarchy": { + handler: async () => { + return hydrateOrganismesEffectifsCountWithHierarchy(); + }, + }, "update:organismes-with-apis": { handler: async () => { return updateAllOrganismesRelatedFormations(); diff --git a/server/tests/integration/http/indicateurs-deca.route.test.ts b/server/tests/integration/http/indicateurs-deca.route.test.ts new file mode 100644 index 000000000..b2060e292 --- /dev/null +++ b/server/tests/integration/http/indicateurs-deca.route.test.ts @@ -0,0 +1,335 @@ +import { ObjectId } from "mongodb"; +import { IOrganisation, IOrganisme } from "shared/models"; + +import { organismesDb, effectifsQueueDb, effectifsDECADb } from "@/common/model/collections"; +import { processEffectifsQueue } from "@/jobs/ingestion/process-ingestion"; +import { useMongo } from "@tests/jest/setupMongo"; +import { RequestAsOrganisationFunc, initTestApp } from "@tests/utils/testUtils"; + +let app: Awaited>; +let requestAsOrganisation: RequestAsOrganisationFunc; + +const idResp = new ObjectId(); +const idForm = new ObjectId(); + +const organismeResponsable: IOrganisme = { + _id: idResp, + fiabilisation_statut: "FIABLE", + ferme: false, + created_at: new Date(), + updated_at: new Date(), + uai: "0000000A", + siret: "00000000000018", + nature: "responsable", + adresse: { + code_postal: "33400", + code_insee: "33522", + commune: "Talence", + departement: "33", + region: "75", + academie: "4", + complete: "680 CRS DE LA LIBERATION 33400 TALENCE", + bassinEmploi: "7505", + }, + nom: "MON ORGANISME FORMATEUR", + organismesFormateurs: [ + { + _id: idForm, + }, + ], +}; + +const organismeFormateur: IOrganisme = { + _id: idForm, + fiabilisation_statut: "FIABLE", + ferme: false, + created_at: new Date(), + updated_at: new Date(), + uai: "0000000B", + siret: "00000000000019", + nature: "formateur", + adresse: { + code_postal: "33400", + code_insee: "33522", + commune: "Talence", + departement: "33", + region: "75", + academie: "4", + complete: "680 CRS DE LA LIBERATION 33400 TALENCE", + bassinEmploi: "7505", + }, + nom: "MON ORGANISME RESPONSABLE", + organismesResponsables: [ + { + _id: idResp, + }, + ], +}; + +const organisation = { + _id: idResp, + type: "ORGANISME_FORMATION", + uai: organismeResponsable.uai, + siret: organismeResponsable.siret, + created_at: new Date(), +}; + +const createEff = (org) => ({ + _id: new ObjectId(), + nom_apprenant: new ObjectId().toString(), + prenom_apprenant: new ObjectId().toString(), + date_de_naissance_apprenant: "1993-05-19T00:00:00.000Z", + annee_scolaire: `2023-2024`, + statut_apprenant: 0, + date_metier_mise_a_jour_statut: `2023-12-28T04:05:47.647Z`, + id_erp_apprenant: new ObjectId().toString(), + api_version: "v3", + source: "SOURCE_TEST", + source_organisme_id: "9999999", + date_inscription_formation: `2023-09-01T00:00:00.000Z`, + date_entree_formation: `2023-09-01T00:00:00.000Z`, + date_fin_formation: `2024-06-30T00:00:00.000Z`, + + etablissement_responsable_uai: org.uai, + etablissement_responsable_siret: org.siret, + etablissement_formateur_uai: org.uai, + etablissement_formateur_siret: org.siret, + etablissement_lieu_de_formation_uai: org.uai, + etablissement_lieu_de_formation_siret: org.siret, + created_at: new Date(), +}); + +const createEffDECA = (org) => ({ + _id: new ObjectId(), + apprenant: { + historique_statut: [ + { + valeur_statut: 0, + date_statut: new Date("2023-12-28T04:05:47.647Z"), + date_reception: new Date("2024-04-24T09:23:11.020Z"), + }, + ], + nom: new ObjectId().toString(), + prenom: new ObjectId().toString(), + date_de_naissance: new Date("1993-05-19T00:00:00.000Z"), + adresse: {}, + }, + contrats: [], + formation: { + periode: [], + date_inscription: new Date("2023-09-01T00:00:00.000Z"), + date_fin: new Date("2024-06-30T00:00:00.000Z"), + date_entree: new Date("2023-09-01T00:00:00.000Z"), + }, + is_lock: { + apprenant: { + nom: true, + prenom: true, + date_de_naissance: true, + historique_statut: true, + }, + formation: { + cfd: true, + periode: true, + }, + contrats: true, + }, + validation_errors: [], + _computed: { + organisme: { + region: org.adresse.region, + departement: org.adresse.departement, + academie: org.adresse.academie, + bassinEmploi: org.adresse.bassinEmploi, + uai: org.uai, + siret: org.siret, + fiable: true, + }, + statut: { + en_cours: "ABANDON", + historique: [ + { + mois: "09", + annee: "2023", + valeur: "INSCRIT", + }, + { + mois: "10", + annee: "2023", + valeur: "INSCRIT", + }, + { + mois: "11", + annee: "2023", + valeur: "ABANDON", + }, + { + mois: "12", + annee: "2023", + valeur: "ABANDON", + }, + { + mois: "01", + annee: "2024", + valeur: "ABANDON", + }, + { + mois: "02", + annee: "2024", + valeur: "ABANDON", + }, + { + mois: "03", + annee: "2024", + valeur: "ABANDON", + }, + { + mois: "04", + annee: "2024", + valeur: "ABANDON", + }, + ], + parcours: [ + { + valeur: "INSCRIT", + date: new Date("2023-09-01T00:00:00.000Z"), + }, + { + valeur: "ABANDON", + date: new Date("2023-11-30T00:00:00.000Z"), + }, + ], + }, + }, + updated_at: new Date("2024-04-24T09:23:11.040Z"), + created_at: new Date("2024-04-24T09:23:11.021Z"), + annee_scolaire: "2023-2024", + source: "SOURCE_TEST", + source_organisme_id: "9999999", + id_erp_apprenant: new ObjectId().toString(), + organisme_id: org._id, + organisme_formateur_id: org._id, + organisme_responsable_id: org._id, + is_deca_compatible: true, +}); + +/** + * + * @param nbEffR Nombre d'effectif transmis présent chez le responsable + * @param nbEffF Nombre d'effectif transmis présent chez le formateur + * @param nbEffR_DECA Nombre d'effectif DECA présent chez le responsable + * @param nbEffF_DECA Nombre d'effectif DECA présent chez le formateur + * @param expected_nbEffR Nombre d'effectif attendu en indicateur chez le reponsable ( cumulé avec hierarchie ) + * @param expected_nbEffF Nombre d'effectif attendu en indicateur chez le formateur + * @param fromDECA Indique si les données sont cénsées provenir de DECA ou des vrais effectifs + */ +const testDeca = async (nbEffR, nbEffF, nbEffR_DECA, nbEffF_DECA, expected_nbEffR, expected_nbEffF, fromDECA) => { + const effR = [...new Array(nbEffR)].map(() => createEff(organismeResponsable)); + const effF = [...new Array(nbEffF)].map(() => createEff(organismeFormateur)); + + const effRDeca: any[] = [...new Array(nbEffR_DECA)].map(() => createEffDECA(organismeResponsable)); + const effFDeca: any[] = [...new Array(nbEffF_DECA)].map(() => createEffDECA(organismeFormateur)); + + effR.length && (await effectifsQueueDb().insertMany(effR)); + effF.length && (await effectifsQueueDb().insertMany(effF)); + effRDeca.length && (await effectifsDECADb().insertMany(effRDeca)); + effFDeca.length && (await effectifsDECADb().insertMany(effFDeca)); + + await processEffectifsQueue(); + const responseResp = await requestAsOrganisation( + organisation as IOrganisation, + "get", + `/api/v1/organismes/${idResp.toString()}/indicateurs/effectifs?date=2024-04-24T15:04:33.359Z` + ); + + const responseForm = await requestAsOrganisation( + organisation as IOrganisation, + "get", + `/api/v1/organismes/${idForm.toString()}/indicateurs/effectifs?date=2024-04-24T15:04:33.359Z` + ); + fromDECA + ? expect((await effectifsDECADb().find({ is_deca_compatible: true }).toArray()).length).toBeGreaterThan(0) + : expect((await effectifsDECADb().find({ is_deca_compatible: true }).toArray()).length).toBe(0); + expect(responseResp.data.abandons).toBe(expected_nbEffR); + expect(responseForm.data.abandons).toBe(expected_nbEffF); +}; + +describe("Test des indicateurs avec des données DECA", () => { + useMongo(); + beforeEach(async () => { + app = await initTestApp(); + requestAsOrganisation = app.requestAsOrganisation; + }); + + beforeEach(async () => { + await organismesDb().insertMany([organismeResponsable, organismeFormateur]); + }); + + it("Indicateur avec DECA - 1", async () => { + await testDeca(1, 0, 0, 0, 1, 0, false); + }); + + it("Indicateur avec DECA - 2", async () => { + await testDeca(0, 0, 1, 0, 1, 0, true); + }); + + it("Indicateur avec DECA - 3", async () => { + await testDeca(0, 0, 0, 1, 1, 1, true); + }); + + it("Indicateur avec DECA - 4", async () => { + await testDeca(0, 1, 0, 0, 1, 1, false); + }); + + it("Indicateur avec DECA - 5", async () => { + await testDeca(0, 1, 0, 1, 1, 1, false); + }); + + it("Indicateur avec DECA - 6", async () => { + await testDeca(0, 0, 1, 0, 1, 0, true); + }); + + it("Indicateur avec DECA - 7", async () => { + await testDeca(0, 0, 1, 1, 2, 1, true); + }); + + it("Indicateurs avec DECA - 8", async () => { + await testDeca(0, 1, 1, 0, 1, 1, false); + }); + + it("Indicateurs avec DECA - 9", async () => { + await testDeca(0, 1, 1, 1, 1, 1, false); + }); + + it("Indicateurs avec DECA - 10", async () => { + await testDeca(1, 0, 0, 0, 1, 0, false); + }); + + it("Indicateurs avec DECA - 11", async () => { + await testDeca(1, 0, 0, 1, 1, 0, false); + }); + + it("Indicateurs avec DECA - 12", async () => { + await testDeca(1, 1, 0, 0, 2, 1, false); + }); + + it("Indicateurs avec DECA - 13", async () => { + await testDeca(1, 1, 0, 1, 2, 1, false); + }); + + it("Indicateurs avec DECA - 14", async () => { + await testDeca(1, 0, 1, 0, 1, 0, false); + }); + + it("Indicateurs avec DECA - 15", async () => { + await testDeca(1, 0, 1, 1, 1, 0, false); + }); + + it("Indicateurs avec DECA - 16", async () => { + await testDeca(1, 1, 1, 0, 2, 1, false); + }); + + it("Indicateurs avec DECA - 17", async () => { + await testDeca(1, 1, 1, 1, 2, 1, false); + }); +}); diff --git a/shared/models/data/decaRaw.model.ts b/shared/models/data/decaRaw.model.ts new file mode 100644 index 000000000..99c7ea961 --- /dev/null +++ b/shared/models/data/decaRaw.model.ts @@ -0,0 +1,78 @@ +import type { CreateIndexesOptions, IndexSpecification } from "mongodb"; +import { z } from "zod"; +import { zObjectId } from "zod-mongodb-schema"; + +const collectionName = "decaRaw"; + +const indexes: [IndexSpecification, CreateIndexesOptions][] = [ + [{ statut: 1 }, {}], + [{ "employeur.siret": 1 }, { unique: false }], + [{ "formation.code_diplome": 1 }, { unique: false }], + [{ created_at: 1 }, { unique: false }], +]; + +const zAdresse = z.object({ + code_postal: z.string(), + numero: z.string().optional(), + voie: z.string().optional(), +}); + +const zAlternant = z.object({ + date_naissance: z.date().describe("Date de naissance de l'alternant").nullish(), + handicap: z.boolean(), + nom: z.string(), + prenom: z.string(), + adresse: zAdresse, + departement_naissance: z.string(), + derniere_classe: z.number(), + nationalite: z.number(), + sexe: z.number(), + telephone: z.string(), + courriel: z.string(), +}); + +const zEmployeur = z.object({ + adresse: zAdresse.pick({ code_postal: true }), + code_idcc: z.string(), + denomination: z.string(), + naf: z.string(), + nombre_de_salaries: z.number(), + siret: z.string(), + telephone: z.string(), +}); + +const zFormation = z.object({ + code_diplome: z.string(), + rncp: z.string(), + intitule_ou_qualification: z.string(), + type_diplome: z.string(), + date_debut_formation: z.date().describe("Date de début de la formation").nullish(), + date_fin_formation: z.date().describe("Date de fin de la formation").nullish(), +}); + +const zDecaRaw = z.object({ + _id: zObjectId.describe("Identifiant MongoDB de l'effectif"), + rupture_avant_debut: z.boolean(), + statut: z.string(), + alternant: zAlternant, + date_debut_contrat: z.date().describe("Date de début du contrat").nullish(), + date_effet_rupture: z.date().describe("Date d'effet de la rupture du contrat").nullish(), + date_fin_contrat: z.date().describe("Date de fin du contrat").nullish(), + employeur: zEmployeur, + flag_correction: z.boolean(), + formation: zFormation, + no_contrat: z.string(), + organisme_formation: z.object({ + uai_cfa: z.string(), + siret: z.string(), + }), + type_contrat: z.string(), + dispositif: z.string(), + etablissement_formation: z.object({}).passthrough(), + created_at: z.date().describe("Date de création de l'enregistrement dans la base de données").nullish(), + updated_at: z.date().describe("Date de dernière mise à jour de l'enregistrement dans la base de données").nullish(), +}); + +export type IDecaRaw = z.infer; + +export default { zod: zDecaRaw, indexes, collectionName }; diff --git a/shared/models/data/effectifsDECA.model.ts b/shared/models/data/effectifsDECA.model.ts new file mode 100644 index 000000000..640da8cb2 --- /dev/null +++ b/shared/models/data/effectifsDECA.model.ts @@ -0,0 +1,215 @@ +import type { CreateIndexesOptions, IndexSpecification } from "mongodb"; +import { z } from "zod"; +import { zObjectId } from "zod-mongodb-schema"; + +import { + SIRET_REGEX, + STATUT_APPRENANT, + STATUT_APPRENANT_VALUES, + TETE_DE_RESEAUX_BY_ID, + UAI_REGEX, + YEAR_RANGE_REGEX, +} from "../../constants"; +import { zodEnumFromArray, zodEnumFromObjKeys } from "../../utils/zodHelper"; +import { zAdresse } from "../parts/adresseSchema"; + +import { zApprenant } from "./effectifs/apprenant.part"; +import { zContrat } from "./effectifs/contrat.part"; +import { zFormationEffectif } from "./effectifs/formation.part"; + +const collectionName = "effectifsDECA"; + +const indexes: [IndexSpecification, CreateIndexesOptions][] = [ + [ + { + organisme_id: 1, + annee_scolaire: 1, + id_erp_apprenant: 1, + "apprenant.nom": 1, + "apprenant.prenom": 1, + "formation.cfd": 1, + "formation.annee": 1, + }, + { unique: true }, + ], + [ + { + "apprenant.nom": "text", + "apprenant.prenom": "text", + annee_scolaire: "text", + id_erp_apprenant: "text", + }, + { + name: "nom_prenom_annsco_iderp_text", + default_language: "french", + collation: { + locale: "simple", // simple binary comparison + strength: 1, // case and accent insensitive + }, + }, + ], + [{ organisme_id: 1, created_at: 1 }, {}], + [{ annee_scolaire: 1 }, { name: "annee_scolaire" }], + [{ id_erp_apprenant: 1 }, { name: "id_erp_apprenant" }], + [{ date_de_naissance: 1 }, { name: "date_de_naissance" }], + [{ "formation.cfd": 1 }, { name: "formation.cfd" }], + [ + { "apprenant.nom": 1 }, + { + name: "nom", + collation: { + locale: "fr", + strength: 1, // case and accent insensitive + }, + }, + ], + [ + { "apprenant.prenom": 1 }, + { + name: "prenom", + collation: { + locale: "fr", + strength: 1, // case and accent insensitive + }, + }, + ], + [{ source: 1 }, { name: "source" }], + [{ source_organisme_id: 1 }, { name: "source_organisme_id" }], + [{ created_at: 1 }, { name: "created_at" }], + [{ "_computed.organisme.region": 1 }, {}], + [{ "_computed.organisme.departement": 1 }, {}], + [{ "_computed.organisme.academie": 1 }, {}], + [{ "_computed.organisme.bassinEmploi": 1 }, {}], + [{ "_computed.organisme.reseaux": 1 }, {}], + [{ "_computed.organisme.uai": 1 }, {}], + [{ "_computed.organisme.siret": 1 }, {}], + [{ "_computed.organisme.fiable": 1, annee_scolaire: 1 }, {}], + [{ "_computed.formation.codes_rome": 1 }, {}], + [{ "_computed.formation.opcos": 1 }, {}], +]; + +const StatutApprenantEnum = zodEnumFromArray( + STATUT_APPRENANT_VALUES as (typeof STATUT_APPRENANT)[keyof typeof STATUT_APPRENANT][] +); + +const zEffectifComputedStatut = z.object({ + en_cours: StatutApprenantEnum, + parcours: z.array( + z.object({ + date: z.date(), + valeur: StatutApprenantEnum, + }) + ), +}); + +export const zEffectifDECA = z.object({ + _id: zObjectId.describe("Identifiant MongoDB de l'effectifDeca"), + deca_raw_id: zObjectId.describe("Identifiant decaraw associé à cet effectif"), + organisme_id: zObjectId.describe("Organisme id (lieu de formation de l'apprenant pour la v3)"), + organisme_responsable_id: zObjectId.describe("Organisme responsable id").nullish(), + organisme_formateur_id: zObjectId.describe("Organisme formateur id").nullish(), + + id_erp_apprenant: z.string({ + description: "Identifiant de l'apprenant dans l'erp", + }), + source: z.string({ + description: "Source du dossier apprenant (Ymag, Gesti, TDB_MANUEL, TDB_FILE...)", + }), + source_organisme_id: z + .string({ + description: "Identifiant de l'organisme id source transmettant", + }) + .nullish(), + annee_scolaire: z + .string({ + description: `Année scolaire sur laquelle l'apprenant est enregistré (ex: "2020-2021")`, + }) + .regex(YEAR_RANGE_REGEX), + apprenant: zApprenant, + formation: zFormationEffectif.nullish(), + contrats: z + .array(zContrat, { + // Note: anciennement dans apprenant.contrats + description: "Historique des contrats de l'apprenant", + }) + .nullish(), + // TODO: remove any + is_lock: z.any(), + + updated_at: z.date({ description: "Date de mise à jour en base de données" }).nullish(), + created_at: z.date({ description: "Date d'ajout en base de données" }).nullish(), + archive: z + .boolean({ + description: "Dossier apprenant est archivé (rétention maximum 5 ans)", + }) + .nullish(), + validation_errors: z + .array( + z.object({ + fieldName: z.string({ description: "Nom du champ en erreur" }).nullish(), + type: z.string({ description: "Type d'erreur" }).nullish(), + inputValue: z.string({ description: "Valeur fournie en entrée" }).nullish(), + message: z.string({ description: "Message de l'erreur" }).nullish(), + }), + { + description: "Erreurs de validation de cet effectif", + } + ) + .nullish(), + _computed: z + .object( + { + organisme: z + .object({ + region: zAdresse.shape.region.nullish(), + departement: zAdresse.shape.departement.nullish(), + academie: zAdresse.shape.academie.nullish(), + reseaux: z + .array(zodEnumFromObjKeys(TETE_DE_RESEAUX_BY_ID)) + .describe("Réseaux du CFA, s'ils existent") + .nullish(), + bassinEmploi: z.string({}).nullish(), + + // 2 champs utiles seulement pour les indicateurs v1 + // à supprimer avec les prochains dashboards indicateurs/effectifs pour utiliser organisme_id + uai: z + .string({ + description: "Code UAI de l'établissement", + }) + .regex(UAI_REGEX) + .nullish(), + siret: z + .string({ + description: "N° SIRET de l'établissement", + }) + .regex(SIRET_REGEX) + .nullish(), + fiable: z + .boolean({ + description: `organismes.fiabilisation_statut == "FIABLE" && ferme != false`, + }) + .nullish(), + }) + .nullish(), + formation: z + .object({ + codes_rome: z.array(z.string()).nullish(), + opcos: z.array(z.string()).nullish(), + }) + .nullish(), + // @TODO: nullish en attendant la migration et passage en nullable ensuite (migration: 20240305085918-effectifs-types.ts) + statut: zEffectifComputedStatut.nullish(), + }, + { + description: "Propriétés calculées ou récupérées d'autres collections", + } + ) + .nullish(), + is_deca_compatible: z.boolean().nullish(), +}); + +export type IEffectifDECA = z.output; +export type IEffectifComputedStatut = z.output; +export type IEffectifApprenant = z.infer; + +export default { zod: zEffectifDECA, indexes, collectionName }; diff --git a/shared/models/data/organismes.model.ts b/shared/models/data/organismes.model.ts index 67496d636..b84e76028 100644 --- a/shared/models/data/organismes.model.ts +++ b/shared/models/data/organismes.model.ts @@ -215,6 +215,12 @@ const zOrganisme = z created_at: z.date({ description: "Date d'ajout en base de données" }), natureValidityWarning: z.boolean().optional(), formations: z.array(z.any()).max(0).optional(), + is_transmission_target: z + .boolean({ + description: + "Indique si cet organisme ( ou un de ces organismes formateur dont il est le responsable ) a été la cible ou non de transmissions d'effectif", + }) + .nullish(), }) .strict(); diff --git a/shared/utils/date.ts b/shared/utils/date.ts index a6a195709..b0a817e1f 100644 --- a/shared/utils/date.ts +++ b/shared/utils/date.ts @@ -2,3 +2,7 @@ export function addDaysUTC(date: Date, days: number) { const result = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days)); return result; } + +export const getYearFromDate = (date?: Date | null): number | undefined => { + return date ? new Date(date).getFullYear() : undefined; +}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 5f87a1d7d..97be3f5a9 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -3,3 +3,4 @@ export * from "./date"; export * from "./sortAlphabetically"; export * from "./tsHelper"; export * from "./regex"; +export * from "./stringUtils"; diff --git a/shared/utils/stringUtils.ts b/shared/utils/stringUtils.ts new file mode 100644 index 000000000..491dc2e53 --- /dev/null +++ b/shared/utils/stringUtils.ts @@ -0,0 +1,25 @@ +export function normalize(string: string | null | undefined) { + return string === null || string === undefined + ? "" + : string + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, ""); +} + +// Source: https://stackoverflow.com/a/52171480/978690 +export const cyrb53Hash = (str: string, seed = 0) => { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + + return (h2 >>> 0).toString(16).padStart(8, "0") + (h1 >>> 0).toString(16).padStart(8, "0"); +}; diff --git a/ui/common/utils/stringUtils.ts b/ui/common/utils/stringUtils.ts index b0df13748..ee49582d2 100644 --- a/ui/common/utils/stringUtils.ts +++ b/ui/common/utils/stringUtils.ts @@ -24,15 +24,6 @@ export const capitalize = (str) => { return `${firstLetter.toUpperCase()}${str.substr(1)}`; }; -export function normalize(string) { - return string === null || string === undefined - ? "" - : string - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, ""); -} - /** * Retourne la représentation textuelle arrondie d'un grand nombre. */ @@ -46,23 +37,6 @@ export function prettyFormatNumber(number: number): string { return `${number % 1 !== 0 ? number.toFixed(1) : number}`; } -// Source: https://stackoverflow.com/a/52171480/978690 -export const cyrb53Hash = (str: string, seed = 0) => { - let h1 = 0xdeadbeef ^ seed; - let h2 = 0x41c6ce57 ^ seed; - for (let i = 0, ch; i < str.length; i++) { - ch = str.charCodeAt(i); - h1 = Math.imul(h1 ^ ch, 2654435761); - h2 = Math.imul(h2 ^ ch, 1597334677); - } - h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); - h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); - h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); - h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); - - return (h2 >>> 0).toString(16).padStart(8, "0") + (h1 >>> 0).toString(16).padStart(8, "0"); -}; - export const toPascalCase = (string) => `${string}` .toLowerCase() diff --git a/ui/hooks/organismes.ts b/ui/hooks/organismes.ts index 10e47e66f..c0a07ba4a 100644 --- a/ui/hooks/organismes.ts +++ b/ui/hooks/organismes.ts @@ -1,10 +1,10 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { useRouter } from "next/router"; import { useMemo } from "react"; +import { normalize } from "shared"; import { _get, _post, _put } from "@/common/httpClient"; import { Organisme } from "@/common/internal/Organisme"; -import { normalize } from "@/common/utils/stringUtils"; import { OrganismeNormalized } from "@/modules/organismes/ListeOrganismesPage"; import { OrganismesFiltersQuery, diff --git a/ui/modules/indicateurs/filters/FiltreFormationSecteurProfessionnel.tsx b/ui/modules/indicateurs/filters/FiltreFormationSecteurProfessionnel.tsx index bd99d4b68..1f45ef651 100644 --- a/ui/modules/indicateurs/filters/FiltreFormationSecteurProfessionnel.tsx +++ b/ui/modules/indicateurs/filters/FiltreFormationSecteurProfessionnel.tsx @@ -15,9 +15,9 @@ import { import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import TreeView, { flattenTree } from "react-accessible-treeview"; +import { normalize } from "shared"; import { _get, _getUI, _post } from "@/common/httpClient"; -import { normalize } from "@/common/utils/stringUtils"; import InputLegend from "@/components/InputLegend/InputLegend"; import { ArrowTriangleDownIcon } from "@/modules/dashboard/icons"; import SimpleOverlayMenu from "@/modules/dashboard/SimpleOverlayMenu"; diff --git a/ui/modules/indicateurs/filters/secteur-professionnel/arborescence-rome.ts b/ui/modules/indicateurs/filters/secteur-professionnel/arborescence-rome.ts index 82d62cbdf..4c31a5e87 100644 --- a/ui/modules/indicateurs/filters/secteur-professionnel/arborescence-rome.ts +++ b/ui/modules/indicateurs/filters/secteur-professionnel/arborescence-rome.ts @@ -1,6 +1,6 @@ // ROME = Répertoire Opérationnel des Métiers et des Emplois -import { normalize } from "@/common/utils/stringUtils"; +import { normalize } from "shared"; export type FamilleMetier = { id: string; diff --git a/ui/modules/mon-espace/effectifs/engine/formEngine/components/Input/InputController.tsx b/ui/modules/mon-espace/effectifs/engine/formEngine/components/Input/InputController.tsx index 5741e4c4c..825f049d3 100644 --- a/ui/modules/mon-espace/effectifs/engine/formEngine/components/Input/InputController.tsx +++ b/ui/modules/mon-espace/effectifs/engine/formEngine/components/Input/InputController.tsx @@ -28,7 +28,8 @@ export const InputController = memo(({ name, fieldType, mt, mb, ml, mr, w, onApp fieldType={fieldType ?? "text"} name={name} {...field} - locked={field.locked && Boolean(field.value)} + // locked={field.locked && Boolean(field.value)} + locked={true} value={field.value ?? ""} onChange={handle} isRequired={field.required} diff --git a/ui/modules/organismes/OrganismesTable.tsx b/ui/modules/organismes/OrganismesTable.tsx index 19419d3aa..28f4d0920 100644 --- a/ui/modules/organismes/OrganismesTable.tsx +++ b/ui/modules/organismes/OrganismesTable.tsx @@ -15,13 +15,13 @@ import { import { AccessorKeyColumnDef, SortingState } from "@tanstack/react-table"; import { useRouter } from "next/router"; import { useEffect, useMemo, useState } from "react"; +import { normalize } from "shared"; import { convertOrganismeToExport, organismesExportColumns } from "@/common/exports"; import { _get } from "@/common/httpClient"; import { Organisme } from "@/common/internal/Organisme"; import { formatDate } from "@/common/utils/dateUtils"; import { exportDataAsXlsx } from "@/common/utils/exportUtils"; -import { normalize } from "@/common/utils/stringUtils"; import DownloadButton from "@/components/buttons/DownloadButton"; import Link from "@/components/Links/Link"; import TooltipNatureOrganisme from "@/components/tooltips/TooltipNatureOrganisme"; diff --git a/ui/modules/organismes/Televersement.tsx b/ui/modules/organismes/Televersement.tsx index 7735b0f00..3d05e6e72 100644 --- a/ui/modules/organismes/Televersement.tsx +++ b/ui/modules/organismes/Televersement.tsx @@ -24,14 +24,13 @@ import { } from "@chakra-ui/react"; import { useMemo, useState } from "react"; import { useDropzone } from "react-dropzone"; -import { TD_MANUEL_ELEMENT_LINK } from "shared"; +import { cyrb53Hash, normalize, TD_MANUEL_ELEMENT_LINK } from "shared"; import XLSX from "xlsx"; import { _post } from "@/common/httpClient"; import { formatDateNumericDayMonthYear } from "@/common/utils/dateUtils"; import parseExcelBoolean from "@/common/utils/parseExcelBoolean"; import parseExcelDate from "@/common/utils/parseExcelDate"; -import { cyrb53Hash, normalize } from "@/common/utils/stringUtils"; import SimplePage from "@/components/Page/SimplePage"; import Ribbons from "@/components/Ribbons/Ribbons"; import useToaster from "@/hooks/useToaster";