WP-08 : export Silae, format relevé sur un export réel du dossier

Le format n'est plus déduit d'une documentation. Il est vérifié octet par
octet sur un export du dossier (juillet 2026, 55 lignes) : un contrôle
d'aller-retour a reproduit ce fichier **sans aucune ligne divergente**.

Ce que le fichier réel a corrigé dans la spécification
- L'export est en **ASCII pur**, pas en UTF-8 : aucun accent, y compris dans
  les libellés (« Heures travaillees », « Date debut »). Le sérialiseur
  translittère, pour qu'un salarié nommé « Rémi » n'introduise pas le premier
  octet non-ASCII du fichier.
- Fins de ligne **CRLF**, la dernière comprise ; ni guillemet ni point-virgule
  final ; décimale point.
- Heures avec au moins une décimale et au plus deux — `96.0`, `52.5`, `69.67` ;
  jours en entier nu — `14`, `22`. L'arrondi se fait **par ligne**, au
  centième : recomposer un total depuis les lignes peut donc s'écarter de
  quelques centièmes. C'est le comportement de l'export existant, et le
  reproduire est délibéré.
- Un salarié sous contrat sans aucun créneau planifié figure quand même, avec
  sa durée contractuelle entière en heures manquantes.

Ce que le fichier n'a pas dit
Les codes sont maintenant connus — `AB-100`, `AB-200`, `AB-300`, `AB-630`,
`EV-HDimanche`, `EV-HFerie`, `HS-HS25` — mais **savoir qu'un code existe ne dit
pas ce qu'il désigne**. `EV-HDimanche` se lit ; `AB-300` non. Les premiers sont
proposés, les seconds restent vides, et rien n'est confirmé d'office : l'export
refuse de tourner tant que le gestionnaire de paie n'a pas validé chaque
correspondance. C'est le signal d'arrêt de PLAN.md §8.2, maintenu.

Refus plutôt que fichier partiel
Un CSV incomplet se charge sans erreur dans Silae et rend la paie fausse pour
les salariés qui en sont absents — l'échec est silencieux jusqu'au bulletin.
L'export liste donc les manques et ne produit rien.

