diff --git a/.database/potentiel-dev.dump b/.database/potentiel-dev.dump index 767013f3057..ec8a7eb7917 100644 Binary files a/.database/potentiel-dev.dump and b/.database/potentiel-dev.dump differ diff --git a/packages/applications/cli/src/commands/candidature/migrer-details.ts b/packages/applications/cli/src/commands/candidature/migrer-details.ts new file mode 100644 index 00000000000..afddda4a4e2 --- /dev/null +++ b/packages/applications/cli/src/commands/candidature/migrer-details.ts @@ -0,0 +1,212 @@ +import { Command, Flags } from '@oclif/core'; +import * as z from 'zod'; + +import { getLogger, Logger } from '@potentiel-libraries/monitoring'; +import { executeSelect } from '@potentiel-libraries/pg-helpers'; +import { Candidature, Document, IdentifiantProjet } from '@potentiel-domain/projet'; +import { DateTime, Email } from '@potentiel-domain/common'; +import { publish } from '@potentiel-infrastructure/pg-event-sourcing'; +import { DocumentAdapter } from '@potentiel-infrastructure/domain-adapters'; + +const configSchema = z.object({ + S3_BUCKET: z.string(), + S3_ENDPOINT: z.string(), + AWS_ACCESS_KEY_ID: z.string(), + AWS_SECRET_ACCESS_KEY: z.string(), + DATABASE_CONNECTION_STRING: z.url(), +}); + +const valuesToStrip = ['', 'N/A', '#N/A', '0']; + +const removeEmptyValues = (obj: Record): Record => + Object.fromEntries( + Object.entries(obj) + .filter(([key, value]) => !!key && value !== undefined && !valuesToStrip.includes(value)) + .map(([key, value]) => [key, value as string]), + ); + +const migrateDétailFromS3File = async ( + key: string, + identifiantProjet: IdentifiantProjet.ValueType, + dateImport: DateTime.ValueType, +) => { + try { + const file = await DocumentAdapter.téléchargerDocumentProjet(key); + + const text = await new Response(file).text(); + const détail = JSON.parse(text); + + const event: Candidature.DétailCandidatureImportéEvent = { + type: 'DétailCandidatureImporté-V1', + payload: { + identifiantProjet: identifiantProjet.formatter(), + détail: removeEmptyValues(détail), + importéLe: dateImport.formatter(), + importéPar: Email.système.formatter(), + }, + }; + + await publish(`candidature|${identifiantProjet.formatter()}`, event); + } catch (error) { + throw new Error( + `Failed to migrate détail from S3 file ${key} for project ${identifiantProjet.formatter()}: ${(error as Error).message}`, + ); + } +}; + +const migrateDétailFromLegacyDatabase = async ( + identifiantProjet: IdentifiantProjet.ValueType, + dateImport: DateTime.ValueType, +) => { + try { + const details = await executeSelect<{ details: Record }>( + `select details from projects where "appelOffreId" = $1 and "periodeId" = $2 and "familleId" = $3 and "numeroCRE" = $4 limit 1;`, + identifiantProjet.appelOffre, + identifiantProjet.période, + identifiantProjet.famille, + identifiantProjet.numéroCRE, + ); + + if (details.length === 0) { + throw new Error(`No details found for project ${identifiantProjet.formatter()}`); + } + + const détail = details[0].details; + + const event: Candidature.DétailCandidatureImportéEvent = { + type: 'DétailCandidatureImporté-V1', + payload: { + identifiantProjet: identifiantProjet.formatter(), + détail: removeEmptyValues(détail), + importéLe: dateImport.formatter(), + importéPar: Email.système.formatter(), + }, + }; + + await publish(`candidature|${identifiantProjet.formatter()}`, event); + } catch (error) { + throw new Error( + `Failed to migrate détail from legacy database for project ${identifiantProjet.formatter()}: ${(error as Error).message}`, + ); + } +}; + +export class RecupererFichiersDetailsCommand extends Command { + #logger!: Logger; + + static flags = { + workers: Flags.integer({ default: 5, description: 'number of upload to make in parallel' }), + progress: Flags.boolean({ default: false, description: 'display the progress' }), + recover: Flags.boolean({ + default: false, + description: 'try to continue work from previous execution, based on last file uploaded', + }), + }; + + async init() { + this.#logger = getLogger(); + + configSchema.parse(process.env); + + Document.registerDocumentProjetQueries({ + récupérerDocumentProjet: DocumentAdapter.téléchargerDocumentProjet, + }); + } + + async run() { + this.#logger.info('🚀 Getting candidature events'); + + const candidatures = await executeSelect<{ + identifiantProjet: string; + dateImport: string; + }>(` + SELECT + payload->>'identifiantProjet' AS "identifiantProjet", + coalesce( + payload->>'importéLe', + payload->>'corrigéLe' + ) AS "dateImport" + FROM + event_store.event_stream + WHERE + type LIKE 'CandidatureImportée-V%' + or ( + type like 'CandidatureCorrigée-V%' + and payload->>'détailsMisÀJour' = 'true' + ) + order by + stream_id, + version; + `); + + const existingEvents = await executeSelect<{ + identifiantProjet: string; + }>(` + SELECT + payload->>'identifiantProjet' AS "identifiantProjet" + FROM + event_store.event_stream + WHERE + type = 'DétailCandidatureImporté-V1' + GROUP BY + payload->>'identifiantProjet'; + `); + + this.#logger.info(`ℹ️ Found ${candidatures.length} candidatures`); + + if (!candidatures.length) { + this.#logger.info('⚠️ No candidature found, exiting'); + return process.exit(1); + } + + const errors: Array<{ identifiantProjet: string; dateImport: string; error: Error }> = []; + + let count = 1; + + for (const { identifiantProjet, dateImport } of candidatures) { + this.#logger.info(`🔄 Processing ${count} / ${candidatures.length}`); + + if (existingEvents.find((e) => e.identifiantProjet === identifiantProjet)) { + count++; + continue; + } + + try { + const identifiantProjetValueType = + IdentifiantProjet.convertirEnValueType(identifiantProjet); + const dateImportValueType = DateTime.convertirEnValueType(dateImport); + + const idProjet = identifiantProjetValueType.formatter(); + + if (idProjet.startsWith('PPE2 - Neutre#2') || idProjet.startsWith('PPE2 - Bâtiment#2')) { + await migrateDétailFromLegacyDatabase(identifiantProjetValueType, dateImportValueType); + count++; + continue; + } + + await migrateDétailFromS3File( + `${idProjet}/candidature/import/${dateImport}.json`, + identifiantProjetValueType, + dateImportValueType, + ); + count++; + } catch (error) { + errors.push({ identifiantProjet, dateImport, error: error as Error }); + count++; + } + } + + this.#logger.info('✅ All done'); + + if (errors.length) { + this.#logger.error('🚨 Some errors occurred during the process:'); + + for (const error of errors) { + this.#logger.error( + `${error.identifiantProjet} (${error.dateImport}): ${error.error.message}`, + ); + } + process.exit(1); + } + } +} diff --git "a/packages/applications/projectors/src/subscribers/candidature/d\303\251tailCandidatureImport\303\251.projector.ts" "b/packages/applications/projectors/src/subscribers/candidature/d\303\251tailCandidatureImport\303\251.projector.ts" new file mode 100644 index 00000000000..0a43208d2ad --- /dev/null +++ "b/packages/applications/projectors/src/subscribers/candidature/d\303\251tailCandidatureImport\303\251.projector.ts" @@ -0,0 +1,14 @@ +import { Candidature } from '@potentiel-domain/projet'; +import { upsertProjection } from '@potentiel-infrastructure/pg-projection-write'; + +export const détailCandidatureImportéProjector = async ({ + payload: { identifiantProjet, détail }, +}: Candidature.DétailCandidatureImportéEvent) => { + await upsertProjection( + `détail-candidature|${identifiantProjet}`, + { + identifiantProjet, + détail, + }, + ); +}; diff --git a/packages/applications/projectors/src/subscribers/candidature/index.ts b/packages/applications/projectors/src/subscribers/candidature/index.ts index edf24290ee0..1f8f0f7914d 100644 --- a/packages/applications/projectors/src/subscribers/candidature/index.ts +++ b/packages/applications/projectors/src/subscribers/candidature/index.ts @@ -13,6 +13,7 @@ import { candidatureImportéeV1Projector } from './candidatureImportéeV1.projec import { candidatureCorrigéeV1Projector } from './candidatureCorrigéeV1.projector'; import { détailsFournisseursCandidatureImportésProjector } from './détailsFournisseursCandidatureImportés.projector'; import { candidatureNotifiéeV3Projector } from './candidatureNotifiéeV3.projector'; +import { détailCandidatureImportéProjector } from './détailCandidatureImporté.projector'; export type SubscriptionEvent = Candidature.CandidatureEvent | RebuildTriggered; @@ -33,6 +34,7 @@ export const register = () => { { type: 'DétailsFournisseursCandidatureImportés-V1' }, détailsFournisseursCandidatureImportésProjector, ) + .with({ type: 'DétailCandidatureImporté-V1' }, détailCandidatureImportéProjector) .exhaustive(); mediator.register('System.Projector.Candidature', handler); diff --git a/packages/applications/ssr/src/app/candidatures/importer/(csv)/importerCandidaturesParCSV.action.ts b/packages/applications/ssr/src/app/candidatures/importer/(csv)/importerCandidaturesParCSV.action.ts index 9526767915f..c89d49e2456 100644 --- a/packages/applications/ssr/src/app/candidatures/importer/(csv)/importerCandidaturesParCSV.action.ts +++ b/packages/applications/ssr/src/app/candidatures/importer/(csv)/importerCandidaturesParCSV.action.ts @@ -50,6 +50,7 @@ const action: FormAction = async ( const errors: ActionResult['errors'] = []; let success: number = 0; + if (!modeMultiple) { const périodeCible = Période.IdentifiantPériode.convertirEnValueType( `${appelOffre}#${periode}`, @@ -80,7 +81,7 @@ const action: FormAction = async ( for (const line of parsedData) { try { const rawLine = removeEmptyValues( - rawData.find((data) => data['Nom projet'] === line.nomProjet) ?? {}, + rawData.find((data) => data['N°CRE'] === line.numéroCRE) ?? {}, ); await mediator.send({ diff --git "a/packages/applications/ssr/src/app/candidatures/importer/(demarche-simplifi\303\251e)/importerCandidaturesParDS.action.ts" "b/packages/applications/ssr/src/app/candidatures/importer/(demarche-simplifi\303\251e)/importerCandidaturesParDS.action.ts" index 48122cf5554..347587163d9 100644 --- "a/packages/applications/ssr/src/app/candidatures/importer/(demarche-simplifi\303\251e)/importerCandidaturesParDS.action.ts" +++ "b/packages/applications/ssr/src/app/candidatures/importer/(demarche-simplifi\303\251e)/importerCandidaturesParDS.action.ts" @@ -141,7 +141,7 @@ const action: FormAction = async ( }, détailsValue: { typeImport: 'démarches-simplifiées', - demarcheId, + demarcheId: demarcheId.toString(), pièceJustificativeGf: dossier.fichiers.garantiesFinancières?.url ?? '', }, instructionValue: instruction, diff --git a/packages/applications/ssr/src/utils/candidature/removeEmptyValues.test.ts b/packages/applications/ssr/src/utils/candidature/removeEmptyValues.test.ts new file mode 100644 index 00000000000..a709b2d1b99 --- /dev/null +++ b/packages/applications/ssr/src/utils/candidature/removeEmptyValues.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { removeEmptyValues } from './removeEmptyValues'; + +describe('removeEmptyValues', () => { + it('Doit supprimer les clés vides', () => { + const data = { + nom: 'Jean Dupont', + '': 'Valeur vide', + }; + + const expectedDétail = { + nom: 'Jean Dupont', + }; + + const result = removeEmptyValues(data); + expect(result).to.deep.equal(expectedDétail); + }); + it('doit supprimer les valeurs vide', () => { + const data = { + nom: 'Jean Dupont', + age: '', + }; + + const expectedDétail = { + nom: 'Jean Dupont', + }; + + const result = removeEmptyValues(data); + expect(result).to.deep.equal(expectedDétail); + }); + it('doit supprimer les valeurs undefined', () => { + const data = { + nom: 'Jean Dupont', + age: undefined, + }; + + const expectedData = { + nom: 'Jean Dupont', + }; + + const result = removeEmptyValues(data); + expect(result).to.deep.equal(expectedData); + }); + it('doit supprimer les valeurs "N/A", "#N/A" et "0"', () => { + const data = { + nom: 'Jean Dupont', + statut1: 'N/A', + statut2: '#N/A', + statut3: '0', + }; + + const expectedData = { + nom: 'Jean Dupont', + }; + + const result = removeEmptyValues(data); + expect(result).to.deep.equal(expectedData); + }); +}); diff --git a/packages/applications/ssr/src/utils/candidature/removeEmptyValues.ts b/packages/applications/ssr/src/utils/candidature/removeEmptyValues.ts index a7148156dd2..26463955d2e 100644 --- a/packages/applications/ssr/src/utils/candidature/removeEmptyValues.ts +++ b/packages/applications/ssr/src/utils/candidature/removeEmptyValues.ts @@ -1,4 +1,10 @@ const valuesToStrip = ['', 'N/A', '#N/A', '0']; -export const removeEmptyValues = (obj: Record) => - Object.fromEntries(Object.entries(obj).filter(([, value]) => !valuesToStrip.includes(value))); +export const removeEmptyValues = ( + obj: Record, +): Record => + Object.fromEntries( + Object.entries(obj) + .filter(([key, value]) => !!key && value !== undefined && !valuesToStrip.includes(value)) + .map(([key, value]) => [key, value as string]), + ); diff --git a/packages/applications/subscribers/src/setup/setupProjet/setupCandidature.ts b/packages/applications/subscribers/src/setup/setupProjet/setupCandidature.ts index 1f9e4d0f765..5d28d2ed402 100644 --- a/packages/applications/subscribers/src/setup/setupProjet/setupCandidature.ts +++ b/packages/applications/subscribers/src/setup/setupProjet/setupCandidature.ts @@ -19,6 +19,7 @@ export const setupCandidature: SetupProjet = async ({ sendEmail }) => { eventType: [ 'RebuildTriggered', 'DétailsFournisseursCandidatureImportés-V1', + 'DétailCandidatureImporté-V1', 'CandidatureImportée-V1', 'CandidatureImportée-V2', 'CandidatureCorrigée-V1', diff --git a/packages/domain/projet/src/candidature/candidature.aggregate.ts b/packages/domain/projet/src/candidature/candidature.aggregate.ts index 72acad586cf..1d72c8684d1 100644 --- a/packages/domain/projet/src/candidature/candidature.aggregate.ts +++ b/packages/domain/projet/src/candidature/candidature.aggregate.ts @@ -61,6 +61,8 @@ import { CandidatureNotifiéeEventV1, CandidatureNotifiéeEventV2, } from './notifier/candidatureNotifiée.event'; +import { DétailCandidatureImportéEvent } from './détail/importer/détailCandidatureImporté.event'; +import { ImporterDétailCandidatureOptions } from './détail/importer/importerDétailCandidature.options'; type CandidatureBehaviorOptions = CorrigerCandidatureOptions | ImporterCandidatureOptions; @@ -204,7 +206,7 @@ export class CandidatureAggregate extends AbstractAggregate< this.vérifierSiLesGarantiesFinancièresSontValides(candidature.dépôt.garantiesFinancières); } - const event: CandidatureImportéeEvent = { + const eventCandidatureImportée: CandidatureImportéeEvent = { type: 'CandidatureImportée-V2', payload: { identifiantProjet: this.projet.identifiantProjet.formatter(), @@ -215,7 +217,13 @@ export class CandidatureAggregate extends AbstractAggregate< }, }; - await this.publish(event); + await this.publish(eventCandidatureImportée); + + await this.importerDétailCandidature({ + détail: candidature.détail, + importéLe: candidature.importéLe, + importéPar: candidature.importéPar, + }); } async corriger(candidature: CorrigerCandidatureOptions) { @@ -240,11 +248,19 @@ export class CandidatureAggregate extends AbstractAggregate< corrigéLe: candidature.corrigéLe.formatter(), corrigéPar: candidature.corrigéPar.formatter(), doitRégénérerAttestation: candidature.doitRégénérerAttestation, - détailsMisÀJour: candidature.détailsMisÀJour, + détailsMisÀJour: candidature.détail ? true : undefined, }, }; await this.publish(event); + + if (candidature.détail) { + await this.importerDétailCandidature({ + détail: candidature.détail, + importéLe: candidature.corrigéLe, + importéPar: candidature.corrigéPar, + }); + } } async notifier({ @@ -283,6 +299,24 @@ export class CandidatureAggregate extends AbstractAggregate< await this.publish(event); } + private async importerDétailCandidature({ + détail, + importéLe, + importéPar, + }: ImporterDétailCandidatureOptions) { + const eventDétailCandidatureImporté: DétailCandidatureImportéEvent = { + type: 'DétailCandidatureImporté-V1', + payload: { + identifiantProjet: this.projet.identifiantProjet.formatter(), + détail, + importéLe: importéLe.formatter(), + importéPar: importéPar.formatter(), + }, + }; + + await this.publish(eventDétailCandidatureImporté); + } + private vérifierQueLeTypeDesGarantiesFinancièresEstModifiable( candidature: CorrigerCandidatureOptions, ) { @@ -475,7 +509,7 @@ export class CandidatureAggregate extends AbstractAggregate< }, (event) => this.applyCandidatureNotifiée(event), ) - + .with({ type: 'DétailCandidatureImporté-V1' }, (_) => {}) .exhaustive(); } diff --git a/packages/domain/projet/src/candidature/candidature.event.ts b/packages/domain/projet/src/candidature/candidature.event.ts index 53c235353b4..c87f6a70ca1 100644 --- a/packages/domain/projet/src/candidature/candidature.event.ts +++ b/packages/domain/projet/src/candidature/candidature.event.ts @@ -12,8 +12,10 @@ import { CandidatureImportéeEventV1, DétailsFournisseursCandidatureImportésEvent, } from './importer/candidatureImportée.event'; +import { DétailCandidatureImportéEvent } from './détail/importer/détailCandidatureImporté.event'; export type CandidatureEvent = + | DétailCandidatureImportéEvent | CandidatureImportéeEvent | CandidatureImportéeEventV1 | DétailsFournisseursCandidatureImportésEvent diff --git a/packages/domain/projet/src/candidature/candidature.register.ts b/packages/domain/projet/src/candidature/candidature.register.ts index 9ae5a237bc3..b7b2be6c573 100644 --- a/packages/domain/projet/src/candidature/candidature.register.ts +++ b/packages/domain/projet/src/candidature/candidature.register.ts @@ -18,6 +18,7 @@ import { registerCorrigerCandidatureUseCase } from './corriger/corrigerCandidatu import { registerImporterCandidatureUseCase } from './importer/importerCandidature.usecase'; import { registerNotifierCandidatureCommand } from './notifier/notifierCandidature.command'; import { registerNotifierCandidatureUseCase } from './notifier/notifierCandidature.usecase'; +import { registerConsulterDétailCandidatureQuery } from './détail/consulter/consulterDétailCandidature.query'; export type CandiatureCommandDependencies = { getProjetAggregateRoot: GetProjetAggregateRoot; @@ -30,6 +31,7 @@ export type CandidatureQueryDependencies = ListerProjetsEligiblesPreuveRecanditu export const registerCandidatureQueries = (dependencies: CandidatureQueryDependencies) => { registerProjetsEligiblesPreuveRecanditureQuery(dependencies); registerConsulterCandidatureQuery(dependencies); + registerConsulterDétailCandidatureQuery(dependencies); registerListerCandidaturesQuery(dependencies); }; diff --git a/packages/domain/projet/src/candidature/corriger/corrigerCandidature.command.ts b/packages/domain/projet/src/candidature/corriger/corrigerCandidature.command.ts index a7886b2d9fa..d384603f701 100644 --- a/packages/domain/projet/src/candidature/corriger/corrigerCandidature.command.ts +++ b/packages/domain/projet/src/candidature/corriger/corrigerCandidature.command.ts @@ -3,7 +3,7 @@ import { Message, MessageHandler, mediator } from 'mediateur'; import { DateTime, Email } from '@potentiel-domain/common'; import { GetProjetAggregateRoot } from '../../getProjetAggregateRoot.port'; -import { Dépôt, Instruction } from '..'; +import { Dépôt, DétailCandidature, Instruction } from '..'; import { IdentifiantProjet } from '../..'; export type CorrigerCandidatureCommand = Message< @@ -12,6 +12,7 @@ export type CorrigerCandidatureCommand = Message< identifiantProjet: IdentifiantProjet.ValueType; dépôt: Dépôt.ValueType; instruction: Instruction.ValueType; + détail?: DétailCandidature.RawType; corrigéLe: DateTime.ValueType; corrigéPar: Email.ValueType; doitRégénérerAttestation?: true; diff --git a/packages/domain/projet/src/candidature/corriger/corrigerCandidature.options.ts b/packages/domain/projet/src/candidature/corriger/corrigerCandidature.options.ts index c0c8ff261ec..75ca60eadef 100644 --- a/packages/domain/projet/src/candidature/corriger/corrigerCandidature.options.ts +++ b/packages/domain/projet/src/candidature/corriger/corrigerCandidature.options.ts @@ -1,12 +1,13 @@ import { DateTime, Email } from '@potentiel-domain/common'; -import { Dépôt, Instruction } from '..'; +import { Dépôt, DétailCandidature, Instruction } from '..'; export type CorrigerCandidatureOptions = { dépôt: Dépôt.ValueType; instruction: Instruction.ValueType; + détail?: DétailCandidature.RawType; + doitRégénérerAttestation?: true; + corrigéLe: DateTime.ValueType; corrigéPar: Email.ValueType; - doitRégénérerAttestation?: true; - détailsMisÀJour?: true; }; diff --git a/packages/domain/projet/src/candidature/corriger/corrigerCandidature.usecase.ts b/packages/domain/projet/src/candidature/corriger/corrigerCandidature.usecase.ts index 5220405463e..9f2363b8682 100644 --- a/packages/domain/projet/src/candidature/corriger/corrigerCandidature.usecase.ts +++ b/packages/domain/projet/src/candidature/corriger/corrigerCandidature.usecase.ts @@ -2,7 +2,7 @@ import { Message, MessageHandler, mediator } from 'mediateur'; import { DateTime, Email } from '@potentiel-domain/common'; -import { Dépôt, Instruction } from '..'; +import { Dépôt, DétailCandidature, Instruction } from '..'; import { IdentifiantProjet } from '../..'; import { DocumentProjet, EnregistrerDocumentProjetCommand } from '../../document-projet'; @@ -19,7 +19,7 @@ export type CorrigerCandidatureUseCase = Message< corrigéLe: string; corrigéPar: string; doitRégénérerAttestation?: true; - détailsValue?: Record; + détailsValue?: DétailCandidature.RawType; } >; @@ -30,9 +30,7 @@ export const registerCorrigerCandidatureUseCase = () => { ); const corrigéLe = DateTime.convertirEnValueType(payload.corrigéLe); - const détailsMisÀJour = payload.détailsValue && Object.keys(payload.détailsValue).length > 0; - - if (détailsMisÀJour) { + if (payload.détailsValue && Object.keys(payload.détailsValue).length > 0) { const buf = Buffer.from(JSON.stringify(payload.détailsValue)); const blob = new Blob([buf]); await mediator.send({ @@ -58,9 +56,13 @@ export const registerCorrigerCandidatureUseCase = () => { corrigéLe: DateTime.convertirEnValueType(payload.corrigéLe), corrigéPar: Email.convertirEnValueType(payload.corrigéPar), doitRégénérerAttestation: payload.doitRégénérerAttestation, - détailsMisÀJour: détailsMisÀJour || undefined, + détail: + payload.détailsValue && Object.keys(payload.détailsValue).length > 0 + ? payload.détailsValue + : undefined, }, }); }; + mediator.register('Candidature.UseCase.CorrigerCandidature', handler); }; diff --git "a/packages/domain/projet/src/candidature/d\303\251tail/consulter/consulterD\303\251tailCandidature.query.ts" "b/packages/domain/projet/src/candidature/d\303\251tail/consulter/consulterD\303\251tailCandidature.query.ts" new file mode 100644 index 00000000000..d7a733d73cb --- /dev/null +++ "b/packages/domain/projet/src/candidature/d\303\251tail/consulter/consulterD\303\251tailCandidature.query.ts" @@ -0,0 +1,57 @@ +import { Message, MessageHandler, mediator } from 'mediateur'; + +import { Option } from '@potentiel-libraries/monads'; +import { Find } from '@potentiel-domain/entity'; + +import { IdentifiantProjet } from '../../..'; +import { DétailCandidatureEntity } from '../détailCandidature.entity'; +import { DétailCandidature } from '../..'; + +export type ConsulterDétailCandidatureReadModel = { + identifiantProjet: IdentifiantProjet.ValueType; + détail: DétailCandidature.RawType; +}; + +export type ConsulterDétailCandidatureQuery = Message< + 'Candidature.Query.ConsulterDétailCandidature', + { + identifiantProjet: string; + }, + Option.Type +>; + +export type ConsulterCandidatureDependencies = { + find: Find; +}; + +export const registerConsulterDétailCandidatureQuery = ({ + find, +}: ConsulterCandidatureDependencies) => { + const handler: MessageHandler = async ({ + identifiantProjet, + }) => { + const détailCandidature = await find( + `détail-candidature|${identifiantProjet}`, + ); + + if (Option.isNone(détailCandidature)) { + return Option.none; + } + + return mapToReadModel(détailCandidature); + }; + + mediator.register('Candidature.Query.ConsulterDétailCandidature', handler); +}; + +type MapToReadModel = ( + candidature: Omit, +) => ConsulterDétailCandidatureReadModel; + +export const mapToReadModel: MapToReadModel = ({ + identifiantProjet, + détail, +}): ConsulterDétailCandidatureReadModel => ({ + identifiantProjet: IdentifiantProjet.convertirEnValueType(identifiantProjet), + détail, +}); diff --git "a/packages/domain/projet/src/candidature/d\303\251tail/d\303\251tailCandidature.entity.ts" "b/packages/domain/projet/src/candidature/d\303\251tail/d\303\251tailCandidature.entity.ts" new file mode 100644 index 00000000000..c3ba2c35a77 --- /dev/null +++ "b/packages/domain/projet/src/candidature/d\303\251tail/d\303\251tailCandidature.entity.ts" @@ -0,0 +1,12 @@ +import { Entity } from '@potentiel-domain/entity'; + +import { IdentifiantProjet } from '../..'; +import { DétailCandidature } from '..'; + +export type DétailCandidatureEntity = Entity< + 'détail-candidature', + { + identifiantProjet: IdentifiantProjet.RawType; + détail: DétailCandidature.RawType; + } +>; diff --git "a/packages/domain/projet/src/candidature/d\303\251tail/d\303\251tailCandidature.valueType.ts" "b/packages/domain/projet/src/candidature/d\303\251tail/d\303\251tailCandidature.valueType.ts" new file mode 100644 index 00000000000..e0ee26d41db --- /dev/null +++ "b/packages/domain/projet/src/candidature/d\303\251tail/d\303\251tailCandidature.valueType.ts" @@ -0,0 +1 @@ +export type RawType = Record; diff --git "a/packages/domain/projet/src/candidature/d\303\251tail/importer/d\303\251tailCandidatureImport\303\251.event.ts" "b/packages/domain/projet/src/candidature/d\303\251tail/importer/d\303\251tailCandidatureImport\303\251.event.ts" new file mode 100644 index 00000000000..4f90daa9f44 --- /dev/null +++ "b/packages/domain/projet/src/candidature/d\303\251tail/importer/d\303\251tailCandidatureImport\303\251.event.ts" @@ -0,0 +1,15 @@ +import { DomainEvent } from '@potentiel-domain/core'; +import { DateTime, Email } from '@potentiel-domain/common'; + +import { IdentifiantProjet } from '../../..'; +import { DétailCandidature } from '../..'; + +export type DétailCandidatureImportéEvent = DomainEvent< + 'DétailCandidatureImporté-V1', + { + identifiantProjet: IdentifiantProjet.RawType; + détail: DétailCandidature.RawType; + importéLe: DateTime.RawType; + importéPar: Email.RawType; + } +>; diff --git "a/packages/domain/projet/src/candidature/d\303\251tail/importer/importerD\303\251tailCandidature.options.ts" "b/packages/domain/projet/src/candidature/d\303\251tail/importer/importerD\303\251tailCandidature.options.ts" new file mode 100644 index 00000000000..47fbc2bd397 --- /dev/null +++ "b/packages/domain/projet/src/candidature/d\303\251tail/importer/importerD\303\251tailCandidature.options.ts" @@ -0,0 +1,9 @@ +import { DateTime, Email } from '@potentiel-domain/common'; + +import { DétailCandidature } from '../..'; + +export type ImporterDétailCandidatureOptions = { + détail: DétailCandidature.RawType; + importéLe: DateTime.ValueType; + importéPar: Email.ValueType; +}; diff --git a/packages/domain/projet/src/candidature/importer/importerCandidature.command.ts b/packages/domain/projet/src/candidature/importer/importerCandidature.command.ts index 8716feb0ef6..1ef8227fc44 100644 --- a/packages/domain/projet/src/candidature/importer/importerCandidature.command.ts +++ b/packages/domain/projet/src/candidature/importer/importerCandidature.command.ts @@ -3,7 +3,7 @@ import { Message, MessageHandler, mediator } from 'mediateur'; import { DateTime, Email } from '@potentiel-domain/common'; import { GetProjetAggregateRoot, IdentifiantProjet } from '../..'; -import { Dépôt, Instruction } from '..'; +import { Dépôt, DétailCandidature, Instruction } from '..'; export type ImporterCandidatureCommand = Message< 'Candidature.Command.ImporterCandidature', @@ -11,6 +11,8 @@ export type ImporterCandidatureCommand = Message< identifiantProjet: IdentifiantProjet.ValueType; dépôt: Dépôt.ValueType; instruction: Instruction.ValueType; + détail: DétailCandidature.RawType; + importéLe: DateTime.ValueType; importéPar: Email.ValueType; } diff --git a/packages/domain/projet/src/candidature/importer/importerCandidature.options.ts b/packages/domain/projet/src/candidature/importer/importerCandidature.options.ts index 8040af4a0d7..10342910712 100644 --- a/packages/domain/projet/src/candidature/importer/importerCandidature.options.ts +++ b/packages/domain/projet/src/candidature/importer/importerCandidature.options.ts @@ -1,10 +1,11 @@ import { DateTime, Email } from '@potentiel-domain/common'; -import { Dépôt, Instruction } from '..'; +import { Dépôt, DétailCandidature, Instruction } from '..'; export type ImporterCandidatureOptions = { dépôt: Dépôt.ValueType; instruction: Instruction.ValueType; + détail: DétailCandidature.RawType; importéLe: DateTime.ValueType; importéPar: Email.ValueType; diff --git a/packages/domain/projet/src/candidature/importer/importerCandidature.usecase.ts b/packages/domain/projet/src/candidature/importer/importerCandidature.usecase.ts index dc563c1cb27..9d48d24f32b 100644 --- a/packages/domain/projet/src/candidature/importer/importerCandidature.usecase.ts +++ b/packages/domain/projet/src/candidature/importer/importerCandidature.usecase.ts @@ -3,7 +3,7 @@ import { Message, MessageHandler, mediator } from 'mediateur'; import { DateTime, Email } from '@potentiel-domain/common'; import { IdentifiantProjet } from '../..'; -import { Dépôt, Instruction } from '..'; +import { Dépôt, DétailCandidature, Instruction } from '..'; import { DocumentProjet, EnregistrerDocumentProjetCommand } from '../../document-projet'; import { ImporterCandidatureCommand } from './importerCandidature.command'; @@ -16,7 +16,7 @@ export type ImporterCandidatureUseCase = Message< dépôtValue: Dépôt.RawType; instructionValue: Instruction.RawType; - détailsValue?: Record; + détailsValue: DétailCandidature.RawType; importéLe: string; importéPar: string; } @@ -53,6 +53,7 @@ export const registerImporterCandidatureUseCase = () => { instruction: Instruction.convertirEnValueType(payload.instructionValue), importéLe, importéPar: Email.convertirEnValueType(payload.importéPar), + détail: payload.détailsValue, }, }); }; diff --git a/packages/domain/projet/src/candidature/index.ts b/packages/domain/projet/src/candidature/index.ts index 84a6122e908..eb9cd7bdf84 100644 --- a/packages/domain/projet/src/candidature/index.ts +++ b/packages/domain/projet/src/candidature/index.ts @@ -7,6 +7,11 @@ import { CandidatureCorrigéeEventV1, } from './corriger/candidatureCorrigée.event'; import { CorrigerCandidatureUseCase } from './corriger/corrigerCandidature.usecase'; +import { + ConsulterDétailCandidatureQuery, + ConsulterDétailCandidatureReadModel, +} from './détail/consulter/consulterDétailCandidature.query'; +import { DétailCandidatureImportéEvent } from './détail/importer/détailCandidatureImporté.event'; import { CandidatureImportéeEvent, CandidatureImportéeEventV1, @@ -33,12 +38,14 @@ import { NotifierCandidatureUseCase } from './notifier/notifierCandidature.useca export type CandidatureQuery = | ListerCandidaturesQuery | ListerProjetsEligiblesPreuveRecanditureQuery - | ConsulterCandidatureQuery; + | ConsulterCandidatureQuery + | ConsulterDétailCandidatureQuery; export { ListerProjetsEligiblesPreuveRecanditureQuery, ListerCandidaturesQuery, ConsulterCandidatureQuery, + ConsulterDétailCandidatureQuery, }; // ReadModel @@ -46,6 +53,7 @@ export { ListerProjetsEligiblesPreuveRecanditureReadModel, ListerCandidaturesReadModel, ConsulterCandidatureReadModel, + ConsulterDétailCandidatureReadModel, }; // Port @@ -62,6 +70,7 @@ export { ImporterCandidatureUseCase, CorrigerCandidatureUseCase, NotifierCandida export { CandidatureEvent } from './candidature.event'; export { + DétailCandidatureImportéEvent, CandidatureImportéeEventV1, CandidatureImportéeEvent, CandidatureCorrigéeEvent, @@ -77,6 +86,7 @@ export * from './candidature.register'; // Entities export * from './candidature.entity'; +export * from './détail/détailCandidature.entity'; // ValueType export * as TypeTechnologie from './typeTechnologie.valueType'; @@ -89,3 +99,7 @@ export * as UnitéPuissance from './unitéPuissance.valueType'; export * as Dépôt from './dépôt.valueType'; export * as Instruction from './instruction.valueType'; export * as TypologieInstallation from './typologieInstallation.valueType'; +export * as DétailCandidature from './détail/détailCandidature.valueType'; + +// Type +export * from './détail/détailCandidature.valueType'; diff --git a/packages/domain/utilisateur/src/role.valueType.ts b/packages/domain/utilisateur/src/role.valueType.ts index f42be02a3ea..2957165cd6f 100644 --- a/packages/domain/utilisateur/src/role.valueType.ts +++ b/packages/domain/utilisateur/src/role.valueType.ts @@ -542,6 +542,7 @@ const référencielPermissions = { candidature: { query: { consulterCandidature: 'Candidature.Query.ConsulterCandidature', + consulterDétailCandidature: 'Candidature.Query.ConsulterDétailCandidature', consulterProjet: 'Candidature.Query.ConsulterProjet', listerProjetsPreuveRecandidature: 'Candidature.Query.ListerProjetsEligiblesPreuveRecandidature', @@ -962,6 +963,7 @@ const policies = { }, candidature: { consulter: [référencielPermissions.candidature.query.consulterCandidature], + consulterDétail: [référencielPermissions.candidature.query.consulterDétailCandidature], importer: [ référencielPermissions.appelOffre.query.consulter, référencielPermissions.candidature.usecase.importer, @@ -1674,6 +1676,7 @@ const adminPolicies: ReadonlyArray = [ // Candidature 'candidature.consulter', + 'candidature.consulterDétail', 'candidature.importer', 'candidature.corriger', 'candidature.lister', diff --git a/packages/specifications/src/candidature/corrigerCandidature.feature b/packages/specifications/src/candidature/corrigerCandidature.feature index dc68775576f..a9e5a1f3a1b 100644 --- a/packages/specifications/src/candidature/corrigerCandidature.feature +++ b/packages/specifications/src/candidature/corrigerCandidature.feature @@ -9,18 +9,21 @@ Fonctionnalité: Corriger une candidature Quand le DGEC validateur corrige la candidature avec : | nom candidat | abcd | Alors la candidature devrait être consultable + Et le détail de la candidature devrait être consultable Et le porteur n'a pas été prévenu que son attestation a été modifiée Scénario: Corriger une candidature et ses détails (typiquement, par CSV) Quand le DGEC validateur corrige la candidature avec : | nom candidat | abcd | | détails | {"Note carbone": "1"} | + Et le détail de la candidature devrait être consultable Alors la candidature devrait être consultable Scénario: Corriger une candidature avec des champs de localité uniquement Quand le DGEC validateur corrige la candidature avec : | adresse 1 | ma nouvelle adresse | Alors la candidature devrait être consultable + Et le détail de la candidature devrait être consultable Et le porteur n'a pas été prévenu que son attestation a été modifiée Scénario: Corriger une candidature notifiée en régénérant l'attestation @@ -31,6 +34,7 @@ Fonctionnalité: Corriger une candidature Alors la candidature devrait être consultable Et l'attestation de désignation de la candidature devrait être consultable Et l'attestation de désignation de la candidature devrait être régénérée + Et le détail de la candidature devrait être consultable Et le porteur a été prévenu que son attestation a été modifiée Scénario: Corriger une candidature notifiée sans régénérer l'attestation @@ -41,6 +45,7 @@ Fonctionnalité: Corriger une candidature Alors la candidature devrait être consultable Et l'attestation de désignation de la candidature devrait être consultable Et l'attestation de désignation de la candidature ne devrait pas être régénérée + Et le détail de la candidature devrait être consultable Et le porteur n'a pas été prévenu que son attestation a été modifiée Scénario: Impossible de régénérer l'attestation d'une candidature non notifiée @@ -231,41 +236,5 @@ Fonctionnalité: Corriger une candidature | installation avec dispositif de stockage | | Alors l'administrateur devrait être informé que "Le dispositif de stockage est requis pour cet appel d'offres" - # champs spécifiques à l'appel d'offres simplifié - fin - # garanties financières - début - Scénario: Impossible de corriger une candidature sans GF, pour un AO soumis aux GF - Etant donné la candidature lauréate "Centrale PV" avec : - | appel d'offres | PPE2 - Bâtiment | - | période | 1 | - | famille | | - Quand le DGEC validateur corrige la candidature avec : - | type GF | | - Alors l'administrateur devrait être informé que "Les garanties financières sont requises pour cet appel d'offres ou famille" - - Scénario: Impossible de corriger une candidature classée avec des GF "avec date d'échéance" si la date d'échéance est manquante - Quand le DGEC validateur corrige la candidature avec : - | type GF | avec-date-échéance | - Alors l'administrateur devrait être informé que "La date d'échéance des garanties financières est requise" - - Scénario: Impossible de corriger une candidature classée avec une date d'échéance pour un type de GF qui n'en attend pas - Quand le DGEC validateur corrige la candidature avec : - | type GF | six-mois-après-achèvement | - | date d'échéance | 2024-01-01 | - Alors l'administrateur devrait être informé que "La date d'échéance ne peut être renseignée pour ce type de garanties financières" - - Scénario: Impossible de changer le type de GF d'une candidature lauréate notifiée - Etant donné le projet lauréat "Boulodrome Sainte Livrade" - Quand le DGEC validateur corrige la candidature avec : - | type GF | six-mois-après-achèvement | - Alors l'administrateur devrait être informé que "Le type de garanties financières d'une candidature ne peut être modifié après la notification" - - Scénario: Impossible de corriger une candidature avec un type de garanties financières non disponible dans l'appel d'offres - Etant donné la candidature lauréate "Du boulodrome de Marseille" avec : - | statut | classé | - | appel d'offres | PPE2 - Bâtiment | - Quand le DGEC validateur corrige la candidature avec : - | type GF | exemption | - Alors l'administrateur devrait être informé que "Ce type de garanties financières n'est pas disponible pour cet appel d'offres" - -# garanties financières - fin \ No newline at end of file +# champs spécifiques à l'appel d'offres simplifié - fin \ No newline at end of file diff --git "a/packages/specifications/src/candidature/corrigerCandidature.garantiesFinanci\303\250res.feature" "b/packages/specifications/src/candidature/corrigerCandidature.garantiesFinanci\303\250res.feature" new file mode 100644 index 00000000000..db85a912f04 --- /dev/null +++ "b/packages/specifications/src/candidature/corrigerCandidature.garantiesFinanci\303\250res.feature" @@ -0,0 +1,40 @@ +# language: fr +@candidature +Fonctionnalité: Corriger une candidature (garanties financières) + + Contexte: + Etant donné la candidature lauréate "Du boulodrome de Marseille" + + Scénario: Impossible de corriger une candidature sans GF, pour un AO soumis aux GF + Etant donné la candidature lauréate "Centrale PV" avec : + | appel d'offres | PPE2 - Bâtiment | + | période | 1 | + | famille | | + Quand le DGEC validateur corrige la candidature avec : + | type GF | | + Alors l'administrateur devrait être informé que "Les garanties financières sont requises pour cet appel d'offres ou famille" + + Scénario: Impossible de corriger une candidature classée avec des GF "avec date d'échéance" si la date d'échéance est manquante + Quand le DGEC validateur corrige la candidature avec : + | type GF | avec-date-échéance | + Alors l'administrateur devrait être informé que "La date d'échéance des garanties financières est requise" + + Scénario: Impossible de corriger une candidature classée avec une date d'échéance pour un type de GF qui n'en attend pas + Quand le DGEC validateur corrige la candidature avec : + | type GF | six-mois-après-achèvement | + | date d'échéance | 2024-01-01 | + Alors l'administrateur devrait être informé que "La date d'échéance ne peut être renseignée pour ce type de garanties financières" + + Scénario: Impossible de changer le type de GF d'une candidature lauréate notifiée + Etant donné le projet lauréat "Boulodrome Sainte Livrade" + Quand le DGEC validateur corrige la candidature avec : + | type GF | six-mois-après-achèvement | + Alors l'administrateur devrait être informé que "Le type de garanties financières d'une candidature ne peut être modifié après la notification" + + Scénario: Impossible de corriger une candidature avec un type de garanties financières non disponible dans l'appel d'offres + Etant donné la candidature lauréate "Du boulodrome de Marseille" avec : + | statut | classé | + | appel d'offres | PPE2 - Bâtiment | + Quand le DGEC validateur corrige la candidature avec : + | type GF | exemption | + Alors l'administrateur devrait être informé que "Ce type de garanties financières n'est pas disponible pour cet appel d'offres" diff --git a/packages/specifications/src/candidature/importerCandidature.feature b/packages/specifications/src/candidature/importerCandidature.feature index 01b46a10381..b579b74a28a 100644 --- a/packages/specifications/src/candidature/importerCandidature.feature +++ b/packages/specifications/src/candidature/importerCandidature.feature @@ -6,6 +6,7 @@ Fonctionnalité: Importer une candidature Quand le DGEC validateur importe la candidature "Du boulodrome de Marseille" avec : | statut | | Alors la candidature devrait être consultable + Et le détail de la candidature devrait être consultable Exemples: | Statut | @@ -17,6 +18,7 @@ Fonctionnalité: Importer une candidature | statut | classé | | obligation de solarisation | oui | Alors la candidature devrait être consultable + Et le détail de la candidature devrait être consultable Scénario: Importer une candidature avec un champ optionnel "installateur" Quand le DGEC validateur importe la candidature "Du boulodrome de Marseille" avec : @@ -24,6 +26,7 @@ Fonctionnalité: Importer une candidature | installateur | Installateur.Inc | | statut | classé | Alors la candidature devrait être consultable + Et le détail de la candidature devrait être consultable Scénario: Importer une candidature avec un champ requis "nature de l'exploitation" Quand le DGEC validateur importe la candidature "Du boulodrome de Marseille" avec : @@ -31,12 +34,14 @@ Fonctionnalité: Importer une candidature | type de nature de l'exploitation | vente-avec-injection-en-totalité | | statut | classé | Alors la candidature devrait être consultable + Et le détail de la candidature devrait être consultable Scénario: Importer une candidature avec une puissance de site pour un appel d'offres qui a ce champ requis Quand le DGEC validateur importe la candidature "Du boulodrome de Marseille" avec : | appel d'offres | PPE2 - Petit PV Bâtiment | | puissance de site | 100 | Alors la candidature devrait être consultable + Et le détail de la candidature devrait être consultable Scénario: Importer une candidature avec une autorisation d'urbanisme pour un appel d'offres qui a ce champ requis Quand le DGEC validateur importe la candidature "Du boulodrome de Marseille" avec : @@ -44,6 +49,7 @@ Fonctionnalité: Importer une candidature | numéro de l'autorisation d'urbanisme | 123 | | date d'obtention de l'autorisation d'urbanisme | 01/08/2025 | Alors la candidature devrait être consultable + Et le détail de la candidature devrait être consultable Scénario: Importer une candidature avec une information sur le couplage avec un dispositif de stockage pour un appel d'offres qui a ce champ requis Quand le DGEC validateur importe la candidature "Du boulodrome de Marseille" avec : @@ -52,6 +58,15 @@ Fonctionnalité: Importer une candidature | puissance du dispositif | 3 | | capacité du dispositif | 4 | Alors la candidature devrait être consultable + Et le détail de la candidature devrait être consultable + + Scénario: Importer une candidature sans technologie si l'AO a une seule technologie + Quand le DGEC validateur importe la candidature "Du boulodrome de Marseille" avec : + | statut | classé | + | appel d'offres | PPE2 - Sol | + | technologie | N/A | + Alors la candidature devrait être consultable + Et le détail de la candidature devrait être consultable Scénario: Impossible d'importer 2 fois la même candidature Etant donné la candidature lauréate "Du boulodrome de Marseille" avec : @@ -122,13 +137,6 @@ Fonctionnalité: Importer une candidature | technologie | N/A | Alors l'administrateur devrait être informé que "Une technologie est requise pour cet appel d'offres" - Scénario: Importer une candidature sans technologie si l'AO a une seule technologie - Quand le DGEC validateur importe la candidature "Du boulodrome de Marseille" avec : - | statut | classé | - | appel d'offres | PPE2 - Sol | - | technologie | N/A | - Alors la candidature devrait être consultable - Scénario: Impossible d'importer une candidature sans choix du coefficient K si la période le propose Quand le DGEC validateur importe la candidature "Du boulodrome de Marseille" avec : | statut | classé | diff --git a/packages/specifications/src/candidature/stepDefinitions/candidature.then.ts b/packages/specifications/src/candidature/stepDefinitions/candidature.then.ts index 5e59eb98072..682d86a138b 100644 --- a/packages/specifications/src/candidature/stepDefinitions/candidature.then.ts +++ b/packages/specifications/src/candidature/stepDefinitions/candidature.then.ts @@ -57,6 +57,27 @@ Alors(`la candidature devrait être consultable`, async function (this: Potentie }); }); +Alors( + 'le détail de la candidature devrait être consultable', + async function (this: PotentielWorld) { + const { identifiantProjet } = this.candidatureWorld.importerCandidature; + + const expectedDétails = + this.candidatureWorld.corrigerCandidature.détailsValue ?? + this.candidatureWorld.importerCandidature.détailsValue; + + const candidatureDétail = await mediator.send({ + type: 'Candidature.Query.ConsulterDétailCandidature', + data: { + identifiantProjet, + }, + }); + + assert(Option.isSome(candidatureDétail), `Détail de la candidature non trouvé`); + candidatureDétail.détail.should.be.deep.equal(expectedDétails); + }, +); + Alors( 'le porteur a été prévenu que son attestation a été modifiée', async function (this: PotentielWorld) {