WP-06 : absences, registre de compteurs, calendrier
Le dernier module de démonstration disparaît : `src/lib/demo/` ne contient plus que l'aperçu. Le décompte, là où l'on se trompe - **`endDate` est le dernier jour d'absence, pas la date de reprise.** C'est la confusion la plus fréquente du domaine : elle décompte un jour de trop ou de trop peu à chaque demande, et le salarié s'en aperçoit au solde, des mois plus tard. Le champ du formulaire s'appelle « Dernier jour d'absence », et un test couvre explicitement la confusion. - **Un jour férié dans un congé ne se décompte pas**, sans que l'utilisateur ait à scinder sa demande. Exiger la scission, c'est lui déplacer la charge d'un calcul que l'outil sait faire. - Le rythme du contrat est respecté : un temps partiel qui ne travaille jamais le mercredi ne consomme pas de congé ce jour-là. - Ouvrables ou ouvrés est un **paramètre**, pas une constante : se tromper fausse tous les soldes de la même façon. Jours fériés calculés, pas listés Une table écrite à la main est juste l'année où on l'écrit et fausse dès la suivante — et un férié manquant se décompte comme un jour de congé, sans que personne ne le voie. Les onze jours légaux sont donc calculés, Pâques comprise (algorithme grégorien anonyme, vérifié sur quatre années de référence). Quand aucun férié n'est enregistré pour l'année demandée, la demande passe mais l'écran le dit : mieux vaut l'annoncer que laisser croire à un décompte complet. Le registre est la source de vérité - Aucun solde n'est stocké : le solde est la **somme** des écritures. Un solde stocké se désynchronise, et la désynchronisation ne se voit qu'au moment où un salarié conteste. - `UPDATE` et `DELETE` sont refusés par un trigger PostgreSQL, pas seulement par l'application : une règle applicative finit par être contournée par un script de reprise. Les tests d'intégration écrivent directement en base pour le prouver. - Annuler une absence acceptée **contre-passe** la prise au lieu de l'effacer, à la date de la correction — antidater masquerait la correction dans les soldes déjà communiqués. `reversesId` est unique : contre-passer deux fois ferait repartir le solde dans l'autre sens. - Un ajustement manuel sans justification est refusé. Confidentialité du motif médical Un manager voit qu'un salarié est absent, pas de quoi il souffre (matrice n° 9). Le motif d'un arrêt n'est **pas chargé** pour qui n'a pas la capacité de le lire — un champ absent de la réponse ne peut fuiter ni par le HTML ni par un journal. Sur la grille de planning, l'absence s'affiche « Absence » : c'est suffisant pour ne pas planifier quelqu'un. Acquisition 2,5 jours ouvrables par mois travaillé ; 2 jours par mois d'arrêt maladie non professionnelle, plafonnés à 24 par an — droit issu de la réforme de 2024, dont l'oubli prive le salarié d'un droit acquis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
18 files changed
+3160
-228
No files matched your search
@@ -0,0 +1,182 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TimeOffStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DECLINED', 'CANCELLED', 'EXPIRED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CounterType" AS ENUM ('PAID_LEAVE', 'RTT', 'COMPENSATORY_REST', 'MODULATION', 'OVERTIME');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LedgerKind" AS ENUM ('ACCRUAL', 'TAKEN', 'ADJUSTMENT', 'CARRY_OVER', 'EXPIRY', 'REGULARISATION');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LedgerUnit" AS ENUM ('DAY', 'HOUR');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LedgerSource" AS ENUM ('TIMEOFF', 'PAY_PERIOD', 'MANUAL', 'SYSTEM');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PolicyStatus" AS ENUM ('ACTIVE', 'ARCHIVED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TimeOff" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"membershipId" TEXT NOT NULL,
|
||||
"userContractId" TEXT,
|
||||
"absenceTypeId" TEXT NOT NULL,
|
||||
"startDate" DATE NOT NULL,
|
||||
"startHalfDay" BOOLEAN NOT NULL DEFAULT false,
|
||||
"endDate" DATE NOT NULL,
|
||||
"endHalfDay" BOOLEAN NOT NULL DEFAULT false,
|
||||
"countedDays" DECIMAL(6,2),
|
||||
"status" "TimeOffStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"comment" TEXT,
|
||||
"requestedBy" TEXT NOT NULL,
|
||||
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"decidedBy" TEXT,
|
||||
"decidedAt" TIMESTAMP(3),
|
||||
"decisionComment" TEXT,
|
||||
"justificationDocumentId" TEXT,
|
||||
|
||||
CONSTRAINT "TimeOff_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Counter" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"membershipId" TEXT NOT NULL,
|
||||
"counterType" "CounterType" NOT NULL,
|
||||
"acquisitionPeriodStart" DATE NOT NULL,
|
||||
"acquisitionPeriodEnd" DATE NOT NULL,
|
||||
|
||||
CONSTRAINT "Counter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LedgerOperation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"counterId" TEXT NOT NULL,
|
||||
"kind" "LedgerKind" NOT NULL,
|
||||
"quantity" DECIMAL(10,4) NOT NULL,
|
||||
"unit" "LedgerUnit" NOT NULL,
|
||||
"effectiveDate" DATE NOT NULL,
|
||||
"sourceType" "LedgerSource" NOT NULL,
|
||||
"sourceId" TEXT,
|
||||
"reason" TEXT,
|
||||
"reversesId" TEXT,
|
||||
"createdBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LedgerOperation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RttPolicy" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"daysPerYear" DECIMAL(5,2) NOT NULL,
|
||||
"periodStart" TEXT NOT NULL,
|
||||
"autoRenew" BOOLEAN NOT NULL DEFAULT true,
|
||||
"status" "PolicyStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
|
||||
CONSTRAINT "RttPolicy_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RttPolicyAssignment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"rttPolicyId" TEXT NOT NULL,
|
||||
"membershipId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "RttPolicyAssignment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LeaveNotice" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"membershipId" TEXT NOT NULL,
|
||||
"timeOffId" TEXT,
|
||||
"returnDate" DATE NOT NULL,
|
||||
"dueDate" DATE NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"deliveredAt" TIMESTAMP(3),
|
||||
"deliveryProof" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LeaveNotice_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TimeOff_accountId_idx" ON "TimeOff"("accountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TimeOff_membershipId_startDate_idx" ON "TimeOff"("membershipId", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TimeOff_accountId_status_idx" ON "TimeOff"("accountId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Counter_accountId_idx" ON "Counter"("accountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Counter_membershipId_counterType_acquisitionPeriodStart_key" ON "Counter"("membershipId", "counterType", "acquisitionPeriodStart");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LedgerOperation_reversesId_key" ON "LedgerOperation"("reversesId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LedgerOperation_accountId_idx" ON "LedgerOperation"("accountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LedgerOperation_counterId_effectiveDate_idx" ON "LedgerOperation"("counterId", "effectiveDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RttPolicy_accountId_idx" ON "RttPolicy"("accountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RttPolicyAssignment_accountId_idx" ON "RttPolicyAssignment"("accountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RttPolicyAssignment_rttPolicyId_membershipId_key" ON "RttPolicyAssignment"("rttPolicyId", "membershipId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LeaveNotice_accountId_idx" ON "LeaveNotice"("accountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LeaveNotice_membershipId_idx" ON "LeaveNotice"("membershipId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TimeOff" ADD CONSTRAINT "TimeOff_absenceTypeId_fkey" FOREIGN KEY ("absenceTypeId") REFERENCES "AbsenceType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LedgerOperation" ADD CONSTRAINT "LedgerOperation_counterId_fkey" FOREIGN KEY ("counterId") REFERENCES "Counter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RttPolicyAssignment" ADD CONSTRAINT "RttPolicyAssignment_rttPolicyId_fkey" FOREIGN KEY ("rttPolicyId") REFERENCES "RttPolicy"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Isolation : toute table portant accountId doit porter sa politique.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['TimeOff','Counter','LedgerOperation','RttPolicy','RttPolicyAssignment','LeaveNotice']
|
||||
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 $$;
|
||||
|
||||
-- Le registre est append-only : une correction s'écrit — contre-passation puis
|
||||
-- nouvelle écriture — elle ne se réécrit pas. La règle est en base et non
|
||||
-- seulement dans l'application, parce qu'une règle applicative finit par être
|
||||
-- contournée par un script de reprise.
|
||||
CREATE TRIGGER ledger_operation_append_only
|
||||
BEFORE UPDATE OR DELETE ON "LedgerOperation"
|
||||
FOR EACH ROW EXECUTE FUNCTION planflow_deny_write();
|
||||
@@ -374,6 +374,7 @@ model AbsenceType {
|
||||
archivedAt DateTime?
|
||||
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
timeOffs TimeOff[]
|
||||
|
||||
@@unique([accountId, code])
|
||||
@@index([accountId])
|
||||
@@ -834,3 +835,180 @@ model PayrollExport {
|
||||
@@index([accountId, periodStart])
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Absences et compteurs — PLAN.md §4.5 et §7, WP-06
|
||||
// ============================================================================
|
||||
|
||||
/// Demande d'absence.
|
||||
///
|
||||
/// `endDate` porte le **dernier jour d'absence**, jamais la date de reprise.
|
||||
/// La confusion est la plus fréquente du domaine : elle décompte un jour de
|
||||
/// trop ou de trop peu à chaque demande, et le salarié s'en aperçoit au solde,
|
||||
/// des mois plus tard.
|
||||
model TimeOff {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
membershipId String
|
||||
userContractId String?
|
||||
absenceTypeId String
|
||||
startDate DateTime @db.Date
|
||||
startHalfDay Boolean @default(false)
|
||||
endDate DateTime @db.Date
|
||||
endHalfDay Boolean @default(false)
|
||||
/// Décompte figé à la décision : le calendrier de l'établissement peut
|
||||
/// changer après coup, le droit acquis ne doit pas bouger avec lui.
|
||||
countedDays Decimal? @db.Decimal(6, 2)
|
||||
status TimeOffStatus @default(PENDING)
|
||||
comment String?
|
||||
|
||||
requestedBy String
|
||||
requestedAt DateTime @default(now())
|
||||
decidedBy String?
|
||||
decidedAt DateTime?
|
||||
decisionComment String?
|
||||
justificationDocumentId String?
|
||||
|
||||
absenceType AbsenceType @relation(fields: [absenceTypeId], references: [id])
|
||||
|
||||
@@index([accountId])
|
||||
@@index([membershipId, startDate])
|
||||
@@index([accountId, status])
|
||||
}
|
||||
|
||||
enum TimeOffStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
DECLINED
|
||||
CANCELLED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
/// Compteur d'un salarié sur une période d'acquisition.
|
||||
///
|
||||
/// Ne porte **aucun solde** : le solde est la somme des écritures. Un solde
|
||||
/// stocké se désynchronise, et la désynchronisation ne se voit qu'au moment où
|
||||
/// un salarié conteste.
|
||||
model Counter {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
membershipId String
|
||||
counterType CounterType
|
||||
acquisitionPeriodStart DateTime @db.Date
|
||||
acquisitionPeriodEnd DateTime @db.Date
|
||||
|
||||
operations LedgerOperation[]
|
||||
|
||||
@@unique([membershipId, counterType, acquisitionPeriodStart])
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
enum CounterType {
|
||||
PAID_LEAVE
|
||||
RTT
|
||||
COMPENSATORY_REST
|
||||
MODULATION
|
||||
OVERTIME
|
||||
}
|
||||
|
||||
/// Écriture de compteur — **append-only**, imposé par un trigger.
|
||||
///
|
||||
/// Une correction s'écrit : contre-passation portant `reversesId`, puis
|
||||
/// nouvelle écriture. Elle ne se réécrit pas.
|
||||
model LedgerOperation {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
counterId String
|
||||
kind LedgerKind
|
||||
/// Signé : positif acquiert, négatif consomme.
|
||||
quantity Decimal @db.Decimal(10, 4)
|
||||
unit LedgerUnit
|
||||
effectiveDate DateTime @db.Date
|
||||
sourceType LedgerSource
|
||||
sourceId String?
|
||||
reason String?
|
||||
reversesId String? @unique
|
||||
createdBy String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
counter Counter @relation(fields: [counterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([accountId])
|
||||
@@index([counterId, effectiveDate])
|
||||
}
|
||||
|
||||
enum LedgerKind {
|
||||
ACCRUAL
|
||||
TAKEN
|
||||
ADJUSTMENT
|
||||
CARRY_OVER
|
||||
EXPIRY
|
||||
REGULARISATION
|
||||
}
|
||||
|
||||
enum LedgerUnit {
|
||||
DAY
|
||||
HOUR
|
||||
}
|
||||
|
||||
enum LedgerSource {
|
||||
TIMEOFF
|
||||
PAY_PERIOD
|
||||
MANUAL
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
model RttPolicy {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
name String
|
||||
daysPerYear Decimal @db.Decimal(5, 2)
|
||||
/// Début de période au format « MM-JJ ».
|
||||
periodStart String
|
||||
autoRenew Boolean @default(true)
|
||||
status PolicyStatus @default(ACTIVE)
|
||||
|
||||
assignments RttPolicyAssignment[]
|
||||
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
enum PolicyStatus {
|
||||
ACTIVE
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
model RttPolicyAssignment {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
rttPolicyId String
|
||||
membershipId String
|
||||
|
||||
policy RttPolicy @relation(fields: [rttPolicyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([rttPolicyId, membershipId])
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
/// Information due au salarié à son retour d'arrêt — PLAN.md §12.3.
|
||||
///
|
||||
/// Obligation **active** et non un affichage : l'employeur doit informer des
|
||||
/// droits acquis et de la date limite de prise dans le mois suivant la reprise.
|
||||
/// La preuve de remise est ce qui rend l'obligation opposable.
|
||||
model LeaveNotice {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
membershipId String
|
||||
timeOffId String?
|
||||
/// Date de reprise qui déclenche le délai d'un mois.
|
||||
returnDate DateTime @db.Date
|
||||
dueDate DateTime @db.Date
|
||||
/// Contenu remis, figé : il fait foi.
|
||||
content String
|
||||
deliveredAt DateTime?
|
||||
deliveryProof String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([accountId])
|
||||
@@index([membershipId])
|
||||
}
|
||||
+165
-44
@@ -20,6 +20,7 @@ import {
|
||||
IDCC_1517_PARAMETERS,
|
||||
IDCC_1517_PROVENANCE,
|
||||
} from '../src/domain/compliance/idcc1517';
|
||||
import { frenchHolidays } from '../src/domain/absences/holidays';
|
||||
import { PAYROLL_ELEMENT_DEFINITIONS } from '../src/domain/payroll/elements';
|
||||
import { evaluateSchedule } from '../src/server/compliance/evaluate';
|
||||
import { withTenant } from '../src/server/tenant';
|
||||
@@ -465,55 +466,51 @@ async function main() {
|
||||
console.log(` ${IDCC_1517_PROVENANCE.length} paramètres tracés`);
|
||||
|
||||
console.log('→ Jours fériés et dimanches autorisés');
|
||||
// Jours fériés légaux français, hors Alsace-Moselle.
|
||||
const holidays2026: Array<[string, string]> = [
|
||||
['2026-01-01', 'Jour de l’an'],
|
||||
['2026-04-06', 'Lundi de Pâques'],
|
||||
['2026-05-01', 'Fête du travail'],
|
||||
['2026-05-08', 'Victoire 1945'],
|
||||
['2026-05-14', 'Ascension'],
|
||||
['2026-05-25', 'Lundi de Pentecôte'],
|
||||
['2026-07-14', 'Fête nationale'],
|
||||
['2026-08-15', 'Assomption'],
|
||||
['2026-11-01', 'Toussaint'],
|
||||
['2026-11-11', 'Armistice 1918'],
|
||||
['2026-12-25', 'Noël'],
|
||||
];
|
||||
// Calculés, pas listés : une table écrite à la main est juste 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.
|
||||
const currentYear = new Date().getUTCFullYear();
|
||||
const years = [currentYear - 1, currentYear, currentYear + 1, currentYear + 2];
|
||||
let holidayCount = 0;
|
||||
|
||||
for (const location of locations) {
|
||||
for (const [date, name] of holidays2026) {
|
||||
await prisma.holiday.upsert({
|
||||
where: {
|
||||
locationId_localDate: {
|
||||
locationId: location.id,
|
||||
localDate: new Date(`${date}T00:00:00Z`),
|
||||
for (const year of years) {
|
||||
for (const holiday of frenchHolidays(year)) {
|
||||
await prisma.holiday.upsert({
|
||||
where: {
|
||||
locationId_localDate: {
|
||||
locationId: location.id,
|
||||
localDate: new Date(`${holiday.isoDate}T00:00:00Z`),
|
||||
},
|
||||
},
|
||||
},
|
||||
update: { name },
|
||||
create: {
|
||||
accountId: account.id,
|
||||
locationId: location.id,
|
||||
localDate: new Date(`${date}T00:00:00Z`),
|
||||
name,
|
||||
// Les trois jours chômés garantis par la convention sont choisis par
|
||||
// l'employeur : ils ne sont pas devinés ici.
|
||||
isPaidOff: date === '2026-05-01',
|
||||
},
|
||||
});
|
||||
update: { name: holiday.name },
|
||||
create: {
|
||||
accountId: account.id,
|
||||
locationId: location.id,
|
||||
localDate: new Date(`${holiday.isoDate}T00:00:00Z`),
|
||||
name: holiday.name,
|
||||
// Seul le 1er mai est chômé de droit. Les trois jours garantis par
|
||||
// la convention sont choisis par l'employeur : ils ne se devinent
|
||||
// pas ici.
|
||||
isPaidOff: holiday.isoDate.slice(5) === '05-01',
|
||||
},
|
||||
});
|
||||
holidayCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Douze dimanches du maire, liste arrêtée avant le 31 décembre pour
|
||||
// l'année suivante (L3132-26). Valeurs de démonstration.
|
||||
// Douze dimanches du maire au plus, liste arrêtée avant le 31 décembre
|
||||
// pour l'année suivante (L3132-26). Valeurs de démonstration.
|
||||
const sundays = [
|
||||
'2026-01-11',
|
||||
'2026-06-28',
|
||||
'2026-07-05',
|
||||
'2026-08-30',
|
||||
'2026-09-06',
|
||||
'2026-11-29',
|
||||
'2026-12-06',
|
||||
'2026-12-13',
|
||||
'2026-12-20',
|
||||
`${currentYear}-01-11`,
|
||||
`${currentYear}-06-28`,
|
||||
`${currentYear}-07-05`,
|
||||
`${currentYear}-08-30`,
|
||||
`${currentYear}-09-06`,
|
||||
`${currentYear}-11-29`,
|
||||
`${currentYear}-12-06`,
|
||||
`${currentYear}-12-13`,
|
||||
`${currentYear}-12-20`,
|
||||
];
|
||||
for (const date of sundays) {
|
||||
await prisma.authorisedSunday.upsert({
|
||||
@@ -533,7 +530,7 @@ async function main() {
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(` ${holidays2026.length} jours fériés par établissement`);
|
||||
console.log(` ${holidayCount} jours fériés sur ${years.length} années`);
|
||||
|
||||
console.log('→ Durées de conservation');
|
||||
const retention = [
|
||||
@@ -567,6 +564,130 @@ async function main() {
|
||||
console.log(` ${retention.length} politiques`);
|
||||
|
||||
|
||||
|
||||
console.log('→ Types d’absence');
|
||||
// `silaeCode` reste **null** : les codes AB-* du dossier sont connus, leur
|
||||
// signification ne l'est pas. Les associer ici reviendrait à deviner à quel
|
||||
// type d'absence correspond AB-300, ce qui produirait une paie fausse.
|
||||
const absenceTypes = [
|
||||
{ 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;
|
||||
|
||||
for (const type of absenceTypes) {
|
||||
await prisma.absenceType.upsert({
|
||||
where: { accountId_code: { accountId: account.id, code: type.code } },
|
||||
update: { name: type.name, colorKey: type.colorKey },
|
||||
create: {
|
||||
accountId: account.id,
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` ${absenceTypes.length} types`);
|
||||
|
||||
console.log('→ Absences de démonstration');
|
||||
const cpType = await prisma.absenceType.findFirst({
|
||||
where: { accountId: account.id, code: 'CP' },
|
||||
});
|
||||
const malType = await prisma.absenceType.findFirst({
|
||||
where: { accountId: account.id, code: 'MAL' },
|
||||
});
|
||||
const demoMembers = await prisma.membership.findMany({
|
||||
where: { accountId: account.id, employeeNumber: { in: ['E0005', 'E0007'] } },
|
||||
});
|
||||
|
||||
const today = new Date();
|
||||
const iso = (offsetDays: number) =>
|
||||
new Date(today.getTime() + offsetDays * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
const demoAbsences = [
|
||||
// Acceptée, en cours : elle doit apparaître sur la grille de planning.
|
||||
{ member: 'E0005', type: cpType?.id, from: iso(1), to: iso(5), status: 'ACCEPTED' as const, days: 5 },
|
||||
// En attente : elle doit apparaître dans la file à traiter.
|
||||
{ member: 'E0007', type: malType?.id, from: iso(-2), to: iso(2), status: 'PENDING' as const, days: 4 },
|
||||
];
|
||||
|
||||
for (const absence of demoAbsences) {
|
||||
const membership = demoMembers.find(
|
||||
(member) => member.employeeNumber === absence.member,
|
||||
);
|
||||
if (!membership || !absence.type) continue;
|
||||
|
||||
const existing = await prisma.timeOff.findFirst({
|
||||
where: { membershipId: membership.id },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
const created = await prisma.timeOff.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
membershipId: membership.id,
|
||||
absenceTypeId: absence.type,
|
||||
startDate: new Date(`${absence.from}T00:00:00Z`),
|
||||
endDate: new Date(`${absence.to}T00:00:00Z`),
|
||||
countedDays: absence.days,
|
||||
status: absence.status,
|
||||
requestedBy: membership.id,
|
||||
...(absence.status === 'ACCEPTED'
|
||||
? { decidedBy: membership.id, decidedAt: new Date() }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (absence.status !== 'ACCEPTED') continue;
|
||||
|
||||
// L'acceptation écrit au registre : le solde bouge parce qu'une écriture a
|
||||
// été posée, jamais parce qu'un champ a été décrémenté.
|
||||
const year = today.getUTCMonth() >= 5 ? today.getUTCFullYear() : today.getUTCFullYear() - 1;
|
||||
const counter = await prisma.counter.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
membershipId: membership.id,
|
||||
counterType: 'PAID_LEAVE',
|
||||
acquisitionPeriodStart: new Date(`${year}-06-01T00:00:00Z`),
|
||||
acquisitionPeriodEnd: new Date(`${year + 1}-05-31T00:00:00Z`),
|
||||
},
|
||||
});
|
||||
await prisma.ledgerOperation.createMany({
|
||||
data: [
|
||||
{
|
||||
accountId: account.id,
|
||||
counterId: counter.id,
|
||||
kind: 'ACCRUAL',
|
||||
quantity: 25,
|
||||
unit: 'DAY',
|
||||
effectiveDate: new Date(`${year}-06-01T00:00:00Z`),
|
||||
sourceType: 'SYSTEM',
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
counterId: counter.id,
|
||||
kind: 'TAKEN',
|
||||
quantity: -absence.days,
|
||||
unit: 'DAY',
|
||||
effectiveDate: new Date(`${absence.from}T00:00:00Z`),
|
||||
sourceType: 'TIMEOFF',
|
||||
sourceId: created.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
console.log(` ${demoAbsences.length} absences`);
|
||||
|
||||
console.log('→ Correspondances Silae');
|
||||
// Semées **non confirmées**, y compris quand le code se lit dans son libellé
|
||||
// (`EV-HDimanche`). Proposer n'est pas confirmer : seul le gestionnaire de
|
||||
|
||||
Reference in new issue
Block a user