Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .database/potentiel-dev.dump
Binary file not shown.
212 changes: 212 additions & 0 deletions packages/applications/cli/src/commands/candidature/migrer-details.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>): Record<string, string> =>
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<string, string> }>(
`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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Candidature.DétailCandidatureEntity>(
`détail-candidature|${identifiantProjet}`,
{
identifiantProjet,
détail,
},
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const action: FormAction<FormState, typeof schema> = async (

const errors: ActionResult['errors'] = [];
let success: number = 0;

if (!modeMultiple) {
const périodeCible = Période.IdentifiantPériode.convertirEnValueType(
`${appelOffre}#${periode}`,
Expand Down Expand Up @@ -80,7 +81,7 @@ const action: FormAction<FormState, typeof schema> = 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<Candidature.ImporterCandidatureUseCase>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ const action: FormAction<FormState, typeof schema> = async (
},
détailsValue: {
typeImport: 'démarches-simplifiées',
demarcheId,
demarcheId: demarcheId.toString(),
pièceJustificativeGf: dossier.fichiers.garantiesFinancières?.url ?? '',
},
instructionValue: instruction,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
const valuesToStrip = ['', 'N/A', '#N/A', '0'];

export const removeEmptyValues = (obj: Record<string, string>) =>
Object.fromEntries(Object.entries(obj).filter(([, value]) => !valuesToStrip.includes(value)));
export const removeEmptyValues = (
obj: Record<string, string | undefined>,
): Record<string, string> =>
Object.fromEntries(
Object.entries(obj)
.filter(([key, value]) => !!key && value !== undefined && !valuesToStrip.includes(value))
.map(([key, value]) => [key, value as string]),
);
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading