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
This commit is contained in:
Claude committed 2026-08-07 23:43:48 +00:00
1 parent e9d459366d
commit 023f236881
27 files changed
+3373 -674

No files matched your search

@@ -0,0 +1,102 @@
-- CreateEnum
CREATE TYPE "ScheduleStatus" AS ENUM ('DRAFT', 'VALIDATED', 'PUBLISHED');
-- CreateEnum
CREATE TYPE "RestType" AS ENUM ('WEEKLY_REST', 'COMPENSATORY_REST');
-- CreateTable
CREATE TABLE "WeeklySchedule" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"isoYear" INTEGER NOT NULL,
"isoWeek" INTEGER NOT NULL,
"status" "ScheduleStatus" NOT NULL DEFAULT 'DRAFT',
"publishedAt" TIMESTAMP(3),
"publishedBy" TEXT,
"version" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "WeeklySchedule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Shift" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"weeklyScheduleId" TEXT NOT NULL,
"membershipId" TEXT,
"localDate" DATE NOT NULL,
"startAt" TIMESTAMPTZ NOT NULL,
"endAt" TIMESTAMPTZ NOT NULL,
"breakMinutes" INTEGER NOT NULL DEFAULT 0,
"actualStartAt" TIMESTAMPTZ,
"actualEndAt" TIMESTAMPTZ,
"actualBreakMinutes" INTEGER,
"labelId" TEXT,
"mealCount" INTEGER NOT NULL DEFAULT 0,
"isValidated" BOOLEAN NOT NULL DEFAULT false,
"validatedAt" TIMESTAMP(3),
"validatedBy" TEXT,
"note" TEXT,
"version" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Shift_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Rest" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"weeklyScheduleId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"localDate" DATE NOT NULL,
"restType" "RestType" NOT NULL,
"minutes" INTEGER,
CONSTRAINT "Rest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DailyNote" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"weeklyScheduleId" TEXT NOT NULL,
"localDate" DATE NOT NULL,
"content" TEXT NOT NULL,
CONSTRAINT "DailyNote_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WeeklySchedule_accountId_idx" ON "WeeklySchedule"("accountId");
-- CreateIndex
CREATE INDEX "WeeklySchedule_locationId_isoYear_isoWeek_idx" ON "WeeklySchedule"("locationId", "isoYear", "isoWeek");
-- CreateIndex
CREATE UNIQUE INDEX "WeeklySchedule_teamId_isoYear_isoWeek_key" ON "WeeklySchedule"("teamId", "isoYear", "isoWeek");
-- CreateIndex
CREATE INDEX "Shift_accountId_idx" ON "Shift"("accountId");
-- CreateIndex
CREATE INDEX "Shift_weeklyScheduleId_localDate_idx" ON "Shift"("weeklyScheduleId", "localDate");
-- CreateIndex
CREATE INDEX "Shift_membershipId_localDate_idx" ON "Shift"("membershipId", "localDate");
-- CreateIndex
CREATE INDEX "Rest_accountId_idx" ON "Rest"("accountId");
-- CreateIndex
CREATE INDEX "DailyNote_accountId_idx" ON "DailyNote"("accountId");
-- AddForeignKey
ALTER TABLE "Shift" ADD CONSTRAINT "Shift_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rest" ADD CONSTRAINT "Rest_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DailyNote" ADD CONSTRAINT "DailyNote_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,25 @@
-- Étend l'isolation aux tables du planning.
--
-- La grille est la table la plus lue de l'application : c'est aussi celle où
-- une fuite inter-comptes serait la plus visible. Le test « toute table portant
-- accountId est protégée » échoue sans ceci.
DO $$
DECLARE
t text;
BEGIN
FOREACH t IN ARRAY ARRAY['WeeklySchedule', 'Shift', 'Rest', 'DailyNote']
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())',
t
);
EXECUTE format(
'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())',
t
);
END LOOP;
END;
$$;
@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "TeamMember" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"isPrimary" BOOLEAN NOT NULL DEFAULT true,
"position" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TeamMember_accountId_idx" ON "TeamMember"("accountId");
-- CreateIndex
CREATE INDEX "TeamMember_membershipId_idx" ON "TeamMember"("membershipId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamMember_teamId_membershipId_key" ON "TeamMember"("teamId", "membershipId");
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Isolation : `TeamMember` porte accountId, donc la politique est exigible.
ALTER TABLE "TeamMember" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TeamMember" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "TeamMember"
USING ("accountId" = planflow_current_account());
CREATE POLICY tenant_insert ON "TeamMember"
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
+128
View File
@@ -66,11 +66,35 @@ model Team {
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
scopes MembershipScope[]
members TeamMember[]
@@index([accountId])
@@index([locationId])
}
/// Rattachement d'un salarié à une équipe.
///
/// Distinct de `MembershipScope`, qui dit ce qu'un manager a le droit de voir.
/// Celui-ci dit où un salarié **apparaît dans la grille** — y compris une
/// semaine où il n'a aucun créneau, ce qui est justement l'état de départ
/// quand on construit un planning.
model TeamMember {
id String @id @default(cuid())
accountId String
teamId String
membershipId String
/// Équipe d'affectation principale ; les autres sont des renforts.
isPrimary Boolean @default(true)
position Int @default(0)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
@@unique([teamId, membershipId])
@@index([accountId])
@@index([membershipId])
}
// ============================================================================
// Identité
// ============================================================================
@@ -122,6 +146,7 @@ model Membership {
profile EmployeeProfile?
contracts UserContract[]
workPermits WorkPermit[]
teams TeamMember[]
@@unique([accountId, employeeNumber])
@@index([accountId])
@@ -545,3 +570,106 @@ model WorkloadReview {
@@index([accountId])
@@index([userContractId])
}
// ============================================================================
// Planning — PLAN.md §4.4, WP-04
// ============================================================================
/// Semaine de planning d'une équipe.
///
/// La publication est **par équipe** : l'audit montre un bouton « Dépublier »
/// sur chaque section de la grille, pas un seul pour l'établissement.
model WeeklySchedule {
id String @id @default(cuid())
accountId String
locationId String
teamId String
isoYear Int
isoWeek Int
status ScheduleStatus @default(DRAFT)
publishedAt DateTime?
publishedBy String?
/// Verrou optimiste : la grille s'édite à plusieurs (PLAN.md §3.4).
version Int @default(0)
shifts Shift[]
rests Rest[]
dailyNotes DailyNote[]
@@unique([teamId, isoYear, isoWeek])
@@index([accountId])
@@index([locationId, isoYear, isoWeek])
}
enum ScheduleStatus {
DRAFT
VALIDATED
PUBLISHED
}
model Shift {
id String @id @default(cuid())
accountId String
weeklyScheduleId String
/// Null = besoin non couvert, affiché sur sa propre ligne.
membershipId String?
/// Regroupement dans la grille uniquement. Les durées se calculent depuis
/// les instants — la nuit du changement d'heure, 22 h–06 h dure 7 h ou 9 h.
localDate DateTime @db.Date
startAt DateTime @db.Timestamptz
endAt DateTime @db.Timestamptz
breakMinutes Int @default(0)
actualStartAt DateTime? @db.Timestamptz
actualEndAt DateTime? @db.Timestamptz
actualBreakMinutes Int?
labelId String?
mealCount Int @default(0)
isValidated Boolean @default(false)
validatedAt DateTime?
validatedBy String?
note String?
version Int @default(0)
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
@@index([accountId])
@@index([weeklyScheduleId, localDate])
@@index([membershipId, localDate])
}
/// Repos de journée — hebdomadaire ou compensateur. Les pauses *dans* un
/// créneau sont `Shift.breakMinutes`.
model Rest {
id String @id @default(cuid())
accountId String
weeklyScheduleId String
membershipId String
localDate DateTime @db.Date
restType RestType
minutes Int?
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
@@index([accountId])
}
enum RestType {
WEEKLY_REST
COMPENSATORY_REST
}
/// Ligne « Notes et événements » de la grille.
model DailyNote {
id String @id @default(cuid())
accountId String
weeklyScheduleId String
localDate DateTime @db.Date
content String
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
@@index([accountId])
}
+287
View File
@@ -9,6 +9,13 @@ import {
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.
@@ -240,6 +247,165 @@ async function main() {
}
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).'],
@@ -274,6 +440,127 @@ async function main() {
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);