diff --git a/server/src/common/actions/effectifs.actions.ts b/server/src/common/actions/effectifs.actions.ts index 87161eccb..603d54221 100644 --- a/server/src/common/actions/effectifs.actions.ts +++ b/server/src/common/actions/effectifs.actions.ts @@ -3,15 +3,17 @@ import { cloneDeep, isObject, merge, mergeWith, reduce, set, uniqBy } from "loda import { ObjectId } from "mongodb"; import { IEffectif } from "shared/models/data/effectifs.model"; import { IOrganisme } from "shared/models/data/organismes.model"; +import { cyrb53Hash, normalize } from "shared/utils/crypt"; import type { Paths } from "type-fest"; import { effectifsDb } from "@/common/model/collections"; import { defaultValuesEffectif } from "@/common/model/effectifs.model/effectifs.model"; import { stripEmptyFields } from "../utils/miscUtils"; +import { IEffectifCreationSchema } from "../validation/effectifsCreationSchema"; import { legacySchema } from "./effectif.legacy_schema"; - +import { getOrganismeById } from "./organismes/organismes.actions"; /** * Méthode de build d'un effectif * @@ -358,3 +360,58 @@ const flattenKeys = (obj: any, path: any = []) => !isObject(obj) ? { [path.join(".")]: obj } : reduce(obj, (cum, next, key) => merge(cum, flattenKeys(next, [...path, key])), {}); + +export const createEffectifFromForm = async (data: IEffectifCreationSchema, organismeId: string) => { + const id_erp_apprenant = cyrb53Hash( + normalize(data.apprenant.prenom || "").trim() + + normalize(data.apprenant.nom || "").trim() + + (data.apprenant.date_de_naissance?.toString() || "").trim() + ); + + const organismeLieuId = new ObjectId(data.organisme.organisme_lieu_id); + const organismeFormationId = new ObjectId(data.organisme.organisme_formateur_id); + const organismeResponsableId = new ObjectId(data.organisme.organisme_responsable_id); + + const organismeLieu = await getOrganismeById(organismeLieuId); + + const newEffectif: IEffectif = { + _id: new ObjectId(), + source: "formulaire", + source_organisme_id: organismeId.toString(), + annee_scolaire: data.annee_scolaire, + validation_errors: [], + organisme_id: organismeLieuId, + organisme_responsable_id: organismeResponsableId, + organisme_formateur_id: organismeFormationId, + id_erp_apprenant, + apprenant: { + ...data.apprenant, + historique_statut: [], + }, + is_lock: { + apprenant: {}, + formation: {}, + }, + formation: data.formation, + contrats: data.contrats, + created_at: new Date(), + updated_at: new Date(), + _computed: addEffectifComputedFields(organismeLieu), + }; + + // To be compliant to the unique indexes + const found = !!(await effectifsDb().findOne({ + organisme_id: newEffectif.organisme_id, + annee_scolaire: newEffectif.annee_scolaire, + id_erp_apprenant: newEffectif.id_erp_apprenant, + "apprenant.nom": newEffectif.apprenant.nom, + "apprenant.prenom": newEffectif.apprenant.prenom, + "formation.cfd": newEffectif.formation?.cfd, + "formation.annee": newEffectif.formation?.annee, + })); + if (found) { + throw Boom.conflict("L'effectif existe déjà"); + } + await effectifsDb().insertOne(newEffectif); + return; +}; diff --git a/server/src/common/validation/effectifsCreationSchema.ts b/server/src/common/validation/effectifsCreationSchema.ts new file mode 100644 index 000000000..e96faebe0 --- /dev/null +++ b/server/src/common/validation/effectifsCreationSchema.ts @@ -0,0 +1,21 @@ +import { zApprenant } from "shared/models/data/effectifs/apprenant.part"; +import { zContrat } from "shared/models/data/effectifs/contrat.part"; +import { zFormationEffectif } from "shared/models/data/effectifs/formation.part"; +import { z } from "zod"; + +import { primitivesV1, primitivesV3 } from "@/common/validation/utils/zodPrimitives"; + +export const effectifCreationSchema = z.object({ + annee_scolaire: primitivesV1.formation.annee_scolaire, + apprenant: zApprenant.omit({ historique_statut: true }), + contrats: z.array(zContrat), + formation: zFormationEffectif, + organisme: z.object({ + organisme_responsable_id: z.string(), + organisme_formateur_id: z.string(), + organisme_lieu_id: z.string(), + type_cfa: primitivesV3.type_cfa, + }), +}); + +export type IEffectifCreationSchema = z.output; diff --git a/server/src/http/server.ts b/server/src/http/server.ts index f7be68668..47f164fd6 100644 --- a/server/src/http/server.ts +++ b/server/src/http/server.ts @@ -23,7 +23,7 @@ import { registerUnknownNetwork, sendForgotPasswordRequest, } from "@/common/actions/account.actions"; -import { getEffectifForm, updateEffectifFromForm } from "@/common/actions/effectifs.actions"; +import { getEffectifForm, updateEffectifFromForm, createEffectifFromForm } from "@/common/actions/effectifs.actions"; import { deleteOldestDuplicates, getDuplicatesEffectifsForOrganismeIdWithPagination, @@ -104,6 +104,7 @@ import { passwordSchema, validateFullObjectSchema, validateFullZodObjectSchema } import { SReqPostVerifyUser } from "@/common/validation/ApiERPSchema"; import { configurationERPSchema } from "@/common/validation/configurationERPSchema"; import { dossierApprenantSchemaV3WithMoreRequiredFieldsValidatingUAISiret } from "@/common/validation/dossierApprenantSchemaV3"; +import { effectifCreationSchema, IEffectifCreationSchema } from "@/common/validation/effectifsCreationSchema"; import loginSchemaLegacy from "@/common/validation/loginSchemaLegacy"; import objectIdSchema from "@/common/validation/objectIdSchema"; import { registrationSchema, registrationUnknownNetworkSchema } from "@/common/validation/registrationSchema"; @@ -637,6 +638,17 @@ function setupRoutes(app: Application) { ) ) .use("/transmission", transmissionRoutes()) + .post( + "/effectif", + requireOrganismePermission("manageEffectifs"), + returnResult(async (req, res) => { + const data: IEffectifCreationSchema = await validateFullZodObjectSchema( + req.body, + effectifCreationSchema.shape + ); + return await createEffectifFromForm(data, res.locals.organismeId); + }) + ) ); /******************************** diff --git a/shared/models/data/effectifs/apprenant.part.ts b/shared/models/data/effectifs/apprenant.part.ts index b400e878a..7da3d6563 100644 --- a/shared/models/data/effectifs/apprenant.part.ts +++ b/shared/models/data/effectifs/apprenant.part.ts @@ -23,7 +23,7 @@ export const zApprenant = zodOpenApi.object({ description: "Sexe de l'apprenant (M: Homme, F: Femme)", }) .nullish(), - date_de_naissance: zodOpenApi.date({ description: "Date de naissance de l'apprenant" }).nullish(), + date_de_naissance: zodOpenApi.coerce.date({ description: "Date de naissance de l'apprenant" }).nullish(), code_postal_de_naissance: zodOpenApi .string({ description: @@ -47,7 +47,7 @@ export const zApprenant = zodOpenApi.object({ description: "Apprenant en situation d'handicape (RQTH)", }) .nullish(), - date_rqth: zodOpenApi + date_rqth: zodOpenApi.coerce .date({ description: "Date de la reconnaissance travailleur handicapé", }) diff --git a/shared/models/data/effectifs/contrat.part.ts b/shared/models/data/effectifs/contrat.part.ts index 43a4b3604..ece63a746 100644 --- a/shared/models/data/effectifs/contrat.part.ts +++ b/shared/models/data/effectifs/contrat.part.ts @@ -50,8 +50,8 @@ export const zContrat = zodOpenApi.object({ .openapi({ example: 10 }) .nullish(), adresse: zAdresse.nullish(), - date_debut: zodOpenApi.date({ description: "Date de début du contrat" }), - date_fin: zodOpenApi.date({ description: "Date de fin du contrat" }).nullish(), - date_rupture: zodOpenApi.date({ description: "Date de rupture du contrat" }).nullish(), + date_debut: zodOpenApi.coerce.date({ description: "Date de début du contrat" }), + date_fin: zodOpenApi.coerce.date({ description: "Date de fin du contrat" }).nullish(), + date_rupture: zodOpenApi.coerce.date({ description: "Date de rupture du contrat" }).nullish(), cause_rupture: zodOpenApi.string({ description: "Cause de rupture du contrat" }).nullish(), }); diff --git a/shared/models/data/effectifs/formation.part.ts b/shared/models/data/effectifs/formation.part.ts index 46fff35b2..e0e8f1935 100644 --- a/shared/models/data/effectifs/formation.part.ts +++ b/shared/models/data/effectifs/formation.part.ts @@ -24,7 +24,7 @@ export const zFormationEffectif = z.object({ niveau_libelle: formationsProps.niveau_libelle.nullish(), annee: z.number({ description: "Numéro de l'année dans la formation (promo)" }).int().nullish(), // FIN champs collectés - date_obtention_diplome: z.date({ description: "Date d'obtention du diplôme" }).nullish(), + date_obtention_diplome: z.coerce.date({ description: "Date d'obtention du diplôme" }).nullish(), duree_formation_relle: z.number({ description: "Durée réelle de la formation en mois" }).int().nullish(), periode: z .array(z.number().int(), { @@ -32,10 +32,10 @@ export const zFormationEffectif = z.object({ }) .nullish(), // V3 - REQUIRED FIELDS (optionel pour l'instant pour supporter V2) - date_inscription: z.date({ description: "Date d'inscription" }).nullish(), + date_inscription: z.coerce.date({ description: "Date d'inscription" }).nullish(), // V3 - OPTIONAL FIELDS obtention_diplome: z.boolean({ description: "Diplôme obtenu" }).nullish(), // vrai si date_obtention_diplome non null - date_exclusion: z.date({ description: "Date d'exclusion" }).nullish(), + date_exclusion: z.coerce.date({ description: "Date d'exclusion" }).nullish(), cause_exclusion: z.string({ description: "Cause de l'exclusion" }).nullish(), referent_handicap: z .object({ @@ -47,8 +47,8 @@ export const zFormationEffectif = z.object({ formation_presentielle: z.boolean({ description: "Formation en présentiel" }).nullish(), duree_theorique: z.number({ description: "Durée théorique de la formation en année" }).int().nullish(), // legacy, should be empty soon duree_theorique_mois: z.number({ description: "Durée théorique de la formation en mois" }).int().nullish(), - date_fin: z.date({ description: "Date de fin de la formation" }).nullish(), - date_entree: z.date({ description: "Date d'entrée en formation" }).nullish(), + date_fin: z.coerce.date({ description: "Date de fin de la formation" }).nullish(), + date_entree: z.coerce.date({ description: "Date d'entrée en formation" }).nullish(), }); export type IFormationEffectif = z.output; diff --git a/shared/utils/crypt.ts b/shared/utils/crypt.ts new file mode 100644 index 000000000..86e2cfac5 --- /dev/null +++ b/shared/utils/crypt.ts @@ -0,0 +1,24 @@ +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 normalize = (str: string) => { + return str === null || str === undefined + ? "" + : str + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, ""); +};