Sortir la démonstration du chemin d'installation

Une instance neuve passait par le seed pour être utilisable. Or le seed installe
une fiction : « Maison Rivage », des salariés inventés, quatre semaines de
planning, des absences. Sur une instance de travail, ces noms se confondent avec
de vrais salariés dans l'annuaire et au registre du personnel.

Le trou qu'il bouchait était réel : après les migrations, une instance avait le
schéma et rien à quoi l'accrocher. Aucun type d'absence, donc aucune demande
saisissable. Aucune étiquette, donc aucun créneau nommé. Aucune convention, donc
un moteur de règles muet qui laisse passer une semaine de soixante heures sans
rien dire.

L'écran d'installation pose donc désormais les référentiels : les douze
étiquettes, cinq types d'absence, la convention d'amorce IDCC 1517 avec
l'origine de chacun de ses paramètres, les durées de conservation et les jours
fériés des deux prochaines années. Ce ne sont pas des exemples mais des minima,
tous modifiables ensuite depuis les réglages.

Trois choses restent délibérément absentes. Aucun dimanche du maire : la liste
vient d'un arrêté municipal, et en inventer rendrait opposable un quota que
personne n'a accordé. Aucun code Silae sur les types d'absence : la
correspondance appartient au dossier du client. Aucun salarié, aucun créneau,
aucune absence.

Le seed devient `prisma/seed-demo.ts`, réservé au harnais Playwright. `db:seed`
disparaît au profit de `db:seed:demo` : il n'y a plus rien à semer pour
démarrer, et un nom qui le dit vaut mieux qu'un commentaire qui l'explique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-11 13:37:19 +02:00
1 parent 0a916ff73e
commit a0524c56d7
7 files changed
+239 -18

No files matched your search

