Commit 55bf7622 authored by Kourser's avatar Kourser
Browse files

Annuaire d'exercice : lire le CSV au lieu de le découper à la hache

L'import restait sur un « split(/[;,]/) », sans voir les guillemets. Une
fonction comme « Directeur, adjoint » cassait la ligne en deux : le
service devenait la nature, la nature devenait l'email, et rien ne le
signalait.

Il passe au lecteur commun, écrit pour les kits. Il accepte au passage
les colonnes NOMMÉES, dans l'ordre voulu — et garde l'ordre positionnel
historique quand il n'y a pas d'en-tête, pour que les fichiers déjà en
usage continuent de passer.

Une en-tête n'est reconnue qu'à partir de DEUX noms de colonnes connus.
Un seul ne prouve rien : une ligne de données porte souvent
« Direction » en service, et la prendre pour une en-tête faisait
disparaître le premier personnage du fichier.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 6b0b2b8b
Loading
Loading
Loading
Loading
+46 −0
Changes for apps/api/src/exercises/annuaire-import.spec.ts: 46 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { lireAnnuaire } from './characters.service';

describe('lireAnnuaire', () => {
  it('garde l’ordre historique quand il n’y a pas d’en-tête', () => {
    expect(lireAnnuaire('Paul Rivière;PDG;Direction;joueur;paul@exemple.fr')).toEqual([
      { name: 'Paul Rivière', title: 'PDG', orgUnit: 'Direction', kind: 'PLAYER', simEmail: 'paul@exemple.fr' },
    ]);
  });

  it('ne décale plus les colonnes sur une virgule entre guillemets', () => {
    // Avant : « Directeur, adjoint » cassait la ligne en deux et le service
    // devenait la nature.
    const [c] = lireAnnuaire('nom;fonction;service\n"Blanc, Thomas";"Directeur, adjoint";DSI');
    expect(c).toEqual({
      name: 'Blanc, Thomas',
      title: 'Directeur, adjoint',
      orgUnit: 'DSI',
      kind: 'PLAYER',
      simEmail: null,
    });
  });

  it('accepte les colonnes nommées dans n’importe quel ordre', () => {
    const [c] = lireAnnuaire('Nature;NOM;Service\nPNJ;Journaliste;Presse');
    expect(c).toMatchObject({ name: 'Journaliste', orgUnit: 'Presse', kind: 'NPC' });
  });

  it('écarte les lignes sans nom', () => {
    expect(lireAnnuaire('nom;fonction\n;Sans nom\nPaul;PDG')).toHaveLength(1);
  });
});

describe('lireAnnuaire, distinction en-tête / données', () => {
  it('ne prend pas une ligne de données pour une en-tête à cause d’un seul mot connu', () => {
    // « Direction » est un nom de colonne connu, mais c'est ici un service.
    const gens = lireAnnuaire('Paul Rivière;PDG;Direction;joueur');
    expect(gens).toHaveLength(1);
    expect(gens[0].name).toBe('Paul Rivière');
  });

  it('reconnaît une en-tête même si une colonne lui est inconnue', () => {
    const gens = lireAnnuaire('nom;fonction;commentaire\nPaul;PDG;à confirmer');
    expect(gens).toHaveLength(1);
    expect(gens[0]).toMatchObject({ name: 'Paul', title: 'PDG' });
  });
});
+74 −21
Changes for apps/api/src/exercises/characters.service.ts: 74 added lines, 21 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,68 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateCharacterDto } from './dto/create-character.dto';
import type { UpdateCharacterDto } from './dto/update-character.dto';
import { normaliserEntete, parseCsv, parseCsvTable } from '../common/csv';

/** Une nature ecrite en toutes lettres, francaise ou anglaise. */
function nature(valeur: string): 'PLAYER' | 'NPC' {
  const v = valeur.trim().toUpperCase();
  return v === 'NPC' || v === 'PNJ' || v === 'ANIME' ? 'NPC' : 'PLAYER';
}

