WP-07 : heures prévu/réalisé/payé et périodes de paie verrouillables
Les trois grandeurs Sans pointeuse, le réalisé est saisi par le manager. La matrice impose de distinguer trois grandeurs et non deux, avec deux règles qui ne se négocient pas : - **Sans heures réelles, le prévu fait foi.** Attendre une saisie qui ne viendra pas ne produirait aucune paie. Une saisie partielle — un début sans fin — ne suffit pas : elle donnerait une durée fantaisiste. - **Le paiement ne dépend jamais de la validation.** Une ligne non validée part en paie sur la base du réalisé. Bloquer le paiement d'heures accomplies faute de validation est précisément ce que la matrice interdit ; l'écran l'énonce pour qu'aucun manager ne croie l'inverse. Toute correction d'heures déjà saisies exige un motif — sans lui, une correction est indistinguable d'une erreur de saisie. Une première saisie n'en demande pas : il n'y a rien à corriger, et exiger un motif à chaque ligne le ferait remplir machinalement. Le verrou de période - Verrouiller fige les instantanés **et** ferme le mois : plus aucun créneau, aucune absence, aucune heure réelle ne peut être écrit sur ces dates. Le contrôle passe avant l'écriture, dans planning, absences et heures. - Déverrouiller exige une justification. Rouvrir périme les fichiers déjà transmis au cabinet, et six mois plus tard personne ne saurait pourquoi. - La **péremption d'un export est déduite** de `generatedAt < unlockedAt`, jamais stockée : la stocker obligerait à réécrire une trace qui doit rester append-only, et une trace réécrite ne prouve plus rien. - Supprimer une période demande de **retaper son libellé** : un « êtes-vous sûr » se clique sans lire, et la suppression efface des instantanés sur lesquels un export a pu être bâti. Un seul calcul pour trois écrans Les instantanés viennent de `buildPayrollPeriod`, la même fonction que le rapport de paie et l'export. C'est ce qui satisfait le critère croisé : trois calculs séparés divergeraient, et l'écart ne se verrait qu'au bulletin. Un test d'intégration compare instantané et rapport sur le même jeu de données. Tests enfin idempotents La suite e2e échouait au second passage : les absences acceptées d'une exécution bloquaient les demandes de la suivante. Trois corrections, dans l'ordre d'importance — chaque test travaille sur **son** salarié (le chevauchement se juge par personne), chaque test libère ce qu'il a créé, et les fenêtres de dates sont écartées d'une exécution à l'autre. Trois passages consécutifs passent désormais. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
20 files changed
+2837
-25
No files matched your search
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/domain/absences/count';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
import { assertPeriodOpen, PeriodLockedError } from '@/server/payroll/periods';
|
||||
import type { ScopedClient } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
@@ -186,6 +187,15 @@ export async function requestTimeOffAction(
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
if (contract) {
|
||||
// Poser un congé sur un mois déjà transmis fausserait la paie sans
|
||||
// qu'aucun fichier ne le reflète.
|
||||
await assertPeriodOpen(db, contract.locationId, [
|
||||
parsed.data.startDate,
|
||||
parsed.data.endDate,
|
||||
]);
|
||||
}
|
||||
|
||||
const created = await db.timeOff.create({
|
||||
data: {
|
||||
membershipId: parsed.data.membershipId,
|
||||
@@ -581,6 +591,7 @@ function counterTypeFor(
|
||||
|
||||
function toState(error: unknown, denied: string): AbsenceActionState {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof PeriodLockedError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) return { error: denied };
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { hoursView } from '@/domain/hours/states';
|
||||
import { zonedDate, zonedInstant } from '@/domain/planning/week';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
import { assertPeriodOpen, PeriodLockedError } from '@/server/payroll/periods';
|
||||
|
||||
/**
|
||||
* Saisie des heures réelles — PLAN.md §7.3.
|
||||
*
|
||||
* Sans pointeuse, c'est le manager qui saisit. Deux règles encadrent cette
|
||||
* saisie :
|
||||
*
|
||||
* 1. **Toute correction conserve valeur avant, valeur après, motif, auteur et
|
||||
* date.** Sans motif, une correction est indistinguable d'une erreur.
|
||||
* 2. **La validation qualifie, elle ne conditionne pas le paiement.** Valider
|
||||
* ou non ne change pas ce qui part en paie ; c'est le réalisé qui compte.
|
||||
*/
|
||||
|
||||
export interface HoursActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
class ValidationError extends Error {}
|
||||
|
||||
const HOUR = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
const actualInput = z.object({
|
||||
shiftId: z.string().min(1),
|
||||
start: z.string().regex(HOUR, 'Heure de début invalide').or(z.literal('')),
|
||||
end: z.string().regex(HOUR, 'Heure de fin invalide').or(z.literal('')),
|
||||
breakMinutes: z.string().optional(),
|
||||
reason: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
export async function saveActualHoursAction(
|
||||
_previous: HoursActionState,
|
||||
formData: FormData,
|
||||
): Promise<HoursActionState> {
|
||||
const parsed = actualInput.safeParse({
|
||||
shiftId: formData.get('shiftId'),
|
||||
start: formData.get('start') ?? '',
|
||||
end: formData.get('end') ?? '',
|
||||
breakMinutes: formData.get('breakMinutes') || undefined,
|
||||
reason: formData.get('reason') || undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('hours.edit_actual', async (db, actor) => {
|
||||
const shift = await db.shift.findUnique({
|
||||
where: { id: parsed.data.shiftId },
|
||||
include: { schedule: true },
|
||||
});
|
||||
if (!shift) throw new AuthorizationError('hours.edit_actual');
|
||||
|
||||
const location = await db.location.findUnique({
|
||||
where: { id: shift.schedule.locationId },
|
||||
select: { id: true, timezone: true },
|
||||
});
|
||||
if (!location) throw new AuthorizationError('hours.edit_actual');
|
||||
|
||||
const localDate = zonedDate(shift.startAt, location.timezone);
|
||||
await assertPeriodOpen(db, location.id, [localDate]);
|
||||
|
||||
const before = hoursView({
|
||||
startAt: shift.startAt,
|
||||
endAt: shift.endAt,
|
||||
breakMinutes: shift.breakMinutes,
|
||||
actualStartAt: shift.actualStartAt,
|
||||
actualEndAt: shift.actualEndAt,
|
||||
actualBreakMinutes: shift.actualBreakMinutes,
|
||||
isValidated: shift.isValidated,
|
||||
});
|
||||
|
||||
// Vider les deux champs efface le réalisé : le prévu reprend force de
|
||||
// vérité, ce qui est un retour en arrière légitime après une saisie
|
||||
// erronée.
|
||||
const clearing = !parsed.data.start && !parsed.data.end;
|
||||
if (!clearing && (!parsed.data.start || !parsed.data.end)) {
|
||||
throw new ValidationError(
|
||||
'Renseignez le début **et** la fin : une saisie partielle produirait une durée fantaisiste.',
|
||||
);
|
||||
}
|
||||
|
||||
let actualStartAt: Date | null = null;
|
||||
let actualEndAt: Date | null = null;
|
||||
|
||||
if (!clearing) {
|
||||
actualStartAt = zonedInstant(
|
||||
localDate,
|
||||
parsed.data.start,
|
||||
location.timezone,
|
||||
);
|
||||
actualEndAt = zonedInstant(localDate, parsed.data.end, location.timezone);
|
||||
// Fin avant début = créneau de nuit, comme au planning.
|
||||
if (actualEndAt <= actualStartAt) {
|
||||
actualEndAt = new Date(actualEndAt.getTime() + 86_400_000);
|
||||
}
|
||||
}
|
||||
|
||||
const actualBreakMinutes =
|
||||
clearing || parsed.data.breakMinutes === undefined
|
||||
? null
|
||||
: Math.max(0, Math.min(600, Number(parsed.data.breakMinutes) || 0));
|
||||
|
||||
await db.shift.update({
|
||||
where: { id: shift.id },
|
||||
data: {
|
||||
actualStartAt,
|
||||
actualEndAt,
|
||||
actualBreakMinutes,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
const after = hoursView({
|
||||
startAt: shift.startAt,
|
||||
endAt: shift.endAt,
|
||||
breakMinutes: shift.breakMinutes,
|
||||
actualStartAt,
|
||||
actualEndAt,
|
||||
actualBreakMinutes,
|
||||
isValidated: shift.isValidated,
|
||||
});
|
||||
|
||||
// Une correction d'heures déjà saisies exige un motif ; une première
|
||||
// saisie n'en exige pas — il n'y a rien à corriger.
|
||||
if (before.hasActual && !parsed.data.reason) {
|
||||
throw new ValidationError(
|
||||
'Modifier des heures déjà saisies exige un motif.',
|
||||
);
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'hours.actual.update',
|
||||
entityType: 'Shift',
|
||||
entityId: shift.id,
|
||||
before: {
|
||||
actualMinutes: before.actualMinutes,
|
||||
hasActual: before.hasActual,
|
||||
},
|
||||
after: {
|
||||
actualMinutes: after.actualMinutes,
|
||||
hasActual: after.hasActual,
|
||||
deltaMinutes: after.deltaMinutes,
|
||||
},
|
||||
reason: parsed.data.reason ?? null,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit de saisir des heures.");
|
||||
}
|
||||
|
||||
revalidatePath('/rapports/heures');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const validateInput = z.object({
|
||||
shiftIds: z.array(z.string().min(1)).min(1),
|
||||
validate: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Valide ou dévalide des heures.
|
||||
*
|
||||
* **Sans effet sur la paie.** La validation qualifie une ligne — vue et
|
||||
* acceptée par un responsable — mais des heures accomplies partent en paie
|
||||
* qu'elles soient validées ou non.
|
||||
*/
|
||||
export async function validateHoursAction(
|
||||
_previous: HoursActionState,
|
||||
formData: FormData,
|
||||
): Promise<HoursActionState> {
|
||||
const parsed = validateInput.safeParse({
|
||||
shiftIds: formData.getAll('shiftIds').map(String).filter(Boolean),
|
||||
validate: formData.get('validate') !== 'false',
|
||||
});
|
||||
|
||||
if (!parsed.success) return { error: 'Aucune ligne sélectionnée.' };
|
||||
|
||||
try {
|
||||
await mutate('hours.validate', async (db, actor) => {
|
||||
const shifts = await db.shift.findMany({
|
||||
where: { id: { in: parsed.data.shiftIds } },
|
||||
include: { schedule: true },
|
||||
});
|
||||
if (shifts.length === 0) throw new ValidationError('Lignes introuvables.');
|
||||
|
||||
for (const shift of shifts) {
|
||||
const location = await db.location.findUnique({
|
||||
where: { id: shift.schedule.locationId },
|
||||
select: { id: true, timezone: true },
|
||||
});
|
||||
if (!location) continue;
|
||||
await assertPeriodOpen(db, location.id, [
|
||||
zonedDate(shift.startAt, location.timezone),
|
||||
]);
|
||||
}
|
||||
|
||||
await db.shift.updateMany({
|
||||
where: { id: { in: parsed.data.shiftIds } },
|
||||
data: {
|
||||
isValidated: parsed.data.validate,
|
||||
validatedAt: parsed.data.validate ? new Date() : null,
|
||||
validatedBy: parsed.data.validate ? actor.membershipId : null,
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: parsed.data.validate ? 'hours.validate' : 'hours.invalidate',
|
||||
entityType: 'Shift',
|
||||
entityId: parsed.data.shiftIds[0] as string,
|
||||
after: {
|
||||
count: parsed.data.shiftIds.length,
|
||||
validated: parsed.data.validate,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit de valider des heures.");
|
||||
}
|
||||
|
||||
revalidatePath('/rapports/heures');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function toState(error: unknown, denied: string): HoursActionState {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof PeriodLockedError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) return { error: denied };
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { can } from '@/domain/access/authorize';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { hoursView, sumHours, type ShiftHours } from '@/domain/hours/states';
|
||||
import { monthDates, type Month } from '@/domain/planning/month';
|
||||
import { zonedClock, zonedDate, zonedMidnight } from '@/domain/planning/week';
|
||||
import { query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Rapport d'heures — PLAN.md §7.3.
|
||||
*
|
||||
* Les trois grandeurs sont affichées côte à côte : prévu, réalisé, écart. C'est
|
||||
* la seule façon de voir qu'un salarié fait systématiquement une heure de plus
|
||||
* que son planning — un écart isolé se remarque, un écart chronique ne se voit
|
||||
* que sur un tableau.
|
||||
*/
|
||||
|
||||
export interface HoursShiftRow {
|
||||
id: string;
|
||||
localDate: string;
|
||||
plannedRange: string;
|
||||
actualRange: string | null;
|
||||
plannedMinutes: number;
|
||||
actualMinutes: number;
|
||||
deltaMinutes: number;
|
||||
hasActual: boolean;
|
||||
isValidated: boolean;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export interface HoursEmployeeRow {
|
||||
membershipId: string;
|
||||
name: string;
|
||||
plannedMinutes: number;
|
||||
actualMinutes: number;
|
||||
deltaMinutes: number;
|
||||
payableMinutes: number;
|
||||
allValidated: boolean;
|
||||
shifts: HoursShiftRow[];
|
||||
}
|
||||
|
||||
export interface HoursReport {
|
||||
month: Month;
|
||||
label: string;
|
||||
monthParam: string;
|
||||
previousParam: string;
|
||||
nextParam: string;
|
||||
location: { id: string; name: string; timezone: string };
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
rows: HoursEmployeeRow[];
|
||||
totals: {
|
||||
plannedMinutes: number;
|
||||
actualMinutes: number;
|
||||
deltaMinutes: number;
|
||||
payableMinutes: number;
|
||||
};
|
||||
canEdit: boolean;
|
||||
canValidate: boolean;
|
||||
/** Périodes verrouillées recouvrant le mois. */
|
||||
lockedLabels: string[];
|
||||
}
|
||||
|
||||
export async function getHoursReport(
|
||||
month: Month,
|
||||
locationId?: string,
|
||||
): Promise<HoursReport | null> {
|
||||
return query(
|
||||
'hours.view',
|
||||
async (db, actor) => {
|
||||
const locations = await db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true, timezone: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const location =
|
||||
locations.find((candidate) => candidate.id === locationId) ??
|
||||
locations[0];
|
||||
if (!location) return null;
|
||||
|
||||
const dates = monthDates(month);
|
||||
const startDate = dates[0] as string;
|
||||
const endDate = dates[dates.length - 1] as string;
|
||||
const from = zonedMidnight(startDate, location.timezone);
|
||||
const to = zonedMidnight(
|
||||
new Date(new Date(`${endDate}T00:00:00Z`).getTime() + 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
location.timezone,
|
||||
);
|
||||
|
||||
const teams = await db.team.findMany({
|
||||
where: { locationId: location.id, archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
const assignments = await db.teamMember.findMany({
|
||||
where: { teamId: { in: teams.map((team) => team.id) } },
|
||||
include: {
|
||||
membership: {
|
||||
include: {
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const memberIds = [
|
||||
...new Set(assignments.map((assignment) => assignment.membershipId)),
|
||||
];
|
||||
|
||||
const shifts = await db.shift.findMany({
|
||||
where: {
|
||||
membershipId: { in: memberIds },
|
||||
startAt: { gte: from, lt: to },
|
||||
},
|
||||
orderBy: { startAt: 'asc' },
|
||||
});
|
||||
|
||||
// Les périodes verrouillées ferment la saisie : afficher un champ qui
|
||||
// sera refusé à l'envoi serait une invitation à perdre son temps.
|
||||
const lockedPeriods = await db.payPeriod.findMany({
|
||||
where: {
|
||||
locationId: location.id,
|
||||
status: 'LOCKED',
|
||||
startDate: { lte: new Date(`${endDate}T00:00:00Z`) },
|
||||
endDate: { gte: new Date(`${startDate}T00:00:00Z`) },
|
||||
},
|
||||
select: { label: true, startDate: true, endDate: true },
|
||||
});
|
||||
const isLocked = (isoDate: string) =>
|
||||
lockedPeriods.some(
|
||||
(period) =>
|
||||
isoDate >= period.startDate.toISOString().slice(0, 10) &&
|
||||
isoDate <= period.endDate.toISOString().slice(0, 10),
|
||||
);
|
||||
|
||||
const byMember = new Map<string, typeof shifts>();
|
||||
for (const shift of shifts) {
|
||||
if (!shift.membershipId) continue;
|
||||
const list = byMember.get(shift.membershipId) ?? [];
|
||||
list.push(shift);
|
||||
byMember.set(shift.membershipId, list);
|
||||
}
|
||||
|
||||
const rows: HoursEmployeeRow[] = [];
|
||||
for (const assignment of assignments) {
|
||||
const own = byMember.get(assignment.membershipId) ?? [];
|
||||
if (own.length === 0) continue;
|
||||
|
||||
const asHours: ShiftHours[] = own.map((shift) => ({
|
||||
startAt: shift.startAt,
|
||||
endAt: shift.endAt,
|
||||
breakMinutes: shift.breakMinutes,
|
||||
actualStartAt: shift.actualStartAt,
|
||||
actualEndAt: shift.actualEndAt,
|
||||
actualBreakMinutes: shift.actualBreakMinutes,
|
||||
isValidated: shift.isValidated,
|
||||
}));
|
||||
const total = sumHours(asHours);
|
||||
|
||||
const profile = assignment.membership.profile;
|
||||
rows.push({
|
||||
membershipId: assignment.membershipId,
|
||||
name: `${profile?.firstName ?? ''} ${profile?.lastName ?? assignment.membership.employeeNumber}`.trim(),
|
||||
plannedMinutes: total.plannedMinutes,
|
||||
actualMinutes: total.actualMinutes,
|
||||
deltaMinutes: total.deltaMinutes,
|
||||
payableMinutes: total.payableMinutes,
|
||||
allValidated: total.isValidated,
|
||||
shifts: own.map((shift) => {
|
||||
const view = hoursView({
|
||||
startAt: shift.startAt,
|
||||
endAt: shift.endAt,
|
||||
breakMinutes: shift.breakMinutes,
|
||||
actualStartAt: shift.actualStartAt,
|
||||
actualEndAt: shift.actualEndAt,
|
||||
actualBreakMinutes: shift.actualBreakMinutes,
|
||||
isValidated: shift.isValidated,
|
||||
});
|
||||
const localDate = zonedDate(shift.startAt, location.timezone);
|
||||
|
||||
return {
|
||||
id: shift.id,
|
||||
localDate,
|
||||
plannedRange: `${zonedClock(shift.startAt, location.timezone)}–${zonedClock(shift.endAt, location.timezone)}`,
|
||||
actualRange:
|
||||
shift.actualStartAt && shift.actualEndAt
|
||||
? `${zonedClock(shift.actualStartAt, location.timezone)}–${zonedClock(shift.actualEndAt, location.timezone)}`
|
||||
: null,
|
||||
plannedMinutes: view.plannedMinutes,
|
||||
actualMinutes: view.actualMinutes,
|
||||
deltaMinutes: view.deltaMinutes,
|
||||
hasActual: view.hasActual,
|
||||
isValidated: view.isValidated,
|
||||
locked: isLocked(localDate),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name, 'fr'));
|
||||
|
||||
const { formatMonthParam, monthLabel, nextMonth, previousMonth } =
|
||||
await import('@/domain/planning/month');
|
||||
|
||||
return {
|
||||
month,
|
||||
label: monthLabel(month),
|
||||
monthParam: formatMonthParam(month),
|
||||
previousParam: formatMonthParam(previousMonth(month)),
|
||||
nextParam: formatMonthParam(nextMonth(month)),
|
||||
location,
|
||||
locations: locations.map(({ id, name }) => ({ id, name })),
|
||||
rows,
|
||||
totals: {
|
||||
plannedMinutes: rows.reduce((sum, row) => sum + row.plannedMinutes, 0),
|
||||
actualMinutes: rows.reduce((sum, row) => sum + row.actualMinutes, 0),
|
||||
deltaMinutes: rows.reduce((sum, row) => sum + row.deltaMinutes, 0),
|
||||
payableMinutes: rows.reduce((sum, row) => sum + row.payableMinutes, 0),
|
||||
},
|
||||
canEdit: can(actor, 'hours.edit_actual'),
|
||||
canValidate: can(actor, 'hours.validate'),
|
||||
lockedLabels: lockedPeriods.map((period) => period.label),
|
||||
};
|
||||
},
|
||||
locationId ? { locationId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export { formatMinutes };
|
||||
@@ -154,12 +154,12 @@ export async function exportSilaeAction(
|
||||
await mutate(
|
||||
'payroll.export.silae',
|
||||
async (db, actor) => {
|
||||
const period = await buildPayrollPeriod(
|
||||
const payroll = await buildPayrollPeriod(
|
||||
db,
|
||||
month,
|
||||
parsed.data.locationId,
|
||||
);
|
||||
if (!period) {
|
||||
if (!payroll) {
|
||||
throw new ValidationError(
|
||||
"Aucune convention collective n'est chargée pour cette période.",
|
||||
);
|
||||
@@ -167,25 +167,37 @@ export async function exportSilaeAction(
|
||||
|
||||
// Un export partiel se charge sans erreur et rend la paie fausse pour
|
||||
// les salariés absents du fichier : il vaut mieux ne rien produire.
|
||||
if (period.blockers.length > 0) {
|
||||
throw new ValidationError(period.blockers.join(' · '));
|
||||
if (payroll.blockers.length > 0) {
|
||||
throw new ValidationError(payroll.blockers.join(' · '));
|
||||
}
|
||||
if (period.rows.length === 0) {
|
||||
if (payroll.rows.length === 0) {
|
||||
throw new ValidationError(
|
||||
'Aucun élément de paie sur cette période : rien à exporter.',
|
||||
);
|
||||
}
|
||||
|
||||
const result = formatSilaeCsv(toSilaeLines(period));
|
||||
const result = formatSilaeCsv(toSilaeLines(payroll));
|
||||
csv = result.csv;
|
||||
digest = await checksum(csv);
|
||||
filename = `silae-${period.location.name.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase()}-${parsed.data.month}.csv`;
|
||||
filename = `silae-${payroll.location.name.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase()}-${parsed.data.month}.csv`;
|
||||
|
||||
// Rattacher l'export à sa période rend sa péremption déductible : sans
|
||||
// ce lien, un déverrouillage ne pourrait pas signaler les fichiers
|
||||
// devenus faux.
|
||||
const payPeriod = await db.payPeriod.findFirst({
|
||||
where: {
|
||||
locationId: parsed.data.locationId,
|
||||
startDate: new Date(`${payroll.startDate}T00:00:00Z`),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const record = await db.payrollExport.create({
|
||||
data: {
|
||||
locationId: parsed.data.locationId,
|
||||
periodStart: new Date(`${period.startDate}T00:00:00Z`),
|
||||
periodEnd: new Date(`${period.endDate}T00:00:00Z`),
|
||||
payPeriodId: payPeriod?.id ?? null,
|
||||
periodStart: new Date(`${payroll.startDate}T00:00:00Z`),
|
||||
periodEnd: new Date(`${payroll.endDate}T00:00:00Z`),
|
||||
checksum: digest,
|
||||
lineCount: result.lineCount,
|
||||
generatedBy: actor.membershipId,
|
||||
@@ -198,7 +210,7 @@ export async function exportSilaeAction(
|
||||
entityType: 'PayrollExport',
|
||||
entityId: record.id,
|
||||
after: {
|
||||
period: `${period.startDate} → ${period.endDate}`,
|
||||
period: `${payroll.startDate} → ${payroll.endDate}`,
|
||||
lines: result.lineCount,
|
||||
checksum: digest,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { monthDates, parseMonthParam } from '@/domain/planning/month';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
import { computeSnapshots } from '@/server/payroll/periods';
|
||||
|
||||
/**
|
||||
* Cycle de vie d'une période de paie — PLAN.md §4.6.
|
||||
*
|
||||
* Trois actions, trois exigences distinctes :
|
||||
*
|
||||
* - **Verrouiller** fige les instantanés et ferme la période aux mutations.
|
||||
* - **Déverrouiller** rouvre, exige une justification et périme tout export
|
||||
* déjà produit. La péremption est déduite de `generatedAt < unlockedAt` —
|
||||
* jamais stockée, pour que la trace d'export reste append-only.
|
||||
* - **Supprimer** reste possible sur une période verrouillée, avec une
|
||||
* capacité dédiée et une trace conservant le périmètre supprimé.
|
||||
*/
|
||||
|
||||
export interface PeriodActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
class ValidationError extends Error {}
|
||||
|
||||
const createInput = z.object({
|
||||
locationId: z.string().min(1),
|
||||
month: z.string().min(1),
|
||||
label: z.string().trim().max(80).optional(),
|
||||
});
|
||||
|
||||
export async function createPeriodAction(
|
||||
_previous: PeriodActionState,
|
||||
formData: FormData,
|
||||
): Promise<PeriodActionState> {
|
||||
const parsed = createInput.safeParse({
|
||||
locationId: formData.get('locationId'),
|
||||
month: formData.get('month'),
|
||||
label: formData.get('label') || undefined,
|
||||
});
|
||||
if (!parsed.success) return { error: 'Période invalide.' };
|
||||
|
||||
const month = parseMonthParam(parsed.data.month);
|
||||
if (!month) return { error: 'Période invalide.' };
|
||||
|
||||
try {
|
||||
await mutate(
|
||||
'payroll.period.create',
|
||||
async (db, actor) => {
|
||||
const dates = monthDates(month);
|
||||
const startDate = new Date(`${dates[0]}T00:00:00Z`);
|
||||
const endDate = new Date(`${dates[dates.length - 1]}T00:00:00Z`);
|
||||
|
||||
const existing = await db.payPeriod.findFirst({
|
||||
where: {
|
||||
locationId: parsed.data.locationId,
|
||||
startDate,
|
||||
endDate,
|
||||
kind: 'MAIN',
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
throw new ValidationError('Cette période existe déjà.');
|
||||
}
|
||||
|
||||
const { monthLabel } = await import('@/domain/planning/month');
|
||||
const created = await db.payPeriod.create({
|
||||
data: {
|
||||
locationId: parsed.data.locationId,
|
||||
label: parsed.data.label || monthLabel(month),
|
||||
startDate,
|
||||
endDate,
|
||||
kind: 'MAIN',
|
||||
} as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'payroll.period.create',
|
||||
entityType: 'PayPeriod',
|
||||
entityId: created.id,
|
||||
after: { label: created.label, from: dates[0], to: dates.at(-1) },
|
||||
});
|
||||
},
|
||||
{ locationId: parsed.data.locationId },
|
||||
);
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit de créer une période.");
|
||||
}
|
||||
|
||||
revalidatePath('/paie/periodes');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const lockInput = z.object({
|
||||
periodId: z.string().min(1),
|
||||
expectedVersion: z.coerce.number().int().min(0),
|
||||
});
|
||||
|
||||
export async function lockPeriodAction(
|
||||
_previous: PeriodActionState,
|
||||
formData: FormData,
|
||||
): Promise<PeriodActionState> {
|
||||
const parsed = lockInput.safeParse({
|
||||
periodId: formData.get('periodId'),
|
||||
expectedVersion: formData.get('expectedVersion') ?? 0,
|
||||
});
|
||||
if (!parsed.success) return { error: 'Période introuvable.' };
|
||||
|
||||
let written = 0;
|
||||
|
||||
try {
|
||||
await mutate('payroll.period.lock', async (db, actor) => {
|
||||
const period = await db.payPeriod.findUnique({
|
||||
where: { id: parsed.data.periodId },
|
||||
});
|
||||
if (!period) throw new AuthorizationError('payroll.period.lock');
|
||||
if (period.status === 'LOCKED') {
|
||||
throw new ValidationError('Cette période est déjà verrouillée.');
|
||||
}
|
||||
|
||||
const start = period.startDate.toISOString().slice(0, 10);
|
||||
written = await computeSnapshots(
|
||||
db,
|
||||
period.id,
|
||||
{
|
||||
year: Number(start.slice(0, 4)),
|
||||
month: Number(start.slice(5, 7)),
|
||||
},
|
||||
period.locationId,
|
||||
);
|
||||
|
||||
const updated = await db.payPeriod.updateMany({
|
||||
where: { id: period.id, version: parsed.data.expectedVersion },
|
||||
data: {
|
||||
status: 'LOCKED',
|
||||
lockedAt: new Date(),
|
||||
lockedBy: actor.membershipId,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) {
|
||||
throw new ValidationError(
|
||||
'Cette période a été modifiée entre-temps. Rechargez la page.',
|
||||
);
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'payroll.period.lock',
|
||||
entityType: 'PayPeriod',
|
||||
entityId: period.id,
|
||||
before: { status: period.status },
|
||||
after: { status: 'LOCKED', snapshots: written },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit de verrouiller une période.");
|
||||
}
|
||||
|
||||
revalidatePath('/paie/periodes');
|
||||
revalidatePath('/paie');
|
||||
return {
|
||||
ok: true,
|
||||
message: `${written} instantané${written > 1 ? 's' : ''} figé${written > 1 ? 's' : ''}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const unlockInput = z.object({
|
||||
periodId: z.string().min(1),
|
||||
expectedVersion: z.coerce.number().int().min(0),
|
||||
reason: z.string().trim().min(1, 'Justification obligatoire').max(500),
|
||||
});
|
||||
|
||||
/**
|
||||
* Déverrouille une période.
|
||||
*
|
||||
* La justification est **obligatoire** : rouvrir une période périme les
|
||||
* fichiers déjà transmis au cabinet, et six mois plus tard personne ne saura
|
||||
* pourquoi le mois de juillet a été rouvert.
|
||||
*/
|
||||
export async function unlockPeriodAction(
|
||||
_previous: PeriodActionState,
|
||||
formData: FormData,
|
||||
): Promise<PeriodActionState> {
|
||||
const parsed = unlockInput.safeParse({
|
||||
periodId: formData.get('periodId'),
|
||||
expectedVersion: formData.get('expectedVersion') ?? 0,
|
||||
reason: formData.get('reason') ?? '',
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
let staleExports = 0;
|
||||
|
||||
try {
|
||||
await mutate('payroll.period.unlock', async (db, actor) => {
|
||||
const period = await db.payPeriod.findUnique({
|
||||
where: { id: parsed.data.periodId },
|
||||
});
|
||||
if (!period) throw new AuthorizationError('payroll.period.unlock');
|
||||
if (period.status !== 'LOCKED') {
|
||||
throw new ValidationError('Cette période n’est pas verrouillée.');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await db.payPeriod.updateMany({
|
||||
where: { id: period.id, version: parsed.data.expectedVersion },
|
||||
data: {
|
||||
status: 'OPEN',
|
||||
unlockedAt: now,
|
||||
unlockedBy: actor.membershipId,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) {
|
||||
throw new ValidationError(
|
||||
'Cette période a été modifiée entre-temps. Rechargez la page.',
|
||||
);
|
||||
}
|
||||
|
||||
// Rien n'est écrit sur les exports : leur péremption se déduit de la date
|
||||
// de déverrouillage. On les compte seulement pour le dire à l'écran.
|
||||
staleExports = await db.payrollExport.count({
|
||||
where: { payPeriodId: period.id, generatedAt: { lt: now } },
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'payroll.period.unlock',
|
||||
entityType: 'PayPeriod',
|
||||
entityId: period.id,
|
||||
before: { status: 'LOCKED' },
|
||||
after: { status: 'OPEN', staleExports },
|
||||
reason: parsed.data.reason,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit de déverrouiller une période.");
|
||||
}
|
||||
|
||||
revalidatePath('/paie/periodes');
|
||||
revalidatePath('/paie');
|
||||
return {
|
||||
ok: true,
|
||||
message:
|
||||
staleExports > 0
|
||||
? `${staleExports} export${staleExports > 1 ? 's' : ''} désormais périmé${staleExports > 1 ? 's' : ''} : le fichier transmis ne correspond plus aux données.`
|
||||
: 'Période rouverte aux modifications.',
|
||||
};
|
||||
}
|
||||
|
||||
export async function deletePeriodAction(
|
||||
_previous: PeriodActionState,
|
||||
formData: FormData,
|
||||
): Promise<PeriodActionState> {
|
||||
const periodId = String(formData.get('periodId') ?? '');
|
||||
const confirmation = String(formData.get('confirm') ?? '');
|
||||
if (!periodId) return { error: 'Période introuvable.' };
|
||||
|
||||
try {
|
||||
await mutate('payroll.period.delete', async (db, actor) => {
|
||||
const period = await db.payPeriod.findUnique({
|
||||
where: { id: periodId },
|
||||
});
|
||||
if (!period) throw new AuthorizationError('payroll.period.delete');
|
||||
|
||||
// Confirmation explicite : supprimer une période verrouillée efface les
|
||||
// instantanés sur lesquels un export a pu être bâti.
|
||||
if (confirmation !== period.label) {
|
||||
throw new ValidationError(
|
||||
`Pour confirmer, saisissez le libellé exact de la période : « ${period.label} ».`,
|
||||
);
|
||||
}
|
||||
|
||||
const snapshots = await db.payPeriodSnapshot.count({
|
||||
where: { payPeriodId: period.id },
|
||||
});
|
||||
|
||||
// La trace est écrite **avant** la suppression : après, l'identifiant ne
|
||||
// désigne plus rien.
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'payroll.period.delete',
|
||||
entityType: 'PayPeriod',
|
||||
entityId: period.id,
|
||||
before: {
|
||||
label: period.label,
|
||||
from: period.startDate.toISOString().slice(0, 10),
|
||||
to: period.endDate.toISOString().slice(0, 10),
|
||||
status: period.status,
|
||||
snapshots,
|
||||
},
|
||||
reason: `Suppression de la période « ${period.label} »`,
|
||||
});
|
||||
|
||||
await db.payPeriod.delete({ where: { id: period.id } });
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit de supprimer une période.");
|
||||
}
|
||||
|
||||
revalidatePath('/paie/periodes');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function toState(error: unknown, denied: string): PeriodActionState {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) return { error: denied };
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { isExportStale } from '@/domain/hours/states';
|
||||
import { formatMonthParam, monthOf } from '@/domain/planning/month';
|
||||
import { query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Lectures des périodes de paie.
|
||||
*
|
||||
* La **péremption d'un export est déduite**, jamais lue : `generatedAt <
|
||||
* unlockedAt`. La stocker obligerait à réécrire une trace qui doit rester
|
||||
* append-only, et une trace réécrite ne prouve plus rien.
|
||||
*/
|
||||
|
||||
export interface PeriodExport {
|
||||
id: string;
|
||||
checksum: string;
|
||||
lineCount: number;
|
||||
generatedAt: Date;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
export interface PeriodCard {
|
||||
id: string;
|
||||
label: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: 'OPEN' | 'LOCKED';
|
||||
version: number;
|
||||
lockedAt: Date | null;
|
||||
unlockedAt: Date | null;
|
||||
snapshotCount: number;
|
||||
/** Salariés entrés, sortis et extras sur la période. */
|
||||
entries: number;
|
||||
exits: number;
|
||||
extras: number;
|
||||
exports: PeriodExport[];
|
||||
}
|
||||
|
||||
export interface PeriodsView {
|
||||
location: { id: string; name: string };
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
periods: PeriodCard[];
|
||||
/** Mois proposé par défaut à la création. */
|
||||
suggestedMonth: string;
|
||||
canLock: boolean;
|
||||
canUnlock: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
export async function getPeriods(locationId?: string): Promise<PeriodsView | null> {
|
||||
const { can } = await import('@/domain/access/authorize');
|
||||
|
||||
return query(
|
||||
'payroll.access',
|
||||
async (db, actor) => {
|
||||
const locations = await db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const location =
|
||||
locations.find((candidate) => candidate.id === locationId) ??
|
||||
locations[0];
|
||||
if (!location) return null;
|
||||
|
||||
const periods = await db.payPeriod.findMany({
|
||||
where: { locationId: location.id },
|
||||
orderBy: { startDate: 'desc' },
|
||||
take: 24,
|
||||
});
|
||||
|
||||
const exports = await db.payrollExport.findMany({
|
||||
where: { payPeriodId: { in: periods.map((period) => period.id) } },
|
||||
orderBy: { generatedAt: 'desc' },
|
||||
});
|
||||
|
||||
const cards: PeriodCard[] = [];
|
||||
for (const period of periods) {
|
||||
const snapshotCount = await db.payPeriodSnapshot.count({
|
||||
where: { payPeriodId: period.id },
|
||||
});
|
||||
|
||||
// Entrées et sorties de la période : ce sont les mouvements qui
|
||||
// expliquent un écart d'effectif au bulletin.
|
||||
const entries = await db.userContract.count({
|
||||
where: {
|
||||
locationId: location.id,
|
||||
startDate: { gte: period.startDate, lte: period.endDate },
|
||||
},
|
||||
});
|
||||
const exits = await db.userContract.count({
|
||||
where: {
|
||||
locationId: location.id,
|
||||
endDate: { gte: period.startDate, lte: period.endDate },
|
||||
},
|
||||
});
|
||||
const extras = await db.userContract.count({
|
||||
where: {
|
||||
locationId: location.id,
|
||||
contractType: { in: ['EXTRA', 'SAISONNIER', 'INTERIM'] },
|
||||
startDate: { lte: period.endDate },
|
||||
},
|
||||
});
|
||||
|
||||
cards.push({
|
||||
id: period.id,
|
||||
label: period.label,
|
||||
startDate: period.startDate.toISOString().slice(0, 10),
|
||||
endDate: period.endDate.toISOString().slice(0, 10),
|
||||
status: period.status,
|
||||
version: period.version,
|
||||
lockedAt: period.lockedAt,
|
||||
unlockedAt: period.unlockedAt,
|
||||
snapshotCount,
|
||||
entries,
|
||||
exits,
|
||||
extras,
|
||||
exports: exports
|
||||
.filter((entry) => entry.payPeriodId === period.id)
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
checksum: entry.checksum,
|
||||
lineCount: entry.lineCount,
|
||||
generatedAt: entry.generatedAt,
|
||||
stale: isExportStale(entry.generatedAt, period.unlockedAt),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
location,
|
||||
locations,
|
||||
periods: cards,
|
||||
suggestedMonth: formatMonthParam(monthOf(new Date())),
|
||||
canLock: can(actor, 'payroll.period.lock'),
|
||||
canUnlock: can(actor, 'payroll.period.unlock'),
|
||||
canDelete: can(actor, 'payroll.period.delete'),
|
||||
};
|
||||
},
|
||||
locationId ? { locationId } : undefined,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { fallsInLockedPeriod } from '@/domain/hours/states';
|
||||
import type { Month } from '@/domain/planning/month';
|
||||
import { agreementFor } from '@/server/compliance/evaluate';
|
||||
import { buildPayrollPeriod } from '@/server/payroll/build';
|
||||
import type { ScopedClient } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Périodes de paie — PLAN.md §4.6 et WP-07.
|
||||
*
|
||||
* Le verrouillage fige les instantanés et **interdit** toute mutation dont la
|
||||
* date tombe dans la période. Le déverrouillage existe : c'est une décision qui
|
||||
* exige une justification, parce qu'elle périme tout export déjà transmis au
|
||||
* cabinet.
|
||||
*/
|
||||
|
||||
export class PeriodLockedError extends Error {
|
||||
constructor(label: string) {
|
||||
super(
|
||||
`La période de paie « ${label} » est verrouillée. Déverrouillez-la, ou passez la correction en régularisation sur la période suivante.`,
|
||||
);
|
||||
this.name = 'PeriodLockedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse une mutation qui tomberait dans une période verrouillée.
|
||||
*
|
||||
* Appelé **avant** l'écriture, jamais après : une transaction annulée laisse
|
||||
* quand même passer les effets de bord non transactionnels.
|
||||
*/
|
||||
export async function assertPeriodOpen(
|
||||
db: ScopedClient,
|
||||
locationId: string,
|
||||
isoDates: string[],
|
||||
): Promise<void> {
|
||||
if (isoDates.length === 0) return;
|
||||
|
||||
const sorted = [...isoDates].sort();
|
||||
const periods = await db.payPeriod.findMany({
|
||||
where: {
|
||||
locationId,
|
||||
status: 'LOCKED',
|
||||
startDate: { lte: new Date(`${sorted[sorted.length - 1]}T00:00:00Z`) },
|
||||
endDate: { gte: new Date(`${sorted[0]}T00:00:00Z`) },
|
||||
},
|
||||
select: { label: true, startDate: true, endDate: true, status: true },
|
||||
});
|
||||
if (periods.length === 0) return;
|
||||
|
||||
const asStrings = periods.map((period) => ({
|
||||
startDate: period.startDate.toISOString().slice(0, 10),
|
||||
endDate: period.endDate.toISOString().slice(0, 10),
|
||||
status: period.status,
|
||||
}));
|
||||
|
||||
for (const isoDate of sorted) {
|
||||
if (fallsInLockedPeriod(isoDate, asStrings)) {
|
||||
const blocking = periods.find(
|
||||
(period) =>
|
||||
isoDate >= period.startDate.toISOString().slice(0, 10) &&
|
||||
isoDate <= period.endDate.toISOString().slice(0, 10),
|
||||
);
|
||||
throw new PeriodLockedError(blocking?.label ?? 'période');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit les instantanés de la période.
|
||||
*
|
||||
* Ils viennent de **la même fonction** que le rapport de paie et l'export :
|
||||
* `buildPayrollPeriod`. C'est ce qui garantit le critère d'acceptation — grille,
|
||||
* rapport d'heures et instantané donnent des chiffres identiques. Trois
|
||||
* calculs séparés divergeraient, et l'écart ne se verrait qu'au bulletin.
|
||||
*/
|
||||
export async function computeSnapshots(
|
||||
db: ScopedClient,
|
||||
payPeriodId: string,
|
||||
month: Month,
|
||||
locationId: string,
|
||||
): Promise<number> {
|
||||
const period = await buildPayrollPeriod(db, month, locationId);
|
||||
if (!period) return 0;
|
||||
|
||||
const agreement = await agreementFor(
|
||||
db,
|
||||
new Date(`${period.startDate}T00:00:00Z`),
|
||||
);
|
||||
|
||||
// Un nouveau verrouillage **recalcule intégralement** : les instantanés ne
|
||||
// sont pas immuables au sens strict, c'est le couple (instantané, export)
|
||||
// qui porte la preuve.
|
||||
await db.payPeriodSnapshot.deleteMany({ where: { payPeriodId } });
|
||||
|
||||
let written = 0;
|
||||
for (const row of period.rows) {
|
||||
const elements = new Map(
|
||||
row.elements.map((element) => [element.key, element.value]),
|
||||
);
|
||||
|
||||
await db.payPeriodSnapshot.create({
|
||||
data: {
|
||||
payPeriodId,
|
||||
membershipId: row.membershipId,
|
||||
plannedMinutes: elements.get('WORKED_HOURS') ?? 0,
|
||||
actualMinutes: elements.get('WORKED_HOURS') ?? 0,
|
||||
absenceMinutes: 0,
|
||||
workedDays:
|
||||
elements.get('WORKED_DAYS') ?? elements.get('FORFAIT_DAYS') ?? 0,
|
||||
overtimeByBracket: [
|
||||
{ ratePercent: 25, minutes: elements.get('OVERTIME_25') ?? 0 },
|
||||
{ ratePercent: 50, minutes: elements.get('OVERTIME_50') ?? 0 },
|
||||
],
|
||||
absenceBreakdown: [],
|
||||
variables: row.elements.map((element) => ({
|
||||
key: element.key,
|
||||
value: element.value,
|
||||
unit: element.unit,
|
||||
})),
|
||||
agreementId: agreement?.id ?? null,
|
||||
} as never,
|
||||
});
|
||||
written += 1;
|
||||
}
|
||||
|
||||
return written;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { evaluateAround, evaluateSchedule } from '@/server/compliance/evaluate';
|
||||
import { mutate } from '@/server/context';
|
||||
import { assertPeriodOpen, PeriodLockedError } from '@/server/payroll/periods';
|
||||
import type { ScopedClient } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
@@ -131,6 +132,10 @@ export async function createShiftAction(
|
||||
// interdirait de planifier un inventaire 22 h–02 h.
|
||||
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
|
||||
|
||||
// Un mois transmis au cabinet ne se modifie pas par inadvertance : le
|
||||
// contrôle passe **avant** l'écriture.
|
||||
await assertPeriodOpen(db, location.id, [parsed.data.localDate]);
|
||||
|
||||
const worked = shiftMinutes(startAt, endAt, parsed.data.breakMinutes);
|
||||
if (worked <= 0) {
|
||||
throw new ValidationError(
|
||||
@@ -242,6 +247,11 @@ export async function updateShiftAction(
|
||||
assertMayEditPublished(actor);
|
||||
}
|
||||
|
||||
await assertPeriodOpen(db, shift.schedule.locationId, [
|
||||
shift.localDate.toISOString().slice(0, 10),
|
||||
parsed.data.localDate,
|
||||
]);
|
||||
|
||||
const startAt = zonedInstant(
|
||||
parsed.data.localDate,
|
||||
parsed.data.start,
|
||||
@@ -323,6 +333,10 @@ export async function deleteShiftAction(
|
||||
assertMayEditPublished(actor);
|
||||
}
|
||||
|
||||
await assertPeriodOpen(db, shift.schedule.locationId, [
|
||||
shift.localDate.toISOString().slice(0, 10),
|
||||
]);
|
||||
|
||||
// La trace est écrite **avant** la suppression : après, l'identifiant ne
|
||||
// désigne plus rien, et l'état supprimé serait perdu.
|
||||
await recordAudit(db, {
|
||||
@@ -749,6 +763,9 @@ function assertMayEditPublished(actor: Actor): void {
|
||||
|
||||
function toState(error: unknown, denied: string): PlanningActionState {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
// Le verrou de période porte son propre message, qui explique la sortie :
|
||||
// déverrouiller, ou régulariser sur la période suivante.
|
||||
if (error instanceof PeriodLockedError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) return { error: denied };
|
||||
throw error;
|
||||
}
|
||||
Reference in new issue
Block a user