+17 -9
View File
@@ -13,19 +13,21 @@ import {
type InstallationForm,
} from '@/domain/install/rules';
import { hashPassword } from '@/server/auth/session';
import { installReferentials } from '@/server/install/referentials';
/**
* Contenu d'une instance neuve — PLAN.md §5.
*
* Le seed, lui, installe une démonstration : deux établissements, des salariés
* fictifs, quatre semaines de planning. Il refuse de tourner en production, et
* c'est bien ainsi. Il restait donc un trou : après les migrations, une
* instance de production a le schéma et rien d'autre — pas de compte, pas
* d'utilisateur, personne pour se connecter.
* Le seed, lui, installe une démonstration : des salariés fictifs, des
* plannings, des absences. Il refuse de tourner en production, et c'est bien
* ainsi. Il restait donc un trou : après les migrations, une instance de
* production a le schéma et rien d'autre — pas de compte, pas d'utilisateur,
* personne pour se connecter.
*
* Ce module comble ce trou, et **rien de plus** : le catalogue des capacités,
* les rôles fournis, un compte, un établissement, un propriétaire. Aucune
* donnée d'exemple.
* Ce module comble ce trou : catalogue des capacités, rôles fournis, compte,
* établissement, propriétaire, puis les **référentiels** sans lesquels rien ne
* s'accroche (voir `referentials.ts`). Aucune donnée d'exemple : pas un
* salarié, pas un créneau, pas une absence.
*/
export interface InstalledInstance {
@@ -105,7 +107,7 @@ export async function installAccount(
});
}
await tx.location.create({
const location = await tx.location.create({
data: {
accountId: account.id,
name: data.locationName,
@@ -113,6 +115,12 @@ export async function installAccount(
},
});
// Étiquettes, types d'absence, convention d'amorce, conservation et jours
// fériés. Ce ne sont pas des exemples : sans eux, l'instance a le schéma et
// rien à quoi l'accrocher — pas de demande d'absence saisissable, pas de
// créneau nommé, et un moteur de règles muet.
await installReferentials(tx, account.id, location.id);
const user = await tx.user.create({
data: {
email: data.email,
+171
View File
@@ -0,0 +1,171 @@
import type { Prisma } from '@prisma/client';
import { frenchHolidays } from '@/domain/absences/holidays';
import { IDCC_1517_PARAMETERS, IDCC_1517_PROVENANCE } from '@/domain/compliance/idcc1517';
import { POSTE_CODES, POSTE_LABELS } from '@/lib/design/postes';
/**
* Référentiels d'une instance neuve — PLAN.md §5 et WP-02.
*
* Sans eux, une instance installée a le schéma et rien à quoi l'accrocher :
* aucun type d'absence, donc aucune demande saisissable ; aucune étiquette,
* donc aucun créneau nommé ; aucune convention, donc un moteur de règles muet
* qui laisse passer une semaine de 60 heures sans rien dire. Le trou se
* comblait jusqu'ici avec le seed de démonstration, qui refuse de tourner en
* production — l'instance de production restait donc inutilisable.
*
* **Ce ne sont pas des données d'exemple.** Chaque ligne posée ici est soit un
* minimum légal, soit une valeur d'amorce que le client remplace depuis les
* réglages. Rien de fictif : ni salarié, ni planning, ni absence.
*/
const AMORCE_DATE = new Date('2026-01-01');
/** Politiques de conservation — durées et justifications de §12.5. */
const RETENTION: ReadonlyArray<
readonly [string, number, string, string]
> = [
['Shift', 12, 'creation', 'Décompte des horaires : 1 an minimum (matrice n° 21).'],
['ForfaitDayEntry', 36, 'creation', 'Décompte des jours de forfait : 3 ans minimum.'],
['UserContract', 60, 'contract_end', 'Pièces contractuelles : 5 ans.'],
[
'PersonnelRegister',
60,
'employee_departure',
'Registre du personnel : 5 ans après le départ.',
],
[
'PayrollVariable',
72,
'period_end',
"Éléments d'assiette transmis au logiciel de paie : 6 ans.",
],
];
/**
* Types d'absence d'amorce.
*
* `silaeCode` reste **nul** : la correspondance entre un type et une rubrique
* de paie appartient au dossier du client. La renseigner ici imputerait des
* congés à la rubrique d'un autre cabinet.
*/
const ABSENCE_TYPES = [
{ code: 'CP', name: 'Congés payés', colorKey: 'cp', isPaid: true, social: false, notice: 30 },
{ code: 'RTT', name: 'RTT', colorKey: 'rtt', isPaid: true, social: false, notice: 7 },
{ code: 'MAL', name: 'Arrêt maladie', colorKey: 'maladie', isPaid: false, social: true, notice: null },
{ code: 'SS', name: 'Congé sans solde', colorKey: 'sans-solde', isPaid: false, social: false, notice: 15 },
{ code: 'RC', name: 'Repos compensateur', colorKey: 'rtt', isPaid: true, social: false, notice: 7 },
] as const;
export async function installReferentials(
tx: Prisma.TransactionClient,
accountId: string,
locationId: string,
): Promise<void> {
// --- Étiquettes de planning --------------------------------------------
// La palette est celle du produit, calculée pour rester distinguable en
// vision daltonienne. Les libellés se renomment depuis les réglages.
for (const [index, code] of POSTE_CODES.entries()) {
await tx.label.create({
data: {
accountId,
code: code.toUpperCase(),
name: POSTE_LABELS[code],
paletteKey: code,
position: index,
},
});
}
// --- Types d'absence ----------------------------------------------------
for (const type of ABSENCE_TYPES) {
await tx.absenceType.create({
data: {
accountId,
code: type.code,
name: type.name,
colorKey: type.colorKey,
isPaid: type.isPaid,
countsAsWorkTime: false,
affectsPaidLeaveAccrual: !type.social,
isSocialSecurity: type.social,
requiresJustification: type.social,
minNoticeDays: type.notice,
},
});
}
// --- Convention d'amorce ------------------------------------------------
// IDCC 1517 par défaut (PLAN.md §2), remplaçable depuis les réglages : la
// convention se réédite en version datée, elle ne se modifie pas.
await tx.collectiveAgreement.create({
data: {
accountId,
idcc: '1517',
name: 'Commerces de détail non alimentaires',
parameters: IDCC_1517_PARAMETERS as never,
version: 1,
effectiveFrom: AMORCE_DATE,
source:
'Sources secondaires publiques — À VALIDER contre le texte consolidé Legifrance',
},
});
// --- Registre de paramétrage juridique ----------------------------------
// L'origine de chaque valeur — ordre public, convention, accord d'entreprise
// — décide de ce qui s'impose et de ce qui se négocie. Elle est enregistrée
// avec sa source, pas seulement commentée dans le code (§12.7).
for (const entry of IDCC_1517_PROVENANCE) {
await tx.legalConfigEntry.create({
data: {
accountId,
domain: 'temps',
key: entry.key,
value: `${entry.label} : ${entry.value}`,
source: `[${entry.origin}] ${entry.source}`,
population: 'Tous salariés',
effectiveFrom: AMORCE_DATE,
},
});
}
// --- Durées de conservation ---------------------------------------------
for (const [objectType, durationMonths, startPoint, justification] of RETENTION) {
await tx.retentionPolicy.create({
data: {
accountId,
objectType,
durationMonths,
startPoint,
justification,
effectiveFrom: AMORCE_DATE,
},
});
}
// --- Jours fériés --------------------------------------------------------
// Calculés, pas listés : une table écrite à la main n'est juste que l'année
// où on l'écrit. Un férié manquant se décompte comme un jour de congé, et le
// salarié perd un jour sans que personne ne le voie.
//
// Seul le 1er mai est chômé de droit. Les jours garantis par la convention
// sont choisis par l'employeur : ils ne se devinent pas ici.
const currentYear = new Date().getUTCFullYear();
for (const year of [currentYear, currentYear + 1]) {
for (const holiday of frenchHolidays(year)) {
await tx.holiday.create({
data: {
accountId,
locationId,
localDate: new Date(`${holiday.isoDate}T00:00:00Z`),
name: holiday.name,
isPaidOff: holiday.isoDate.slice(5) === '05-01',
},
});
}
}
// Aucun dimanche du maire n'est posé : la liste est arrêtée par arrêté
// municipal, établissement par établissement. En inventer rendrait opposable
// un quota qui n'a été autorisé nulle part.
}