/** Personnages lus d'un CSV, en-tete ou non. */
export function lireAnnuaire(csv: string): {
  name: string;
  title: string | null;
  orgUnit: string | null;
  kind: 'PLAYER' | 'NPC';
  simEmail: string | null;
}[] {
  const brut = parseCsv(csv);
  if (brut.length === 0) return [];

  /*
   * En-tete reconnue des qu'UNE colonne porte un nom connu, et pas seulement la
   * premiere : un fichier commencant par « Nature » ou « Service » est une
   * en-tete tout autant qu'un fichier commencant par « Nom », et le lire comme
   * une donnee creerait un personnage appele « Nature ».
   */
  const COLONNES_CONNUES = new Set([
    'nom', 'name', 'prenom',
    'fonction', 'titre', 'title',
    'service', 'entite', 'orgunit', 'direction',
    'nature', 'type', 'kind',
    'email', 'emailsimule', 'simemail',
  ]);
  /*
   * DEUX noms connus au moins. Un seul ne prouve rien : une ligne de donnees
   * porte souvent « Direction » en service, et la prendre pour une en-tete
   * faisait disparaitre le premier personnage du fichier.
   */
  const connues = brut[0].filter((c) => COLONNES_CONNUES.has(normaliserEntete(c))).length;
  const avecEntete = connues >= 2 || (brut[0].length === 1 && connues === 1);
  if (avecEntete) {
    const { lignes } = parseCsvTable(csv);
    return lignes
      .filter((l) => !l.vide && l.valeur('nom', 'name', 'prenom'))
      .map((l) => ({
        name: l.valeur('nom', 'name', 'prenom'),
        title: l.valeur('fonction', 'titre', 'title') || null,
        orgUnit: l.valeur('service', 'entite', 'orgunit', 'direction') || null,
        kind: nature(l.valeur('nature', 'type', 'kind')),
        simEmail: l.valeur('email', 'emailsimule', 'simemail') || null,
      }));
  }

  return brut
    .filter((cols) => (cols[0] ?? '').length > 0)
    .map((cols) => ({
      name: cols[0],
      title: cols[1] || null,
      orgUnit: cols[2] || null,
      kind: nature(cols[3] ?? ''),
      simEmail: cols[4] || null,
    }));
}

@Injectable()
export class CharactersService {
@@ -46,30 +108,21 @@ export class CharactersService {

  /**
   * Import en masse de personnages depuis un CSV.
   * Colonnes : name,title,orgUnit,kind,simEmail (une ligne d'entete facultative est ignoree).
   *
   * DEUX ECRITURES acceptees, et c'est deliberé. Avec une ligne d'en-tete, les
   * colonnes sont nommees et leur ordre est libre — c'est la forme retenue pour
   * les kits, et la seule qui pardonne un fichier venu d'ailleurs. Sans en-tete,
   * l'ordre historique tient encore : nom, fonction, service, nature, email. Les
   * fichiers deja utilises continuent donc de passer.
   *
   * Le decoupage passe par le lecteur commun. L'ancien coupait sur « ; » ou « , »
   * sans voir les guillemets : une fonction comme « Directeur, adjoint » decalait
   * toutes les colonnes suivantes, en silence — le service devenait la nature, la
   * nature devenait l'email.
   */
  async importCsv(tenantId: string, exerciseId: string, csv: string): Promise<{ imported: number }> {
    await this.assertExercise(tenantId, exerciseId);
    const lines = csv
      .split(/\r?\n/)
      .map((l) => l.trim())
      .filter((l) => l.length > 0);
    // Ignore une ligne d'entete eventuelle (FR/EN).
    if (lines.length > 0 && /^\s*(name|nom|pr[ée]nom)\b/i.test(lines[0])) lines.shift();

    const rows = lines
      .map((line) => line.split(/[;,]/).map((c) => c.trim()))
      .filter((cols) => cols[0])
      .map((cols) => ({
        tenantId,
        exerciseId,
        name: cols[0],
        title: cols[1] || null,
        orgUnit: cols[2] || null,
        kind: (cols[3]?.toUpperCase() === 'NPC' ? 'NPC' : 'PLAYER') as 'NPC' | 'PLAYER',
        simEmail: cols[4] || null,
      }));

    const rows = lireAnnuaire(csv).map((c) => ({ tenantId, exerciseId, ...c }));
    if (rows.length === 0) return { imported: 0 };
    await this.prisma.forTenant(tenantId).character.createMany({ data: rows });
    return { imported: rows.length };