Retrait des disponibilités et des limites de temps dans la journée
Disponibilités déclarées supprimées (table `availability`, migration 0022, API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) ainsi que les règles OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus de disponibilité et de repos du solveur et le filtre « Surchargés » de la vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f671bb85bf
commit
d722a81a87
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
ArtistRead,
|
||||
AssignmentRead,
|
||||
AvailabilityRead,
|
||||
DayPartRead,
|
||||
MatrixResponse,
|
||||
PositionCategory,
|
||||
@@ -25,71 +24,6 @@ export function foldAccents(value: string): string {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Disponibilités : couverture d'un créneau par les fenêtres déclarées //
|
||||
// (portage de `backend/app/validation/engine.py`). //
|
||||
// --------------------------------------------------------------------------- //
|
||||
const _dayNum = (iso: string): number => {
|
||||
const [y, m, d] = iso.split('-').map(Number);
|
||||
return Math.floor(Date.UTC(y, m - 1, d) / 86_400_000);
|
||||
};
|
||||
|
||||
/** Minutes absolues (depuis une origine arbitraire) d'un datetime naïf ISO. */
|
||||
function absMinutes(iso: string): number {
|
||||
const [date, time] = iso.split('T');
|
||||
const [h, min] = time.split(':').map(Number);
|
||||
return _dayNum(date) * 1440 + h * 60 + min;
|
||||
}
|
||||
|
||||
function windowInterval(w: AvailabilityRead): [number, number] {
|
||||
const [sh, sm] = w.start_time.split(':').map(Number);
|
||||
const [eh, em] = w.end_time.split(':').map(Number);
|
||||
const start = _dayNum(w.date) * 1440 + sh * 60 + sm;
|
||||
let end = _dayNum(w.date) * 1440 + eh * 60 + em;
|
||||
if (end <= start) end += 1440; // fenêtre à cheval sur minuit
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
/** Minutes du créneau `[startIso, endIso)` hors de l'union des fenêtres. */
|
||||
export function uncoveredMinutes(
|
||||
windows: AvailabilityRead[],
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): number {
|
||||
const shiftStart = absMinutes(startIso);
|
||||
const shiftEnd = absMinutes(endIso);
|
||||
const intervals = windows
|
||||
.map(windowInterval)
|
||||
.filter(([a, b]) => b > a)
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
let cursor = shiftStart;
|
||||
let gap = 0;
|
||||
for (const [s, e] of intervals) {
|
||||
if (e <= cursor) continue;
|
||||
if (s > shiftEnd) break;
|
||||
if (s > cursor) gap += Math.min(s, shiftEnd) - cursor;
|
||||
cursor = Math.max(cursor, e);
|
||||
if (cursor >= shiftEnd) return gap;
|
||||
}
|
||||
if (cursor < shiftEnd) gap += shiftEnd - cursor;
|
||||
return gap;
|
||||
}
|
||||
|
||||
export type Coverage = 'full' | 'partial' | 'none';
|
||||
|
||||
/** État de couverture d'un créneau par les fenêtres d'un bénévole. */
|
||||
export function availabilityCoverage(
|
||||
windows: AvailabilityRead[],
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): Coverage {
|
||||
if (windows.length === 0) return 'none';
|
||||
const gap = uncoveredMinutes(windows, startIso, endIso);
|
||||
if (gap === 0) return 'full';
|
||||
const total = absMinutes(endIso) - absMinutes(startIso);
|
||||
return gap >= total ? 'none' : 'partial';
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Score de chance d'un candidat pour un créneau — portage simplifié du //
|
||||
// solveur (`backend/app/solver/engine.py`) : sert à trier la liste des //
|
||||
@@ -145,28 +79,25 @@ export function buildEffectiveCompanionIds(
|
||||
}
|
||||
|
||||
const CHANCE_WISH_POSITION_BONUS = 30;
|
||||
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
||||
const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
|
||||
const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2;
|
||||
const CHANCE_COMPANION_BONUS_PER_MATCH = 25;
|
||||
|
||||
/**
|
||||
* Score de chance d'un bénévole pour un créneau : vœu de poste, disponibilité
|
||||
* (y compris le conflit avec un artiste qu'il veut voir), compagnons
|
||||
* souhaités déjà affectés au créneau, charge déjà assignée. Plus haut =
|
||||
* Score de chance d'un bénévole pour un créneau : vœu de poste, conflit avec
|
||||
* un artiste qu'il veut voir, compagnons souhaités déjà affectés au créneau,
|
||||
* charge déjà assignée. Plus haut =
|
||||
* meilleur candidat. Sert uniquement à trier la liste affichée ; la
|
||||
* validation (blocage / avertissements) reste seule juge.
|
||||
*/
|
||||
export function candidateChanceScore(params: {
|
||||
wishesPosition: boolean;
|
||||
uncoveredMinutes: number;
|
||||
conflictsWishedArtist: boolean;
|
||||
companionMatches: number;
|
||||
assignedHours: number;
|
||||
}): number {
|
||||
let score = 0;
|
||||
if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS;
|
||||
score -= Math.floor(params.uncoveredMinutes / 30) * CHANCE_AVAIL_PENALTY_PER_HALF_HOUR;
|
||||
if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY;
|
||||
score += CHANCE_COMPANION_BONUS_PER_MATCH * params.companionMatches;
|
||||
score -= CHANCE_FAIRNESS_PENALTY_PER_HOUR * params.assignedHours;
|
||||
@@ -239,7 +170,7 @@ export function dayBoundaryFromDayParts(
|
||||
* « journée de festival » plutôt que par date calendaire brute : un créneau
|
||||
* commençant avant cette heure (ex. 02:00, suite d'une soirée qui a passé
|
||||
* minuit) est rattaché au jour précédent et trié après 23:00, pas avant
|
||||
* 19:00 — voir CLAUDE.md § 3 (« journée » pour le cumul des 8 h). */
|
||||
* 19:00 — voir CLAUDE.md § 3. */
|
||||
export function buildLattice(
|
||||
starts: Array<{ starts_at: string; ends_at: string }>,
|
||||
dayBoundary = '00:00',
|
||||
@@ -460,32 +391,6 @@ export function hoursByVolunteer(
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** Surcharge : > 8 h sur une journée civile ou < 8 h de repos entre créneaux. */
|
||||
export function isOverloaded(
|
||||
volunteerId: string,
|
||||
assignments: AssignmentRead[],
|
||||
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
|
||||
): boolean {
|
||||
const shifts = assignments
|
||||
.filter((a) => a.volunteer_id === volunteerId)
|
||||
.map((a) => shiftsById.get(a.shift_id))
|
||||
.filter((s): s is ShiftRead => Boolean(s))
|
||||
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
|
||||
|
||||
const perDay = new Map<string, number>();
|
||||
for (const s of shifts) {
|
||||
const day = datePart(s.starts_at);
|
||||
perDay.set(day, (perDay.get(day) ?? 0) + hoursBetween(s.starts_at, s.ends_at));
|
||||
}
|
||||
for (const hours of perDay.values()) if (hours > 8) return true;
|
||||
|
||||
for (let i = 1; i < shifts.length; i += 1) {
|
||||
const gap = naiveMinutes(shifts[i].starts_at) - naiveMinutes(shifts[i - 1].ends_at);
|
||||
if (gap > 0 && gap < 8 * 60) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rend une violation en texte lisible. Les `params` suivent exactement le
|
||||
* contrat du moteur back-end (`backend/app/validation/engine.py`).
|
||||
@@ -502,25 +407,7 @@ export function violationText(
|
||||
? t('validation.SHIFT_OVERLAP.at', { position: where })
|
||||
: t('validation.SHIFT_OVERLAP');
|
||||
}
|
||||
case 'OUTSIDE_AVAILABILITY':
|
||||
return t('validation.OUTSIDE_AVAILABILITY', {
|
||||
gap: formatMinutes(Number(p.uncovered_minutes)),
|
||||
});
|
||||
case 'DAILY_HOURS_EXCEEDED':
|
||||
return t('validation.DAILY_HOURS_EXCEEDED', {
|
||||
hours: formatMinutes(Number(p.total_minutes)),
|
||||
});
|
||||
case 'INSUFFICIENT_REST':
|
||||
return t('validation.INSUFFICIENT_REST', {
|
||||
rest: formatMinutes(Number(p.gap_minutes)),
|
||||
});
|
||||
default:
|
||||
return violation.code;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMinutes(total: number): string {
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user