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:
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());
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user