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:
1 parent
0a916ff73e
commit
a0524c56d7
7 files changed
+239
-18
No files matched your search
@@ -67,8 +67,8 @@ jobs:
|
||||
- name: Appliquer les migrations
|
||||
run: pnpm db:deploy
|
||||
|
||||
- name: Installer le jeu de données
|
||||
run: pnpm db:seed
|
||||
- name: Installer le jeu de démonstration (Playwright)
|
||||
run: pnpm db:seed:demo
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
|
||||
@@ -104,10 +104,12 @@ docker compose restart app
|
||||
|
||||
### Première installation
|
||||
|
||||
Les migrations posent le schéma, rien de plus : une instance neuve n'a **aucun compte et aucun utilisateur**. Le jeu de données de démonstration (`pnpm db:seed`) n'y remédie pas et refuse de tourner en production, à raison — personne ne veut de « Maison Rivage » et de salariés fictifs dans son registre du personnel.
|
||||
Les migrations posent le schéma, rien de plus : une instance neuve n'a **aucun compte et aucun utilisateur**. Il n'y a rien à semer pour démarrer — et surtout pas le jeu de démonstration (`pnpm db:seed:demo`), réservé au harnais de tests : des salariés inventés dans un annuaire réel se confondent avec de vrais salariés, et il refuse de toute façon de tourner en production.
|
||||
|
||||
À la place, la première visite est redirigée vers `/installation`. L'écran demande le nom de l'entreprise, un premier établissement avec son fuseau horaire, et le compte qui administrera l'instance. Il crée le compte, le catalogue des capacités, les cinq rôles fournis, et vous connecte.
|
||||
|
||||
Il pose aussi les **référentiels** sans lesquels rien ne s'accroche : les douze étiquettes de planning, 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 : sans type d'absence aucune demande n'est saisissable, et sans convention le moteur de règles laisse passer une semaine de soixante heures sans rien dire. Tout se modifie ensuite depuis les réglages — la convention notamment, qui se réédite en version datée.
|
||||
|
||||
Deux choses à savoir :
|
||||
|
||||
- **L'écran ne se rouvre pas.** Il crée un propriétaire sans demander de session ; le laisser accessible ensuite reviendrait à offrir tous les droits au premier visiteur. Une ligne en base marque l'installation, et cette ligne ne se modifie ni ne s'efface depuis l'application. Remettre une instance à zéro est un geste d'exploitant, fait depuis la base.
|
||||
|
||||
+2
-2
@@ -19,11 +19,11 @@
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"mfa:reset": "tsx scripts/mfa-reset.ts",
|
||||
"retention:purge": "tsx scripts/retention-purge.ts",
|
||||
"verify": "pnpm typecheck && pnpm lint && pnpm test"
|
||||
"verify": "pnpm typecheck && pnpm lint && pnpm test",
|
||||
"db:seed:demo": "tsx prisma/seed-demo.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
|
||||
@@ -31,11 +31,20 @@ import { evaluateSchedule } from '../src/server/compliance/evaluate';
|
||||
import { withTenant } from '../src/server/tenant';
|
||||
|
||||
/**
|
||||
* Jeu de données de départ — PLAN.md §11.
|
||||
* Jeu de démonstration — PLAN.md §11.
|
||||
*
|
||||
* **Entièrement fictif.** Deux établissements, des équipes, et les rôles
|
||||
* fournis. Le mot de passe de démonstration n'a de sens qu'en développement ;
|
||||
* il est refusé si NODE_ENV vaut production.
|
||||
* **Entièrement fictif, et réservé aux tests.** Deux établissements, des
|
||||
* salariés inventés, quatre semaines de planning, des absences. Il sert au
|
||||
* harnais Playwright et à qui veut voir l'application peuplée ; il n'a rien à
|
||||
* faire sur une instance de travail, fût-elle de développement — des noms
|
||||
* inventés dans un annuaire réel se confondent avec de vrais salariés.
|
||||
*
|
||||
* Une instance neuve ne passe donc **plus** par ce fichier : l'écran de
|
||||
* première installation pose le compte, le propriétaire et les référentiels
|
||||
* (`src/server/install/referentials.ts`). Il n'y a rien à semer pour démarrer.
|
||||
*
|
||||
* Le mot de passe de démonstration n'a de sens qu'en développement ; il est
|
||||
* refusé si NODE_ENV vaut production.
|
||||
*/
|
||||
|
||||
const DEMO_PASSWORD = 'planflow-demo-2026';
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -122,6 +122,37 @@ describeIfDb('première installation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('pose les référentiels sans lesquels rien ne s’accroche', async () => {
|
||||
// Une instance installée doit être utilisable, pas seulement peuplée d'un
|
||||
// compte. Sans type d'absence aucune demande n'est saisissable ; sans
|
||||
// convention, le moteur de règles laisse passer une semaine de soixante
|
||||
// heures sans rien dire.
|
||||
await installThenRollback(form(), async (tx, instance) => {
|
||||
const where = { accountId: instance.accountId };
|
||||
|
||||
expect(await tx.label.count({ where })).toBeGreaterThan(0);
|
||||
expect(await tx.absenceType.count({ where })).toBeGreaterThan(0);
|
||||
expect(await tx.collectiveAgreement.count({ where })).toBe(1);
|
||||
expect(await tx.retentionPolicy.count({ where })).toBeGreaterThan(0);
|
||||
expect(await tx.holiday.count({ where })).toBeGreaterThan(0);
|
||||
|
||||
// Aucune donnée d'exemple en revanche : le propriétaire est le seul
|
||||
// salarié, et il n'a ni planning ni absence.
|
||||
expect(await tx.membership.count({ where })).toBe(1);
|
||||
expect(await tx.shift.count({ where })).toBe(0);
|
||||
expect(await tx.timeOff.count({ where })).toBe(0);
|
||||
|
||||
// Le dimanche du maire s'autorise par arrêté municipal : en poser un
|
||||
// rendrait opposable un quota que personne n'a accordé.
|
||||
expect(await tx.authorisedSunday.count({ where })).toBe(0);
|
||||
|
||||
// Le code Silae d'un type d'absence appartient au dossier du client :
|
||||
// l'inventer imputerait des congés à la rubrique d'un autre cabinet.
|
||||
const types = await tx.absenceType.findMany({ where });
|
||||
expect(types.every((type) => type.silaeCode === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('rattache le propriétaire à tous les établissements', async () => {
|
||||
await installThenRollback(form(), async (tx, instance) => {
|
||||
const membership = await tx.membership.findUniqueOrThrow({
|
||||
|
||||
Reference in new issue
Block a user