Données personnelles
L'export de référence porte les heures et les absences de salariés
identifiables. Il **n'est pas versionné**, ni comme fixture ni comme donnée de
démonstration. Ce sont les règles de forme qui sont figées dans les tests, avec
les valeurs observées mais sans les matricules ni les volumes réels. Le fichier
produit revient dans la réponse de l'action plutôt que par une URL : un fichier
de paie ne doit pas rester adressable, mis en cache ou présent dans un
historique de navigation. En base, seule l'empreinte est conservée — elle
suffit à prouver qu'un réexport est identique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 07:58:34 +00:00
1 parent 0babdb0936
commit af9429901d
16 files changed
+2050 -15

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { PAYROLL_ELEMENT_DEFINITIONS } from '@/domain/payroll/elements';
import {
checksum,
formatSilaeCsv,
SilaeExportError,
} from '@/domain/payroll/silae';
import { parseMonthParam } from '@/domain/planning/month';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
import { buildPayrollPeriod, toSilaeLines } from '@/server/payroll/build';
export interface PayrollActionState {
error?: string;
ok?: boolean;
/** Contenu du fichier, remis au navigateur pour téléchargement. */
csv?: string;
filename?: string;
checksum?: string;
}
class ValidationError extends Error {}
const ELEMENT_KEYS = PAYROLL_ELEMENT_DEFINITIONS.map(
(definition) => definition.key,
) as [string, ...string[]];
const mappingInput = z.object({
sourceKey: z.enum(ELEMENT_KEYS),
silaeCode: z.string().trim().max(80),
confirmed: z.boolean(),
});
/**
* Enregistre la correspondance d'un élément vers un code Silae.
*
* `confirmed` n'est pas une case décorative : l'export refuse de tourner tant
* qu'une correspondance n'a pas été confirmée. Les codes appartiennent au
* dossier du cabinet, et une correspondance devinée produirait une paie fausse
* qui se chargerait sans erreur.
*/
export async function saveMappingAction(
_previous: PayrollActionState,
formData: FormData,
): Promise<PayrollActionState> {
const parsed = mappingInput.safeParse({
sourceKey: formData.get('sourceKey'),
silaeCode: formData.get('silaeCode') ?? '',
confirmed: formData.get('confirmed') === 'on',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const definition = PAYROLL_ELEMENT_DEFINITIONS.find(
(entry) => entry.key === parsed.data.sourceKey,
);
if (!definition) return { error: 'Élément de paie inconnu.' };
try {
await mutate('payroll.export.silae', async (db, actor) => {
if (parsed.data.confirmed && !parsed.data.silaeCode) {
throw new ValidationError(
'Une correspondance ne peut pas être confirmée sans code.',
);
}
const existing = await db.silaeCodeMapping.findFirst({
where: { sourceKey: parsed.data.sourceKey },
});
if (existing) {
await db.silaeCodeMapping.update({
where: { id: existing.id },
data: {
silaeCode: parsed.data.silaeCode,
confirmed: parsed.data.confirmed,
},
});
} else {
await db.silaeCodeMapping.create({
data: {
sourceKey: parsed.data.sourceKey,
silaeCode: parsed.data.silaeCode,
label: definition.label,
kind: definition.kind,
confirmed: parsed.data.confirmed,
} as never,
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'payroll.mapping.update',
entityType: 'SilaeCodeMapping',
entityId: parsed.data.sourceKey,
before: existing
? { silaeCode: existing.silaeCode, confirmed: existing.confirmed }
: null,
after: {
silaeCode: parsed.data.silaeCode,
confirmed: parsed.data.confirmed,
},
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de modifier les correspondances." };
}
throw error;
}
revalidatePath('/paie/silae');
return { ok: true };
}
const exportInput = z.object({
month: z.string().min(1),
locationId: z.string().min(1),
});
/**
* Produit le fichier Silae de la période.
*
* Le fichier n'est **pas** conservé en base : il porte les heures et les
* absences de salariés identifiables, et sa génération est déterministe. Seule
* l'empreinte est écrite, ce qui suffit à prouver qu'un réexport est identique.
*/
export async function exportSilaeAction(
_previous: PayrollActionState,
formData: FormData,
): Promise<PayrollActionState> {
const parsed = exportInput.safeParse({
month: formData.get('month'),
locationId: formData.get('locationId'),
});
if (!parsed.success) return { error: 'Période ou établissement invalide.' };
const month = parseMonthParam(parsed.data.month);
if (!month) return { error: 'Période invalide.' };
let csv = '';
let digest = '';
let filename = '';
try {
await mutate(
'payroll.export.silae',
async (db, actor) => {
const period = await buildPayrollPeriod(
db,
month,
parsed.data.locationId,
);
if (!period) {
throw new ValidationError(
"Aucune convention collective n'est chargée pour cette période.",
);
}
// 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 (period.rows.length === 0) {
throw new ValidationError(
'Aucun élément de paie sur cette période : rien à exporter.',
);
}
const result = formatSilaeCsv(toSilaeLines(period));
csv = result.csv;
digest = await checksum(csv);
filename = `silae-${period.location.name.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase()}-${parsed.data.month}.csv`;
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`),
checksum: digest,
lineCount: result.lineCount,
generatedBy: actor.membershipId,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'payroll.export.silae',
entityType: 'PayrollExport',
entityId: record.id,
after: {
period: `${period.startDate} → ${period.endDate}`,
lines: result.lineCount,
checksum: digest,
},
});
},
{ locationId: parsed.data.locationId },
);
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof SilaeExportError) {
return { error: error.issues.map((issue) => issue.message).join(' · ') };
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de produire cet export." };
}
throw error;
}
revalidatePath('/paie');
return { ok: true, csv, filename, checksum: digest };
}
+334
View File
@@ -0,0 +1,334 @@
import { splitOvertime, splitComplementary } from '@/domain/compliance/overtime';
import { shiftMinutes } from '@/domain/counters/week';
import {
PAYROLL_ELEMENTS,
elementDefinition,
type PayrollElementKey,
} from '@/domain/payroll/elements';
import type { SilaeLine } from '@/domain/payroll/silae';
import { monthDates, type Month } from '@/domain/planning/month';
import {
isoWeekOf,
weekBounds,
zonedDate,
zonedMidnight,
} from '@/domain/planning/week';
import { agreementFor } from '@/server/compliance/evaluate';
import type { ScopedClient } from '@/server/tenant';
/**
* Construction des éléments de paie d'une période — PLAN.md §8.
*
* Les heures viennent des créneaux **réalisés quand ils le sont**, planifiés
* sinon. Sans pointeuse, c'est le manager qui saisit le réalisé ; tant qu'il ne
* l'a pas fait, le planifié est la meilleure information disponible, et
* l'attendre indéfiniment ne produirait aucune paie.
*/
export interface PayrollRowElement {
key: PayrollElementKey;
label: string;
/** Minutes pour les heures, jours entiers pour les jours. */
value: number;
unit: 'HOURS' | 'DAYS';
silaeCode: string | null;
confirmed: boolean;
}
export interface PayrollRow {
membershipId: string;
employeeNumber: string;
silaeMatricule: string | null;
name: string;
forfaitJours: boolean;
elements: PayrollRowElement[];
}
export interface PayrollPeriod {
month: Month;
startDate: string;
endDate: string;
location: { id: string; name: string; timezone: string };
rows: PayrollRow[];
/** Manques qui empêchent l'export : matricule absent, code non mappé. */
blockers: string[];
}
/** Un salarié dont on suit le mois entier, minute par minute. */
interface Tally {
workedMinutes: number;
workedDays: Set<string>;
sundayMinutes: number;
holidayMinutes: number;
/** Minutes travaillées par semaine ISO, pour découper les majorations. */
byWeek: Map<string, number>;
}
export async function buildPayrollPeriod(
db: ScopedClient,
month: Month,
locationId: string,
): Promise<PayrollPeriod | null> {
const location = await db.location.findUnique({
where: { id: locationId },
select: { id: true, name: true, timezone: true },
});
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 agreement = await agreementFor(db, new Date(`${startDate}T00:00:00Z`));
if (!agreement) return null;
const teams = await db.team.findMany({
where: { locationId, 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 } },
contracts: {
where: { status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
take: 1,
},
},
},
},
});
const memberIds = [
...new Set(assignments.map((assignment) => assignment.membershipId)),
];
const shifts = await db.shift.findMany({
where: { membershipId: { in: memberIds }, startAt: { gte: from, lt: to } },
select: {
membershipId: true,
startAt: true,
endAt: true,
breakMinutes: true,
actualStartAt: true,
actualEndAt: true,
actualBreakMinutes: true,
},
});
const holidays = await db.holiday.findMany({
where: {
locationId,
localDate: {
gte: new Date(`${startDate}T00:00:00Z`),
lte: new Date(`${endDate}T00:00:00Z`),
},
},
select: { localDate: true },
});
const holidayDates = new Set(
holidays.map((holiday) => holiday.localDate.toISOString().slice(0, 10)),
);
const mappings = await db.silaeCodeMapping.findMany();
const mappingByKey = new Map(
mappings.map((mapping) => [mapping.sourceKey, mapping]),
);
const tallies = new Map<string, Tally>();
const tallyFor = (membershipId: string): Tally => {
const existing = tallies.get(membershipId);
if (existing) return existing;
const created: Tally = {
workedMinutes: 0,
workedDays: new Set(),
sundayMinutes: 0,
holidayMinutes: 0,
byWeek: new Map(),
};
tallies.set(membershipId, created);
return created;
};
for (const shift of shifts) {
if (!shift.membershipId) continue;
const tally = tallyFor(shift.membershipId);
// Réalisé s'il est saisi, planifié sinon. Le réalisé prime dès qu'il
// existe : c'est lui qui est dû.
const start = shift.actualStartAt ?? shift.startAt;
const end = shift.actualEndAt ?? shift.endAt;
const pause = shift.actualBreakMinutes ?? shift.breakMinutes;
const minutes = shiftMinutes(start, end, pause);
if (minutes === 0) continue;
const date = zonedDate(start, location.timezone);
tally.workedMinutes += minutes;
tally.workedDays.add(date);
const weekday = new Date(`${date}T00:00:00Z`).getUTCDay();
if (weekday === 0) tally.sundayMinutes += minutes;
if (holidayDates.has(date)) tally.holidayMinutes += minutes;
// Les majorations se calculent par semaine, pas sur le mois : 45 h une
// semaine et 25 h la suivante ne font pas 70 h sans majoration.
const week = isoWeekOf(start);
const key = `${week.isoYear}-${week.isoWeek}`;
tally.byWeek.set(key, (tally.byWeek.get(key) ?? 0) + minutes);
}
const rows: PayrollRow[] = [];
const blockers = new Set<string>();
for (const assignment of assignments) {
const membership = assignment.membership;
const contract = membership.contracts[0];
const tally = tallies.get(assignment.membershipId);
const forfaitJours = contract?.workTimeArrangement === 'FORFAIT_JOURS';
const weeklyMinutes = contract
? Math.round(Number(contract.weeklyHours.toString()) * 60)
: 0;
// Durée mensuelle contractuelle : 35 h hebdomadaires valent 151,67 h par
// mois, soit 52 semaines réparties sur 12 mois.
const monthlyContractMinutes = Math.round((weeklyMinutes * 52) / 12);
const elements: PayrollRowElement[] = [];
const push = (key: PayrollElementKey, value: number) => {
if (value <= 0) return;
const definition = elementDefinition(key);
const mapping = mappingByKey.get(key);
elements.push({
key,
label: definition.label,
value,
unit: definition.unit,
silaeCode: mapping?.silaeCode ?? null,
confirmed: mapping?.confirmed ?? false,
});
};
if (forfaitJours) {
// Les jours de forfait ne sont pas des heures : les exporter comme telles
// produirait une paie fausse (PLAN.md §6.4).
push(PAYROLL_ELEMENTS.FORFAIT_DAYS, tally?.workedDays.size ?? 0);
} else {
push(PAYROLL_ELEMENTS.WORKED_DAYS, tally?.workedDays.size ?? 0);
push(PAYROLL_ELEMENTS.WORKED_HOURS, tally?.workedMinutes ?? 0);
const missing = monthlyContractMinutes - (tally?.workedMinutes ?? 0);
// Un salarié sous contrat sans aucun créneau planifié apparaît quand
// même, avec sa durée contractuelle entière en heures manquantes :
// l'export de référence contient exactement ce cas.
push(PAYROLL_ELEMENTS.MISSING_HOURS, missing);
let overtime25 = 0;
let overtime50 = 0;
let complementary10 = 0;
let complementary25 = 0;
for (const weekMinutes of tally?.byWeek.values() ?? []) {
if (weeklyMinutes > 0 && weeklyMinutes < agreement.parameters.weeklyReferenceMinutes) {
const split = splitComplementary(
weekMinutes,
weeklyMinutes,
agreement.parameters,
);
complementary10 += split.firstTierMinutes;
complementary25 += split.beyondMinutes + split.overCapMinutes;
} else {
const split = splitOvertime(weekMinutes, agreement.parameters);
for (const slice of split.slices) {
if (slice.ratePercent >= 50) overtime50 += slice.minutes;
else overtime25 += slice.minutes;
}
}
}
push(PAYROLL_ELEMENTS.OVERTIME_25, overtime25);
push(PAYROLL_ELEMENTS.OVERTIME_50, overtime50);
push(PAYROLL_ELEMENTS.COMPLEMENTARY_10, complementary10);
push(PAYROLL_ELEMENTS.COMPLEMENTARY_25, complementary25);
}
push(PAYROLL_ELEMENTS.SUNDAY_HOURS, tally?.sundayMinutes ?? 0);
push(PAYROLL_ELEMENTS.HOLIDAY_HOURS, tally?.holidayMinutes ?? 0);
if (elements.length === 0) continue;
const name =
`${membership.profile?.firstName ?? ''} ${membership.profile?.lastName ?? membership.employeeNumber}`.trim();
if (!membership.silaeMatricule) {
blockers.add(`${name} (${membership.employeeNumber}) : matricule Silae absent.`);
}
for (const element of elements) {
if (!element.silaeCode) {
blockers.add(`« ${element.label} » : aucun code Silae associé.`);
} else if (!element.confirmed) {
blockers.add(
`« ${element.label} » → ${element.silaeCode} : correspondance non confirmée par le gestionnaire de paie.`,
);
}
}
rows.push({
membershipId: assignment.membershipId,
employeeNumber: membership.employeeNumber,
silaeMatricule: membership.silaeMatricule,
name,
forfaitJours,
elements,
});
}
rows.sort((a, b) => a.name.localeCompare(b.name, 'fr'));
return {
month,
startDate,
endDate,
location,
rows,
blockers: [...blockers],
};
}
/** Traduit une période en lignes CSV. */
export function toSilaeLines(period: PayrollPeriod): SilaeLine[] {
return period.rows.flatMap((row) =>
row.elements.map((element) => ({
matricule: row.silaeMatricule ?? '',
code: element.silaeCode ?? '',
value: element.value,
kind: element.unit,
// Les agrégats couvrent la période de paie entière. Les absences, qui
// portent leurs propres dates, arriveront avec le lot suivant.
startDate: period.startDate,
endDate: period.endDate,
})),
);
}
/** Semaines ISO couvrant le mois, pour information à l'écran. */
export function weeksOfMonth(month: Month, timeZone: string): string[] {
const dates = monthDates(month);
const weeks = new Set<string>();
for (const date of dates) {
const week = isoWeekOf(new Date(`${date}T12:00:00Z`));
weeks.add(`${week.isoYear}-W${String(week.isoWeek).padStart(2, '0')}`);
void weekBounds(week, timeZone);
}
return [...weeks];
}
+115
View File
@@ -0,0 +1,115 @@
import {
PAYROLL_ELEMENT_DEFINITIONS,
type PayrollElementDefinition,
} from '@/domain/payroll/elements';
import { SILAE_OBSERVED_CODES, SILAE_SERVICE_CODES } from '@/domain/payroll/silae';
import type { Month } from '@/domain/planning/month';
import { query } from '@/server/context';
import { buildPayrollPeriod, type PayrollPeriod } from '@/server/payroll/build';
/**
* Lectures de l'écran de paie.
*
* `payroll.access` ouvre le rapport, `payroll.export.silae` produit le
* fichier : un responsable de magasin doit pouvoir contrôler ses heures sans
* pouvoir transmettre au cabinet.
*/
export interface PayrollView extends PayrollPeriod {
locations: Array<{ id: string; name: string }>;
monthParam: string;
previousParam: string;
nextParam: string;
label: string;
}
export async function getPayrollPeriod(
month: Month,
locationId?: string,
): Promise<PayrollView | null> {
return query(
'payroll.access',
async (db) => {
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 period = await buildPayrollPeriod(db, month, location.id);
if (!period) return null;
const { formatMonthParam, monthLabel, nextMonth, previousMonth } =
await import('@/domain/planning/month');
return {
...period,
locations,
label: monthLabel(month),
monthParam: formatMonthParam(month),
previousParam: formatMonthParam(previousMonth(month)),
nextParam: formatMonthParam(nextMonth(month)),
};
},
locationId ? { locationId } : undefined,
);
}
export interface MappingRow extends PayrollElementDefinition {
silaeCode: string | null;
confirmed: boolean;
}
export interface MappingView {
rows: MappingRow[];
/** Codes relevés dans l'export de référence, proposés à la saisie. */
knownCodes: string[];
exports: Array<{
id: string;
periodStart: Date;
periodEnd: Date;
checksum: string;
lineCount: number;
generatedAt: Date;
}>;
}
export async function getSilaeMapping(): Promise<MappingView> {
return query('payroll.export.silae', async (db) => {
const mappings = await db.silaeCodeMapping.findMany();
const byKey = new Map(mappings.map((entry) => [entry.sourceKey, entry]));
const exports = await db.payrollExport.findMany({
orderBy: { generatedAt: 'desc' },
take: 10,
select: {
id: true,
periodStart: true,
periodEnd: true,
checksum: true,
lineCount: true,
generatedAt: true,
},
});
return {
rows: PAYROLL_ELEMENT_DEFINITIONS.map((definition) => {
const mapping = byKey.get(definition.key);
return {
...definition,
silaeCode: mapping?.silaeCode ?? null,
confirmed: mapping?.confirmed ?? false,
};
}),
knownCodes: [
...Object.values(SILAE_SERVICE_CODES),
...SILAE_OBSERVED_CODES,
],
exports,
};
});
}