Repository: MTES-MCT/zero-logement-vacant
Pull request #1212: Create scripts to import buildings and link them to housings
Author: login: Falinor
Created: 2025-04-03T08:03:03Z | Merged: 2025-04-03T15:06:05Z



Diff:
diff --git a/packages/utils/src/stream.ts b/packages/utils/src/stream.ts
index 6f7e7beb5e..d792ab422b 100644
--- a/packages/utils/src/stream.ts
+++ b/packages/utils/src/stream.ts
@@ -5,9 +5,37 @@ import {
   TransformStream,
   WritableStream
 } from 'node:stream/web';
+import { AsyncOrSync } from 'ts-essentials';
 
 import { Predicate } from './compare';
-import { AsyncOrSync } from 'ts-essentials';
+
+interface ChunkifyOptions {
+  /**
+   * @default 1000
+   */
+  size?: number;
+}
+
+export function chunkify<A>(options?: ChunkifyOptions) {
+  const size = options?.size ?? 1000;
+  const chunk: A[] = [];
+
+  return new TransformStream<A, ReadonlyArray<A>>({
+    async transform(a, controller) {
+      chunk.push(a);
+
+      if (chunk.length >= size) {
+        controller.enqueue([...chunk]);
+        chunk.length = 0;
+      }
+    },
+    flush(controller) {
+      if (chunk.length > 0) {
+        controller.enqueue([...chunk]);
+      }
+    }
+  });
+}
 
 export async function countLines(file: ReadableStream): Promise<number> {
   return new Promise((resolve) => {
diff --git a/server/src/infra/database/migrations/20250402070742_buildings-add-rnb.ts b/server/src/infra/database/migrations/20250402070742_buildings-add-rnb.ts
new file mode 100644
index 0000000000..191ce0786f
--- /dev/null
+++ b/server/src/infra/database/migrations/20250402070742_buildings-add-rnb.ts
@@ -0,0 +1,15 @@
+import type { Knex } from 'knex';
+
+export async function up(knex: Knex): Promise<void> {
+  await knex.schema.alterTable('buildings', (table) => {
+    table.string('rnb_id').nullable();
+    table.integer('rnb_id_score').nullable();
+  });
+}
+
+export async function down(knex: Knex): Promise<void> {
+  await knex.schema.alterTable('buildings', (table) => {
+    table.dropColumn('rnb_id');
+    table.dropColumn('rnb_id_score');
+  });
+}
diff --git a/server/src/infra/database/migrations/20250402133311_fast-housing-add-building-foreign-key.ts b/server/src/infra/database/migrations/20250402133311_fast-housing-add-building-foreign-key.ts
new file mode 100644
index 0000000000..cb2a76b8d6
--- /dev/null
+++ b/server/src/infra/database/migrations/20250402133311_fast-housing-add-building-foreign-key.ts
@@ -0,0 +1,20 @@
+import type { Knex } from 'knex';
+
+export async function up(knex: Knex): Promise<void> {
+  await knex.schema.alterTable('fast_housing', (table) => {
+    table
+      .string('building_id')
+      .nullable()
+      .references('id')
+      .inTable('buildings')
+      .onUpdate('CASCADE')
+      .onDelete('SET NULL')
+      .alter();
+  });
+}
+
+export async function down(knex: Knex): Promise<void> {
+  await knex.schema.alterTable('fast_housing', (table) => {
+    table.dropForeign('building_id');
+  });
+}
diff --git a/server/src/models/BuildingApi.ts b/server/src/models/BuildingApi.ts
index 02193d97cc..96baaf2bc4 100644
--- a/server/src/models/BuildingApi.ts
+++ b/server/src/models/BuildingApi.ts
@@ -1,6 +1,8 @@
 export interface BuildingApi {
   id: string;
   housingCount: number;
-  rentHousingCount?: number;
+  rentHousingCount: number | null;
   vacantHousingCount: number;
+  rnbId: string | null;
+  rnbIdScore: number | null;
 }
diff --git a/server/src/repositories/buildingRepository.ts b/server/src/repositories/buildingRepository.ts
index 4c2d5c590e..e0b78e23aa 100644
--- a/server/src/repositories/buildingRepository.ts
+++ b/server/src/repositories/buildingRepository.ts
@@ -1,38 +1,73 @@
-import db from '~/infra/database';
+import db, { ConflictOptions, onConflict } from '~/infra/database';
 import { BuildingApi } from '~/models/BuildingApi';
 
-export const buildingTable = 'buildings';
+export const BUILDING_TABLE = 'buildings';
 export const Buildings = (transaction = db) =>
-  transaction<BuildingDBO>(buildingTable);
+  transaction<BuildingDBO>(BUILDING_TABLE);
 
 export interface BuildingDBO {
   id: string;
   housing_count: number;
   vacant_housing_count: number;
-  rent_housing_count?: number;
+  rent_housing_count: number | null;
+  rnb_id: string | null;
+  /**
+   * A score >= 0 representing the quality of the RNB ID.
+   * @see https://doc-datafoncier.cerema.fr/doc/ff/batiment/rnb_id_score
+   */
+  rnb_id_score: number | null;
 }
 
-export async function save(building: BuildingApi): Promise<void> {
+type SaveOptions = ConflictOptions<BuildingDBO>;
+
+export async function save(
+  building: BuildingApi,
+  options?: SaveOptions
+): Promise<void> {
+  await saveMany([building], options);
+}
+
+export async function saveMany(
+  buildings: ReadonlyArray<BuildingApi>,
+  options?: SaveOptions
+): Promise<void> {
   await Buildings()
-    .insert(formatBuildingApi(building))
-    .onConflict(['id'])
-    .merge(['housing_count', 'vacant_housing_count', 'rent_housing_count']);
+    .insert(buildings.map(formatBuildingApi))
+    .modify(
+      onConflict({
+        onConflict: options?.onConflict ?? ['id'],
+        merge: options?.merge ?? [
+          'housing_count',
+          'vacant_housing_count',
+          'rent_housing_count',
+          'rnb_id',
+          'rnb_id_score'
+        ]
+      })
+    );
 }
 
 export const formatBuildingApi = (building: BuildingApi): BuildingDBO => ({
   id: building.id,
   housing_count: building.housingCount,
   vacant_housing_count: building.vacantHousingCount,
-  rent_housing_count: building.rentHousingCount
+  rent_housing_count: building.rentHousingCount,
+  rnb_id: building.rnbId,
+  rnb_id_score: building.rnbIdScore
 });
 
 export const parseBuildingApi = (building: BuildingDBO): BuildingApi => ({
   id: building.id,
   housingCount: building.housing_count,
   vacantHousingCount: building.vacant_housing_count,
-  rentHousingCount: building.rent_housing_count
+  rentHousingCount: building.rent_housing_count,
+  rnbId: building.rnb_id,
+  rnbIdScore: building.rnb_id_score
 });
 
-export default {
-  save
+const buildingRepository = {
+  save,
+  saveMany
 };
+
+export default buildingRepository;
diff --git a/server/src/repositories/test/buildingRepository.test.ts b/server/src/repositories/test/buildingRepository.test.ts
index b3fa8c3181..512ae24c1f 100644
--- a/server/src/repositories/test/buildingRepository.test.ts
+++ b/server/src/repositories/test/buildingRepository.test.ts
@@ -1,15 +1,13 @@
-import { genBuildingApi, genHousingApi } from '~/test/testFixtures';
 import buildingRepository, {
   Buildings,
   formatBuildingApi
 } from '~/repositories/buildingRepository';
+import { genBuildingApi } from '~/test/testFixtures';
 
 describe('Building repository', () => {
   describe('save', () => {
-    const housings = Array.from({ length: 10 }, () => genHousingApi());
-
     it('should create a building if it does not exist', async () => {
-      const building = genBuildingApi(housings);
+      const building = genBuildingApi();
 
       await buildingRepository.save(building);
 
@@ -18,7 +16,7 @@ describe('Building repository', () => {
     });
 
     it('should update a building if it exists', async () => {
-      const building = genBuildingApi(housings);
+      const building = genBuildingApi();
       await Buildings().insert(formatBuildingApi(building));
 
       await buildingRepository.save({
@@ -29,5 +27,27 @@ describe('Building repository', () => {
       const actual = await Buildings().where({ id: building.id }).first();
       expect(actual?.housing_count).toBe(10);
     });
+
+    it('should update only the chosen properties', async () => {
+      const building = genBuildingApi();
+      await Buildings().insert(formatBuildingApi(building));
+      expect(building.rnbId).not.toBeNull();
+
+      await buildingRepository.save(
+        {
+          ...building,
+          housingCount: 10,
+          rnbId: null
+        },
+        {
+          onConflict: ['id'],
+          merge: ['housing_count']
+        }
+      );
+
+      const actual = await Buildings().where({ id: building.id }).first();
+      expect(actual?.housing_count).toBe(10);
+      expect(actual?.rnb_id).toBe(building.rnbId);
+    });
   });
 });
diff --git a/server/src/repositories/test/housingRepository.test.ts b/server/src/repositories/test/housingRepository.test.ts
index db0733abca..012a15bba8 100644
--- a/server/src/repositories/test/housingRepository.test.ts
+++ b/server/src/repositories/test/housingRepository.test.ts
@@ -1257,22 +1257,27 @@ describe('Housing repository', () => {
       });
 
       describe('by housing count by building', () => {
-        function createHousingByBuilding(count: number): HousingApi[] {
-          const buildingId = faker.string.alphanumeric(10);
-          return new Array(count)
-            .fill('0')
-            .map(() => ({ ...genHousingApi(), buildingId }));
+        function createHousingByBuilding(building: BuildingApi): HousingApi[] {
+          return Array.from({ length: building.housingCount }).map(() => ({
+            ...genHousingApi(),
+            buildingId: building.id
+          }));
         }
 
         beforeEach(async () => {
-          const housingByBuilding: HousingApi[][] = [
-            createHousingByBuilding(4),
-            createHousingByBuilding(5),
-            createHousingByBuilding(19),
-            createHousingByBuilding(20),
-            createHousingByBuilding(49),
-            createHousingByBuilding(50)
-          ];
+          const testAmounts = [4, 5, 19, 20, 49, 50];
+
+          const buildings: BuildingApi[] = testAmounts.map((amount) => {
+            return {
+              ...genBuildingApi(),
+              housingCount: amount
+            };
+          });
+          await Buildings().insert(buildings.map(formatBuildingApi));
+
+          const housingByBuilding: HousingApi[][] = buildings.map((building) =>
+            createHousingByBuilding(building)
+          );
           const housingList = housingByBuilding.flat();
           await Housing().insert(housingList.map(formatHousingRecordApi));
           const owner = genOwnerApi();
@@ -1282,10 +1287,6 @@ describe('Housing repository', () => {
               formatHousingOwnersApi(housing, [owner])
             )
           );
-          const buildings: BuildingApi[] = housingByBuilding.map(
-            (housingList) => genBuildingApi(housingList)
-          );
-          await Buildings().insert(buildings.map(formatBuildingApi));
         });
 
         const tests = [
@@ -1338,29 +1339,39 @@ describe('Housing repository', () => {
       });
 
       describe('by vacancy rate by building', () => {
-        function createHousingByBuilding(
-          vacant: number,
-          other: number
-        ): HousingApi[] {
-          const buildingId = faker.string.alphanumeric(10);
-          return new Array(vacant + other).fill('0').map((_, i) => ({
+        function createHousingByBuilding(building: BuildingApi): HousingApi[] {
+          const { housingCount: total, vacantHousingCount: vacant } = building;
+          return Array.from({ length: total - vacant }).map((_, i) => ({
             ...genHousingApi(),
-            buildingId,
+            buildingId: building.id,
             occupancy: i < vacant ? Occupancy.VACANT : Occupancy.RENT
           }));
         }
 
         beforeEach(async () => {
-          const housingByBuilding: HousingApi[][] = [
-            createHousingByBuilding(19, 81), // 19 %
-            createHousingByBuilding(2, 8), // 20 %
-            createHousingByBuilding(39, 61), // 39 %
-            createHousingByBuilding(4, 6), // 40 %
-            createHousingByBuilding(59, 41), // 59 %
-            createHousingByBuilding(6, 4), // 60 %
-            createHousingByBuilding(79, 21), // 79 %
-            createHousingByBuilding(8, 2) // 80 %
+          const buildings: BuildingApi[] = [
+            // 19 %
+            { ...genBuildingApi(), vacantHousingCount: 19, housingCount: 100 },
+            // 20 %
+            { ...genBuildingApi(), vacantHousingCount: 2, housingCount: 10 },
+            // 39 %
+            { ...genBuildingApi(), vacantHousingCount: 39, housingCount: 100 },
+            // 40 %
+            { ...genBuildingApi(), vacantHousingCount: 4, housingCount: 10 },
+            // 59 %
+            { ...genBuildingApi(), vacantHousingCount: 59, housingCount: 100 },
+            // 60 %
+            { ...genBuildingApi(), vacantHousingCount: 6, housingCount: 10 },
+            // 79 %
+            { ...genBuildingApi(), vacantHousingCount: 79, housingCount: 100 },
+            // 80 %
+            { ...genBuildingApi(), vacantHousingCount: 8, housingCount: 10 }
           ];
+          await Buildings().insert(buildings.map(formatBuildingApi));
+
+          const housingByBuilding: HousingApi[][] = buildings.map(
+            createHousingByBuilding
+          );
           const housingList = housingByBuilding.flat();
           await Housing().insert(housingList.map(formatHousingRecordApi));
           const owner = genOwnerApi();
@@ -1370,10 +1381,6 @@ describe('Housing repository', () => {
               formatHousingOwnersApi(housing, [owner])
             )
           );
-          const buildings: BuildingApi[] = housingByBuilding.map(
-            (housingList) => genBuildingApi(housingList)
-          );
-          await Buildings().insert(buildings.map(formatBuildingApi));
         });
 
         const tests = [
diff --git a/server/src/scripts/import-building-housings/index.ts b/server/src/scripts/import-building-housings/index.ts
new file mode 100644
index 0000000000..5ac5d18a12
--- /dev/null
+++ b/server/src/scripts/import-building-housings/index.ts
@@ -0,0 +1,78 @@
+import { chunkify, tap } from '@zerologementvacant/utils/node';
+import { Bar } from 'cli-progress';
+import { Readable } from 'node:stream';
+import { ReadableStream, WritableStream } from 'node:stream/web';
+import db from '~/infra/database';
+
+import { housingTable } from '~/repositories/housingRepository';
+
+const CHUNK_SIZE = 10_000;
+const TOTAL = 39_048_568;
+const TEMPORARY_TABLE = 'buildings_housing_updates';
+
+async function run(): Promise<void> {
+  const progress = new Bar({
+    fps: 4,
+    etaAsynchronousUpdate: true,
+    etaBuffer: 1000,
+    stopOnComplete: true
+  });
+  progress.start(TOTAL, 0);
+
+  await createTemporaryTableStream()
+    .pipeThrough(
+      chunkify({
+        size: CHUNK_SIZE
+      })
+    )
+    .pipeThrough(saver())
+    .pipeThrough(
+      tap((chunk) => {
+        progress.increment(chunk.length);
+      })
+    )
+    .pipeTo(reporter());
+}
+
+interface Input {
+  idbat: string;
+  idlocal: string;
+  geocode: string;
+}
+
+function createTemporaryTableStream(): ReadableStream<Input> {
+  return Readable.toWeb(db(TEMPORARY_TABLE).select().stream());
+}
+
+function saver() {
+  return tap<ReadonlyArray<Input>>(async (chunk) => {
+    await db.transaction(async (transaction) => {
+      await transaction(housingTable)
+        .update({
+          building_id: db.ref(`${TEMPORARY_TABLE}.idbat`)
+        })
+        .updateFrom(TEMPORARY_TABLE)
+        .where(`${housingTable}.local_id`, db.ref(`${TEMPORARY_TABLE}.idlocal`))
+        .where(`${housingTable}.geo_code`, db.ref(`${TEMPORARY_TABLE}.geocode`))
+        .whereIn(
+          `${TEMPORARY_TABLE}.idlocal`,
+          chunk.map((building) => building.idlocal)
+        );
+    });
+  });
+}
+
+function reporter<A>() {
+  let total = 0;
+
+  return new WritableStream<ReadonlyArray<A>>({
+    write(chunk) {
+      total += chunk.length;
+    },
+    close() {
+      console.log(`Total saved: ${total}`);
+    }
+  });
+}
+
+run();
diff --git a/server/src/scripts/import-buildings/index.ts b/server/src/scripts/import-buildings/index.ts
new file mode 100644
index 0000000000..88e27d7215
--- /dev/null
+++ b/server/src/scripts/import-buildings/index.ts
@@ -0,0 +1,88 @@
+import { chunkify, map, tap } from '@zerologementvacant/utils/node';
+import { Bar } from 'cli-progress';
+import { parse as parseJSONL } from 'jsonlines';
+import fs from 'node:fs';
+import path from 'node:path';
+import { Readable } from 'node:stream';
+import { ReadableStream, WritableStream } from 'node:stream/web';
+
+import { BuildingApi } from '~/models/BuildingApi';
+import buildingRepository from '~/repositories/buildingRepository';
+
+const CHUNK_SIZE = 1_000;
+const TOTAL = 25_958_378;
+
+async function run(): Promise<void> {
+  const progress = new Bar({
+    fps: 4,
+    etaAsynchronousUpdate: true,
+    etaBuffer: CHUNK_SIZE,
+    stopOnComplete: true
+  });
+  progress.start(TOTAL, 0);
+
+  const file = path.join(__dirname, 'buildings.jsonl');
+  await stream(file)
+    .pipeThrough(mapper())
+    .pipeThrough(chunkify({ size: CHUNK_SIZE }))
+    .pipeThrough(saver())
+    .pipeThrough(
+      tap((buildings) => {
+        progress.increment(buildings.length);
+      })
+    )
+    .pipeTo(reporter());
+}
+
+interface Input {
+  idbat: string;
+  nlogh: number;
+  rnb_id: string | null;
+  rnb_id_score: number;
+}
+
+function stream(file: string): ReadableStream<Input> {
+  const fileStream = fs.createReadStream(file);
+  return Readable.toWeb(
+    fileStream.pipe(
+      parseJSONL({
+        emitInvalidLines: true
+      })
+    )
+  );
+}
+
+function mapper() {
+  return map<Input, BuildingApi>((building) => ({
+    id: building.idbat,
+    housingCount: building.nlogh,
+    rnbId: building.rnb_id,
+    rnbIdScore: building.rnb_id_score,
+    rentHousingCount: 0,
+    vacantHousingCount: 0
+  }));
+}
+
+function saver() {
+  return tap<ReadonlyArray<BuildingApi>>(async (buildings) => {
+    await buildingRepository.saveMany(buildings, {
+      onConflict: ['id'],
+      merge: ['housing_count', 'rnb_id', 'rnb_id_score']
+    });
+  });
+}
+
+function reporter() {
+  let total = 0;
+
+  return new WritableStream<ReadonlyArray<BuildingApi>>({
+    write(chunk) {
+      total += chunk.length;
+    },
+    close() {
+      console.log(`Total saved: ${total}`);
+    }
+  });
+}
+
+run();
diff --git a/server/src/scripts/import-lovac/source-buildings/source-building-processor.ts b/server/src/scripts/import-lovac/source-buildings/source-building-processor.ts
index 3465d76e5f..666c2d1b00 100644
--- a/server/src/scripts/import-lovac/source-buildings/source-building-processor.ts
+++ b/server/src/scripts/import-lovac/source-buildings/source-building-processor.ts
@@ -1,9 +1,9 @@
 import { WritableStream } from 'node:stream/web';
 
 import { createLogger } from '~/infra/logger';
+import { BuildingApi } from '~/models/BuildingApi';
 import { ReporterError, ReporterOptions } from '~/scripts/import-lovac/infra';
 import { SourceBuilding } from '~/scripts/import-lovac/source-buildings/source-building';
-import { BuildingApi } from '~/models/BuildingApi';
 
 const logger = createLogger('sourceBuildingProcessor');
 
@@ -24,7 +24,10 @@ export function createSourceBuildingProcessor(options: ProcessorOptions) {
           id: chunk.building_id,
           vacantHousingCount: chunk.housing_vacant_count,
           rentHousingCount: chunk.housing_rent_count,
-          housingCount: 0 // Should be provided later, by the file
+          // Should be provided later, by the file
+          housingCount: 0,
+          rnbId: null,
+          rnbIdScore: null
         };
         await buildingRepository.save(building);
         reporter.passed(chunk);
diff --git a/server/src/scripts/import-lovac/source-housings/test/source-housing-command.test.ts b/server/src/scripts/import-lovac/source-housings/test/source-housing-command.test.ts
index aea1c86371..23484c2ae9 100644
--- a/server/src/scripts/import-lovac/source-housings/test/source-housing-command.test.ts
+++ b/server/src/scripts/import-lovac/source-housings/test/source-housing-command.test.ts
@@ -1,43 +1,56 @@
+import { Occupancy } from '@zerologementvacant/models';
 import { stringify } from 'csv-stringify';
 import fp from 'lodash/fp';
 import fs from 'node:fs';
 import path from 'node:path';
 import { Transform, Writable } from 'node:stream';
 import { ReadableStream, TransformStream } from 'node:stream/web';
-
-import { createSourceHousingCommand } from '~/scripts/import-lovac/source-housings/source-housing-command';
+import config from '~/infra/config';
+import { BuildingApi } from '~/models/BuildingApi';
+import { HousingApi } from '~/models/HousingApi';
+import { HousingStatusApi } from '~/models/HousingStatusApi';
+import { UserApi } from '~/models/UserApi';
+import {
+  Buildings,
+  formatBuildingApi
+} from '~/repositories/buildingRepository';
+import {
+  Establishments,
+  formatEstablishmentApi
+} from '~/repositories/establishmentRepository';
 import {
   formatHousingRecordApi,
   Housing,
   HousingRecordDBO
 } from '~/repositories/housingRepository';
-import { SourceHousing } from '~/scripts/import-lovac/source-housings/source-housing';
+import { formatUserApi, Users } from '~/repositories/userRepository';
 import { genSourceHousing } from '~/scripts/import-lovac/infra/fixtures';
-import { HousingStatusApi } from '~/models/HousingStatusApi';
+import { SourceHousing } from '~/scripts/import-lovac/source-housings/source-housing';
+
+import { createSourceHousingCommand } from '~/scripts/import-lovac/source-housings/source-housing-command';
 import {
+  genBuildingApi,
   genEstablishmentApi,
   genHousingApi,
   genUserApi
 } from '~/test/testFixtures';
-import { HousingApi } from '~/models/HousingApi';
-import {
-  Establishments,
-  formatEstablishmentApi
-} from '~/repositories/establishmentRepository';
-import { UserApi } from '~/models/UserApi';
-import config from '~/infra/config';
-import { formatUserApi, Users } from '~/repositories/userRepository';
-import { Occupancy } from '@zerologementvacant/models';
 
 describe('Source housing command', () => {
   const command = createSourceHousingCommand();
   const file = path.join(__dirname, 'housings.csv');
 
-  const missingSourceHousings = Array.from({ length: 3 }, () =>
-    genSourceHousing()
+  const building: BuildingApi = genBuildingApi();
+  const missingSourceHousings: SourceHousing[] = Array.from(
+    { length: 3 },
+    () => ({ ...genSourceHousing(), building_id: building.id })
   );
-  const nonVacantUnsupervisedHousings = [genSourceHousing()];
-  const vacantHousings = Array.from({ length: 3 }, () => genSourceHousing());
+  const nonVacantUnsupervisedHousings = [
+    { ...genSourceHousing(), building_id: building.id }
+  ];
+  const vacantHousings = Array.from({ length: 3 }, () => ({
+    ...genSourceHousing(),
+    building_id: building.id
+  }));
 
   const sourceHousings: ReadonlyArray<SourceHousing> = [
     ...missingSourceHousings,
@@ -53,15 +66,18 @@ describe('Source housing command', () => {
       email: config.app.system
     };
     await Users().insert(formatUserApi(auth));
+    await Buildings().insert(formatBuildingApi(building));
     const housings: ReadonlyArray<HousingApi> = [
       ...nonVacantUnsupervisedHousings.map<HousingApi>((sourceHousing) => ({
         ...genHousingApi(),
+        buildingId: building.id,
         geoCode: sourceHousing.geo_code,
         localId: sourceHousing.local_id,
         occupancy: Occupancy.RENT
       })),
       ...vacantHousings.map<HousingApi>((sourceHousing) => ({
         ...genHousingApi(),
+        buildingId: building.id,
         geoCode: sourceHousing.geo_code,
         localId: sourceHousing.local_id,
         occupancy: Occupancy.VACANT,
diff --git a/server/src/test/testFixtures.ts b/server/src/test/testFixtures.ts
index 6574cadc90..4046716d4f 100644
--- a/server/src/test/testFixtures.ts
+++ b/server/src/test/testFixtures.ts
@@ -21,7 +21,6 @@ import {
 } from '@zerologementvacant/models';
 
 import { genGeoCode } from '@zerologementvacant/models/fixtures';
-import { firstDefined } from '@zerologementvacant/utils';
 import { addHours } from 'date-fns';
 import type { BBox } from 'geojson';
 import fp from 'lodash/fp';
@@ -262,20 +261,23 @@ export const genHousingOwnerApi = (
   rank: faker.number.int({ min: 1, max: 6 })
 });
 
-export const genBuildingApi = (housingList: HousingApi[]): BuildingApi => {
+export function genBuildingApi(): BuildingApi {
+  const housingCount = faker.number.int({ min: 1, max: 10 });
+  const vacantHousingCount = faker.number.int({ min: 0, max: housingCount });
+  const rentHousingCount = faker.number.int({
+    min: housingCount - vacantHousingCount,
+    max: housingCount
+  });
+
   return {
-    id:
-      housingList.map((housing) => housing.buildingId).reduce(firstDefined) ??
-      uuidv4(),
-    housingCount: housingList.length,
-    vacantHousingCount: housingList.filter(
-      (housing) => housing.occupancy === Occupancy.VACANT
-    ).length,
-    rentHousingCount: housingList.filter(
-      (housing) => housing.occupancy === Occupancy.RENT
-    ).length
+    id: uuidv4(),
+    housingCount: vacantHousingCount,
+    vacantHousingCount,
+    rentHousingCount,
+    rnbId: faker.string.alphanumeric({ casing: 'upper' }),
+    rnbIdScore: faker.helpers.arrayElement([0, 1, 2, 3, 8, 9])
   };
-};
+}
 
 export const genHousingApi = (
   geoCode: string = genGeoCode()


Reviews:
user: login: loicguillois
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T08:15:48Z
user: login: Falinor
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T08:34:49Z
user: login: loicguillois
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-03T13:32:06Z

Review comments:
diff_hunk: @@ -0,0 +1,78 @@
+import { chunkify, tap } from '@zerologementvacant/utils/node';
+import { Bar } from 'cli-progress';
+import { Readable } from 'node:stream';
+import { ReadableStream, WritableStream } from 'node:stream/web';
+import db from '~/infra/database';
+
+import { housingTable } from '~/repositories/housingRepository';
+
+const CHUNK_SIZE = 10_000;
+const TOTAL = 39_048_568;
path: server/src/scripts/import-building-housings/index.ts
user: login: loicguillois
body: pourquoi avoir hardcodé le total ?
created_at: 2025-04-03T08:12:58Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,78 @@
+import { chunkify, tap } from '@zerologementvacant/utils/node';
+import { Bar } from 'cli-progress';
+import { Readable } from 'node:stream';
+import { ReadableStream, WritableStream } from 'node:stream/web';
+import db from '~/infra/database';
+
+import { housingTable } from '~/repositories/housingRepository';
+
+const CHUNK_SIZE = 10_000;
+const TOTAL = 39_048_568;
+const TEMPORARY_TABLE = 'buildings_housing_updates';
+
+async function run(): Promise<void> {
+  const progress = new Bar({
path: server/src/scripts/import-building-housings/index.ts
user: login: loicguillois
body: 🔥 
created_at: 2025-04-03T08:13:36Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,88 @@
+import { chunkify, map, tap } from '@zerologementvacant/utils/node';
+import { Bar } from 'cli-progress';
+import { parse as parseJSONL } from 'jsonlines';
+import fs from 'node:fs';
+import path from 'node:path';
+import { Readable } from 'node:stream';
+import { ReadableStream, WritableStream } from 'node:stream/web';
+
+import { BuildingApi } from '~/models/BuildingApi';
+import buildingRepository from '~/repositories/buildingRepository';
+
+const CHUNK_SIZE = 1_000;
+const TOTAL = 25_958_378;
path: server/src/scripts/import-buildings/index.ts
user: login: loicguillois
body: même question ici, pourquoi avoir hardcodé le total ?
created_at: 2025-04-03T08:15:03Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,88 @@
+import { chunkify, map, tap } from '@zerologementvacant/utils/node';
+import { Bar } from 'cli-progress';
+import { parse as parseJSONL } from 'jsonlines';
+import fs from 'node:fs';
+import path from 'node:path';
+import { Readable } from 'node:stream';
+import { ReadableStream, WritableStream } from 'node:stream/web';
+
+import { BuildingApi } from '~/models/BuildingApi';
+import buildingRepository from '~/repositories/buildingRepository';
+
+const CHUNK_SIZE = 1_000;
+const TOTAL = 25_958_378;
path: server/src/scripts/import-buildings/index.ts
user: login: Falinor
body: Hmm le fichier n’est pas amené à varier et c’est du one-shot. J’ai pensé que c’était préférable de compter avec `wc -l file.jsonl` plutôt que développer quelque chose
created_at: 2025-04-03T08:34:49Z
author_association: COLLABORATOR

PR comments:
user: login: notion-workspace[bot]
created_at: 2025-04-03T08:08:02Z
body: <a href="https://www.notion.so/B-timents-Injecter-nlogh-rnb-id-dans-Prod-Buildings-1999ec2a056c80558dfef2c88107f7a6?pvs=8&n=github_linkback">🙋‍♀️ [Bâtiments] Injecter nlogh + rnb id dans Prod>Buildings</a>
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-03T14:51:47Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=MTES-MCT_zero-logement-vacant&pullRequest=1212) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=MTES-MCT_zero-logement-vacant&pullRequest=1212&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=MTES-MCT_zero-logement-vacant&pullRequest=1212&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=MTES-MCT_zero-logement-vacant&pullRequest=1212&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=MTES-MCT_zero-logement-vacant&pullRequest=1212&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=MTES-MCT_zero-logement-vacant&pullRequest=1212&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=MTES-MCT_zero-logement-vacant&pullRequest=1212)


author_association: NONE