diff --git a/packages/api/package.json b/packages/api/package.json index e38eef6ed3c05411bdbb327f2e33cb78ada6f2f4..75cb20513e70c32a288fde86df5a6c020744c5b2 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -7,7 +7,7 @@ "type": "module", "scripts": { "build": "tsc --incremental", - "check-perimetres": "node --enable-source-maps --loader ts-node/esm/transpile-only ./src/scripts/check-perimetres.ts", + "check-prolongations": "node --enable-source-maps --loader ts-node/esm/transpile-only ./src/scripts/check-prolongations.ts", "daily": "node --enable-source-maps --loader ts-node/esm/transpile-only ./src/scripts/daily.ts", "monthly": "node --enable-source-maps --loader ts-node/esm/transpile-only ./src/scripts/monthly.ts", "db:dump": "rm -rf ./backups/* && pg_dump --host=localhost --username=postgres --clean --if-exists --format=d --no-owner --no-privileges --dbname=camino --file=./backups/", diff --git a/packages/api/src/business/check-prolongations.ts b/packages/api/src/business/check-prolongations.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f378675dcc3fd1b5da6e529837e824d795da4f0 --- /dev/null +++ b/packages/api/src/business/check-prolongations.ts @@ -0,0 +1,303 @@ +import { Pool } from 'pg' +import { getDemarcheByIdOrSlug, GetDemarcheByIdOrSlugErrors, getDemarches, getEtapesByDemarcheId } from '../api/rest/demarches.queries' +import { Effect, pipe } from 'effect' +import { CaminoError } from 'camino-common/src/zod-tools' +import { DemarcheId } from 'camino-common/src/demarche' +import { isEtapeComplete } from 'camino-common/src/permissions/titres-etapes' +import { userSuper } from '../database/user-super' +import { firstEtapeDateValidator } from 'camino-common/src/date' +import { titreEtapeGet, titreEtapeUpsert } from '../database/queries/titres-etapes' +import { isNotNullNorUndefined, isNotNullNorUndefinedNorEmpty, isNullOrUndefined, isNullOrUndefinedOrEmpty, memoize, toSorted } from 'camino-common/src/typescript-tools' +import { iTitreEtapeToFlattenEtape, TitreEtapeToFlattenEtapeErrors } from '../api/_format/titres-etapes' +import { ITitreEtape } from '../types' +import { + getDocumentsByEtapeId, + getEntrepriseDocumentIdsByEtapeId, + getEtapeAvisLargeObjectIdsByEtapeId, + GetEtapeAvisLargeObjectIdsByEtapeIdErrors, + GetEtapeDocumentLargeObjectIdsByEtapeIdErrors, +} from '../database/queries/titres-etapes.queries' +import { getAdministrationsLocales } from 'camino-common/src/administrations' +import { ZodUnparseable } from '../tools/fp-tools' +import { TitreId, TitreSlug } from 'camino-common/src/validators/titres' +import { EtapeId } from 'camino-common/src/etape' +import { isDemarcheTypeProlongations } from 'camino-common/src/static/demarchesTypes' +import { getMostRecentValuePropFromEtapeFondamentaleValide, TitreGetDemarche } from 'camino-common/src/titres' +import { isDemarcheStatutNonStatue, isDemarcheStatutNonValide } from 'camino-common/src/static/demarchesStatuts' +import { getTitre } from '../api/rest/titres.queries' + +type CheckedProlongation = { + titre_slug: TitreSlug | null + demarche_id: DemarcheId + etape_id: EtapeId | null + titre_id: TitreId | null + errors: string[] +} + +const demarchePasProlongationError = 'pas une prolongation' as const +const demarcheSansEtapesError = "La démarche ne contient pas d'étapes" as const +const titreEtapeGetError = 'Le fetch de la firstEtape a échoué' as const +type CheckOnePrologationErrors = + | GetDemarcheByIdOrSlugErrors + | TitreEtapeToFlattenEtapeErrors + | GetEtapeDocumentLargeObjectIdsByEtapeIdErrors + | ZodUnparseable + | GetEtapeAvisLargeObjectIdsByEtapeIdErrors + | typeof titreEtapeGetError + | typeof demarcheSansEtapesError + | typeof demarchePasProlongationError +const checkOneProlongation = (pool: Pool, demarcheId: DemarcheId): Effect.Effect<CheckedProlongation, CaminoError<CheckOnePrologationErrors>> => { + return Effect.Do.pipe( + Effect.bind('demarche', () => getDemarcheByIdOrSlug(pool, demarcheId)), + Effect.filterOrFail( + ({ demarche }) => isDemarcheTypeProlongations(demarche.demarche_type_id), + () => ({ message: demarchePasProlongationError }) + ), + Effect.bind('etapes', () => getEtapesByDemarcheId(pool, demarcheId)), + Effect.filterOrFail( + ({ etapes }) => isNotNullNorUndefinedNorEmpty(etapes), + () => ({ message: demarcheSansEtapesError, extra: {} }) + ), + Effect.bind('firstEtape', ({ etapes }) => + pipe( + Effect.tryPromise({ + try: () => titreEtapeGet(toSorted(etapes, (a, b) => a.ordre - b.ordre)[0].id, { fields: { id: {} }, fetchHeritage: true }, userSuper), + catch: error => ({ message: titreEtapeGetError, extra: error }), + }), + Effect.filterOrFail( + (firstEtape): firstEtape is ITitreEtape => isNotNullNorUndefined(firstEtape), + () => ({ message: titreEtapeGetError, extra: 'La first étape est null ou undefined' }) + ) + ) + ), + Effect.let('titreProps', ({ demarche, firstEtape }) => ({ + titreTypeId: memoize(() => Promise.resolve(demarche.titre_type_id)), + administrationsLocales: memoize(() => + Promise.resolve(getAdministrationsLocales(isNotNullNorUndefined(firstEtape.communes) ? firstEtape.communes.map(({ id }) => id) : [], firstEtape.secteursMaritime)) + ), + entreprisesTitulairesOuAmodiataires: memoize(() => Promise.resolve([...(firstEtape.titulaireIds ?? []), ...(firstEtape.amodiataireIds ?? [])])), + })), + Effect.bind('etapeDocuments', ({ firstEtape, demarche, titreProps }) => { + return getDocumentsByEtapeId(firstEtape.id, pool, userSuper, titreProps.titreTypeId, titreProps.administrationsLocales, titreProps.entreprisesTitulairesOuAmodiataires, firstEtape.typeId, { + demarche_type_id: demarche.demarche_type_id, + entreprises_lecture: demarche.entreprises_lecture, + public_lecture: demarche.public_lecture, + titre_public_lecture: false, + }) + }), + Effect.bind('entrepriseDocuments', ({ firstEtape }) => getEntrepriseDocumentIdsByEtapeId({ titre_etape_id: firstEtape.id }, pool, userSuper)), + Effect.bind('etapeAvis', ({ firstEtape, demarche, titreProps }) => + getEtapeAvisLargeObjectIdsByEtapeId( + firstEtape.id, + pool, + userSuper, + titreProps.titreTypeId, + titreProps.administrationsLocales, + titreProps.entreprisesTitulairesOuAmodiataires, + firstEtape.typeId, + { + demarche_type_id: demarche.demarche_type_id, + entreprises_lecture: demarche.entreprises_lecture, + public_lecture: demarche.public_lecture, + titre_public_lecture: false, + } + ) + ), + Effect.bind('flattenFirstEtape', ({ firstEtape }) => iTitreEtapeToFlattenEtape(firstEtape)), + Effect.map(({ demarche, firstEtape, flattenFirstEtape, etapeDocuments, entrepriseDocuments, etapeAvis }) => { + const isFirstEtapeComplete = isEtapeComplete( + flattenFirstEtape, + demarche.titre_type_id, + demarcheId, + demarche.demarche_type_id, + etapeDocuments, + entrepriseDocuments, + firstEtape.sdomZones, + isNotNullNorUndefined(firstEtape.communes) ? firstEtape.communes.map(({ id }) => id) : [], + etapeAvis, + userSuper, + firstEtapeDateValidator.parse(flattenFirstEtape.date) + ) + + return { + titre_slug: demarche.titre_slug, + demarche_id: demarche.demarche_id, + etape_id: firstEtape.id, + titre_id: demarche.titre_id, + errors: isFirstEtapeComplete.valid ? [] : isFirstEtapeComplete.errors, + } + }), + Effect.catchAll(caminoError => { + return Effect.succeed({ + titre_slug: null, + demarche_id: demarcheId, + etape_id: null, + titre_id: null, + errors: [caminoError.message], + }) + }) + ) +} + +const titreIdVideError = "Pas d'id de titre" as const +const upsertFailError = "Échec de mise à jour de l'étape" as const +const titreGetError = 'Impossible de fetch le titre' as const +const titreGetIsNullError = 'Titre introuvable' as const +const etapeIntrouvableError = 'Étape introuvable dans le titre' as const +const demarcheListIsEmptyError = 'Liste des démarches héritables vide' as const +type FixProlongationErrors = typeof titreIdVideError | typeof upsertFailError | typeof titreGetError | typeof titreGetIsNullError | typeof demarcheListIsEmptyError | typeof etapeIntrouvableError +const fixProlongation = (pool: Pool, checkedProlongation: CheckedProlongation): Effect.Effect<null, CaminoError<FixProlongationErrors>> => { + return Effect.Do.pipe( + Effect.bind('titre', () => + pipe( + Effect.succeed(checkedProlongation.titre_id), + Effect.filterOrFail( + titreId => isNotNullNorUndefined(titreId), + () => ({ message: titreIdVideError }) + ), + Effect.flatMap(titreId => + Effect.tryPromise({ + try: () => getTitre(pool, userSuper, titreId), + catch: error => ({ message: titreGetError, extra: error }), + }) + ), + Effect.filterOrFail( + (titre): titre is NonNullable<typeof titre> => isNotNullNorUndefined(titre), + error => ({ message: titreGetIsNullError, extra: error }) + ) + ) + ), + Effect.bind('etape', ({ titre }) => + pipe( + Effect.succeed(titre.demarches.find(({ id }) => id === checkedProlongation.demarche_id)), + Effect.filterOrFail( + (demarche): demarche is NonNullable<typeof demarche> => isNotNullNorUndefined(checkedProlongation.demarche_id) && isNotNullNorUndefined(demarche), + () => ({ message: etapeIntrouvableError, extra: 'Démarche introuvable' }) + ), + Effect.map(demarche => demarche.etapes.find(({ id }) => id === checkedProlongation.etape_id)), + Effect.filterOrFail( + (etape): etape is NonNullable<typeof etape> => isNotNullNorUndefined(checkedProlongation.etape_id) && isNotNullNorUndefined(etape), + () => ({ message: etapeIntrouvableError, extra: 'Étape introuvable' }) + ) + ) + ), + Effect.bind('propsHeritees', ({ titre }) => + Effect.Do.pipe( + Effect.bind('demarches', () => { + const demarches: TitreGetDemarche[] = [] + for (const demarche of toSorted([...titre.demarches], (a, b) => a.ordre - b.ordre)) { + if (demarche.id === checkedProlongation.demarche_id) { + return Effect.succeed([...demarches, demarche]) + } else if (!isDemarcheStatutNonStatue(demarche.demarche_statut_id) && !isDemarcheStatutNonValide(demarche.demarche_statut_id)) { + demarches.push(demarche) + } + } + + return Effect.fail({ message: demarcheListIsEmptyError }) + }), + Effect.map(({ demarches }) => { + return { + substances: getMostRecentValuePropFromEtapeFondamentaleValide('substances', demarches), + titulaireIds: getMostRecentValuePropFromEtapeFondamentaleValide('titulaireIds', demarches), + perimetre: getMostRecentValuePropFromEtapeFondamentaleValide('perimetre', demarches), + } + }) + ) + ), + Effect.flatMap(({ titre, etape, propsHeritees }) => + Effect.tryPromise({ + try: () => { + console.info(`Trying to fix http://localhost:4180/etapes/${etape.id}`) + return titreEtapeUpsert( + { + id: etape.id, + titreDemarcheId: checkedProlongation.demarche_id, + date: etape.date, + substances: 'fondamentale' in etape && isNotNullNorUndefinedNorEmpty(etape.fondamentale.substances) ? etape.fondamentale.substances : propsHeritees.substances, + titulaireIds: 'fondamentale' in etape && isNotNullNorUndefinedNorEmpty(etape.fondamentale.titulaireIds) ? etape.fondamentale.titulaireIds : propsHeritees.titulaireIds, + surface: 'fondamentale' in etape && isNotNullNorUndefined(etape.fondamentale.perimetre?.surface) ? etape.fondamentale.perimetre.surface : propsHeritees.perimetre?.surface, + geojson4326Perimetre: + 'fondamentale' in etape && isNotNullNorUndefined(etape.fondamentale.perimetre?.geojson4326_perimetre) + ? etape.fondamentale.perimetre.geojson4326_perimetre + : propsHeritees.perimetre?.geojson4326_perimetre, + geojsonOriginePerimetre: + 'fondamentale' in etape && isNotNullNorUndefined(etape.fondamentale.perimetre?.geojson_origine_perimetre) + ? etape.fondamentale.perimetre.geojson_origine_perimetre + : propsHeritees.perimetre?.geojson_origine_perimetre, + geojsonOrigineGeoSystemeId: + 'fondamentale' in etape && isNotNullNorUndefined(etape.fondamentale.perimetre?.geojson_origine_geo_systeme_id) + ? etape.fondamentale.perimetre.geojson_origine_geo_systeme_id + : propsHeritees.perimetre?.geojson_origine_geo_systeme_id, + }, + userSuper, + titre.id + ) + }, + catch: error => ({ message: upsertFailError, extra: error }), + }) + ), + Effect.map(() => null), + Effect.catchAll(error => { + console.info(error) + return Effect.succeed(null) + }) + ) +} + +const toPercentage = (percentage: number): string => { + return `${Math.round(percentage * 10000) / 100} %` +} + +export const checkProlongations = (pool: Pool): Effect.Effect<CheckedProlongation[], CaminoError<CheckOnePrologationErrors | FixProlongationErrors>> => { + return pipe( + getDemarches(pool), + Effect.flatMap(demarches => { + const initialAcc: CheckedProlongation[] = [] + return Effect.reduce( + demarches.map(({ id }) => id), + initialAcc, + (acc, demarcheId) => { + return pipe( + checkOneProlongation(pool, demarcheId), + Effect.tap(checkedProlongation => { + if (isNullOrUndefinedOrEmpty(checkedProlongation.errors) || checkedProlongation.errors.includes(demarchePasProlongationError)) { + return Effect.succeed(checkedProlongation) + } + + return fixProlongation(pool, checkedProlongation) + }), + Effect.map(checkedProlongation => { + // if (isNotNullNorUndefinedNorEmpty(checkedProlongation.errors) && !checkedProlongation.errors.includes(demarchePasProlongationError)) { + // const link = isNotNullNorUndefined(checkedProlongation.titre_slug) + // ? `http://localhost:4180/titres/${checkedProlongation.titre_slug}` + // : `http://localhost:4180/demarches/${checkedProlongation.demarche_id}` + // console.info(`${link} : ${checkedProlongation.errors.join(' | ')}`) + // } + + return [...acc, checkedProlongation] + }) + ) + } + ) + }), + Effect.tap(checkedProlongation => { + const prolongations = checkedProlongation.filter(({ errors }) => !errors.includes(demarchePasProlongationError)) + const prolongationsEnVrac = prolongations.filter(({ errors }) => errors.length > 0) + const prolongationsAvecDureeManquante = prolongations.filter(({ errors }) => errors.some(error => error.includes('la durée est obligatoire'))) + + const prolongationsParType = prolongationsEnVrac.reduce<{ [key: string]: number }>((acc, prolongation) => { + const domaine = isNotNullNorUndefined(prolongation.titre_slug) ? `${prolongation.titre_slug.slice(2, 4)}${prolongation.titre_slug.slice(0, 1)}` : 'inconnu' + if (isNullOrUndefined(acc[domaine])) { + acc[domaine] = 1 + } else { + acc[domaine] += 1 + } + + return acc + }, {}) + console.info(`Prolongations en vrac : ${prolongationsEnVrac.length} (${toPercentage(prolongationsEnVrac.length / prolongations.length)})`) + console.info(JSON.stringify(prolongationsParType, null, 2)) + console.info(`Prolongations sans durée : ${prolongationsAvecDureeManquante.length} (${toPercentage(prolongationsAvecDureeManquante.length / prolongationsEnVrac.length)})`) + }) + ) +} diff --git a/packages/api/src/scripts/check-prolongations.ts b/packages/api/src/scripts/check-prolongations.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa142e25b82e0e085841bfe3c489f6de28e62418 --- /dev/null +++ b/packages/api/src/scripts/check-prolongations.ts @@ -0,0 +1,14 @@ +import '../init' +import pg from 'pg' +import { config } from '../config' +import { checkProlongations } from '../business/check-prolongations' +import { Effect } from 'effect' + +const pool = new pg.Pool({ + host: config().PGHOST, + user: config().PGUSER, + password: config().PGPASSWORD, + database: config().PGDATABASE, +}) + +Effect.runPromiseExit(checkProlongations(pool)).catch(console.error)