Files
planflow/prisma/seed.ts
T
Claude 023f236881 WP-04: planning sur données réelles — grille semaine, vue jour, publication par équipe
Le planning quitte le module de démonstration : la grille lit `WeeklySchedule`
et `Shift`, et les quatre derniers fichiers de `src/lib/demo/` qui portaient des
salariés fictifs disparaissent.

Modèle
- `TeamMember` : rattachement d'un salarié à une équipe, distinct de
  `MembershipScope` (qui dit ce qu'un manager a le droit de voir). Sans lui, un
  salarié sans créneau n'apparaîtrait pas dans la grille — c'est-à-dire l'état
  de départ de toute semaine en construction.
- RLS sur `WeeklySchedule`, `Shift`, `Rest`, `DailyNote` et `TeamMember`.

Temps
- `src/domain/planning/week.ts` : repérage par couple année ISO + semaine ISO,
  jamais par date de début. Le lundi 29 décembre 2025 appartient à la semaine 1
  de 2026 ; une clé fondée sur la date ferait apparaître deux semaines 1.
- `zonedInstant` / `zonedMidnight` corrigent le décalage mesuré **à l'instant
  visé**. Le 29 mars 2026, minuit est en UTC+1 et 09 h en UTC+2 : ajouter neuf
  heures à minuit donnerait 10 h locales.
- La semaine du retour à l'heure d'hiver dure 169 h, celle du passage à l'heure
  d'été 167 — vérifié par test.

Écritures
- Création, modification, suppression de créneau ; publication et dépublication
  **par équipe**, avec verrou optimiste sur `version` : deux managers sur la
  même grille est le cas normal, pas l'exception.
- Chevauchement refusé en transaction, pas seulement dans le formulaire.
- Modifier une semaine publiée exige `planning.edit_published`, capacité que le
  rôle manager n'a pas : un salarié a organisé sa semaine sur ce qu'il a lu.
- Toute mutation laisse une entrée d'audit ; la suppression écrit sa trace
  **avant** l'effacement, sinon l'état supprimé serait perdu.

Lecture
- `planning.view_unpublished` filtre en base : sans cette capacité, les
  brouillons ne sont pas chargés du tout. Un test vérifie que les horaires
  n'apparaissent pas dans le HTML servi — un masquage CSS les y laisserait.
- Vue jour reconstruite sur les mêmes données, amplitude déduite de la journée
  réelle plutôt que figée à 06 h–21 h.

Vérification
- 26 tests unitaires sur le repérage des semaines et la mise en grille.
- Parcours e2e : poser un créneau, refus de chevauchement, publier, dépublier ;
  et ce que voient un salarié et un manager sur la même semaine.
- `scripts/dev-db.sh` : la base de développement est éphémère dans cet
  environnement, la remonter ne doit pas être une redécouverte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-07 23:43:48 +00:00

570 lines
19 KiB
TypeScript

import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { hash } from '@node-rs/argon2';
import {
DEFAULT_ROLE_PERMISSIONS,
PERMISSION_DEFINITIONS,
SYSTEM_ROLES,
} from '../src/domain/access/permissions';
import {
isoWeekOf,
previousIsoWeek,
weekDates,
zonedInstant,
} from '../src/domain/planning/week';
import { POSTE_CODES, POSTE_LABELS } from '../src/lib/design/postes';
/**
* Jeu de données de départ — 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.
*/
const DEMO_PASSWORD = 'planflow-demo-2026';
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL ?? '',
});
const prisma = new PrismaClient({ adapter });
async function main() {
if (process.env.NODE_ENV === 'production') {
throw new Error(
'Le seed installe un compte de démonstration : refusé en production.',
);
}
console.log('→ Capacités');
for (const permission of PERMISSION_DEFINITIONS) {
await prisma.permission.upsert({
where: { code: permission.code },
update: { category: permission.category, label: permission.label },
create: permission,
});
}
console.log(` ${PERMISSION_DEFINITIONS.length} capacités`);
console.log('→ Compte');
const account = await prisma.account.upsert({
where: { id: 'demo-account' },
update: {},
create: {
id: 'demo-account',
name: 'Maison Rivage',
siren: '000000000',
apeCode: '4759B',
collectiveAgreementId: '1517',
},
});
console.log('→ Rôles');
const roleIds = new Map<string, string>();
for (const role of SYSTEM_ROLES) {
const created = await prisma.role.upsert({
where: { accountId_key: { accountId: account.id, key: role.key } },
update: { name: role.name },
create: {
accountId: account.id,
key: role.key,
name: role.name,
isSystem: true,
},
});
roleIds.set(role.key, created.id);
const codes = DEFAULT_ROLE_PERMISSIONS[role.key];
const permissions = await prisma.permission.findMany({
where: { code: { in: codes } },
});
await prisma.rolePermission.deleteMany({ where: { roleId: created.id } });
await prisma.rolePermission.createMany({
data: permissions.map((permission) => ({
roleId: created.id,
permissionId: permission.id,
})),
skipDuplicates: true,
});
console.log(` ${role.name} — ${permissions.length} capacités`);
}
console.log('→ Établissements');
const locations = [
{ id: 'loc-nantes', name: 'Nantes Atlantis' },
{ id: 'loc-rennes', name: 'Rennes Alma' },
];
for (const location of locations) {
await prisma.location.upsert({
where: { id: location.id },
update: { name: location.name },
create: {
id: location.id,
accountId: account.id,
name: location.name,
employerContributionRate: 42,
},
});
for (const [index, team] of ['Vente', 'Caisse', 'Réserve'].entries()) {
await prisma.team.upsert({
where: { id: `${location.id}-${index}` },
update: {},
create: {
id: `${location.id}-${index}`,
accountId: account.id,
locationId: location.id,
name: team,
position: index,
},
});
}
}
console.log('→ Comptes utilisateurs');
const passwordHash = await hash(DEMO_PASSWORD, {
memoryCost: 19_456,
timeCost: 2,
parallelism: 1,
});
const people = [
{ email: 'direction@example.test', firstName: 'Camille', lastName: 'Ferrand', role: 'owner', all: true },
{ email: 'manager.nantes@example.test', firstName: 'Jonas', lastName: 'Meyer', role: 'manager', all: false },
{ email: 'manager.rennes@example.test', firstName: 'Inès', lastName: 'Bakhti', role: 'manager', all: false },
{ email: 'salarie@example.test', firstName: 'Rémi', lastName: 'Chartier', role: 'employee', all: false },
];
for (const [index, person] of people.entries()) {
const user = await prisma.user.upsert({
where: { email: person.email },
// Remet le compteur d'échecs à zéro : sans cela, des exécutions
// répétées des tests finissent par verrouiller le compte au bout de
// huit tentatives, et l'échec suivant est incompréhensible.
update: { failedAttempts: 0, lockedUntil: null, passwordHash },
create: {
email: person.email,
firstName: person.firstName,
lastName: person.lastName,
passwordHash,
},
});
const membership = await prisma.membership.upsert({
where: {
accountId_employeeNumber: {
accountId: account.id,
employeeNumber: `E${String(index + 1).padStart(4, '0')}`,
},
},
update: { userId: user.id, status: 'ACTIVE' },
create: {
accountId: account.id,
userId: user.id,
roleId: roleIds.get(person.role) as string,
employeeNumber: `E${String(index + 1).padStart(4, '0')}`,
status: 'ACTIVE',
},
});
await prisma.membershipScope.deleteMany({
where: { membershipId: membership.id },
});
await prisma.membershipScope.create({
data: {
accountId: account.id,
membershipId: membership.id,
allLocations: person.all,
locationId: person.all
? null
: person.email.includes('rennes')
? 'loc-rennes'
: 'loc-nantes',
},
});
console.log(` ${person.email} — ${person.role}`);
}
console.log('→ Contrats et dossiers');
const contractSpecs = [
{ number: 'E0001', type: 'CDI', hours: 39, forfait: false, location: 'loc-nantes' },
{ number: 'E0002', type: 'CDI', hours: 0, forfait: true, location: 'loc-nantes' },
{ number: 'E0003', type: 'CDI', hours: 35, forfait: false, location: 'loc-rennes' },
{ number: 'E0004', type: 'CDD', hours: 24, forfait: false, location: 'loc-nantes' },
] as const;
for (const spec of contractSpecs) {
const membership = await prisma.membership.findUnique({
where: {
accountId_employeeNumber: {
accountId: account.id,
employeeNumber: spec.number,
},
},
});
if (!membership) continue;
const holder = membership.userId
? await prisma.user.findUnique({ where: { id: membership.userId } })
: null;
await prisma.employeeProfile.upsert({
where: { membershipId: membership.id },
update: {},
create: {
membershipId: membership.id,
accountId: account.id,
firstName: holder?.firstName ?? 'Prénom',
lastName: holder?.lastName ?? 'À compléter',
city: 'Nantes',
phone: '00 00 00 00 00',
},
});
const existing = await prisma.userContract.findFirst({
where: { membershipId: membership.id },
});
if (existing) continue;
await prisma.userContract.create({
data: {
accountId: account.id,
membershipId: membership.id,
locationId: spec.location,
contractType: spec.type,
startDate: new Date('2024-01-08'),
workTimeArrangement: spec.forfait ? 'FORFAIT_JOURS' : 'HOURLY',
weeklyHours: spec.forfait ? 0 : spec.hours,
forfaitDaysPerYear: spec.forfait ? 218 : null,
forfaitAgreementRef: spec.forfait ? 'CONV-2024-002' : null,
forfaitAgreedAt: spec.forfait ? new Date('2024-01-05') : null,
monthlySalary: 2100,
},
});
}
console.log(` ${contractSpecs.length} contrats`);
console.log('→ Étiquettes de planning');
for (const [index, code] of POSTE_CODES.entries()) {
await prisma.label.upsert({
where: { accountId_code: { accountId: account.id, code: code.toUpperCase() } },
update: { name: POSTE_LABELS[code], paletteKey: code, position: index },
create: {
accountId: account.id,
code: code.toUpperCase(),
name: POSTE_LABELS[code],
paletteKey: code,
position: index,
},
});
}
console.log(` ${POSTE_CODES.length} étiquettes`);
console.log('→ Intitulés de poste');
const jobTitles = [
'Responsable de magasin',
'Adjoint·e de direction',
'Vendeur·se conseil',
'Hôte·sse de caisse',
'Employé·e de réserve',
];
const jobTitleIds = new Map<string, string>();
for (const name of jobTitles) {
const created = await prisma.jobTitle.upsert({
where: { accountId_name: { accountId: account.id, name } },
update: {},
create: { accountId: account.id, name },
});
jobTitleIds.set(name, created.id);
}
console.log('→ Salariés sans compte applicatif');
// La majorité d'une équipe de vente ne se connecte jamais à l'outil : ces
// salariés existent en planning et en paie, sans identifiants.
const staff = [
{ number: 'E0005', first: 'Sofia', last: 'Marchetti', job: 'Vendeur·se conseil', hours: 35, team: 0, location: 'loc-nantes' },
{ number: 'E0006', first: 'Yanis', last: 'Trabelsi', job: 'Vendeur·se conseil', hours: 30, team: 0, location: 'loc-nantes' },
{ number: 'E0007', first: 'Léa', last: 'Nguyen', job: 'Hôte·sse de caisse', hours: 24, team: 1, location: 'loc-nantes' },
{ number: 'E0008', first: 'Marius', last: 'Kowalski', job: 'Hôte·sse de caisse', hours: 35, team: 1, location: 'loc-nantes' },
{ number: 'E0009', first: 'Awa', last: 'Diallo', job: 'Employé·e de réserve', hours: 35, team: 2, location: 'loc-nantes' },
{ number: 'E0010', first: 'Théo', last: 'Berger', job: 'Vendeur·se conseil', hours: 28, team: 0, location: 'loc-rennes' },
{ number: 'E0011', first: 'Clara', last: 'Fontaine', job: 'Hôte·sse de caisse', hours: 35, team: 1, location: 'loc-rennes' },
{ number: 'E0012', first: 'Noé', last: 'Perrin', job: 'Employé·e de réserve', hours: 20, team: 2, location: 'loc-rennes' },
] as const;
for (const person of staff) {
const membership = await prisma.membership.upsert({
where: {
accountId_employeeNumber: {
accountId: account.id,
employeeNumber: person.number,
},
},
update: { status: 'ACTIVE' },
create: {
accountId: account.id,
roleId: roleIds.get('employee') as string,
employeeNumber: person.number,
status: 'ACTIVE',
},
});
await prisma.employeeProfile.upsert({
where: { membershipId: membership.id },
update: { firstName: person.first, lastName: person.last },
create: {
membershipId: membership.id,
accountId: account.id,
firstName: person.first,
lastName: person.last,
city: person.location === 'loc-rennes' ? 'Rennes' : 'Nantes',
},
});
const existing = await prisma.userContract.findFirst({
where: { membershipId: membership.id },
});
if (!existing) {
await prisma.userContract.create({
data: {
accountId: account.id,
membershipId: membership.id,
locationId: person.location,
contractType: 'CDI',
startDate: new Date('2024-09-02'),
workTimeArrangement: 'HOURLY',
weeklyHours: person.hours,
jobTitleId: jobTitleIds.get(person.job) ?? null,
monthlySalary: 1900,
},
});
}
await prisma.teamMember.upsert({
where: {
teamId_membershipId: {
teamId: `${person.location}-${person.team}`,
membershipId: membership.id,
},
},
update: {},
create: {
accountId: account.id,
teamId: `${person.location}-${person.team}`,
membershipId: membership.id,
},
});
}
console.log(` ${staff.length} salariés`);
console.log('→ Rattachements des titulaires de comptes');
// Les quatre comptes applicatifs planifient aussi : sans rattachement, ils
// n'apparaîtraient pas dans la grille.
const accountHolders = [
{ number: 'E0001', team: 'loc-nantes-0', job: 'Responsable de magasin' },
{ number: 'E0002', team: 'loc-nantes-0', job: 'Adjoint·e de direction' },
{ number: 'E0003', team: 'loc-rennes-0', job: 'Responsable de magasin' },
{ number: 'E0004', team: 'loc-nantes-1', job: 'Hôte·sse de caisse' },
] as const;
for (const holder of accountHolders) {
const membership = await prisma.membership.findUnique({
where: {
accountId_employeeNumber: {
accountId: account.id,
employeeNumber: holder.number,
},
},
});
if (!membership) continue;
await prisma.userContract.updateMany({
where: { membershipId: membership.id },
data: { jobTitleId: jobTitleIds.get(holder.job) ?? null },
});
await prisma.teamMember.upsert({
where: {
teamId_membershipId: {
teamId: holder.team,
membershipId: membership.id,
},
},
update: {},
create: {
accountId: account.id,
teamId: holder.team,
membershipId: membership.id,
},
});
}
console.log('→ Plannings');
await seedPlanning(account.id);
console.log('→ Durées de conservation');
const retention = [
['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 à Silae : 6 ans."],
] as const;
for (const [objectType, durationMonths, startPoint, justification] of retention) {
await prisma.retentionPolicy.upsert({
where: {
accountId_objectType_effectiveFrom: {
accountId: account.id,
objectType,
effectiveFrom: new Date('2026-01-01'),
},
},
update: { durationMonths, startPoint, justification },
create: {
accountId: account.id,
objectType,
durationMonths,
startPoint,
justification,
effectiveFrom: new Date('2026-01-01'),
},
});
}
console.log(` ${retention.length} politiques`);
console.log(`\nMot de passe de démonstration : ${DEMO_PASSWORD}`);
}
/**
* Deux semaines de planning : la précédente publiée, la courante en brouillon.
*
* Semer les deux états est délibéré — c'est la seule façon de voir en un coup
* d'œil que la publication est bien **par équipe** et que les créneaux d'une
* semaine publiée se distinguent d'un brouillon.
*/
async function seedPlanning(accountId: string): Promise<void> {
const current = isoWeekOf(new Date());
const previous = previousIsoWeek(current);
const teams = await prisma.team.findMany({
where: { accountId },
orderBy: [{ locationId: 'asc' }, { position: 'asc' }],
});
const locations = await prisma.location.findMany({ where: { accountId } });
const timezoneOf = new Map(locations.map((l) => [l.id, l.timezone]));
const labels = await prisma.label.findMany({ where: { accountId } });
const labelOf = new Map(labels.map((label) => [label.paletteKey, label.id]));
/** Poste dominant de l'équipe, par position. */
const teamPoste = ['vte', 'cai', 'res'];
/** Trois plages types du commerce de détail. */
const patterns = [
{ start: '09:00', end: '17:00', pause: 60 },
{ start: '11:00', end: '19:00', pause: 60 },
{ start: '13:00', end: '20:00', pause: 30 },
];
let shiftsCreated = 0;
for (const week of [previous, current]) {
const dates = weekDates(week);
for (const team of teams) {
const timezone = timezoneOf.get(team.locationId) ?? 'Europe/Paris';
const members = await prisma.teamMember.findMany({
where: { teamId: team.id },
orderBy: { position: 'asc' },
});
if (members.length === 0) continue;
const schedule = await prisma.weeklySchedule.upsert({
where: {
teamId_isoYear_isoWeek: {
teamId: team.id,
isoYear: week.isoYear,
isoWeek: week.isoWeek,
},
},
update: {},
create: {
accountId,
teamId: team.id,
locationId: team.locationId,
isoYear: week.isoYear,
isoWeek: week.isoWeek,
status: week === previous ? 'PUBLISHED' : 'DRAFT',
publishedAt: week === previous ? new Date() : null,
},
});
const already = await prisma.shift.count({
where: { weeklyScheduleId: schedule.id },
});
if (already > 0) continue;
const poste = teamPoste[team.position] ?? 'vte';
const labelId = labelOf.get(poste) ?? null;
for (const [index, member] of members.entries()) {
const pattern = patterns[index % patterns.length] as (typeof patterns)[number];
// Deux jours de repos glissants : la semaine de six jours consécutifs
// est justement ce que les règles de convention interdisent.
const rest = new Set([(index * 2) % 7, ((index * 2) + 1) % 7]);
for (const [dayIndex, date] of dates.entries()) {
if (rest.has(dayIndex)) continue;
await prisma.shift.create({
data: {
accountId,
weeklyScheduleId: schedule.id,
membershipId: member.membershipId,
localDate: new Date(`${date}T00:00:00Z`),
startAt: zonedInstant(date, pattern.start, timezone),
endAt: zonedInstant(date, pattern.end, timezone),
breakMinutes: pattern.pause,
labelId,
isValidated: week === previous,
},
});
shiftsCreated += 1;
}
}
// Un besoin non couvert par équipe sur la semaine courante : c'est l'état
// normal d'un planning en construction, et la ligne dédiée doit s'afficher.
if (week === current) {
const saturday = dates[5] as string;
await prisma.shift.create({
data: {
accountId,
weeklyScheduleId: schedule.id,
membershipId: null,
localDate: new Date(`${saturday}T00:00:00Z`),
startAt: zonedInstant(saturday, '10:00', timezone),
endAt: zonedInstant(saturday, '18:00', timezone),
breakMinutes: 60,
labelId,
},
});
shiftsCreated += 1;
}
}
}
console.log(` ${shiftsCreated} créneaux sur 2 semaines`);
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());