From 99c47a108b693127d6053aaec79e0a4f3344b54e Mon Sep 17 00:00:00 2001 From: commanderxgr Date: Mon, 7 Sep 2026 15:20:38 +0300 Subject: [PATCH 1/4] feat(scheduler-v3): checkpoint engine core and contract for validation --- docs/SCHEDULER_CONTRACT_V3.md | 232 +++++++++++++ src/scheduler-engine-v3/config.ts | 225 ++++++++++++ src/scheduler-engine-v3/coverage.ts | 172 ++++++++++ src/scheduler-engine-v3/eligibility.ts | 233 +++++++++++++ src/scheduler-engine-v3/employeeProfile.ts | 213 ++++++++++++ src/scheduler-engine-v3/generator.ts | 381 +++++++++++++++++++++ src/scheduler-engine-v3/index.ts | 14 + src/scheduler-engine-v3/types.ts | 272 +++++++++++++++ src/scheduler-engine-v3/warnings.ts | 313 +++++++++++++++++ 9 files changed, 2055 insertions(+) create mode 100644 docs/SCHEDULER_CONTRACT_V3.md create mode 100644 src/scheduler-engine-v3/config.ts create mode 100644 src/scheduler-engine-v3/coverage.ts create mode 100644 src/scheduler-engine-v3/eligibility.ts create mode 100644 src/scheduler-engine-v3/employeeProfile.ts create mode 100644 src/scheduler-engine-v3/generator.ts create mode 100644 src/scheduler-engine-v3/index.ts create mode 100644 src/scheduler-engine-v3/types.ts create mode 100644 src/scheduler-engine-v3/warnings.ts diff --git a/docs/SCHEDULER_CONTRACT_V3.md b/docs/SCHEDULER_CONTRACT_V3.md new file mode 100644 index 0000000..756bf25 --- /dev/null +++ b/docs/SCHEDULER_CONTRACT_V3.md @@ -0,0 +1,232 @@ +# ShiftOryx Scheduler Contract V3 — Owner-Authoritative Standard Architecture + +**Version**: 3.0.0 +**Status**: Implementation Draft +**Supersedes**: V2 only when `schemaVersion=3` is active for the tenant. V2 remains fully supported. + +--- + +## 1. Purpose + +Scheduler V3 replaces the hidden CORE/FLEX/EXTRA role topology with a **generic, owner-authoritative** scheduling model based on: + +- Tenant-defined **headcount-only coverage** +- **Active employees** as normal scheduling participants by default +- Standard **days off**, optional **weekly target hours**, optional **standard shifts** +- Per-employee **weekly shift rotation** +- **Deterministic** automatic baseline generation +- **Editable draft preview** with live statistics +- **Non-blocking** scheduling warnings (OWNER decides) +- **Immutable publication versions** with permanent PDF history + +--- + +## 2. Non-Negotiable Contracts + +### 2.1 No Implicit Role Classification +V3 does NOT depend on `CORE_A`, `CORE_B`, `FLEX_A`, `FLEX_B`, `EXTRA_A`, `EXTRA_B`, employee array position, or employee count-based special cases. + +### 2.2 Active Employee = Normal Participant +An active employee with `workMode=NORMAL` participates in normal scheduling. No employee becomes substitute-only implicitly. + +### 2.3 Non-Blocking Warnings +All scheduling/business warnings are `blocking: false`. They inform the OWNER but never prevent draft saving, preview editing, or publishing. Only technical/security errors block operations. + +### 2.4 Immutable Publications +Every Publish creates a new immutable version. Old versions are never overwritten or deleted. + +### 2.5 PDF History +Each publication stores both a structured snapshot AND the exact PDF artifact. Historical PDFs remain stable regardless of future configuration changes. + +### 2.6 Deterministic Generation +Same inputs → same output. No `Math.random()`, no `localeCompare` in scheduling paths, no wall-clock dependencies. + +--- + +## 3. Data Model + +### 3.1 SchedulerConfigV3 +```typescript +type SchedulerConfigV3 = { + schemaVersion: 3; + tenantId: string; + timezone: string; + weekStartDay: 1 | 2 | 3 | 4 | 5 | 6 | 7; + operatingDays: OperatingDayConfigV3[]; + shiftTemplates: ShiftTemplateV3[]; + coverageRequirements: CoverageRequirementV3[]; + generationDefaults: { balanceWeeklyTargetsForMonth: boolean }; + warningPolicies?: WarningPoliciesV3; + templateId: string; + templateVersion: number; +}; +``` + +Coverage is **headcount-only**. No role/skill requirements. + +### 3.2 Employee Scheduling Profile V3 +```typescript +type EmployeeSchedulingProfileV3 = { + workMode: 'NORMAL' | 'SUBSTITUTE_ONLY'; + fixedDayOff: number | null; + targetWeeklyHours: number | null; + standardShiftTemplateId: string | null; + rotateStandardShiftWeekly: boolean; + rotationAlternateShiftTemplateId: string | null; + rotationAnchorWeekStart: string | null; +}; +``` + +Defaults for new employees: +- `workMode = NORMAL` +- All other fields = `null` / `false` + +### 3.3 Coverage Slot +```typescript +type CoverageSlotV3 = { + shiftTemplateId: string; + headcount: number; // integer >= 0 +}; +``` + +No role or skill fields. + +### 3.4 Warning +```typescript +type ScheduleWarningV3 = { + id: string; + code: WarningCodeV3; + severity: 'INFO' | 'WARNING'; + blocking: false; // ALWAYS false + date?: string; + employeeId?: string; + message: string; + details?: Record; +}; +``` + +### 3.5 Publication +```typescript +type SchedulePublicationV3 = { + id: string; + tenantId: string; + schemaVersion: 3; + periodType: 'WEEK' | 'MONTH'; + periodStart: string; + periodEnd: string; + periodKey: string; + version: number; + templateSnapshot: SchedulerConfigV3; + employeeSnapshot: SafeEmployeeSnapshot[]; + shifts: SafePublishedShiftV3[]; + calculatedHours: EmployeeHoursSummaryV3[]; + warningsAtPublish: ScheduleWarningV3[]; + publishedWithWarnings: boolean; + pdfStoragePath: string; + pdfGeneratedAt: string; + publishedAt: string; + publishedByUid: string; +}; +``` + +Employee snapshots contain ONLY: `employeeId`, `displayName`, optional `color`, optional `displayLabel`. NO email, phone, AFM, auth UID, or private metadata. + +--- + +## 4. Generator Contract + +### 4.1 Pure Function +`generateScheduleV3()` is pure. No Firestore. No Firebase. No side effects. + +### 4.2 Assignment Priority +For each coverage slot: +1. Employee is auto-eligible (active, no absence, no fixed day off) +2. Employee's effective standard shift matches slot +3. Employee has remaining weekly target-hours deficit +4. Deterministic round-robin for equal candidates +5. Final `employeeId` lexicographic tie-break + +### 4.3 Target Hours +- `targetWeeklyHours` is an objective, NOT a hard constraint +- Target is never treated as a maximum — coverage may exceed it +- `null` target means no weekly hours guidance + +### 4.4 Weekly Rotation +- Anchor-based parity: `weeks_since_anchor % 2` +- Month/year boundaries do NOT reset rotation +- Manual preview edits do NOT alter rotation settings + +### 4.5 Coverage Gaps +- Unfilled slots produce `COVERAGE_UNDER_TARGET` warnings +- Draft generation NEVER aborts due to coverage shortages +- Partial schedules are valid drafts + +--- + +## 5. Warning Codes + +| Code | Trigger | +|---|---| +| `COVERAGE_UNDER_TARGET` | Coverage slot unfilled | +| `COVERAGE_OVER_TARGET` | More employees than headcount | +| `FIXED_DAY_OFF_OVERRIDE` | Manual assignment on fixed day off | +| `ABSENCE_OVERRIDE` | Manual assignment during absence | +| `SHIFT_OVERLAP` | Employee has overlapping assignments | +| `OUTSIDE_OPERATING_WINDOW` | Shift outside operating hours | +| `STANDARD_SHIFT_DEVIATION` | Employee not on their standard shift | +| `TARGET_HOURS_UNDER` | Below weekly target hours | +| `TARGET_HOURS_OVER` | Above weekly target hours | +| `REST_INTERVAL_WARNING` | Less than minimum rest between shifts | +| `DAILY_HOURS_WARNING` | Exceeds daily hour limit | +| `WEEKLY_HOURS_WARNING` | Exceeds weekly hour limit | +| `CONSECUTIVE_DAYS_WARNING` | Too many consecutive working days | +| `ROTATION_CONFIGURATION_WARNING` | Rotation misconfigured | +| `SUBSTITUTE_MANUAL_ASSIGNMENT` | Substitute-only employee manually assigned | +| `DEACTIVATED_EMPLOYEE_REFERENCE` | Reference to inactive employee | + +ALL warnings: `blocking: false`. + +--- + +## 6. Publication Flow + +``` +Draft + → Structural validation (blocking) + → Warning analysis (non-blocking) + → OWNER reviews warnings + → Publish Anyway + → Immutable version created + → PDF stored + → Period index updated + → Public projection updated +``` + +### 6.1 Version Allocation +Transaction-safe. Two concurrent publishes must produce unique version numbers. + +### 6.2 PDF +Generated from the immutable in-memory snapshot. Stored at: +`tenants/{tenantId}/schedule-publications/{publicationId}/schedule.pdf` + +No permanent public URL. Download through authorized Firebase Storage SDK. + +--- + +## 7. V2 Coexistence + +- `schemaVersion=2` → existing V2/legacy paths +- `schemaVersion=3` → V3 paths +- V2 tests, contract, and functionality remain untouched +- No automatic production migration + +--- + +## 8. Security + +- All new collections are tenant-scoped +- Publications: create-only, deny update/delete +- PDF: OWNER-only access via tenant membership +- No cross-tenant access +- Employee snapshots exclude private fields +- No secrets in warning details or audit logs diff --git a/src/scheduler-engine-v3/config.ts b/src/scheduler-engine-v3/config.ts new file mode 100644 index 0000000..011e413 --- /dev/null +++ b/src/scheduler-engine-v3/config.ts @@ -0,0 +1,225 @@ +import type { SchedulerConfigV3, ShiftTemplateV3 } from './types.ts'; + +const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; + +/** + * Validates if a string is in HH:mm format. + * @param value String to test + * @returns Boolean indicating validity + */ +export function isValidTimeV3(value: string): boolean { + if (typeof value !== 'string') return false; + return TIME_REGEX.test(value); +} + +/** + * Converts a HH:mm time string to minutes past midnight. + * @param time HH:mm time string + * @returns Minutes since midnight + */ +export function timeToMinutesV3(time: string): number { + if (!isValidTimeV3(time)) return 0; + const [h, m] = time.split(':').map(Number); + return h * 60 + m; +} + +/** + * Calculates duration in hours between two HH:mm times with quarter-hour precision. + * @param startTime HH:mm start time + * @param endTime HH:mm end time + * @param crossMidnight True if the shift crosses midnight + * @returns Number of hours rounded to nearest 0.25 + */ +export function calculateShiftDurationHoursV3(startTime: string, endTime: string, crossMidnight?: boolean): number { + if (!isValidTimeV3(startTime) || !isValidTimeV3(endTime)) return 0; + + const startMins = timeToMinutesV3(startTime); + let endMins = timeToMinutesV3(endTime); + + if (crossMidnight || endMins < startMins) { + endMins += 24 * 60; + } + + const diffMins = Math.max(0, endMins - startMins); + const hours = diffMins / 60; + + return Math.round(hours * 4) / 4; +} + +/** + * Validates a single shift template independently. + * @param template The template to validate + * @returns Object containing valid boolean and any error messages + */ +export function validateShiftTemplateV3(template: unknown): { valid: boolean; errors: string[] } { + const errors: string[] = []; + if (!template || typeof template !== 'object') { + return { valid: false, errors: ['Το πρότυπο βάρδιας πρέπει να είναι αντικείμενο.'] }; + } + const t = template as any; + + if (!t.id || typeof t.id !== 'string') { + errors.push('Το πρότυπο βάρδιας πρέπει να έχει ένα έγκυρο id (string).'); + } + if (!t.label || typeof t.label !== 'string' || t.label.trim() === '') { + errors.push('Το πρότυπο βάρδιας πρέπει να έχει μία μη κενή ετικέτα (label).'); + } + + if (typeof t.isActive !== 'boolean') { + errors.push('Το πεδίο isActive πρέπει να είναι boolean.'); + } + + if (!t.startTime || !isValidTimeV3(t.startTime)) { + errors.push('Μη έγκυρη μορφή ώρας έναρξης. Πρέπει να είναι HH:mm.'); + } + if (!t.endTime || !isValidTimeV3(t.endTime)) { + errors.push('Μη έγκυρη μορφή ώρας λήξης. Πρέπει να είναι HH:mm.'); + } + + if (typeof t.durationHours !== 'number' || t.durationHours <= 0) { + errors.push('Η διάρκεια της βάρδιας πρέπει να είναι θετικός αριθμός μεγαλύτερος του μηδενός.'); + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Validates the V3 Scheduler configuration structurally. + * @param config The configuration to validate + * @returns Object containing valid boolean and any error messages + */ +export function validateSchedulerConfigV3(config: unknown): { valid: boolean; errors: string[] } { + const errors: string[] = []; + if (!config || typeof config !== 'object') { + return { valid: false, errors: ['Η διαμόρφωση πρέπει να είναι αντικείμενο.'] }; + } + + const c = config as any; + + if (c.schemaVersion !== 3) { + errors.push('Η έκδοση σχήματος (schemaVersion) πρέπει να είναι 3.'); + } + if (!c.tenantId || typeof c.tenantId !== 'string' || c.tenantId.trim() === '') { + errors.push('Το tenantId είναι υποχρεωτικό και πρέπει να είναι μη κενό κείμενο (string).'); + } + if (!c.timezone || typeof c.timezone !== 'string' || c.timezone.trim() === '') { + errors.push('Το timezone είναι υποχρεωτικό και πρέπει να είναι μη κενό κείμενο (string).'); + } + if (typeof c.weekStartDay !== 'number' || c.weekStartDay < 1 || c.weekStartDay > 7) { + errors.push('Το weekStartDay πρέπει να είναι αριθμός από το 1 έως το 7.'); + } + if (!c.templateId || typeof c.templateId !== 'string' || c.templateId.trim() === '') { + errors.push('Το templateId είναι υποχρεωτικό και πρέπει να είναι μη κενό κείμενο (string).'); + } + if (typeof c.templateVersion !== 'number' || c.templateVersion < 1 || !Number.isInteger(c.templateVersion)) { + errors.push('Το templateVersion πρέπει να είναι ακέραιος αριθμός μεγαλύτερος ή ίσος του 1.'); + } + + // Operating Days + if (!Array.isArray(c.operatingDays) || c.operatingDays.length !== 7) { + errors.push('Το operatingDays πρέπει να είναι πίνακας με ακριβώς 7 καταχωρήσεις.'); + } else { + for (let i = 0; i < c.operatingDays.length; i++) { + const day = c.operatingDays[i]; + if (!Array.isArray(day.windows)) { + errors.push(`Οι ώρες λειτουργίας στην ημέρα δείκτη ${i} πρέπει να περιέχουν πίνακα από windows.`); + } else { + for (const w of day.windows) { + if (!w.openTime || !isValidTimeV3(w.openTime) || !w.closeTime || !isValidTimeV3(w.closeTime)) { + errors.push('Τα ωράρια (openTime, closeTime) πρέπει να έχουν μορφή HH:mm.'); + } else { + if (w.openTime >= w.closeTime && !w.crossMidnight) { + errors.push('Η ώρα έναρξης πρέπει να είναι πριν την ώρα λήξης εκτός αν πρόκειται για crossMidnight.'); + } + } + } + } + } + } + + const templateIds = new Set(); + // Shift Templates + if (!Array.isArray(c.shiftTemplates)) { + errors.push('Το shiftTemplates πρέπει να είναι πίνακας.'); + } else { + for (const t of c.shiftTemplates) { + const tValidation = validateShiftTemplateV3(t); + if (!tValidation.valid) { + errors.push(...tValidation.errors); + } + if (t.id) { + if (templateIds.has(t.id)) { + errors.push(`Το shiftTemplate id "${t.id}" υπάρχει ήδη.`); + } + templateIds.add(t.id); + } + } + } + + // Coverage Requirements + const coverageWeekdays = new Set(); + if (!Array.isArray(c.coverageRequirements)) { + errors.push('Το coverageRequirements πρέπει να είναι πίνακας.'); + } else { + for (const req of c.coverageRequirements) { + if (coverageWeekdays.has(req.weekday)) { + errors.push(`Υπάρχει ήδη εγγραφή coverage για την ημέρα ${req.weekday}.`); + } + coverageWeekdays.add(req.weekday); + if (!Array.isArray(req.slots)) { + errors.push(`Τα slots κάλυψης πρέπει να είναι πίνακας (${req.weekday}).`); + } else { + for (const slot of req.slots) { + if (!templateIds.has(slot.shiftTemplateId)) { + errors.push(`Το coverageRequirement αναφέρεται σε μη έγκυρο shiftTemplateId: ${slot.shiftTemplateId}.`); + } + if (typeof slot.headcount !== 'number' || slot.headcount < 0 || !Number.isInteger(slot.headcount)) { + errors.push('Το headcount πρέπει να είναι ακέραιος αριθμός >= 0.'); + } + } + } + } + } + + // Warning Policies + if (c.warningPolicies && typeof c.warningPolicies === 'object') { + for (const [key, val] of Object.entries(c.warningPolicies)) { + if (val !== null && (typeof val !== 'number' || val <= 0)) { + errors.push(`Το warningPolicy "${key}" πρέπει να είναι null ή θετικός αριθμός.`); + } + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Normalizes a partial V3 configuration by applying default values. + * @param config Partial configuration + * @returns Fully populated SchedulerConfigV3 + */ +export function normalizeSchedulerConfigV3(config: Partial): SchedulerConfigV3 { + const defaultOperatingDays = [ + { weekday: 'MONDAY' as const, isOpen: true, windows: [{ openTime: '06:00', closeTime: '22:00' }] }, + { weekday: 'TUESDAY' as const, isOpen: true, windows: [{ openTime: '06:00', closeTime: '22:00' }] }, + { weekday: 'WEDNESDAY' as const, isOpen: true, windows: [{ openTime: '06:00', closeTime: '22:00' }] }, + { weekday: 'THURSDAY' as const, isOpen: true, windows: [{ openTime: '06:00', closeTime: '22:00' }] }, + { weekday: 'FRIDAY' as const, isOpen: true, windows: [{ openTime: '06:00', closeTime: '22:00' }] }, + { weekday: 'SATURDAY' as const, isOpen: true, windows: [{ openTime: '06:00', closeTime: '22:00' }] }, + { weekday: 'SUNDAY' as const, isOpen: false, windows: [] }, + ]; + + return { + schemaVersion: 3, + tenantId: config?.tenantId || '', + timezone: config?.timezone || 'Europe/Athens', + weekStartDay: config?.weekStartDay || 1, + operatingDays: config?.operatingDays || defaultOperatingDays, + shiftTemplates: config?.shiftTemplates || [], + coverageRequirements: config?.coverageRequirements || [], + generationDefaults: config?.generationDefaults || { balanceWeeklyTargetsForMonth: true }, + warningPolicies: config?.warningPolicies, + templateId: config?.templateId || '', + templateVersion: config?.templateVersion || 1, + }; +} diff --git a/src/scheduler-engine-v3/coverage.ts b/src/scheduler-engine-v3/coverage.ts new file mode 100644 index 0000000..43294bd --- /dev/null +++ b/src/scheduler-engine-v3/coverage.ts @@ -0,0 +1,172 @@ +/** + * ShiftOryx Scheduler V3 — Coverage Slot Processing + * + * Expands coverage requirements into concrete date/slot demands, + * tracks filled vs unfilled, and generates coverage summary. + * No role/skill coverage — headcount only. + * Pure functions — no Firestore, no Firebase. + */ + +import type { + CoverageRequirementV3, + CoverageSlotV3, + CoverageSummaryV3, + GeneratedShiftV3, + OperatingDayConfigV3, + SchedulerConfigV3, + ShiftTemplateV3, + Weekday, +} from './types.ts'; + +export type ExpandedCoverageSlot = { + date: string; + weekday: Weekday; + shiftTemplateId: string; + shiftTemplate: ShiftTemplateV3 | null; + headcount: number; + slotIndex: number; // 0-based within headcount for this slot +}; + +const DATE_TO_WEEKDAY: Record = { + 0: 'SUNDAY', 1: 'MONDAY', 2: 'TUESDAY', 3: 'WEDNESDAY', + 4: 'THURSDAY', 5: 'FRIDAY', 6: 'SATURDAY', +}; + +/** + * Get the weekday name for an ISO date string. + */ +export function getWeekdayForDate(dateStr: string): Weekday { + const d = new Date(dateStr + 'T00:00:00Z'); + return DATE_TO_WEEKDAY[d.getUTCDay()]; +} + +/** + * Generate all dates in [startDate, endDate] inclusive. + */ +export function eachDateInRange(startDate: string, endDate: string): string[] { + const dates: string[] = []; + const current = new Date(startDate + 'T00:00:00Z'); + const end = new Date(endDate + 'T00:00:00Z'); + while (current <= end) { + dates.push(current.toISOString().slice(0, 10)); + current.setUTCDate(current.getUTCDate() + 1); + } + return dates; +} + +/** + * Check if a weekday is open according to operating day config. + */ +function isDayOpen(weekday: Weekday, operatingDays: OperatingDayConfigV3[]): boolean { + const day = operatingDays.find((d) => d.weekday === weekday); + return day ? day.isOpen : false; +} + +/** + * Expand coverage requirements into concrete per-date, per-slot entries + * for the given date range. + * + * Each headcount unit becomes its own slot entry to enable individual filling. + */ +export function expandCoverageSlots( + config: SchedulerConfigV3, + startDate: string, + endDate: string, +): ExpandedCoverageSlot[] { + const slots: ExpandedCoverageSlot[] = []; + const templateMap = new Map(config.shiftTemplates.map((t) => [t.id, t])); + const dates = eachDateInRange(startDate, endDate); + + for (const date of dates) { + const weekday = getWeekdayForDate(date); + + // Skip closed days + if (!isDayOpen(weekday, config.operatingDays)) continue; + + // Find coverage for this weekday + const coverage = config.coverageRequirements.find((c) => c.weekday === weekday); + if (!coverage) continue; + + for (const slot of coverage.slots) { + const template = templateMap.get(slot.shiftTemplateId) || null; + // Skip inactive templates + if (template && !template.isActive) continue; + + for (let i = 0; i < slot.headcount; i++) { + slots.push({ + date, + weekday, + shiftTemplateId: slot.shiftTemplateId, + shiftTemplate: template, + headcount: slot.headcount, + slotIndex: i, + }); + } + } + } + + return slots; +} + +/** + * Calculate coverage summary by comparing expanded slots to assigned shifts. + */ +export function calculateCoverageSummary( + config: SchedulerConfigV3, + startDate: string, + endDate: string, + assignedShifts: GeneratedShiftV3[], +): CoverageSummaryV3 { + const allSlots = expandCoverageSlots(config, startDate, endDate); + const dates = eachDateInRange(startDate, endDate); + + // Build a map of filled shifts per date/template + const fillMap = new Map(); + for (const shift of assignedShifts) { + const key = `${shift.date}:${shift.shiftTemplateId || ''}`; + fillMap.set(key, (fillMap.get(key) || 0) + 1); + } + + // Aggregate by date/template for required vs filled + const demandMap = new Map(); + for (const slot of allSlots) { + const key = `${slot.date}:${slot.shiftTemplateId}`; + if (!demandMap.has(key)) { + demandMap.set(key, { required: 0, filled: 0 }); + } + demandMap.get(key)!.required++; + } + for (const [key, entry] of demandMap) { + entry.filled = fillMap.get(key) || 0; + } + + let totalSlots = 0; + let filledSlots = 0; + let unfilledSlots = 0; + let overfilledSlots = 0; + const byWeekday: Record = {}; + + for (const weekday of ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] as Weekday[]) { + byWeekday[weekday] = { required: 0, filled: 0 }; + } + + for (const [key, entry] of demandMap) { + const date = key.split(':')[0]; + const weekday = getWeekdayForDate(date); + totalSlots += entry.required; + const filled = Math.min(entry.filled, entry.required); + filledSlots += filled; + unfilledSlots += Math.max(0, entry.required - entry.filled); + overfilledSlots += Math.max(0, entry.filled - entry.required); + byWeekday[weekday].required += entry.required; + byWeekday[weekday].filled += filled; + } + + return { + totalSlots, + filledSlots, + unfilledSlots, + overfilledSlots, + byWeekday: byWeekday as Record, + }; +} diff --git a/src/scheduler-engine-v3/eligibility.ts b/src/scheduler-engine-v3/eligibility.ts new file mode 100644 index 0000000..4133ff3 --- /dev/null +++ b/src/scheduler-engine-v3/eligibility.ts @@ -0,0 +1,233 @@ +/** + * ShiftOryx Scheduler V3 — Employee Eligibility + * + * Evaluates whether an employee is eligible for automatic assignment + * on a specific date/slot. No CORE/FLEX/EXTRA dependencies. + * Pure function — no Firestore, no Firebase. + */ + +import type { + EmployeeV3, + EmployeeAbsenceV3, + ShiftTemplateV3, + GeneratedShiftV3, + Weekday, +} from './types.ts'; +import { isFixedDayOff, isWithinActiveDates, resolveEffectiveStandardShift } from './employeeProfile.ts'; +import { timeToMinutesV3, calculateShiftDurationHoursV3 } from './config.ts'; + +export type EligibilityReason = + | 'ELIGIBLE' + | 'INACTIVE' + | 'SUBSTITUTE_ONLY' + | 'FIXED_DAY_OFF' + | 'ABSENCE' + | 'OUTSIDE_ACTIVE_DATES' + | 'ALREADY_ASSIGNED' + | 'REST_INTERVAL' + | 'DAILY_HOURS_EXCEEDED' + | 'WEEKLY_HOURS_EXCEEDED' + | 'CONSECUTIVE_DAYS_EXCEEDED'; + +export type EligibilityResult = { + eligible: boolean; + reason: EligibilityReason; + employeeId: string; +}; + +/** + * Check if a date falls within any of the employee's absences. + */ +function hasAbsenceOnDate( + employeeId: string, + dateStr: string, + absences: EmployeeAbsenceV3[], +): boolean { + return absences.some( + (a) => + a.employeeId === employeeId && + dateStr >= a.startDate && + dateStr <= a.endDate && + a.scope === 'FULL_DAY', + ); +} + +/** + * Check if assigning a shift on this date would violate rest interval. + * Returns true if the rest period is insufficient. + */ +function wouldViolateRestInterval( + employeeId: string, + dateStr: string, + shiftStartTime: string, + existingShifts: GeneratedShiftV3[], + minRestHours: number | null | undefined, +): boolean { + if (!minRestHours || minRestHours <= 0) return false; + + const minRestMinutes = minRestHours * 60; + const newStartMin = timeToMinutesV3(shiftStartTime); + + // Check shifts on previous day and same day + const prevDate = addDaysSimple(dateStr, -1); + const relevantShifts = existingShifts.filter( + (s) => s.employeeId === employeeId && (s.date === prevDate || s.date === dateStr), + ); + + for (const s of relevantShifts) { + if (s.date === prevDate) { + // End of previous shift to start of new shift + const endMin = timeToMinutesV3(s.endTime); + const restMinutes = (24 * 60 - endMin) + newStartMin; + if (restMinutes < minRestMinutes) return true; + } + if (s.date === dateStr && s.endTime !== shiftStartTime) { + // Same-day overlap check + return true; + } + } + + return false; +} + +/** Simple date arithmetic */ +function addDaysSimple(dateStr: string, days: number): string { + const d = new Date(dateStr + 'T00:00:00Z'); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +/** + * Calculate total hours already assigned to an employee for a given date range. + */ +function getEmployeeHoursInRange( + employeeId: string, + startDate: string, + endDate: string, + existingShifts: GeneratedShiftV3[], +): number { + return existingShifts + .filter((s) => s.employeeId === employeeId && s.date >= startDate && s.date <= endDate) + .reduce((sum, s) => sum + s.durationHours, 0); +} + +/** + * Count shifts assigned to employee on a specific date. + */ +function getEmployeeShiftCountOnDate( + employeeId: string, + dateStr: string, + existingShifts: GeneratedShiftV3[], +): number { + return existingShifts.filter((s) => s.employeeId === employeeId && s.date === dateStr).length; +} + +/** + * Count consecutive working days ending on the given date. + */ +function getConsecutiveWorkDays( + employeeId: string, + dateStr: string, + existingShifts: GeneratedShiftV3[], +): number { + let count = 0; + let checkDate = dateStr; + const shiftDates = new Set( + existingShifts.filter((s) => s.employeeId === employeeId).map((s) => s.date), + ); + + while (shiftDates.has(checkDate)) { + count++; + checkDate = addDaysSimple(checkDate, -1); + } + return count; +} + +/** + * Evaluate employee eligibility for automatic assignment on a given date/shift. + * + * @param employee The employee to evaluate + * @param dateStr ISO date string (YYYY-MM-DD) + * @param absences All employee absences for the period + * @param existingShifts Shifts already assigned in this generation pass + * @param shiftStartTime The start time of the shift being considered + * @param options Policy limits + */ +export function evaluateEmployeeEligibilityV3( + employee: EmployeeV3, + dateStr: string, + absences: EmployeeAbsenceV3[], + existingShifts: GeneratedShiftV3[], + shiftStartTime: string, + options: { + minRestIntervalHours?: number | null; + maxDailyHours?: number | null; + maxWeeklyHours?: number | null; + maxConsecutiveWorkingDays?: number | null; + weekStartDate?: string; + weekEndDate?: string; + } = {}, +): EligibilityResult { + const eid = employee.id; + + // 1. Active check + if (!employee.isActive) { + return { eligible: false, reason: 'INACTIVE', employeeId: eid }; + } + + // 2. Work mode check + if (employee.schedulerV3.workMode === 'SUBSTITUTE_ONLY') { + return { eligible: false, reason: 'SUBSTITUTE_ONLY', employeeId: eid }; + } + + // 3. Seasonal active dates + if (!isWithinActiveDates(dateStr, employee.activeFrom, employee.activeTo)) { + return { eligible: false, reason: 'OUTSIDE_ACTIVE_DATES', employeeId: eid }; + } + + // 4. Fixed day off + if (isFixedDayOff(dateStr, employee.schedulerV3.fixedDayOff)) { + return { eligible: false, reason: 'FIXED_DAY_OFF', employeeId: eid }; + } + + // 5. Absence + if (hasAbsenceOnDate(eid, dateStr, absences)) { + return { eligible: false, reason: 'ABSENCE', employeeId: eid }; + } + + // 6. Already assigned (no double shifts from auto-generation) + if (getEmployeeShiftCountOnDate(eid, dateStr, existingShifts) > 0) { + return { eligible: false, reason: 'ALREADY_ASSIGNED', employeeId: eid }; + } + + // 7. Rest interval + if (wouldViolateRestInterval(eid, dateStr, shiftStartTime, existingShifts, options.minRestIntervalHours)) { + return { eligible: false, reason: 'REST_INTERVAL', employeeId: eid }; + } + + // 8. Daily hours + if (options.maxDailyHours && options.maxDailyHours > 0) { + const dailyHours = getEmployeeHoursInRange(eid, dateStr, dateStr, existingShifts); + if (dailyHours >= options.maxDailyHours) { + return { eligible: false, reason: 'DAILY_HOURS_EXCEEDED', employeeId: eid }; + } + } + + // 9. Weekly hours + if (options.maxWeeklyHours && options.maxWeeklyHours > 0 && options.weekStartDate && options.weekEndDate) { + const weeklyHours = getEmployeeHoursInRange(eid, options.weekStartDate, options.weekEndDate, existingShifts); + if (weeklyHours >= options.maxWeeklyHours) { + return { eligible: false, reason: 'WEEKLY_HOURS_EXCEEDED', employeeId: eid }; + } + } + + // 10. Consecutive days + if (options.maxConsecutiveWorkingDays && options.maxConsecutiveWorkingDays > 0) { + const consecutive = getConsecutiveWorkDays(eid, dateStr, existingShifts); + if (consecutive >= options.maxConsecutiveWorkingDays) { + return { eligible: false, reason: 'CONSECUTIVE_DAYS_EXCEEDED', employeeId: eid }; + } + } + + return { eligible: true, reason: 'ELIGIBLE', employeeId: eid }; +} diff --git a/src/scheduler-engine-v3/employeeProfile.ts b/src/scheduler-engine-v3/employeeProfile.ts new file mode 100644 index 0000000..8e79934 --- /dev/null +++ b/src/scheduler-engine-v3/employeeProfile.ts @@ -0,0 +1,213 @@ +/** + * ShiftOryx Scheduler V3 — Employee Profile Normalization & Utilities + * + * Handles V3 employee scheduling profile defaults, validation, + * standard shift resolution, and weekly rotation logic. + * Pure functions — no Firestore, no Firebase. + */ + +import type { + EmployeeSchedulingProfileV3, + EmployeeV3, + ShiftTemplateV3, + Weekday, +} from './types.ts'; +import { DEFAULT_EMPLOYEE_PROFILE_V3 } from './types.ts'; + +const WEEKDAY_JS_INDEX: Record = { + SUNDAY: 0, MONDAY: 1, TUESDAY: 2, WEDNESDAY: 3, + THURSDAY: 4, FRIDAY: 5, SATURDAY: 6, +}; + +const JS_INDEX_TO_WEEKDAY: Weekday[] = [ + 'SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', +]; + +/** + * Normalize an employee's V3 scheduling profile, applying defaults + * for any missing fields. Does NOT mutate the input. + */ +export function normalizeEmployeeProfileV3( + profile?: Partial | null, +): EmployeeSchedulingProfileV3 { + if (!profile) return { ...DEFAULT_EMPLOYEE_PROFILE_V3 }; + return { + workMode: profile.workMode || 'NORMAL', + fixedDayOff: typeof profile.fixedDayOff === 'number' ? profile.fixedDayOff : null, + targetWeeklyHours: typeof profile.targetWeeklyHours === 'number' && profile.targetWeeklyHours >= 0 + ? profile.targetWeeklyHours + : null, + standardShiftTemplateId: profile.standardShiftTemplateId || null, + rotateStandardShiftWeekly: profile.rotateStandardShiftWeekly === true, + rotationAlternateShiftTemplateId: profile.rotationAlternateShiftTemplateId || null, + rotationAnchorWeekStart: profile.rotationAnchorWeekStart || null, + }; +} + +/** + * Validate an employee's V3 profile fields. + * Returns blocking structural errors only. + */ +export function validateEmployeeProfileV3( + profile: EmployeeSchedulingProfileV3, + availableTemplateIds: Set, +): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (profile.workMode !== 'NORMAL' && profile.workMode !== 'SUBSTITUTE_ONLY') { + errors.push('Ο τρόπος εργασίας (workMode) πρέπει να είναι NORMAL ή SUBSTITUTE_ONLY.'); + } + + if (profile.fixedDayOff !== null) { + if (!Number.isInteger(profile.fixedDayOff) || profile.fixedDayOff < 0 || profile.fixedDayOff > 6) { + errors.push('Η σταθερή ημέρα ανάπαυσης πρέπει να είναι 0-6 ή null.'); + } + } + + if (profile.targetWeeklyHours !== null) { + if (typeof profile.targetWeeklyHours !== 'number' || profile.targetWeeklyHours < 0) { + errors.push('Ο στόχος εβδομαδιαίων ωρών πρέπει να είναι μη αρνητικός αριθμός ή null.'); + } + } + + if (profile.standardShiftTemplateId !== null && !availableTemplateIds.has(profile.standardShiftTemplateId)) { + errors.push(`Μη έγκυρη αναφορά τυπικής βάρδιας: ${profile.standardShiftTemplateId}.`); + } + + if (profile.rotateStandardShiftWeekly) { + if (!profile.standardShiftTemplateId) { + errors.push('Η εβδομαδιαία εναλλαγή απαιτεί ορισμό τυπικής βάρδιας.'); + } + if (!profile.rotationAlternateShiftTemplateId) { + errors.push('Η εβδομαδιαία εναλλαγή απαιτεί ορισμό εναλλακτικής βάρδιας.'); + } + if (profile.rotationAlternateShiftTemplateId && !availableTemplateIds.has(profile.rotationAlternateShiftTemplateId)) { + errors.push(`Μη έγκυρη αναφορά εναλλακτικής βάρδιας: ${profile.rotationAlternateShiftTemplateId}.`); + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Compute the ISO Monday start date for a given ISO date string (YYYY-MM-DD). + */ +export function getMondayOfWeek(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00Z'); + const dayOfWeek = d.getUTCDay(); // 0=Sun, 1=Mon... + const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + d.setUTCDate(d.getUTCDate() + diff); + return d.toISOString().slice(0, 10); +} + +/** + * Compute the number of weeks between two ISO Monday dates. + * Positive if target is after anchor. + */ +export function weeksBetween(anchorMonday: string, targetMonday: string): number { + const a = new Date(anchorMonday + 'T00:00:00Z').getTime(); + const b = new Date(targetMonday + 'T00:00:00Z').getTime(); + return Math.round((b - a) / (7 * 24 * 60 * 60 * 1000)); +} + +/** + * Compute rotation week parity for a given date. + * Returns 0 for standard shift weeks, 1 for alternate shift weeks. + * Deterministic — uses explicit anchor date. + */ +export function computeRotationWeekParity( + dateStr: string, + rotationAnchorWeekStart: string | null, +): 0 | 1 { + const anchor = rotationAnchorWeekStart || '2026-01-05'; // Default anchor: first Monday of 2026 + const anchorMonday = getMondayOfWeek(anchor); + const targetMonday = getMondayOfWeek(dateStr); + const weeks = weeksBetween(anchorMonday, targetMonday); + // Use absolute value to support dates before the anchor + return (Math.abs(weeks) % 2) as 0 | 1; +} + +/** + * Resolve the effective standard shift template ID for an employee on a given date. + * Considers rotation if enabled. + * + * @returns The effective shift template ID, or null if no standard shift configured. + */ +export function resolveEffectiveStandardShift( + profile: EmployeeSchedulingProfileV3, + dateStr: string, +): string | null { + if (!profile.standardShiftTemplateId) return null; + + if (!profile.rotateStandardShiftWeekly || !profile.rotationAlternateShiftTemplateId) { + return profile.standardShiftTemplateId; + } + + const parity = computeRotationWeekParity(dateStr, profile.rotationAnchorWeekStart); + return parity === 0 + ? profile.standardShiftTemplateId + : profile.rotationAlternateShiftTemplateId; +} + +/** + * Resolve an alternate shift template for rotation. + * If the standard shift is MORNING, find the first active AFTERNOON template. + * If AFTERNOON, find the first active MORNING template. + * Returns null if no unambiguous alternate can be resolved. + */ +export function resolveRotationAlternateTemplate( + standardTemplateId: string, + allTemplates: ShiftTemplateV3[], +): { alternateId: string | null; ambiguous: boolean } { + const standard = allTemplates.find((t) => t.id === standardTemplateId); + if (!standard) return { alternateId: null, ambiguous: false }; + + let oppositeType: string; + if (standard.shiftType === 'MORNING') { + oppositeType = 'AFTERNOON'; + } else if (standard.shiftType === 'AFTERNOON') { + oppositeType = 'MORNING'; + } else { + // INTERMEDIATE, NIGHT, CUSTOM — no automatic rotation + return { alternateId: null, ambiguous: false }; + } + + const candidates = allTemplates.filter( + (t) => t.isActive && t.shiftType === oppositeType && t.id !== standardTemplateId, + ); + + if (candidates.length === 0) return { alternateId: null, ambiguous: false }; + if (candidates.length === 1) return { alternateId: candidates[0].id, ambiguous: false }; + // Multiple candidates — ambiguous + return { alternateId: null, ambiguous: true }; +} + +/** + * Convert a fixedDayOff number (0=Sunday..6=Saturday) to a Weekday string. + */ +export function fixedDayOffToWeekday(dayOff: number | null): Weekday | null { + if (dayOff === null || dayOff < 0 || dayOff > 6) return null; + return JS_INDEX_TO_WEEKDAY[dayOff]; +} + +/** + * Check if a date string falls on an employee's fixed day off. + */ +export function isFixedDayOff(dateStr: string, fixedDayOff: number | null): boolean { + if (fixedDayOff === null) return false; + const d = new Date(dateStr + 'T00:00:00Z'); + return d.getUTCDay() === fixedDayOff; +} + +/** + * Check if a date falls within an employee's active seasonal range. + */ +export function isWithinActiveDates( + dateStr: string, + activeFrom?: string | null, + activeTo?: string | null, +): boolean { + if (activeFrom && dateStr < activeFrom) return false; + if (activeTo && dateStr > activeTo) return false; + return true; +} diff --git a/src/scheduler-engine-v3/generator.ts b/src/scheduler-engine-v3/generator.ts new file mode 100644 index 0000000..eb8cff5 --- /dev/null +++ b/src/scheduler-engine-v3/generator.ts @@ -0,0 +1,381 @@ +/** + * ShiftOryx Scheduler V3 — Pure Schedule Generator + * + * Deterministic schedule generation based on: + * - Headcount-only coverage (no CORE/FLEX/EXTRA) + * - Employee eligibility + * - Standard shift matching + * - Target-hours deficit balancing + * - Round-robin with employeeId tie-breaking + * + * Same inputs → same output. No Math.random(), no localeCompare. + * Pure function — no Firestore, no Firebase. + */ + +import type { + EmployeeV3, + EmployeeAbsenceV3, + GeneratedShiftV3, + GenerateScheduleV3Input, + GenerateScheduleV3Result, + ScheduleWarningV3, + EmployeeHoursSummaryV3, + CoverageSummaryV3, + SchedulerConfigV3, + ShiftTemplateV3, + WarningCodeV3, +} from './types.ts'; +import { evaluateEmployeeEligibilityV3 } from './eligibility.ts'; +import { resolveEffectiveStandardShift } from './employeeProfile.ts'; +import { expandCoverageSlots, calculateCoverageSummary, eachDateInRange, getWeekdayForDate } from './coverage.ts'; +import { getMondayOfWeek } from './employeeProfile.ts'; +import { calculateShiftDurationHoursV3 } from './config.ts'; + +let _idCounter = 0; + +/** + * Generate a deterministic unique ID. + * Uses a prefix + counter to ensure uniqueness within a single generation run. + */ +function generateId(prefix: string): string { + _idCounter++; + return `${prefix}-${_idCounter.toString().padStart(6, '0')}`; +} + +/** + * Deterministic hash for diagnostics (FNV-1a inspired). + */ +function simpleHash(input: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = (hash * 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +/** + * Get the ISO week start (Monday) and end (Sunday) for a given date. + */ +function getWeekBounds(dateStr: string): { weekStart: string; weekEnd: string } { + const monday = getMondayOfWeek(dateStr); + const d = new Date(monday + 'T00:00:00Z'); + d.setUTCDate(d.getUTCDate() + 6); + return { weekStart: monday, weekEnd: d.toISOString().slice(0, 10) }; +} + +/** + * Calculate total assigned hours for an employee within a date range. + */ +function calcHoursInRange( + employeeId: string, + startDate: string, + endDate: string, + shifts: GeneratedShiftV3[], +): number { + return shifts + .filter((s) => s.employeeId === employeeId && s.date >= startDate && s.date <= endDate) + .reduce((sum, s) => sum + s.durationHours, 0); +} + +/** + * Generate a V3 schedule. + * + * @param input - Complete generation input with config, employees, absences, and options + * @returns Deterministic schedule result with shifts, warnings, coverage summary, and diagnostics + */ +export function generateScheduleV3(input: GenerateScheduleV3Input): GenerateScheduleV3Result { + // Reset ID counter for determinism + _idCounter = 0; + + const { config, employees, absences, existingManualShifts, periodStart, periodEnd, options } = input; + + // 1. Prepare lookup structures + const templateMap = new Map(config.shiftTemplates.map((t) => [t.id, t])); + const activeEmployees = employees + .filter((e) => e.isActive) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); // Deterministic order by ID + + const shifts: GeneratedShiftV3[] = []; + const warnings: ScheduleWarningV3[] = []; + + // 2. Include existing manual shifts + if (existingManualShifts) { + for (const manual of existingManualShifts) { + shifts.push({ ...manual }); + } + } + + // 3. Expand coverage slots + const coverageSlots = expandCoverageSlots(config, periodStart, periodEnd); + + // 4. Group coverage slots by date for ordered processing + const slotsByDate = new Map(); + for (const slot of coverageSlots) { + if (!slotsByDate.has(slot.date)) slotsByDate.set(slot.date, []); + slotsByDate.get(slot.date)!.push(slot); + } + + // Process dates in chronological order + const sortedDates = [...slotsByDate.keys()].sort(); + + // 5. Round-robin tracker per shift template + const roundRobinIndex = new Map(); + + for (const date of sortedDates) { + const dateSlots = slotsByDate.get(date) || []; + const { weekStart, weekEnd } = getWeekBounds(date); + + // Process slots ordered by shiftTemplateId for determinism + const sortedSlots = [...dateSlots].sort((a, b) => { + if (a.shiftTemplateId !== b.shiftTemplateId) { + return a.shiftTemplateId < b.shiftTemplateId ? -1 : 1; + } + return a.slotIndex - b.slotIndex; + }); + + for (const slot of sortedSlots) { + // Check if already filled by a manual shift + const alreadyFilled = shifts.filter( + (s) => s.date === date && s.shiftTemplateId === slot.shiftTemplateId, + ).length; + if (alreadyFilled > slot.slotIndex) continue; + + const template = slot.shiftTemplate; + if (!template) { + warnings.push({ + id: generateId('w'), + code: 'COVERAGE_UNDER_TARGET', + severity: 'WARNING', + blocking: false, + date, + message: `Δεν βρέθηκε πρότυπο βάρδιας: ${slot.shiftTemplateId}.`, + }); + continue; + } + + // 6. Find eligible candidates + const candidates: { + employee: EmployeeV3; + matchesStandard: boolean; + hoursDeficit: number; + }[] = []; + + for (const emp of activeEmployees) { + const eligibility = evaluateEmployeeEligibilityV3( + emp, date, absences, shifts, template.startTime, + { + minRestIntervalHours: config.warningPolicies?.minRestIntervalHours, + maxDailyHours: config.warningPolicies?.maxDailyHours, + maxWeeklyHours: config.warningPolicies?.maxWeeklyHours, + maxConsecutiveWorkingDays: config.warningPolicies?.maxConsecutiveWorkingDays, + weekStartDate: weekStart, + weekEndDate: weekEnd, + }, + ); + + if (!eligibility.eligible) continue; + + // Check standard shift match + const effectiveShift = resolveEffectiveStandardShift(emp.schedulerV3, date); + const matchesStandard = effectiveShift === template.id; + + // Calculate target-hours deficit + let hoursDeficit = 0; + if (emp.schedulerV3.targetWeeklyHours !== null) { + const currentHours = calcHoursInRange(emp.id, weekStart, weekEnd, shifts); + hoursDeficit = emp.schedulerV3.targetWeeklyHours - currentHours; + } + + candidates.push({ employee: emp, matchesStandard, hoursDeficit }); + } + + // 7. Sort candidates by priority (deterministic) + candidates.sort((a, b) => { + // Priority 1: Standard shift match + if (a.matchesStandard !== b.matchesStandard) { + return a.matchesStandard ? -1 : 1; + } + // Priority 2: Highest target-hours deficit + if (a.hoursDeficit !== b.hoursDeficit) { + return b.hoursDeficit - a.hoursDeficit; + } + // Priority 3: Round-robin (least recently assigned for this template) + const rrKey = slot.shiftTemplateId; + const rrIdx = roundRobinIndex.get(rrKey) || 0; + const aIdx = activeEmployees.indexOf(a.employee); + const bIdx = activeEmployees.indexOf(b.employee); + const aRR = (aIdx - rrIdx + activeEmployees.length) % activeEmployees.length; + const bRR = (bIdx - rrIdx + activeEmployees.length) % activeEmployees.length; + if (aRR !== bRR) return aRR - bRR; + // Priority 4: Tie-break by employeeId (lexicographic) + return a.employee.id < b.employee.id ? -1 : 1; + }); + + if (candidates.length > 0) { + const selected = candidates[0]; + const duration = calculateShiftDurationHoursV3( + template.startTime, + template.endTime, + template.crossMidnight, + ); + + shifts.push({ + id: generateId('shift-v3'), + date, + employeeId: selected.employee.id, + employeeName: selected.employee.fullName, + shiftTemplateId: template.id, + startTime: template.startTime, + endTime: template.endTime, + durationHours: duration, + crossMidnight: template.crossMidnight || undefined, + source: 'AUTO', + isManualOverride: false, + schedulerSchemaVersion: 3, + }); + + // Update round-robin index + const selIdx = activeEmployees.indexOf(selected.employee); + roundRobinIndex.set(slot.shiftTemplateId, (selIdx + 1) % activeEmployees.length); + + // Standard shift deviation warning + if (!selected.matchesStandard && selected.employee.schedulerV3.standardShiftTemplateId) { + warnings.push({ + id: generateId('w'), + code: 'STANDARD_SHIFT_DEVIATION', + severity: 'INFO', + blocking: false, + date, + employeeId: selected.employee.id, + message: `Ο ${selected.employee.fullName} δεν εργάζεται στην τυπική του βάρδια.`, + }); + } + } else { + // Coverage gap + warnings.push({ + id: generateId('w'), + code: 'COVERAGE_UNDER_TARGET', + severity: 'WARNING', + blocking: false, + date, + message: `Δεν βρέθηκε διαθέσιμος εργαζόμενος για ${template.label} (${date}).`, + details: { shiftTemplateId: template.id }, + }); + } + } + } + + // 8. Post-generation warnings: target hours + const allDates = eachDateInRange(periodStart, periodEnd); + const weekBoundsMap = new Map(); + for (const date of allDates) { + const bounds = getWeekBounds(date); + weekBoundsMap.set(bounds.weekStart, bounds); + } + + for (const emp of activeEmployees) { + if (emp.schedulerV3.targetWeeklyHours === null) continue; + for (const [, bounds] of weekBoundsMap) { + const hours = calcHoursInRange(emp.id, bounds.weekStart, bounds.weekEnd, shifts); + const target = emp.schedulerV3.targetWeeklyHours; + if (hours < target * 0.9) { + warnings.push({ + id: generateId('w'), + code: 'TARGET_HOURS_UNDER', + severity: 'INFO', + blocking: false, + employeeId: emp.id, + message: `Ο ${emp.fullName} έχει ${hours.toFixed(1)}h (στόχος: ${target}h).`, + details: { actual: hours, target, weekStart: bounds.weekStart }, + }); + } else if (hours > target * 1.1) { + warnings.push({ + id: generateId('w'), + code: 'TARGET_HOURS_OVER', + severity: 'INFO', + blocking: false, + employeeId: emp.id, + message: `Ο ${emp.fullName} έχει ${hours.toFixed(1)}h (στόχος: ${target}h).`, + details: { actual: hours, target, weekStart: bounds.weekStart }, + }); + } + } + } + + // 9. Calculate coverage summary + const coverageSummary = calculateCoverageSummary(config, periodStart, periodEnd, shifts); + + // 10. Calculate employee hours + const employeeHours = calculateEmployeeHoursV3(employees, shifts); + + // 11. Build diagnostics + const inputStr = JSON.stringify({ + periodStart, + periodEnd, + employeeIds: activeEmployees.map((e) => e.id), + templateIds: config.shiftTemplates.map((t) => t.id), + }); + + // Sort shifts deterministically + shifts.sort((a, b) => { + if (a.date !== b.date) return a.date < b.date ? -1 : 1; + if (a.startTime !== b.startTime) return a.startTime < b.startTime ? -1 : 1; + if (a.employeeId !== b.employeeId) return a.employeeId < b.employeeId ? -1 : 1; + return 0; + }); + + return { + shifts, + warnings, + coverageSummary, + employeeHours, + diagnostics: { + generatedAt: new Date().toISOString(), + inputHash: simpleHash(inputStr), + deterministicSeed: `v3-${periodStart}-${periodEnd}`, + }, + }; +} + +/** + * Calculate per-employee hours summary, including zero-hour employees. + */ +export function calculateEmployeeHoursV3( + employees: EmployeeV3[], + shifts: GeneratedShiftV3[], +): EmployeeHoursSummaryV3[] { + const hoursByEmployee = new Map(); + + // Initialize all active employees (including zero-shift ones) + for (const emp of employees) { + if (emp.isActive) { + hoursByEmployee.set(emp.id, { hours: 0, shiftCount: 0 }); + } + } + + // Accumulate shift hours + for (const shift of shifts) { + const entry = hoursByEmployee.get(shift.employeeId); + if (entry) { + entry.hours += shift.durationHours; + entry.shiftCount++; + } else { + hoursByEmployee.set(shift.employeeId, { + hours: shift.durationHours, + shiftCount: 1, + }); + } + } + + // Build result with target hours + const empMap = new Map(employees.map((e) => [e.id, e])); + return [...hoursByEmployee.entries()] + .map(([employeeId, stats]) => ({ + employeeId, + hours: Math.round(stats.hours * 4) / 4, // Quarter-hour precision + shiftCount: stats.shiftCount, + targetWeeklyHours: empMap.get(employeeId)?.schedulerV3?.targetWeeklyHours ?? null, + })) + .sort((a, b) => (a.employeeId < b.employeeId ? -1 : 1)); // Deterministic order +} diff --git a/src/scheduler-engine-v3/index.ts b/src/scheduler-engine-v3/index.ts new file mode 100644 index 0000000..b6f60e2 --- /dev/null +++ b/src/scheduler-engine-v3/index.ts @@ -0,0 +1,14 @@ +/** + * ShiftOryx Scheduler V3 — Barrel Export + * + * Provides all V3 engine exports from a single entry point. + * V3 is active only when schemaVersion=3. + */ + +export * from './types.ts'; +export * from './config.ts'; +export * from './employeeProfile.ts'; +export * from './eligibility.ts'; +export * from './coverage.ts'; +export * from './generator.ts'; +export * from './warnings.ts'; diff --git a/src/scheduler-engine-v3/types.ts b/src/scheduler-engine-v3/types.ts new file mode 100644 index 0000000..6fd1f70 --- /dev/null +++ b/src/scheduler-engine-v3/types.ts @@ -0,0 +1,272 @@ +/** + * ShiftOryx Scheduler V3 Types + * + * NOTE: These types completely supersede the V2 engine types when schemaVersion=3. + * Do NOT import or mix types from the V2 engine (src/scheduler-engine/types.ts). + * This engine version uses deterministic scheduling and relies strictly on these pure types. + */ + +export type Weekday = + | 'MONDAY' + | 'TUESDAY' + | 'WEDNESDAY' + | 'THURSDAY' + | 'FRIDAY' + | 'SATURDAY' + | 'SUNDAY'; + +export type ShiftTypeV3 = + | 'MORNING' + | 'INTERMEDIATE' + | 'AFTERNOON' + | 'NIGHT' + | 'CUSTOM'; + +export type WorkMode = 'NORMAL' | 'SUBSTITUTE_ONLY'; + +export type OperatingDayConfigV3 = { + weekday: Weekday; + isOpen: boolean; + windows: Array<{ + openTime: string; + closeTime: string; + crossMidnight?: boolean; + }>; +}; + +export type ShiftTemplateV3 = { + id: string; + label: string; + shortCode: string; + shiftType: ShiftTypeV3; + startTime: string; // HH:mm + endTime: string; // HH:mm + durationHours: number; + crossMidnight: boolean; + color?: string; + isActive: boolean; +}; + +export type CoverageSlotV3 = { + shiftTemplateId: string; + headcount: number; // integer >= 0 +}; + +export type CoverageRequirementV3 = { + weekday: Weekday; + slots: CoverageSlotV3[]; +}; + +export type WarningPoliciesV3 = { + minRestIntervalHours?: number | null; + maxDailyHours?: number | null; + maxWeeklyHours?: number | null; + maxConsecutiveWorkingDays?: number | null; +}; + +export type SchedulerConfigV3 = { + schemaVersion: 3; + tenantId: string; + timezone: string; + weekStartDay: 1 | 2 | 3 | 4 | 5 | 6 | 7; + operatingDays: Array; + shiftTemplates: Array; + coverageRequirements: Array; + generationDefaults: { balanceWeeklyTargetsForMonth: boolean }; + warningPolicies?: WarningPoliciesV3; + templateId: string; + templateVersion: number; +}; + +export type EmployeeSchedulingProfileV3 = { + workMode: WorkMode; + fixedDayOff: number | null; // (0=Sunday..6=Saturday) + targetWeeklyHours: number | null; + standardShiftTemplateId: string | null; + rotateStandardShiftWeekly: boolean; + rotationAlternateShiftTemplateId: string | null; + rotationAnchorWeekStart: string | null; // (YYYY-MM-DD) +}; + +export type EmployeeV3 = { + id: string; + fullName: string; + isActive: boolean; + schedulerV3: EmployeeSchedulingProfileV3; + activeFrom?: string | null; + activeTo?: string | null; + color?: string; +}; + +export type GeneratedShiftV3 = { + id: string; + date: string; // YYYY-MM-DD + employeeId: string; + employeeName: string; + shiftTemplateId: string | null; + startTime: string; // HH:mm + endTime: string; // HH:mm + durationHours: number; + crossMidnight?: boolean; + source: 'AUTO' | 'MANUAL'; + isManualOverride: boolean; + schedulerSchemaVersion: 3; + draftId?: string; +}; + +export type WarningCodeV3 = + | 'COVERAGE_UNDER_TARGET' + | 'COVERAGE_OVER_TARGET' + | 'FIXED_DAY_OFF_OVERRIDE' + | 'ABSENCE_OVERRIDE' + | 'SHIFT_OVERLAP' + | 'OUTSIDE_OPERATING_WINDOW' + | 'STANDARD_SHIFT_DEVIATION' + | 'TARGET_HOURS_UNDER' + | 'TARGET_HOURS_OVER' + | 'REST_INTERVAL_WARNING' + | 'DAILY_HOURS_WARNING' + | 'WEEKLY_HOURS_WARNING' + | 'CONSECUTIVE_DAYS_WARNING' + | 'ROTATION_CONFIGURATION_WARNING' + | 'SUBSTITUTE_MANUAL_ASSIGNMENT' + | 'DEACTIVATED_EMPLOYEE_REFERENCE'; + +export type ScheduleWarningV3 = { + id: string; + code: WarningCodeV3; + severity: 'INFO' | 'WARNING'; + blocking: false; + date?: string; + employeeId?: string; + shiftId?: string; + message: string; + details?: Record; +}; + +export type ScheduleDraftMetadataV3 = { + id: string; + tenantId: string; + schemaVersion: 3; + periodType: 'WEEK' | 'MONTH'; + periodStart: string; + periodEnd: string; + sourcePublicationId?: string | null; + generationOptions: { balanceWeeklyTargets: boolean }; + status: 'DRAFT'; + warningSummary: { + total: number; + byCode: Record; + }; + createdAt: string; + updatedAt: string; + createdBy: string; +}; + +export type SafeEmployeeSnapshot = { + employeeId: string; + displayName: string; + color?: string; + displayLabel?: string; +}; + +export type SafePublishedShiftV3 = { + id: string; + date: string; + employeeId: string; + employeeName: string; + startTime: string; + endTime: string; + durationHours: number; + shiftTemplateId?: string | null; + source: 'AUTO' | 'MANUAL'; +}; + +export type EmployeeHoursSummaryV3 = { + employeeId: string; + hours: number; + shiftCount: number; + targetWeeklyHours?: number | null; +}; + +export type SchedulePublicationV3 = { + id: string; + tenantId: string; + schemaVersion: 3; + periodType: 'WEEK' | 'MONTH'; + periodStart: string; + periodEnd: string; + periodKey: string; + version: number; + sourceDraftId?: string | null; + sourcePublicationId?: string | null; + templateSnapshot: SchedulerConfigV3; + employeeSnapshot: SafeEmployeeSnapshot[]; + shifts: SafePublishedShiftV3[]; + calculatedHours: EmployeeHoursSummaryV3[]; + warningsAtPublish: ScheduleWarningV3[]; + publishedWithWarnings: boolean; + pdfStoragePath: string; + pdfGeneratedAt: string; + publishedAt: string; + publishedByUid: string; +}; + +export type SchedulePublicationPeriodIndex = { + tenantId: string; + periodKey: string; + latestVersion: number; + latestPublicationId: string; + updatedAt: string; +}; + +export type EmployeeAbsenceV3 = { + id: string; + employeeId: string; + type: 'LEAVE' | 'SICK' | 'OTHER'; + startDate: string; + endDate: string; + scope: 'FULL_DAY' | 'PARTIAL_DAY'; + note?: string; +}; + +export type GenerateScheduleV3Input = { + config: SchedulerConfigV3; + employees: EmployeeV3[]; + absences: EmployeeAbsenceV3[]; + existingManualShifts?: GeneratedShiftV3[]; + periodType: 'WEEK' | 'MONTH'; + periodStart: string; + periodEnd: string; + options: { balanceWeeklyTargets: boolean }; +}; + +export type CoverageSummaryV3 = { + totalSlots: number; + filledSlots: number; + unfilledSlots: number; + overfilledSlots: number; + byWeekday: Record; +}; + +export type GenerateScheduleV3Result = { + shifts: GeneratedShiftV3[]; + warnings: ScheduleWarningV3[]; + coverageSummary: CoverageSummaryV3; + employeeHours: EmployeeHoursSummaryV3[]; + diagnostics: { + generatedAt: string; + inputHash: string; + deterministicSeed: string; + }; +}; + +export const DEFAULT_EMPLOYEE_PROFILE_V3: EmployeeSchedulingProfileV3 = { + workMode: 'NORMAL', + fixedDayOff: null, + targetWeeklyHours: null, + standardShiftTemplateId: null, + rotateStandardShiftWeekly: false, + rotationAlternateShiftTemplateId: null, + rotationAnchorWeekStart: null, +}; diff --git a/src/scheduler-engine-v3/warnings.ts b/src/scheduler-engine-v3/warnings.ts new file mode 100644 index 0000000..c027931 --- /dev/null +++ b/src/scheduler-engine-v3/warnings.ts @@ -0,0 +1,313 @@ +/** + * ShiftOryx Scheduler V3 — Warning Engine + * + * Shared non-blocking schedule analyzer. + * Used for generation, preview edits, and publish validation. + * ALL warnings are blocking: false. + * Pure function — no Firestore, no Firebase. + */ + +import type { + EmployeeV3, + EmployeeAbsenceV3, + GeneratedShiftV3, + SchedulerConfigV3, + ScheduleWarningV3, + WarningCodeV3, +} from './types.ts'; +import { timeToMinutesV3 } from './config.ts'; +import { isFixedDayOff, isWithinActiveDates } from './employeeProfile.ts'; +import { eachDateInRange, getWeekdayForDate } from './coverage.ts'; +import { getMondayOfWeek } from './employeeProfile.ts'; + +let _warnIdCounter = 0; + +function warnId(): string { + _warnIdCounter++; + return `warn-${_warnIdCounter.toString().padStart(6, '0')}`; +} + +function makeWarning( + code: WarningCodeV3, + severity: 'INFO' | 'WARNING', + message: string, + overrides: Partial = {}, +): ScheduleWarningV3 { + return { + id: warnId(), + code, + severity, + blocking: false, + message, + ...overrides, + }; +} + +/** + * Analyze a schedule for all warning conditions. + * This is the single shared analyzer used by: + * - Generator (post-generation) + * - Preview editor (after each edit) + * - Publish gate (pre-publish review) + * + * @param config Tenant V3 configuration + * @param employees All employees + * @param absences All absences in the period + * @param shifts Current schedule shifts + * @param periodStart ISO date + * @param periodEnd ISO date + * @returns Array of non-blocking warnings + */ +export function analyzeScheduleWarningsV3( + config: SchedulerConfigV3, + employees: EmployeeV3[], + absences: EmployeeAbsenceV3[], + shifts: GeneratedShiftV3[], + periodStart: string, + periodEnd: string, +): ScheduleWarningV3[] { + _warnIdCounter = 0; + const warnings: ScheduleWarningV3[] = []; + const empMap = new Map(employees.map((e) => [e.id, e])); + const policies = config.warningPolicies || {}; + + // 1. Coverage analysis + const templateMap = new Map(config.shiftTemplates.map((t) => [t.id, t])); + const dates = eachDateInRange(periodStart, periodEnd); + + for (const date of dates) { + const weekday = getWeekdayForDate(date); + const dayConfig = config.operatingDays.find((d) => d.weekday === weekday); + if (!dayConfig || !dayConfig.isOpen) continue; + + const coverage = config.coverageRequirements.find((c) => c.weekday === weekday); + if (!coverage) continue; + + for (const slot of coverage.slots) { + const assigned = shifts.filter( + (s) => s.date === date && s.shiftTemplateId === slot.shiftTemplateId, + ).length; + if (assigned < slot.headcount) { + warnings.push(makeWarning( + 'COVERAGE_UNDER_TARGET', 'WARNING', + `Ελλιπής κάλυψη: ${assigned}/${slot.headcount} εργαζόμενοι (${date}).`, + { date, details: { shiftTemplateId: slot.shiftTemplateId, assigned, required: slot.headcount } }, + )); + } else if (assigned > slot.headcount) { + warnings.push(makeWarning( + 'COVERAGE_OVER_TARGET', 'INFO', + `Υπερκάλυψη: ${assigned}/${slot.headcount} εργαζόμενοι (${date}).`, + { date, details: { shiftTemplateId: slot.shiftTemplateId, assigned, required: slot.headcount } }, + )); + } + } + } + + // Per-employee analyses + for (const shift of shifts) { + const emp = empMap.get(shift.employeeId); + + // 2. Fixed day off override + if (emp && isFixedDayOff(shift.date, emp.schedulerV3.fixedDayOff)) { + warnings.push(makeWarning( + 'FIXED_DAY_OFF_OVERRIDE', 'WARNING', + `Ο ${emp.fullName} εργάζεται στη σταθερή ημέρα ανάπαυσής του (${shift.date}).`, + { date: shift.date, employeeId: shift.employeeId }, + )); + } + + // 3. Absence override + const hasAbsence = absences.some( + (a) => + a.employeeId === shift.employeeId && + shift.date >= a.startDate && + shift.date <= a.endDate && + a.scope === 'FULL_DAY', + ); + if (hasAbsence) { + warnings.push(makeWarning( + 'ABSENCE_OVERRIDE', 'WARNING', + `Ο ${emp?.fullName || shift.employeeName} εργάζεται ενώ έχει άδεια (${shift.date}).`, + { date: shift.date, employeeId: shift.employeeId }, + )); + } + + // 4. Outside operating window + if (shift.shiftTemplateId) { + const weekday = getWeekdayForDate(shift.date); + const dayConfig = config.operatingDays.find((d) => d.weekday === weekday); + if (dayConfig && dayConfig.isOpen && dayConfig.windows.length > 0) { + const shiftStart = timeToMinutesV3(shift.startTime); + const shiftEnd = timeToMinutesV3(shift.endTime); + const inWindow = dayConfig.windows.some((w) => { + const wStart = timeToMinutesV3(w.openTime); + const wEnd = timeToMinutesV3(w.closeTime); + if (w.crossMidnight) return true; // simplified for cross-midnight + return shiftStart >= wStart && shiftEnd <= wEnd; + }); + if (!inWindow) { + warnings.push(makeWarning( + 'OUTSIDE_OPERATING_WINDOW', 'WARNING', + `Βάρδια εκτός ωραρίου λειτουργίας (${shift.date}, ${shift.startTime}-${shift.endTime}).`, + { date: shift.date, employeeId: shift.employeeId, shiftId: shift.id }, + )); + } + } + } + + // 5. Substitute-only employee assignment + if (emp && emp.schedulerV3.workMode === 'SUBSTITUTE_ONLY' && shift.source === 'MANUAL') { + warnings.push(makeWarning( + 'SUBSTITUTE_MANUAL_ASSIGNMENT', 'INFO', + `Ο ${emp.fullName} είναι μόνο αναπληρωματικός και ανατέθηκε χειροκίνητα (${shift.date}).`, + { date: shift.date, employeeId: shift.employeeId }, + )); + } + + // 6. Deactivated employee reference + if (emp && !emp.isActive) { + warnings.push(makeWarning( + 'DEACTIVATED_EMPLOYEE_REFERENCE', 'WARNING', + `Αναφορά σε ανενεργό εργαζόμενο: ${emp.fullName} (${shift.date}).`, + { date: shift.date, employeeId: shift.employeeId }, + )); + } + } + + // 7. Shift overlaps (per employee, per date) + const shiftsByEmployeeDate = new Map(); + for (const shift of shifts) { + const key = `${shift.employeeId}:${shift.date}`; + if (!shiftsByEmployeeDate.has(key)) shiftsByEmployeeDate.set(key, []); + shiftsByEmployeeDate.get(key)!.push(shift); + } + for (const [key, dayShifts] of shiftsByEmployeeDate) { + if (dayShifts.length < 2) continue; + const sorted = [...dayShifts].sort((a, b) => + a.startTime < b.startTime ? -1 : a.startTime > b.startTime ? 1 : 0, + ); + for (let i = 0; i < sorted.length - 1; i++) { + const endMin = timeToMinutesV3(sorted[i].endTime); + const nextStartMin = timeToMinutesV3(sorted[i + 1].startTime); + if (endMin > nextStartMin) { + const emp = empMap.get(sorted[i].employeeId); + warnings.push(makeWarning( + 'SHIFT_OVERLAP', 'WARNING', + `Αλληλεπικαλυπτόμενες βάρδιες για ${emp?.fullName || sorted[i].employeeName} (${sorted[i].date}).`, + { date: sorted[i].date, employeeId: sorted[i].employeeId }, + )); + } + } + } + + // 8. Rest interval (between consecutive days) + if (policies.minRestIntervalHours && policies.minRestIntervalHours > 0) { + const minRestMin = policies.minRestIntervalHours * 60; + const employeeIds = [...new Set(shifts.map((s) => s.employeeId))]; + for (const eid of employeeIds) { + const empShifts = shifts + .filter((s) => s.employeeId === eid) + .sort((a, b) => { + if (a.date !== b.date) return a.date < b.date ? -1 : 1; + return a.startTime < b.startTime ? -1 : 1; + }); + for (let i = 0; i < empShifts.length - 1; i++) { + const curr = empShifts[i]; + const next = empShifts[i + 1]; + if (curr.date === next.date) continue; // same day — overlap check already done + const currEnd = timeToMinutesV3(curr.endTime); + const nextStart = timeToMinutesV3(next.startTime); + // Days between + const d1 = new Date(curr.date + 'T00:00:00Z'); + const d2 = new Date(next.date + 'T00:00:00Z'); + const daysDiff = Math.round((d2.getTime() - d1.getTime()) / (24 * 60 * 60 * 1000)); + if (daysDiff === 1) { + const restMin = (24 * 60 - currEnd) + nextStart; + if (restMin < minRestMin) { + const emp = empMap.get(eid); + warnings.push(makeWarning( + 'REST_INTERVAL_WARNING', 'WARNING', + `Ανεπαρκής ανάπαυση (${(restMin / 60).toFixed(1)}h) για ${emp?.fullName || eid} μεταξύ ${curr.date} και ${next.date}.`, + { date: next.date, employeeId: eid, details: { restHours: Math.round(restMin / 60 * 4) / 4 } }, + )); + } + } + } + } + } + + // 9. Consecutive working days + if (policies.maxConsecutiveWorkingDays && policies.maxConsecutiveWorkingDays > 0) { + const maxConsec = policies.maxConsecutiveWorkingDays; + const employeeIds = [...new Set(shifts.map((s) => s.employeeId))]; + for (const eid of employeeIds) { + const workDates = [...new Set(shifts.filter((s) => s.employeeId === eid).map((s) => s.date))].sort(); + let consecutive = 1; + for (let i = 1; i < workDates.length; i++) { + const prev = new Date(workDates[i - 1] + 'T00:00:00Z'); + const curr = new Date(workDates[i] + 'T00:00:00Z'); + const diff = Math.round((curr.getTime() - prev.getTime()) / (24 * 60 * 60 * 1000)); + if (diff === 1) { + consecutive++; + if (consecutive > maxConsec) { + const emp = empMap.get(eid); + warnings.push(makeWarning( + 'CONSECUTIVE_DAYS_WARNING', 'WARNING', + `${emp?.fullName || eid}: ${consecutive} συνεχόμενες ημέρες εργασίας (μέγιστο: ${maxConsec}).`, + { date: workDates[i], employeeId: eid }, + )); + } + } else { + consecutive = 1; + } + } + } + } + + // 10. Weekly hours + if (policies.maxWeeklyHours && policies.maxWeeklyHours > 0) { + const maxWeekly = policies.maxWeeklyHours; + const employeeIds = [...new Set(shifts.map((s) => s.employeeId))]; + const weekStarts = new Set(); + for (const date of dates) { + weekStarts.add(getMondayOfWeek(date)); + } + for (const eid of employeeIds) { + for (const ws of weekStarts) { + const weEnd = new Date(ws + 'T00:00:00Z'); + weEnd.setUTCDate(weEnd.getUTCDate() + 6); + const weekEnd = weEnd.toISOString().slice(0, 10); + const hours = shifts + .filter((s) => s.employeeId === eid && s.date >= ws && s.date <= weekEnd) + .reduce((sum, s) => sum + s.durationHours, 0); + if (hours > maxWeekly) { + const emp = empMap.get(eid); + warnings.push(makeWarning( + 'WEEKLY_HOURS_WARNING', 'WARNING', + `${emp?.fullName || eid}: ${hours.toFixed(1)}h σε εβδομάδα ${ws} (μέγιστο: ${maxWeekly}h).`, + { employeeId: eid, details: { hours, maxWeeklyHours: maxWeekly, weekStart: ws } }, + )); + } + } + } + } + + // 11. Daily hours + if (policies.maxDailyHours && policies.maxDailyHours > 0) { + const maxDaily = policies.maxDailyHours; + for (const [key, dayShifts] of shiftsByEmployeeDate) { + const totalHours = dayShifts.reduce((sum, s) => sum + s.durationHours, 0); + if (totalHours > maxDaily) { + const [eid, date] = key.split(':'); + const emp = empMap.get(eid); + warnings.push(makeWarning( + 'DAILY_HOURS_WARNING', 'WARNING', + `${emp?.fullName || eid}: ${totalHours.toFixed(1)}h στις ${date} (μέγιστο: ${maxDaily}h).`, + { date, employeeId: eid }, + )); + } + } + } + + return warnings; +} From ece9f8f13fed17d5e0b393511c9ce799b98242b1 Mon Sep 17 00:00:00 2001 From: commanderxgr Date: Wed, 9 Sep 2026 00:24:58 +0300 Subject: [PATCH 2/4] feat(scheduler-v3): integrate editable drafts and immutable publications --- docs/SCHEDULER_CONTRACT_V3.md | 25 + docs/SCHEDULER_V3_CONTINUATION_REPORT.md | 53 +++ firestore.rules | 56 ++- package.json | 1 + scripts/run-scheduler-v3-emulator.mjs | 9 + scripts/test-scheduler-contract-v3.mjs | 80 ++++ scripts/test-scheduler-v3-emulator.mjs | 67 +++ scripts/test-scheduler-v3-services.mjs | 41 ++ src/components/scheduler/MainDashboard.jsx | 15 +- .../scheduler/PublicationHistoryV3.jsx | 1 + .../scheduler/QuarterHourTimePicker.jsx | 4 + .../scheduler/SchedulePreviewV3.jsx | 14 + .../scheduler/SchedulerSettingsV3.jsx | 58 +++ .../scheduler/SchedulerWorkspaceV3.jsx | 49 ++ src/components/scheduler/WarningsPanelV3.jsx | 1 + src/firebase/shiftService.js | 14 +- src/hooks/useSchedulerStore.js | 18 +- .../schedulePublicationsRepository.ts | 96 ++++ src/scheduler-engine-v3/config.ts | 41 +- src/scheduler-engine-v3/coverage.ts | 2 + src/scheduler-engine-v3/eligibility.ts | 24 +- src/scheduler-engine-v3/employeeProfile.ts | 9 +- src/scheduler-engine-v3/generator.ts | 428 +++--------------- src/scheduler-engine-v3/index.ts | 2 + src/scheduler-engine-v3/migration.ts | 17 + src/scheduler-engine-v3/validation.ts | 27 ++ src/scheduler-engine-v3/warnings.ts | 358 +++------------ src/services/publicationProjectionsV3.ts | 18 + src/services/schedulePublicationPdf.ts | 15 + src/services/schedulePublicationService.ts | 38 ++ src/services/schedulerV3Service.ts | 45 ++ src/utils/analytics.js | 6 + storage.rules | 6 + tests/scheduler-v3.e2e.spec.js | 35 ++ 34 files changed, 981 insertions(+), 692 deletions(-) create mode 100644 docs/SCHEDULER_V3_CONTINUATION_REPORT.md create mode 100644 scripts/run-scheduler-v3-emulator.mjs create mode 100644 scripts/test-scheduler-contract-v3.mjs create mode 100644 scripts/test-scheduler-v3-emulator.mjs create mode 100644 scripts/test-scheduler-v3-services.mjs create mode 100644 src/components/scheduler/PublicationHistoryV3.jsx create mode 100644 src/components/scheduler/QuarterHourTimePicker.jsx create mode 100644 src/components/scheduler/SchedulePreviewV3.jsx create mode 100644 src/components/scheduler/SchedulerSettingsV3.jsx create mode 100644 src/components/scheduler/SchedulerWorkspaceV3.jsx create mode 100644 src/components/scheduler/WarningsPanelV3.jsx create mode 100644 src/repositories/schedulePublicationsRepository.ts create mode 100644 src/scheduler-engine-v3/migration.ts create mode 100644 src/scheduler-engine-v3/validation.ts create mode 100644 src/services/publicationProjectionsV3.ts create mode 100644 src/services/schedulePublicationPdf.ts create mode 100644 src/services/schedulePublicationService.ts create mode 100644 src/services/schedulerV3Service.ts create mode 100644 tests/scheduler-v3.e2e.spec.js diff --git a/docs/SCHEDULER_CONTRACT_V3.md b/docs/SCHEDULER_CONTRACT_V3.md index 756bf25..9d152c2 100644 --- a/docs/SCHEDULER_CONTRACT_V3.md +++ b/docs/SCHEDULER_CONTRACT_V3.md @@ -230,3 +230,28 @@ No permanent public URL. Download through authorized Firebase Storage SDK. - No cross-tenant access - Employee snapshots exclude private fields - No secrets in warning details or audit logs + +## 9. Implemented Integration And Verification (8 September 2026) + +- Activation requires `VITE_ENABLE_SCHEDULER_V3=true` and tenant settings `schedulerSchemaVersion: 3`. The default is off. Repository operations also enforce the global flag. Existing broker/gate flags are unchanged. +- V3 settings and employee profiles are saved atomically. The V3 workspace includes employee creation/deactivation and the existing absence editor. +- The primary scheduling workspace is V3. A separate disclosure retains previous schedules, exports and tools; legacy public refresh/month-clear actions are gated for a V3 tenant and cannot replace the new publication projections. +- Drafts use `shifts` with `schedulerSchemaVersion: 3` and `draftId`, plus private `scheduleDrafts` metadata. Revision checks reject stale saves. Reads reconstruct one consistent transaction snapshot. Legacy bulk reads/clear operations exclude V3 draft rows. +- Publication version reservation is transactional and bound to the publication ID. PDF upload precedes publication visibility. Snapshot, latest-period pointer and intersecting public week/month projections commit together. Published snapshots and PDFs deny overwrite/delete. +- Public projections contain only display names, dates, times, work labels and non-sensitive V3/cross-midnight markers; internal IDs, warning details and profile data stay private. Public overnight analytics use the explicit V3 marker. +- `previewV2Migration` is an offline preview function. Role/skill demand, replacement strategies, special dates and special Sunday policies are reported for owner review rather than silently migrated. It performs no writes. +- `npm run test:scheduler-contract-v3`: 304 engine cases and 17 service cases. The service cases include real PDF generation, privacy allowlists and publication failure ordering. +- V2 contract suite: 2,118 assertions unchanged. Browser coverage: three existing V2 tests plus V3 mobile and desktop tests. Emulator coverage exercises the actual V3 repository, concurrent reservations, draft roundtrips, stale saves, immutable snapshot/PDF denial and tenant/identity boundaries. + +Technical limits and follow-up before production activation: + +- Draft writes are bounded to one Firestore transaction (450 operations). Oversized drafts fail explicitly rather than partially saving. +- Failed PDF/finalization attempts can leave an unused reserved version or an unreferenced immutable PDF. No publication pointer is exposed on upload failure; abandoned artifacts require a separately approved cleanup policy. +- Time calculations use dated civil-clock intervals. IANA timezone/DST elapsed-hour conversion is not implemented. +- The separate lifecycle branch's 152-case script is absent from this branch and was not cherry-picked. V3 lifecycle behavior has its own tests; the independent branch is preserved. +- Vite transpilation/build is not TypeScript type checking. No standalone TypeScript compiler was installed. +- No production activation, Rules deployment, migration, DNS change or PR merge is part of this work. + +Partial-day absences currently have no time bounds and conservatively exclude their full date from automatic assignment. V2 break-adjusted durations require review during preview migration because V3 templates represent gross time spans. + +Emulator testing uses `scripts/run-scheduler-v3-emulator.mjs` inside Firebase `emulators:exec` for demo project `demo-shiftoryx-v3`. Run the emulator from a temporary directory with a config referencing this checkout's Rules; debug logs must remain outside the checkout. diff --git a/docs/SCHEDULER_V3_CONTINUATION_REPORT.md b/docs/SCHEDULER_V3_CONTINUATION_REPORT.md new file mode 100644 index 0000000..61ae9a3 --- /dev/null +++ b/docs/SCHEDULER_V3_CONTINUATION_REPORT.md @@ -0,0 +1,53 @@ +# Scheduler V3 continuation — 8 September 2026 + +## Scope and base + +Branch: `antigravity/scheduler-v3-standard-architecture`. +Base: `5edabcae33653c93abc51ad035c058807ca08199`. +Initial A–D checkpoint: `99c47a108b693127d6053aaec79e0a4f3344b54e`. +V2 engine and adapter remain unchanged. The separate lifecycle branch is preserved. + +## Changes + +Completed barrel exports, validated the supplied engine with new tests, corrected full-result determinism, malformed configuration handling, night intervals and missing warnings. Added V3 settings/profiles, quarter-hour draft editing, live warnings and zero-hour statistics, versioned draft storage, immutable publication/PDF history, public week/month synchronization, and offline migration preview. + +The app routes to V3 only with both the default-off global flag and tenant version 3. OWNER membership remains the authorization boundary; Platform Admin does not gain tenant access. Draft data is excluded from legacy schedule reads and bulk date/employee removal. V3 publication snapshots/PDFs are create-only. + +## Verification + +| Command/check | Result | +| --- | --- | +| `npm run test:scheduler-contract-v3` | PASS: 304 engine cases + 17 service cases | +| `npm run test:scheduler-contract-v2` | PASS: 2,118 assertions | +| `npm run qa:scheduler` | PASS, including legacy employee regressions and build | +| `npm run qa:scheduler-engine` | PASS | +| `npm run qa:repositories` | PASS | +| `npm run qa:public-readonly` | PASS | +| `npm run qa:tenant-authorization` | PASS | +| `npm run qa:auth-broker` | PASS | +| `npm run qa:export-security` | PASS | +| `npm run security:hardening` | PASS | +| `npm run security:integrity` | PASS | +| `npm run build` | PASS; existing large-chunk warnings | +| `npm audit --audit-level=high` | PASS threshold: 4 moderate, 1 low; initial DNS failure recovered | +| `npm audit --prefix functions --audit-level=high` | PASS threshold: 8 moderate | +| Playwright V2 + V3 | PASS: 5 tests; V3 at 390×900 and 1440×900; screenshots inspected | +| V3 Firebase emulator | PASS: actual repository settings/draft read-write, stale revision rejection, concurrent unique versions, latest pointer ordering, month-to-week projection replacement, immutable publication/PDF, cross-tenant/Platform Admin/anonymous denials | +| Separate lifecycle matrix (152) | NOT RUN: file absent from this branch | +| Standalone TypeScript check | NOT RUN: no configured compiler; Vite does not type-check | + +Browser command: `E2E_BASE_URL=http://127.0.0.1:5187 node node_modules/playwright/cli.js test tests/scheduler-v3.e2e.spec.js tests/scheduler-roles.e2e.spec.js --workers=1` (PowerShell environment assignment used). + +Emulator command: Firebase CLI `emulators:exec --project demo-shiftoryx-v3 --config firebase.json --only auth,firestore,storage "node /scripts/run-scheduler-v3-emulator.mjs"`, from a dedicated temporary directory. The temporary config references this checkout's Rules and uses loopback ports Auth 9299, Firestore 8188 and Storage 9399. The runner compiles the actual repository with demo-only configuration and refuses non-loopback emulator variables. Logs remain outside the checkout. Expected permission denials are assertions; the Storage runtime printed a shutdown exception after the successful test process exited 0. + +## Review and limits + +Independent engine review found reproducible defects addressed by the V3 tests. Integration review identified weekly Rules payload mismatch and stale public weekly data after month publication; both were fixed and verified in the emulator. That reviewer hit a usage limit before its final review, so no complete independent final security-scan certification is claimed. Primary review and executable authorization/immutability checks cover the changed paths. + +Draft transaction size is bounded to 450 operations. Interrupted publication can retain a reserved version or orphaned immutable PDF. Civil-clock interval arithmetic does not implement DST elapsed-time adjustment. Migration conversion is preview-only with explicit unconverted semantics. Browser tests cover editing and warning acceptance; actual persistence and security are exercised separately in the emulator, not via a signed-in production browser. + +## Security, deployment and rollback + +New dependencies: 0. Lockfiles and GitHub Actions unchanged. No credentials were read or modified. No production data, Firebase deployment, DNS, IAM or Vercel production configuration was changed. The original protected log and `.serena/` were not inspected. + +The delivery is a Draft PR for review, not production activation. Rollback is to leave/restore the global V3 flag off; V2 data and code remain available. Any future production Rules rollout and tenant activation require separate approval. Preserve publication history and PDFs during rollback. diff --git a/firestore.rules b/firestore.rules index 1588829..c58e5d8 100644 --- a/firestore.rules +++ b/firestore.rules @@ -65,7 +65,7 @@ service cloud.firestore { function employeeFields() { return [ - 'fullName', 'role', 'color', 'afm', 'phone', 'email', 'hireDate', 'isActive', + 'fullName', 'role', 'color', 'afm', 'phone', 'email', 'hireDate', 'isActive', 'schedulerV3', 'scheduleRole', 'roleType', 'fixedDayOff', 'fixedDaysOff', 'defaultShiftPreference', 'participatesInRotation', 'participatesInSundayRotation', 'weeklyFixedShiftSideRotation', 'fixedStartTime', 'fixedEndTime', 'staticStartTime', 'staticEndTime', @@ -97,6 +97,7 @@ service cloud.firestore { request.resource.data.keys().hasOnly(employeeFields()) && request.resource.data.fullName is string && request.resource.data.fullName.size() > 0 && + (!('schedulerV3' in request.resource.data) || validEmployeeProfileV3(request.resource.data.schedulerV3)) && isOptionalString('role') && isOptionalString('color') && isOptionalString('afm') && @@ -121,6 +122,18 @@ service cloud.firestore { isOptionalIntOrNull('fixedDayOff'); } + function validEmployeeProfileV3(p) { + return p is map && p.keys().hasOnly(['workMode','fixedDayOff','targetWeeklyHours','standardShiftTemplateId','rotateStandardShiftWeekly','rotationAlternateShiftTemplateId','rotationAnchorWeekStart']) && + p.workMode in ['NORMAL','SUBSTITUTE_ONLY'] && (p.fixedDayOff == null || (p.fixedDayOff is int && p.fixedDayOff >= 0 && p.fixedDayOff <= 6)) && + (p.targetWeeklyHours == null || (p.targetWeeklyHours is number && p.targetWeeklyHours >= 0 && p.targetWeeklyHours <= 168)) && p.rotateStandardShiftWeekly is bool; + } + function validDraftShiftV3() { + let d = request.resource.data; + return d.keys().hasOnly(['id','date','employeeId','employeeName','shiftTemplateId','startTime','endTime','durationHours','crossMidnight','source','isManualOverride','schedulerSchemaVersion','draftId','type']) && + d.schedulerSchemaVersion == 3 && d.draftId is string && d.draftId.matches('^[a-zA-Z0-9_-]{1,100}$') && d.employeeId is string && isIsoDate(d.date) && isTime(d.startTime) && isTime(d.endTime) && + d.durationHours is number && d.durationHours > 0 && d.durationHours <= 24 && d.source in ['AUTO','MANUAL'] && d.type == 'work'; + } + function validPublicEmployee() { return request.resource.data.keys().hasAll(['fullName']) && request.resource.data.keys().hasOnly(publicEmployeeFields()) && @@ -261,11 +274,13 @@ service cloud.firestore { } function settingsFields() { - return ['generatorRules', 'schedulerConfigV2', 'specialDaysByDate', 'createdAt', 'updatedAt']; + return ['generatorRules', 'schedulerConfigV2', 'schedulerConfigV3', 'schedulerSchemaVersion', 'specialDaysByDate', 'createdAt', 'updatedAt']; } function validSchedulerSettings() { return request.resource.data.keys().hasOnly(settingsFields()) && + (!('schedulerSchemaVersion' in request.resource.data) || request.resource.data.schedulerSchemaVersion in [2,3]) && + (!('schedulerConfigV3' in request.resource.data) || (request.resource.data.schedulerConfigV3 is map && request.resource.data.schedulerConfigV3.schemaVersion == 3)) && (!( 'generatorRules' in request.resource.data) || request.resource.data.generatorRules is map) && (!( 'schedulerConfigV2' in request.resource.data) || request.resource.data.schedulerConfigV2 is map) && (!( 'specialDaysByDate' in request.resource.data) || request.resource.data.specialDaysByDate is map); @@ -621,8 +636,8 @@ service cloud.firestore { match /shifts/{shiftId} { allow read: if isTenantAdmin(tenantId); - allow create: if isTenantAdmin(tenantId) && validShift(); - allow update: if isTenantAdmin(tenantId) && onlyChanged(shiftFields()) && validShift(); + allow create: if isTenantAdmin(tenantId) && (validShift() || validDraftShiftV3()); + allow update: if isTenantAdmin(tenantId) && ((onlyChanged(shiftFields()) && validShift()) || (resource.data.schedulerSchemaVersion == 3 && validDraftShiftV3())); allow delete: if isTenantAdmin(tenantId); } @@ -657,6 +672,39 @@ service cloud.firestore { allow delete: if isTenantAdmin(tenantId); } + match /scheduleDrafts/{draftId} { + allow read: if isTenantAdmin(tenantId); + allow create, update: if isTenantAdmin(tenantId) && request.resource.data.tenantId == tenantId && request.resource.data.schemaVersion == 3 && request.resource.data.id == draftId && request.resource.data.config.tenantId == tenantId && request.resource.data.updatedBy == request.auth.uid; + allow delete: if false; + } + match /schedulePublicationCounters/{periodKey} { + allow read: if isTenantAdmin(tenantId); + allow create: if isTenantAdmin(tenantId) && request.resource.data.keys().hasOnly(['tenantId','version','lastReservedId']) && request.resource.data.tenantId == tenantId && request.resource.data.lastReservedId is string && request.resource.data.version == 1; + allow update: if isTenantAdmin(tenantId) && request.resource.data.keys().hasOnly(['tenantId','version','lastReservedId']) && request.resource.data.tenantId == tenantId && request.resource.data.lastReservedId is string && request.resource.data.version == resource.data.version + 1; + allow delete: if false; + } + match /schedulePublicationReservations/{publicationId} { + allow read: if isTenantAdmin(tenantId); + allow create: if isTenantAdmin(tenantId) && request.resource.data.keys().hasOnly(['tenantId','periodKey','version','publishedByUid']) && request.resource.data.tenantId == tenantId && request.resource.data.publishedByUid == request.auth.uid && request.resource.data.version == getAfter(/databases/$(database)/documents/tenants/$(tenantId)/schedulePublicationCounters/$(request.resource.data.periodKey)).data.version && publicationId == getAfter(/databases/$(database)/documents/tenants/$(tenantId)/schedulePublicationCounters/$(request.resource.data.periodKey)).data.lastReservedId; + allow update, delete: if false; + } + match /schedulePublications/{publicationId} { + allow read: if isTenantAdmin(tenantId); + allow create: if isTenantAdmin(tenantId) && request.resource.data.keys().hasOnly(['id','tenantId','schemaVersion','periodType','periodStart','periodEnd','periodKey','version','sourceDraftId','sourcePublicationId','templateSnapshot','employeeSnapshot','shifts','calculatedHours','warningsAtPublish','publishedWithWarnings','pdfStoragePath','pdfGeneratedAt','publishedAt','publishedByUid']) && + request.resource.data.id == publicationId && request.resource.data.tenantId == tenantId && request.resource.data.schemaVersion == 3 && request.resource.data.templateSnapshot.tenantId == tenantId && request.resource.data.publishedByUid == request.auth.uid && request.resource.data.periodType in ['WEEK','MONTH'] && + request.resource.data.pdfStoragePath == 'tenants/' + tenantId + '/schedule-publications/' + publicationId + '/schedule.pdf' && + request.resource.data.version == get(/databases/$(database)/documents/tenants/$(tenantId)/schedulePublicationReservations/$(publicationId)).data.version && + request.resource.data.periodKey == get(/databases/$(database)/documents/tenants/$(tenantId)/schedulePublicationReservations/$(publicationId)).data.periodKey; + allow update, delete: if false; + } + match /schedulePublicationPeriods/{periodKey} { + allow read: if isTenantAdmin(tenantId); + allow create, update: if isTenantAdmin(tenantId) && request.resource.data.keys().hasOnly(['tenantId','periodKey','latestVersion','latestPublicationId','updatedAt']) && request.resource.data.tenantId == tenantId && request.resource.data.periodKey == periodKey && + request.resource.data.latestVersion == getAfter(/databases/$(database)/documents/tenants/$(tenantId)/schedulePublications/$(request.resource.data.latestPublicationId)).data.version && + periodKey == getAfter(/databases/$(database)/documents/tenants/$(tenantId)/schedulePublications/$(request.resource.data.latestPublicationId)).data.periodKey; + allow delete: if false; + } + match /announcements/{announcementId} { allow read: if isTenantAdmin(tenantId); allow create, update: if isTenantAdmin(tenantId) && validAnnouncement(); diff --git a/package.json b/package.json index bf86bc8..176bbbb 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "qa:scheduler": "node scripts/run-validate-scheduler-rules.mjs && node scripts/test-new-employee-scheduler-regression.mjs && npm run build", "test:new-employee-scheduler": "node scripts/test-new-employee-scheduler-regression.mjs", "test:scheduler-contract-v2": "node scripts/test-scheduler-contract-v2.mjs", + "test:scheduler-contract-v3": "node scripts/test-scheduler-contract-v3.mjs && node scripts/test-scheduler-v3-services.mjs", "qa:export-security": "node scripts/validate-export-audit-security.mjs", "qa:public-readonly": "node scripts/validate-public-readonly.mjs", "qa:tenant-authorization": "node scripts/validate-tenant-authorization.mjs", diff --git a/scripts/run-scheduler-v3-emulator.mjs b/scripts/run-scheduler-v3-emulator.mjs new file mode 100644 index 0000000..ff94f0a --- /dev/null +++ b/scripts/run-scheduler-v3-emulator.mjs @@ -0,0 +1,9 @@ +import { build } from 'esbuild'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +const output=join(mkdtempSync(join(tmpdir(),'shiftoryx-v3-test-')),'emulator.cjs'); +await build({entryPoints:[fileURLToPath(new URL('./test-scheduler-v3-emulator.mjs',import.meta.url))],outfile:output,bundle:true,platform:'node',format:'cjs',define:{'import.meta':'{}','import.meta.env':JSON.stringify({VITE_ENABLE_SCHEDULER_V3:'true',VITE_FIREBASE_API_KEY:'demo-key',VITE_FIREBASE_AUTH_DOMAIN:'demo-shiftoryx-v3.firebaseapp.com',VITE_FIREBASE_PROJECT_ID:'demo-shiftoryx-v3',VITE_FIREBASE_STORAGE_BUCKET:'demo-shiftoryx-v3.appspot.com',VITE_FIREBASE_MESSAGING_SENDER_ID:'123',VITE_FIREBASE_APP_ID:'demo-app',DEV:false})}}); +const result=spawnSync(process.execPath,[output],{stdio:'inherit',env:process.env});process.exitCode=result.status??1; diff --git a/scripts/test-scheduler-contract-v3.mjs b/scripts/test-scheduler-contract-v3.mjs new file mode 100644 index 0000000..5d35676 --- /dev/null +++ b/scripts/test-scheduler-contract-v3.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import * as v3 from '../src/scheduler-engine-v3/index.ts'; + +let passed = 0; +const failures = []; +function test(name, fn) { + try { fn(); passed++; } catch (error) { failures.push(`${name}: ${error.message}`); } +} +export function fixture() { + const template = { id: 'day', label: 'Ημέρα', shortCode: 'ΗΜ', shiftType: 'MORNING', startTime: '08:00', endTime: '16:00', durationHours: 8, crossMidnight: false, isActive: true }; + const config = v3.normalizeSchedulerConfigV3({ tenantId: 'test-tenant', templateId: 'scheduler-standard', shiftTemplates: [template] }); + config.coverageRequirements = config.operatingDays.map(d => ({ weekday: d.weekday, slots: [{ shiftTemplateId: 'day', headcount: 1 }] })); + const employees = ['a', 'b', 'c'].map(id => ({ id, fullName: id, isActive: true, schedulerV3: v3.normalizeEmployeeProfileV3() })); + return { config, employees, absences: [], periodStart: '2026-09-07', periodEnd: '2026-09-13', periodType: 'WEEK', options: { balanceWeeklyTargets: true } }; +} +function manual(input, fields = {}) { + return { id: 'manual', employeeId: 'a', employeeName: 'a', date: input.periodStart, shiftTemplateId: 'day', startTime: '08:00', endTime: '16:00', durationHours: 8, source: 'MANUAL', isManualOverride: true, schedulerSchemaVersion: 3, ...fields }; +} +for (let quarter = 0; quarter < 96; quarter++) { + const label = `${String(Math.floor(quarter / 4)).padStart(2, '0')}:${String((quarter % 4) * 15).padStart(2, '0')}`; + test(`quarter ${label} valid`, () => assert.equal(v3.isValidTimeV3(label), true)); + test(`quarter ${label} minutes`, () => assert.equal(v3.timeToMinutesV3(label), quarter * 15)); +} +test('baseline config', () => assert.equal(v3.validateSchedulerConfigV3(fixture().config).valid, true)); +test('default normal', () => assert.equal(v3.normalizeEmployeeProfileV3().workMode, 'NORMAL')); +test('complete result deterministic', () => { + const input = fixture(); const before = JSON.stringify(input); + const first = v3.generateScheduleV3(input); + const start = Date.now(); while (Date.now() === start) { /* advance clock without changing inputs */ } + assert.deepEqual(v3.generateScheduleV3(input), first); + assert.equal(JSON.stringify(input), before); +}); +test('array order cannot alter output', () => { + const a = fixture(), b = structuredClone(a); b.employees.reverse(); + assert.deepEqual(v3.generateScheduleV3(a), v3.generateScheduleV3(b)); +}); +for (let n = 1; n <= 30; n++) { + test(`pool ${n} headcount and zero-hours visibility`, () => { + const input = fixture(); input.employees = Array.from({ length: n }, (_, i) => ({ ...input.employees[0], id: `e${i}`, fullName: `e${i}` })); + const result = v3.generateScheduleV3(input); + assert.equal(result.shifts.length, 6); assert.equal(result.employeeHours.length, n); + assert.ok(result.warnings.every(w => w.blocking === false)); + assert.deepEqual(v3.generateScheduleV3(input), result); + }); +} +for (let week = -26; week <= 26; week++) { + test(`rotation continuity ${week}`, () => { + const d = new Date('2026-01-05T00:00:00Z'); d.setUTCDate(d.getUTCDate() + week * 7); + assert.equal(v3.computeRotationWeekParity(d.toISOString().slice(0, 10), '2026-01-05'), Math.abs(week) % 2); + }); +} +for (const [name, mutate] of [ + ['null operating day', c => c.operatingDays[0] = null], + ['duplicate day', c => c.operatingDays[1].weekday = 'MONDAY'], + ['invalid day', c => c.operatingDays[0].weekday = 'FUNDAY'], + ['NaN duration', c => c.shiftTemplates[0].durationHours = NaN], + ['duration mismatch', c => c.shiftTemplates[0].durationHours = 1], + ['32-hour window', c => c.operatingDays[0].windows[0].crossMidnight = true], + ['duplicate demand template', c => c.coverageRequirements[0].slots.push({ shiftTemplateId: 'day', headcount: 1 })], + ['unknown template', c => c.coverageRequirements[0].slots[0].shiftTemplateId = 'missing'], + ['NaN policy', c => c.warningPolicies = { maxDailyHours: NaN }], + ['fractional week start', c => c.weekStartDay = 1.5], + ['role demand', c => c.coverageRequirements[0].slots[0].requiredRole = 'CORE_A'], + ['infinite headcount', c => c.coverageRequirements[0].slots[0].headcount = Infinity], +]) test(name, () => { const { config } = fixture(); mutate(config); assert.equal(v3.validateSchedulerConfigV3(config).valid, false); }); +test('shortage remains editable', () => { const i = fixture(); i.employees = []; const r = v3.generateScheduleV3(i); assert.equal(r.coverageSummary.unfilledSlots, 6); assert.ok(r.warnings.every(w => !w.blocking)); }); +test('substitute excluded automatically', () => { const i = fixture(); i.employees.forEach(e => e.schedulerV3.workMode = 'SUBSTITUTE_ONLY'); assert.equal(v3.generateScheduleV3(i).shifts.length, 0); }); +test('fixed days off and absences', () => { const i = fixture(); i.employees = [i.employees[0]]; i.employees[0].schedulerV3.fixedDayOff = 1; i.absences = [{id:'absence', employeeId:'a',type:'LEAVE',startDate:'2026-09-08',endDate:'2026-09-09',scope:'FULL_DAY'}]; const r=v3.generateScheduleV3(i); assert.ok(r.shifts.every(s => s.date >= '2026-09-10')); }); +test('manual deviation retained with warning', () => { const i = fixture(); i.employees[0].schedulerV3.fixedDayOff=1; i.existingManualShifts=[manual(i)]; const r=v3.generateScheduleV3(i); assert.ok(r.shifts.some(s=>s.id==='manual')); assert.ok(r.warnings.some(w=>w.code==='FIXED_DAY_OFF_OVERRIDE')); }); +test('cross-midnight overlap warning', () => { const i=fixture(); const shifts=[manual(i,{startTime:'22:00',endTime:'06:00',durationHours:8,crossMidnight:true}),manual(i,{id:'next',date:'2026-09-08',startTime:'05:00',endTime:'13:00'})]; assert.ok(v3.analyzeScheduleWarningsV3(i.config,i.employees,[],shifts,i.periodStart,i.periodEnd).some(w=>w.code==='SHIFT_OVERLAP')); }); +test('closed day custom work warns', () => { const i=fixture(); assert.ok(v3.analyzeScheduleWarningsV3(i.config,i.employees,[],[manual(i,{date:'2026-09-13',shiftTemplateId:null})],i.periodStart,i.periodEnd).some(w=>w.code==='OUTSIDE_OPERATING_WINDOW')); }); +test('standard deviation analyzer', () => { const i=fixture(); i.employees[0].schedulerV3.standardShiftTemplateId='other'; assert.ok(v3.analyzeScheduleWarningsV3(i.config,i.employees,[],[manual(i)],i.periodStart,i.periodEnd).some(w=>w.code==='STANDARD_SHIFT_DEVIATION')); }); +test('rotation configuration warning', () => { const i=fixture(); i.employees[0].schedulerV3.rotateStandardShiftWeekly=true; assert.ok(v3.analyzeScheduleWarningsV3(i.config,i.employees,[],[],i.periodStart,i.periodEnd).some(w=>w.code==='ROTATION_CONFIGURATION_WARNING')); }); +test('date input fails structurally', () => { const i=fixture(); i.periodStart='not-a-date'; assert.throws(()=>v3.generateScheduleV3(i)); }); +test('overnight rest includes midnight rollover', () => { const i=fixture(); const e=i.employees[0]; const s=manual(i,{date:'2026-09-06',startTime:'22:00',endTime:'06:00',crossMidnight:true}); assert.equal(v3.evaluateEmployeeEligibilityV3(e,'2026-09-07',[],[s],'08:00',{minRestIntervalHours:11,shiftEndTime:'16:00',shiftDurationHours:8}).reason,'REST_INTERVAL'); }); +test('prospective daily hours counted', () => { const i=fixture(); assert.equal(v3.evaluateEmployeeEligibilityV3(i.employees[0],i.periodStart,[],[],'08:00',{maxDailyHours:6,shiftDurationHours:8}).reason,'DAILY_HOURS_EXCEEDED'); }); +test('consecutive days include candidate', () => { const i=fixture(); const prior=['2026-09-04','2026-09-05','2026-09-06'].map((date,n)=>manual(i,{date,id:String(n)})); assert.equal(v3.evaluateEmployeeEligibilityV3(i.employees[0],i.periodStart,[],prior,'08:00',{maxConsecutiveWorkingDays:3}).reason,'CONSECUTIVE_DAYS_EXCEEDED'); }); +test('unknown manual employee is technical error', () => { const i=fixture(); i.existingManualShifts=[manual(i,{employeeId:'foreign'})]; assert.throws(()=>v3.generateScheduleV3(i)); }); +console.log(`V3 tests: PASS=${passed} FAIL=${failures.length}`); +if (failures.length) { console.error(failures.join('\n')); process.exitCode=1; } diff --git a/scripts/test-scheduler-v3-emulator.mjs b/scripts/test-scheduler-v3-emulator.mjs new file mode 100644 index 0000000..09fb1e0 --- /dev/null +++ b/scripts/test-scheduler-v3-emulator.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { connectAuthEmulator, createUserWithEmailAndPassword, signOut } from 'firebase/auth'; +import { connectFirestoreEmulator, doc, getDoc, updateDoc, deleteDoc } from 'firebase/firestore'; +import { connectStorageEmulator, ref, deleteObject } from 'firebase/storage'; +import { auth, db, storage } from '../src/firebase/config.js'; +import { schedulePublicationsRepository as repository } from '../src/repositories/schedulePublicationsRepository.ts'; +import { makeDefaultConfigV3, mapEmployeesV3, createDraftV3 } from '../src/services/schedulerV3Service.ts'; +import { buildPublicationV3 } from '../src/services/schedulePublicationService.ts'; + +async function main() { + for(const key of ['FIRESTORE_EMULATOR_HOST','FIREBASE_AUTH_EMULATOR_HOST','FIREBASE_STORAGE_EMULATOR_HOST'])if(!/^127\.0\.0\.1:\d+$/.test(process.env[key]||''))throw new Error('Local emulator variables required.'); + connectAuthEmulator(auth,'http://'+process.env.FIREBASE_AUTH_EMULATOR_HOST,{disableWarnings:true}); + const [fh,fp]=process.env.FIRESTORE_EMULATOR_HOST.split(':');connectFirestoreEmulator(db,fh,Number(fp)); + const [sh,sp]=process.env.FIREBASE_STORAGE_EMULATOR_HOST.split(':');connectStorageEmulator(storage,sh,Number(sp)); + const project='demo-shiftoryx-v3'; + const seed=async(path,values)=>{ + const fields=Object.fromEntries(Object.entries(values).map(([k,v])=>[k,{stringValue:String(v)}])); + const result=await fetch(`http://${process.env.FIRESTORE_EMULATOR_HOST}/v1/projects/${project}/databases/(default)/documents/${path}`,{method:'PATCH',headers:{Authorization:'Bearer owner','Content-Type':'application/json'},body:JSON.stringify({fields})}); + assert.equal(result.ok,true,'emulator seed'); + }; + const {user}=await createUserWithEmailAndPassword(auth,`v3-${Date.now()}@example.test`,'emulator-only-12345'); + await seed(`tenantMemberships/${user.uid}_tenant-a`,{uid:user.uid,tenantId:'tenant-a',status:'ACTIVE',role:'OWNER'}); + await seed('tenants/tenant-a/employees/a',{fullName:'Μαρία'}); + const employees=mapEmployeesV3([{id:'a',fullName:'Μαρία',isActive:true}]);const config=makeDefaultConfigV3('tenant-a'); + console.log('CHECK settings'); + await repository.saveSettings(config,employees); + assert.equal((await getDoc(doc(db,'tenants/tenant-a/settings/scheduler'))).data().schedulerSchemaVersion,3); + const draft=createDraftV3({config,employees,absences:[],periodStart:'2026-09-07',periodEnd:'2026-09-13',periodType:'WEEK',options:{balanceWeeklyTargets:true}},'emulator-draft'); + console.log('CHECK draft roundtrip'); + await repository.saveDraft(draft);const loaded=await repository.loadDraft('tenant-a',draft.id);assert.equal(loaded.shifts.length,draft.shifts.length); + loaded.shifts.pop();await repository.saveDraft(loaded);assert.equal((await repository.loadDraft('tenant-a',draft.id)).shifts.length,loaded.shifts.length); + await assert.rejects(()=>repository.saveDraft(loaded),'stale draft revision rejected'); + console.log('CHECK concurrent reservations'); + const key='WEEK_2026-09-07_2026-09-13';const versions=await Promise.all([repository.reserve('tenant-a',key,'pub-a'),repository.reserve('tenant-a',key,'pub-b')]); + assert.deepEqual([...versions].sort(),[1,2]); + const snapshots=versions.map((version,n)=>buildPublicationV3(draft,{tenantId:'tenant-a',uid:user.uid,id:n?'pub-b':'pub-a',version,timestamp:'2026-09-08T00:00:00Z'})); + console.log('CHECK PDF upload'); + for(const snapshot of snapshots)await repository.uploadPdf('tenant-a',snapshot.pdfStoragePath,new TextEncoder().encode('%PDF-1.4 emulator immutable test')); + // Newer completion first; older completion must not roll the pointer back. + console.log('CHECK finalize'); + for(const snapshot of [...snapshots].sort((a,b)=>b.version-a.version))await repository.finalize(snapshot); + assert.equal((await getDoc(doc(db,'tenants/tenant-a/schedulePublicationPeriods',key))).data().latestVersion,2); + assert.equal((await repository.list('tenant-a')).length,2); + assert.equal((await getDoc(doc(db,'tenants/tenant-a/publicMonths/2026-09'))).data().shiftCount,draft.shifts.length); + console.log('CHECK month clears stale weekly projections'); + const monthDraft={...draft,periodType:'MONTH',periodStart:'2026-09-01',periodEnd:'2026-09-30',shifts:[]}; + const monthKey='MONTH_2026-09-01_2026-09-30'; + const monthVersion=await repository.reserve('tenant-a',monthKey,'pub-month'); + const monthSnapshot=buildPublicationV3(monthDraft,{tenantId:'tenant-a',uid:user.uid,id:'pub-month',version:monthVersion,timestamp:'2026-09-08T01:00:00Z'}); + await repository.uploadPdf('tenant-a',monthSnapshot.pdfStoragePath,new TextEncoder().encode('%PDF-1.4 month test')); + await repository.finalize(monthSnapshot); + assert.equal((await getDoc(doc(db,'tenants/tenant-a/publicSchedules/2026-09-07'))).data().shiftCount,0); + assert.equal((await getDoc(doc(db,'tenants/tenant-a/schedulePublications/pub-a'))).data().shifts.length,draft.shifts.length); + await assert.rejects(()=>updateDoc(doc(db,'tenants/tenant-a/schedulePublications/pub-a'),{version:99})); + await assert.rejects(()=>deleteDoc(doc(db,'tenants/tenant-a/schedulePublications/pub-a'))); + await assert.rejects(()=>repository.uploadPdf('tenant-a',snapshots[0].pdfStoragePath,new Uint8Array([1]))); + await assert.rejects(()=>deleteObject(ref(storage,snapshots[0].pdfStoragePath))); + assert.ok((await repository.download('tenant-a','pub-a')).byteLength>0); + await assert.rejects(()=>repository.list('tenant-b')); + await seed(`platformAdmins/${user.uid}`,{status:'ACTIVE'}); + await assert.rejects(()=>repository.list('tenant-a')); + await assert.rejects(()=>repository.download('tenant-a','pub-a')); + await signOut(auth);await assert.rejects(()=>getDoc(doc(db,'tenants/tenant-a/schedulePublications/pub-a'))); + assert.equal((await getDoc(doc(db,'tenants/tenant-a/publicSchedules/2026-09-07'))).exists(),true); + console.log('V3 emulator PASS: settings, draft roundtrip/replacement, concurrent versions, latest pointer, immutable snapshot/PDF, OWNER access, cross-tenant/platform-admin/anonymous denial, public projection.'); +} +main().then(()=>process.exit(0)).catch(error=>{console.error('V3 emulator FAIL:',error.code||error.message);process.exit(1);}); diff --git a/scripts/test-scheduler-v3-services.mjs b/scripts/test-scheduler-v3-services.mjs new file mode 100644 index 0000000..973deb7 --- /dev/null +++ b/scripts/test-scheduler-v3-services.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { createDraftV3, makeDefaultConfigV3, mapEmployeesV3, analyzeDraftV3, editDraftV3, isSchedulerV3Active } from '../src/services/schedulerV3Service.ts'; +import { buildPublicationV3, publishDraftV3, publicProjectionV3 } from '../src/services/schedulePublicationService.ts'; +import { renderPublicationPdfV3 } from '../src/services/schedulePublicationPdf.ts'; +import { previewV2Migration } from '../src/scheduler-engine-v3/migration.ts'; +import { projectionTargetsV3, projectionPayloadV3 } from '../src/services/publicationProjectionsV3.ts'; +import { getShiftDurationHours } from '../src/utils/analytics.js'; +let passed=0; +async function test(name,fn){await fn();passed++;console.log('PASS '+name);} +const config=makeDefaultConfigV3('tenant-a'); +const employees=mapEmployeesV3([{id:'one',fullName:'Εργαζόμενος',isActive:true,email:'private@example.test',afm:'PRIVATE',scheduleRole:'EXTRA_A'},{id:'two',fullName:'Δεύτερος',isActive:true}]); +const input={config,employees,absences:[],periodStart:'2026-09-07',periodEnd:'2026-09-13',periodType:'WEEK',options:{balanceWeeklyTargets:true}}; +const draft=createDraftV3(input,'draft-1'); +const context={tenantId:'tenant-a',uid:'owner-a',id:'publication-1',timestamp:'2026-09-07T10:00:00Z',version:1,acceptWarnings:true}; +await test('dual activation gate',()=>{for(const flag of [false,undefined,'false'])assert.equal(isSchedulerV3Active(flag,3),false);assert.equal(isSchedulerV3Active('true',2),false);assert.equal(isSchedulerV3Active('true',3),true);}); +await test('legacy role never means substitute',()=>assert.equal(employees[0].schedulerV3.workMode,'NORMAL')); +await test('draft input immutable',()=>{const before=JSON.stringify(input);createDraftV3(input,'draft-2');assert.equal(JSON.stringify(input),before);}); +await test('shortage save analysis nonblocking',()=>{const shortage=editDraftV3(draft,[]);assert.ok(analyzeDraftV3(shortage).warnings.every(w=>!w.blocking));}); +await test('technical invalid edit rejected',()=>assert.throws(()=>editDraftV3(draft,[{...draft.shifts[0],employeeId:'foreign'}]))); +await test('publication tenant binding',()=>assert.throws(()=>buildPublicationV3(draft,{...context,tenantId:'tenant-b'}))); +const snapshot=buildPublicationV3(draft,context); +await test('immutable independent snapshot',()=>{const x=buildPublicationV3(draft,context);x.templateSnapshot.shiftTemplates[0].label='changed';assert.equal(draft.config.shiftTemplates[0].label,'Ημέρα');}); +await test('private employee data excluded',()=>{const text=JSON.stringify(snapshot);assert.ok(!text.includes('private@example.test'));assert.ok(!text.includes('PRIVATE'));assert.deepEqual(Object.keys(snapshot.employeeSnapshot[0]).sort(),['displayName','employeeId']);}); +await test('public projection has no internal identity or warnings',()=>{for(const shift of publicProjectionV3(snapshot)){assert.deepEqual(Object.keys(shift).sort(),['crossMidnight','date','employeeName','endTime','label','schedulerSchemaVersion','shiftType','startTime','type']);assert.equal(shift.label,'ΕΡΓ');}}); +await test('PDF is real and derived from snapshot',async()=>{const bytes=await renderPublicationPdfV3(snapshot);assert.equal(new TextDecoder().decode(bytes.slice(0,5)),'%PDF-');assert.ok(bytes.length>1000);}); +await test('publication follows reserve PDF upload finalize order',async()=>{const calls=[];const result=await publishDraftV3(draft,context,{reserve:async()=>{calls.push('reserve');return 2;},renderPdf:async s=>{calls.push('render');assert.equal(s.version,2);return new Uint8Array([37,80,68,70]);},uploadPdf:async path=>{calls.push('upload');assert.equal(path,'tenants/tenant-a/schedule-publications/publication-1/schedule.pdf');},finalize:async()=>{calls.push('finalize');}});assert.equal(result.version,2);assert.deepEqual(calls,['reserve','render','upload','finalize']);}); +await test('upload failure does not expose publication',async()=>{let finalized=false;await assert.rejects(()=>publishDraftV3(draft,context,{reserve:async()=>1,renderPdf:async()=>new Uint8Array([1]),uploadPdf:async()=>{throw new Error('offline');},finalize:async()=>{finalized=true;}}));assert.equal(finalized,false);}); +await test('invalid draft has zero side effects',async()=>{let called=false;await assert.rejects(()=>publishDraftV3({...draft,periodStart:'invalid'},context,{reserve:async()=>{called=true;return 1;}}));assert.equal(called,false);}); +await test('warnings require explicit acknowledgment and can be published',async()=>{const empty=editDraftV3(draft,[]);let calls=0;const io={reserve:async()=>{calls++;return 1;},renderPdf:async()=>new Uint8Array([1]),uploadPdf:async()=>{},finalize:async()=>{calls++;}};await assert.rejects(()=>publishDraftV3(empty,{...context,acceptWarnings:false},io));assert.equal(calls,0);await publishDraftV3(empty,context,io);assert.equal(calls,2);}); +await test('migration remains preview only and reports incompatible semantics',()=>{const raw={...config,coverageRequirements:config.coverageRequirements.map(p=>({...p,slots:p.slots.map(s=>({...s,targetHeadcount:s.headcount,requiredRole:'CORE_A'}))}))};const before=JSON.stringify(raw);const result=previewV2Migration(raw,'tenant-a');assert.equal(JSON.stringify(raw),before);assert.equal(result.requiresOwnerReview,true);assert.ok(result.deferred.length);assert.ok(result.config.coverageRequirements.every(p=>p.slots.every(s=>!('requiredRole'in s))));}); +await test('month publication updates every overlapping week and preserves boundary days',()=>{ + const month={...snapshot,periodType:'MONTH',periodStart:'2026-09-01',periodEnd:'2026-09-30'}; + const targets=projectionTargetsV3(month);assert.equal(targets.filter(t=>t.collection==='publicSchedules').length,5); + const prior={shifts:[{employeeName:'Old',date:'2026-08-31',startTime:'08:00',endTime:'16:00'},{employeeName:'Stale',date:'2026-09-01',startTime:'08:00',endTime:'16:00'}]}; + const payload=projectionPayloadV3(month,targets[0],prior);assert.ok(payload.shifts.some(s=>s.date==='2026-08-31'));assert.ok(!payload.shifts.some(s=>s.employeeName==='Stale')); +}); +await test('public V3 overnight analytics and legacy behavior',()=>{ + assert.equal(getShiftDurationHours({schedulerSchemaVersion:3,crossMidnight:true,startTime:'22:00',endTime:'06:00'}),8); + assert.throws(()=>getShiftDurationHours({startTime:'22:00',endTime:'06:00'})); +}); +console.log(`V3 service tests PASS=${passed}`); diff --git a/src/components/scheduler/MainDashboard.jsx b/src/components/scheduler/MainDashboard.jsx index e5bd079..e6be780 100644 --- a/src/components/scheduler/MainDashboard.jsx +++ b/src/components/scheduler/MainDashboard.jsx @@ -16,6 +16,9 @@ import AnnouncementBoard from './AnnouncementBoard'; import AnalyticsPanel from './AnalyticsPanel'; import ProgramHistoryPanel from './ProgramHistoryPanel'; import SchedulingRulesPanel from './SchedulingRulesPanel'; +import SchedulerWorkspaceV3, { SchedulerSetupV3 } from './SchedulerWorkspaceV3'; +import { isSchedulerV3Active } from '../../services/schedulerV3Service.ts'; +import { validateSchedulerConfigV3 } from '../../scheduler-engine-v3/config.ts'; import SchedulerSidebar from './SchedulerSidebar'; import SpecialDaysPanel from './SpecialDaysPanel'; import UndoSnackbar from './UndoSnackbar'; @@ -247,6 +250,8 @@ function MessageBanner({ } export default function MainDashboard() { + const configV3 = useSchedulerStore(state => state.schedulerConfigV3); + const versionV3 = useSchedulerStore(state => state.schedulerSchemaVersion); const [activeDragItem, setActiveDragItem] = useState(null); const [profileEmployee, setProfileEmployee] = useState(null); const [isSidebarOpen, setIsSidebarOpen] = useState(false); @@ -1399,6 +1404,11 @@ export default function MainDashboard() { return

Φόρτωση προγράμματος...

; } + const v3Active = isAdmin && isSchedulerV3Active(import.meta.env.VITE_ENABLE_SCHEDULER_V3, versionV3); + const v3Workspace = v3Active ? (validateSchedulerConfigV3(configV3).valid + ? + :

Οι ρυθμίσεις V3 δεν είναι έγκυρες. Χρειάζεται διόρθωση πριν τη δημιουργία προγράμματος.

) : null; + const dashboardContent = ( <>
@@ -1592,6 +1602,7 @@ export default function MainDashboard() { {isAdmin ? (
+ {import.meta.env.VITE_ENABLE_SCHEDULER_V3 === 'true' && !v3Active && } {dashboardContent} ); + if (v3Active) return <>{v3Workspace}
Προηγούμενα προγράμματα και εργαλεία

Ιστορικό, εξαγωγές και εργαλεία V2. Οι αλλαγές εδώ αφορούν τα προηγούμενα προγράμματα, όχι το ανοιχτό προσχέδιο V3.

{legacyTools}
; + return legacyTools; } diff --git a/src/components/scheduler/PublicationHistoryV3.jsx b/src/components/scheduler/PublicationHistoryV3.jsx new file mode 100644 index 0000000..ab3fa12 --- /dev/null +++ b/src/components/scheduler/PublicationHistoryV3.jsx @@ -0,0 +1 @@ +export default function PublicationHistoryV3({publications,onDownload,busy=false}) { return

Ιστορικό δημοσιεύσεων

{!publications.length&&

Δεν υπάρχουν δημοσιεύσεις.

}{publications.map(p=>
{p.periodStart} – {p.periodEnd} · Έκδοση {p.version}{p.publishedWithWarnings?'Με προειδοποιήσεις':'Χωρίς προειδοποιήσεις'}
)}
; } diff --git a/src/components/scheduler/QuarterHourTimePicker.jsx b/src/components/scheduler/QuarterHourTimePicker.jsx new file mode 100644 index 0000000..3869e18 --- /dev/null +++ b/src/components/scheduler/QuarterHourTimePicker.jsx @@ -0,0 +1,4 @@ +export default function QuarterHourTimePicker({ label, value, onChange, disabled=false }) { + const options=Array.from({length:96},(_,n)=>`${String(Math.floor(n/4)).padStart(2,'0')}:${String(n%4*15).padStart(2,'0')}`); + return ; +} diff --git a/src/components/scheduler/SchedulePreviewV3.jsx b/src/components/scheduler/SchedulePreviewV3.jsx new file mode 100644 index 0000000..eac362e --- /dev/null +++ b/src/components/scheduler/SchedulePreviewV3.jsx @@ -0,0 +1,14 @@ +import { calculateShiftDurationHoursV3, eachDateInRange } from '../../scheduler-engine-v3/index.ts'; +import QuarterHourTimePicker from './QuarterHourTimePicker'; +export default function SchedulePreviewV3({draft,onChange,disabled=false}) { + const update=(id,patch)=>onChange(draft.shifts.map(s=>{if(s.id!==id)return s;const next={...s,...patch,source:'MANUAL',isManualOverride:true};return {...next,durationHours:calculateShiftDurationHoursV3(next.startTime,next.endTime,next.crossMidnight)};})); + return
Προσχέδιο — επεξεργασία πριν τη δημοσίευση + {draft.shifts.map(s=>
+ + + update(s.id,{startTime,shiftTemplateId:null,crossMidnight:s.endTimeupdate(s.id,{endTime,shiftTemplateId:null,crossMidnight:endTime + {s.durationHours} ώρες{s.crossMidnight?' · επόμενη ημέρα':''} +
)} + +
; +} diff --git a/src/components/scheduler/SchedulerSettingsV3.jsx b/src/components/scheduler/SchedulerSettingsV3.jsx new file mode 100644 index 0000000..82be9a9 --- /dev/null +++ b/src/components/scheduler/SchedulerSettingsV3.jsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; +import { calculateShiftDurationHoursV3, validateSchedulerConfigV3 } from '../../scheduler-engine-v3/index.ts'; +import QuarterHourTimePicker from './QuarterHourTimePicker'; + +const days={MONDAY:'Δευτέρα',TUESDAY:'Τρίτη',WEDNESDAY:'Τετάρτη',THURSDAY:'Πέμπτη',FRIDAY:'Παρασκευή',SATURDAY:'Σάββατο',SUNDAY:'Κυριακή'}; +const button='rounded border border-slate-400 px-3 py-2 text-sm disabled:opacity-50'; +export default function SchedulerSettingsV3({ config, employees, onSave, busy=false }) { + const [draft,setDraft]=useState(()=>structuredClone(config)); + const [profiles,setProfiles]=useState(()=>structuredClone(employees)); + useEffect(()=>setProfiles(previous=>employees.map(employee=>{ + const pending=previous.find(p=>p.id===employee.id); + return {...employee,schedulerV3:pending?.schedulerV3||structuredClone(employee.schedulerV3)}; + })),[employees]); + const validation=validateSchedulerConfigV3(draft); + const updateDay=(index,patch)=>setDraft(c=>({...c,operatingDays:c.operatingDays.map((d,n)=>n===index?{...d,...patch}:d)})); + const updateTemplate=(index,patch)=>setDraft(c=>({...c,shiftTemplates:c.shiftTemplates.map((t,n)=>{if(n!==index)return t;const next={...t,...patch};return {...next,durationHours:calculateShiftDurationHoursV3(next.startTime,next.endTime,next.crossMidnight)};})})); + const updateProfile=(index,patch)=>setProfiles(list=>list.map((e,n)=>n===index?{...e,schedulerV3:{...e.schedulerV3,...patch}}:e)); + return
{e.preventDefault();if(validation.valid)onSave(draft,profiles);}} className="space-y-5" aria-label="Ρυθμίσεις προγράμματος V3"> +
+ Ρυθμίσεις προγράμματος +
{draft.operatingDays.map((day,index)=>
{days[day.weekday]} + + {day.windows.map((w,n)=>
+ updateDay(index,{windows:day.windows.map((x,j)=>j===n?{...x,openTime}:x)})}/> + updateDay(index,{windows:day.windows.map((x,j)=>j===n?{...x,closeTime}:x)})}/> + + +
)} +
)}
+

Πρότυπα βαρδιών

+ {draft.shiftTemplates.map((t,index)=>
{t.label} + + updateTemplate(index,{startTime})}/>updateTemplate(index,{endTime})}/> + + + + {t.durationHours} ώρες +
)} + +

Άτομα ανά βάρδια

+ {draft.operatingDays.map(day=>
{days[day.weekday]}{draft.shiftTemplates.map(t=>)}
)} +

Στόχοι και προειδοποιήσεις

+ +
{[['minRestIntervalHours','Ελάχιστες ώρες ανάπαυσης'],['maxDailyHours','Προειδοποίηση ημερήσιων ωρών'],['maxWeeklyHours','Προειδοποίηση εβδομαδιαίων ωρών'],['maxConsecutiveWorkingDays','Προειδοποίηση συνεχόμενων ημερών']].map(([key,label])=>)}
+

Προφίλ εργαζομένων

+ {profiles.map((e,index)=>
{e.fullName} + + + + {['standardShiftTemplateId','rotationAlternateShiftTemplateId'].map((key,n)=>)} + + +
)} + {!validation.valid&&
{validation.errors.join(' ')}
} + +
+
; +} diff --git a/src/components/scheduler/SchedulerWorkspaceV3.jsx b/src/components/scheduler/SchedulerWorkspaceV3.jsx new file mode 100644 index 0000000..2955193 --- /dev/null +++ b/src/components/scheduler/SchedulerWorkspaceV3.jsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { createDraftV3, analyzeDraftV3, editDraftV3, mapEmployeesV3, makeDefaultConfigV3 } from '../../services/schedulerV3Service.ts'; +import { refreshDraftPeopleV3 } from '../../services/schedulerV3Service.ts'; +import { publishDraftV3 } from '../../services/schedulePublicationService.ts'; +import { renderPublicationPdfV3 } from '../../services/schedulePublicationPdf.ts'; +import { schedulePublicationsRepository as repository } from '../../repositories/schedulePublicationsRepository.ts'; +import { useSchedulerStore } from '../../hooks/useSchedulerStore'; +import SchedulerSettingsV3 from './SchedulerSettingsV3'; +import SchedulePreviewV3 from './SchedulePreviewV3'; +import WarningsPanelV3 from './WarningsPanelV3'; +import PublicationHistoryV3 from './PublicationHistoryV3'; +import AbsencesPanel from './AbsencesPanel'; + +export function SchedulerSetupV3({tenantId,employees}) { + const [message,setMessage]=useState('');const [busy,setBusy]=useState(false); + const config=makeDefaultConfigV3(tenantId); + return
Ρυθμίσεις νέου προγράμματος V3

Η αποθήκευση ενεργοποιεί το νέο πρόγραμμα για αυτό το κατάστημα. Τα παλιά προγράμματα διατηρούνται.

{setBusy(true);try{await repository.saveSettings(c,e);setMessage('Αποθηκεύτηκαν.');}catch{setMessage('Η αποθήκευση απέτυχε. Έλεγξε τις ρυθμίσεις και την πρόσβασή σου.');}finally{setBusy(false);}}}/>

{message}

; +} +export default function SchedulerWorkspaceV3({config,employees,absences,uid,onLogout}) { + const [draft,setDraft]=useState(null),[start,setStart]=useState(()=>new Date().toISOString().slice(0,10)),[periodType,setPeriodType]=useState('WEEK'),[busy,setBusy]=useState(false),[message,setMessage]=useState(''),[publications,setPublications]=useState([]),[drafts,setDrafts]=useState([]),[acceptWarnings,setAcceptWarnings]=useState(false); + const mapped=mapEmployeesV3(employees); + const store=useSchedulerStore(); + const [newName,setNewName]=useState(''); + useEffect(()=>{setDraft(d=>d?refreshDraftPeopleV3(d,mapEmployeesV3(employees),absences):null);setAcceptWarnings(false);},[employees,absences]); + const report=draft?analyzeDraftV3(draft):null; + const run=async fn=>{if(busy)return;setBusy(true);setMessage('');try{await fn();}catch(error){setMessage(error?.message||'Η ενέργεια δεν ολοκληρώθηκε.');}finally{setBusy(false);}}; + const change=shifts=>{try{setDraft(editDraftV3(draft,shifts));setAcceptWarnings(false);setMessage('');}catch(error){setMessage(error.message);}}; + const download=async publication=>run(async()=>{const bytes=await repository.download(config.tenantId,publication.id);const url=URL.createObjectURL(new Blob([bytes],{type:'application/pdf'}));const a=document.createElement('a');a.href=url;a.download=`schedule-${publication.periodKey}-v${publication.version}.pdf`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}); + return
+

Πρόγραμμα βαρδιών

+
Εργαζόμενοι και απουσίες +
{e.preventDefault();run(async()=>{if(await store.addEmployee({fullName:newName})){setNewName('');}else throw new Error('Η προσθήκη εργαζομένου απέτυχε.');});}}>
+ {employees.map(e=>
{e.fullName}
)} + +
+
Ρυθμίσεις και προφίλ εργαζομένωνrun(async()=>{await repository.saveSettings(c,e);setMessage('Οι ρυθμίσεις αποθηκεύτηκαν. Το ανοιχτό προσχέδιο διατηρεί τις δικές του ρυθμίσεις.');})}/>
+
Δημιουργία προσχεδίου + +
+

{busy?'Επεξεργασία…':message}

+ {drafts.map(d=>)} + {draft&&<>

Σύνολα

{report.employeeHours.map(h=>

{draft.employees.find(e=>e.id===h.employeeId)?.fullName}: {h.hours} ώρες · {h.shiftCount} βάρδιες

)}
+ + {report.warnings.length>0&&} + + } + +
; +} diff --git a/src/components/scheduler/WarningsPanelV3.jsx b/src/components/scheduler/WarningsPanelV3.jsx new file mode 100644 index 0000000..6f10760 --- /dev/null +++ b/src/components/scheduler/WarningsPanelV3.jsx @@ -0,0 +1 @@ +export default function WarningsPanelV3({warnings}) { return

Προειδοποιήσεις ({warnings.length})

Μπορείς να διορθώσεις το πρόγραμμα ή να δημοσιεύσεις αφού τις ελέγξεις.

    {warnings.map(w=>
  • {w.date} {w.message}
  • )}
; } diff --git a/src/firebase/shiftService.js b/src/firebase/shiftService.js index ffd9746..b3998ef 100644 --- a/src/firebase/shiftService.js +++ b/src/firebase/shiftService.js @@ -37,7 +37,7 @@ export function subscribeShifts({ tenantId }, onData, onError) { return onSnapshot( shiftsQuery, (snapshot) => { - onData(toDataWithId(snapshot)); + onData(toDataWithId(snapshot).filter(shift => shift.schedulerSchemaVersion !== 3)); }, onError, ); @@ -132,8 +132,9 @@ export async function removeShiftsByEmployee(employeeId, { tenantId } = {}) { query(tenantCollection(tenantId, TENANT_SCOPED_COLLECTIONS.shifts), where('employeeId', '==', employeeId)), ); - const removed = shiftsSnapshot.docs.map((item) => ({ id: item.id, ...item.data() })); - await commitBatchChunks(shiftsSnapshot.docs, (batch, item) => { + const legacyDocs = shiftsSnapshot.docs.filter(item => item.data().schedulerSchemaVersion !== 3); + const removed = legacyDocs.map((item) => ({ id: item.id, ...item.data() })); + await commitBatchChunks(legacyDocs, (batch, item) => { batch.delete(tenantDoc(tenantId, TENANT_SCOPED_COLLECTIONS.shifts, item.id)); }); @@ -154,7 +155,7 @@ export async function removeShiftsByDates(dates, { tenantId } = {}) { ); shiftsSnapshot.docs.forEach((item) => { - matchedDocsById.set(item.id, item); + if (item.data().schedulerSchemaVersion !== 3) matchedDocsById.set(item.id, item); }); } @@ -188,7 +189,7 @@ export async function fetchShiftsOnce({ tenantId } = {}) { const shiftsQuery = query(tenantCollection(tenantId, TENANT_SCOPED_COLLECTIONS.shifts), orderBy('date', 'asc')); try { const snapshot = await getDocs(shiftsQuery); - return toDataWithId(snapshot); + return toDataWithId(snapshot).filter(shift => shift.schedulerSchemaVersion !== 3); } catch (error) { handleFirestoreFailure(error); return []; @@ -208,7 +209,7 @@ export async function fetchShiftsByDates(dates, { tenantId } = {}) { query(tenantCollection(tenantId, TENANT_SCOPED_COLLECTIONS.shifts), where('date', 'in', dateChunk)), ); - matchedShifts.push(...toDataWithId(shiftsSnapshot)); + matchedShifts.push(...toDataWithId(shiftsSnapshot).filter(shift => shift.schedulerSchemaVersion !== 3)); } return matchedShifts.sort((a, b) => `${a.date}_${a.startTime}`.localeCompare(`${b.date}_${b.startTime}`)); @@ -229,6 +230,7 @@ export async function hasConsecutiveSundayAssignment({ tenantId, employeeId, pre return sundaySnapshot.docs.some((item) => { const shift = item.data(); + if (shift.schedulerSchemaVersion === 3) return false; return shift.startTime === '08:00' && shift.endTime === '20:00'; }); } diff --git a/src/hooks/useSchedulerStore.js b/src/hooks/useSchedulerStore.js index a994072..5baad5b 100644 --- a/src/hooks/useSchedulerStore.js +++ b/src/hooks/useSchedulerStore.js @@ -228,6 +228,8 @@ function stopAdminDataSubscriptions(get) { function clearAdminDataState() { return { + schedulerConfigV3: null, + schedulerSchemaVersion: 2, employees: [], shifts: [], shiftTemplates: [], @@ -423,6 +425,8 @@ export const useSchedulerStore = create((set, get) => ({ publicAnnouncements: [], generatorRules: { ...defaultGeneratorRules }, schedulerConfigV2: null, + schedulerConfigV3: null, + schedulerSchemaVersion: 2, specialDaysByDate: {}, selectedHistoryWeekId: '', selectedTemplateId: '', @@ -729,7 +733,7 @@ export const useSchedulerStore = create((set, get) => ({ settingsDoc?.specialDaysByDate && typeof settingsDoc.specialDaysByDate === 'object' ? settingsDoc.specialDaysByDate : {}; - set({ generatorRules, schedulerConfigV2, specialDaysByDate }); + set({ generatorRules, schedulerConfigV2, specialDaysByDate, schedulerConfigV3: settingsDoc?.schedulerConfigV3 || null, schedulerSchemaVersion: settingsDoc?.schedulerSchemaVersion || 2 }); }, () => set({ warningMessage: 'Αποτυχία φόρτωσης ρυθμίσεων προγραμματισμού.' }), ); @@ -972,6 +976,10 @@ export const useSchedulerStore = create((set, get) => ({ }, refreshPublicWeekSnapshot: async ({ weekStart = get().weekStart, shifts = null, silent = false } = {}) => { + if (get().schedulerSchemaVersion === 3) { + if (!silent) set({ warningMessage: 'Η δημόσια προβολή ενημερώνεται μόνο με νέα δημοσίευση V3.' }); + return false; + } if (!get().isAdmin || !weekStart) return true; const weekDays = getWeekDays(weekStart); const weekSet = new Set(weekDays); @@ -1004,6 +1012,10 @@ export const useSchedulerStore = create((set, get) => ({ }, refreshPublicMonthSnapshot: async ({ year, month, monthDays, shifts = null } = {}) => { + if (get().schedulerSchemaVersion === 3) { + set({ warningMessage: 'Η δημόσια προβολή ενημερώνεται μόνο με νέα δημοσίευση V3.' }); + return false; + } if (!get().isAdmin) return true; const numericYear = Number(year); const numericMonth = Number(month); @@ -2110,6 +2122,10 @@ export const useSchedulerStore = create((set, get) => ({ clearMonthShifts: async ({ year, month }) => { if (!requireAdmin(get, set)) return false; + if (get().schedulerSchemaVersion === 3) { + set({ warningMessage: 'Ο καθαρισμός V2 δεν επιτρέπεται σε κατάστημα V3. Δημιούργησε νέο προσχέδιο.' }); + return false; + } const numericYear = Number(year); const numericMonth = Number(month); diff --git a/src/repositories/schedulePublicationsRepository.ts b/src/repositories/schedulePublicationsRepository.ts new file mode 100644 index 0000000..0a01adf --- /dev/null +++ b/src/repositories/schedulePublicationsRepository.ts @@ -0,0 +1,96 @@ +import { collection, doc, getDoc, getDocs, query, where, runTransaction, writeBatch } from 'firebase/firestore'; +import { ref, uploadBytes, getBytes } from 'firebase/storage'; +import { db, auth, storage } from '../firebase/config'; +import { normalizeTenantId } from '../utils/tenantDataPaths'; +import { analyzeDraftV3, type DraftV3 } from '../services/schedulerV3Service.ts'; +import { projectionTargetsV3, projectionPayloadV3 } from '../services/publicationProjectionsV3.ts'; +import { validateSchedulerConfigV3, validateEmployeeProfileV3 } from '../scheduler-engine-v3/index.ts'; +import type { EmployeeV3, SchedulerConfigV3, SchedulePublicationV3 } from '../scheduler-engine-v3/types.ts'; + +const clean = (data: unknown) => JSON.parse(JSON.stringify(data)); +function context(tenantId:string) { + if (import.meta.env.VITE_ENABLE_SCHEDULER_V3 !== 'true') throw new Error('Το Scheduler V3 είναι απενεργοποιημένο.'); + const tenant=normalizeTenantId(tenantId); + if(!db||!auth?.currentUser||!storage)throw new Error('Απαιτείται σύνδεση OWNER.'); + return { tenant, uid:auth.currentUser.uid, root:`tenants/${tenant}` }; +} +const idCheck=(id:string)=>{if(!/^[a-zA-Z0-9_-]{1,120}$/.test(id))throw new Error('Μη έγκυρο αναγνωριστικό.');return id;}; +export const schedulePublicationsRepository={ + async setEmployeeActive(tenantId:string,id:string,isActive:boolean) { + const c=context(tenantId);const batch=writeBatch(db); + batch.update(doc(db,c.root,'employees',idCheck(id)),{isActive}); + await batch.commit(); + }, + async saveSettings(config:SchedulerConfigV3,employees:EmployeeV3[]) { + const c=context(config.tenantId); + if(!validateSchedulerConfigV3(config).valid||employees.length>100)throw new Error('Μη έγκυρες ρυθμίσεις.'); + const batch=writeBatch(db); const ids=new Set(config.shiftTemplates.map(t=>t.id)); + for(const employee of employees) { + if(!validateEmployeeProfileV3(employee.schedulerV3,ids).valid)throw new Error('Μη έγκυρο προφίλ.'); + batch.update(doc(db,c.root,'employees',idCheck(employee.id)),{schedulerV3:clean(employee.schedulerV3)}); + } + batch.set(doc(db,c.root,'settings','scheduler'),{schedulerSchemaVersion:3,schedulerConfigV3:clean(config)},{merge:true}); + await batch.commit(); + }, + async saveDraft(draft:DraftV3) { + analyzeDraftV3(draft); const c=context(draft.config.tenantId);idCheck(draft.id); + return runTransaction(db,async tx=>{ + const metadata=doc(db,c.root,'scheduleDrafts',draft.id);const previous=await tx.get(metadata); + const prior=previous.data(); + if((prior?.revision||0)!==(draft.revision||0))throw new Error('Το προσχέδιο άλλαξε αλλού. Επαναφόρτωσέ το πριν την αποθήκευση.'); + const previousIds:string[]=prior?.shiftDocumentIds||[]; + const shiftDocumentIds=draft.shifts.map((_,n)=>`${draft.id}_${n}`); + const removedIds=previousIds.filter(id=>!shiftDocumentIds.includes(id)); + if(removedIds.length+draft.shifts.length+1>450)throw new Error('Το προσχέδιο υπερβαίνει το όριο ατομικής αποθήκευσης. Χρησιμοποίησε μικρότερη περίοδο.'); + removedIds.forEach(id=>tx.delete(doc(db,c.root,'shifts',idCheck(id)))); + draft.shifts.forEach((s,n)=>tx.set(doc(db,c.root,'shifts',shiftDocumentIds[n]),clean({...s,type:'work',schedulerSchemaVersion:3,draftId:draft.id}))); + const revision=(draft.revision||0)+1; + tx.set(metadata,clean({id:draft.id,tenantId:c.tenant,schemaVersion:3,periodType:draft.periodType,periodStart:draft.periodStart,periodEnd:draft.periodEnd,config:draft.config,employees:draft.employees,absences:draft.absences,options:draft.options,updatedBy:c.uid,revision,shiftDocumentIds})); + return revision; + }); + }, + async loadDraft(tenantId:string,id:string):Promise { + const c=context(tenantId);idCheck(id); + return runTransaction(db,async tx=>{ + const metadata=await tx.get(doc(db,c.root,'scheduleDrafts',id)); + if(!metadata.exists())throw new Error('Το προσχέδιο δεν βρέθηκε.'); + const ids:string[]=metadata.data().shiftDocumentIds||[]; + const shifts=await Promise.all(ids.map(shiftId=>tx.get(doc(db,c.root,'shifts',idCheck(shiftId))))); + if(shifts.some(s=>!s.exists()))throw new Error('Λείπουν δεδομένα προσχεδίου.'); + const draft={...metadata.data(),id,shifts:shifts.map(d=>d.data())} as DraftV3; + if(draft.config.tenantId!==c.tenant)throw new Error('Μη έγκυρο κατάστημα.'); + analyzeDraftV3(draft);return draft; + }); + }, + async listDrafts(tenantId:string) {const c=context(tenantId);return (await getDocs(collection(db,c.root,'scheduleDrafts'))).docs.map(d=>({id:d.id,...d.data()}));}, + async reserve(tenantId:string,periodKey:string,id:string) { + const c=context(tenantId);idCheck(id);idCheck(periodKey); + return runTransaction(db,async tx=>{ + const counter=doc(db,c.root,'schedulePublicationCounters',periodKey), reservation=doc(db,c.root,'schedulePublicationReservations',id); + const [prior,count]=await Promise.all([tx.get(reservation),tx.get(counter)]); + if(prior.exists()) {if(prior.data().periodKey!==periodKey)throw new Error('Σύγκρουση δημοσίευσης.');return prior.data().version;} + const version=(count.data()?.version||0)+1; + tx.set(counter,{tenantId:c.tenant,version,lastReservedId:id});tx.set(reservation,{tenantId:c.tenant,periodKey,version,publishedByUid:c.uid});return version; + }); + }, + async uploadPdf(tenantId:string,path:string,bytes:Uint8Array) { + const c=context(tenantId);if(!path.startsWith(c.root+'/schedule-publications/')||!path.endsWith('/schedule.pdf'))throw new Error('Μη έγκυρη διαδρομή PDF.'); + await uploadBytes(ref(storage,path),bytes,{contentType:'application/pdf'}); + }, + async finalize(snapshot:SchedulePublicationV3) { + const c=context(snapshot.tenantId);idCheck(snapshot.id);idCheck(snapshot.periodKey); + await runTransaction(db,async tx=>{ + const publication=doc(db,c.root,'schedulePublications',snapshot.id),index=doc(db,c.root,'schedulePublicationPeriods',snapshot.periodKey); + const targets=projectionTargetsV3(snapshot); + const [prior,latest,...projections]=await Promise.all([tx.get(publication),tx.get(index),...targets.map(t=>tx.get(doc(db,c.root,t.collection,t.id)))]); + if(prior.exists())throw new Error('Η έκδοση έχει ήδη δημοσιευτεί.'); + tx.set(publication,clean(snapshot)); + if((latest.data()?.latestVersion||0)tx.set(doc(db,c.root,target.collection,target.id),projectionPayloadV3(snapshot,target,projections[n].data()))); + } + }); + }, + async list(tenantId:string) {const c=context(tenantId);return (await getDocs(collection(db,c.root,'schedulePublications'))).docs.map(d=>d.data() as SchedulePublicationV3).sort((a,b)=>b.publishedAt.localeCompare(a.publishedAt));}, + async download(tenantId:string,publicationId:string) {const c=context(tenantId);idCheck(publicationId);return getBytes(ref(storage,`${c.root}/schedule-publications/${publicationId}/schedule.pdf`),10*1024*1024);}, +}; diff --git a/src/scheduler-engine-v3/config.ts b/src/scheduler-engine-v3/config.ts index 011e413..26a4f22 100644 --- a/src/scheduler-engine-v3/config.ts +++ b/src/scheduler-engine-v3/config.ts @@ -1,6 +1,16 @@ import type { SchedulerConfigV3, ShiftTemplateV3 } from './types.ts'; const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; +export const WEEKDAYS_V3 = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] as const; +export function isIsoDateV3(value: string): boolean { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const date = new Date(`${value}T00:00:00Z`); + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; +} +export function shiftIntervalV3(date: string, start: string, end: string, cross = false) { + const midnight = Date.parse(`${date}T00:00:00Z`); + return { start: midnight + timeToMinutesV3(start) * 60000, end: midnight + (timeToMinutesV3(end) + (cross ? 1440 : 0)) * 60000 }; +} /** * Validates if a string is in HH:mm format. @@ -56,7 +66,7 @@ export function validateShiftTemplateV3(template: unknown): { valid: boolean; er if (!template || typeof template !== 'object') { return { valid: false, errors: ['Το πρότυπο βάρδιας πρέπει να είναι αντικείμενο.'] }; } - const t = template as any; + const t = template as Partial; if (!t.id || typeof t.id !== 'string') { errors.push('Το πρότυπο βάρδιας πρέπει να έχει ένα έγκυρο id (string).'); @@ -76,10 +86,15 @@ export function validateShiftTemplateV3(template: unknown): { valid: boolean; er errors.push('Μη έγκυρη μορφή ώρας λήξης. Πρέπει να είναι HH:mm.'); } - if (typeof t.durationHours !== 'number' || t.durationHours <= 0) { + if (!Number.isFinite(t.durationHours) || t.durationHours <= 0 || t.durationHours > 24) { errors.push('Η διάρκεια της βάρδιας πρέπει να είναι θετικός αριθμός μεγαλύτερος του μηδενός.'); } + if (typeof t.crossMidnight !== 'boolean' || (isValidTimeV3(t.startTime) && isValidTimeV3(t.endTime) && + (t.startTime === t.endTime || t.crossMidnight !== (t.endTime < t.startTime)))) errors.push('Μη έγκυρη διέλευση μεσονυχτίου.'); + if (isValidTimeV3(t.startTime) && isValidTimeV3(t.endTime) && Math.abs(calculateShiftDurationHoursV3(t.startTime, t.endTime, t.crossMidnight) - t.durationHours) > 0.001) errors.push('Η διάρκεια δεν συμφωνεί με το ωράριο.'); + if (!['MORNING', 'INTERMEDIATE', 'AFTERNOON', 'NIGHT', 'CUSTOM'].includes(t.shiftType)) errors.push('Μη έγκυρος τύπος βάρδιας.'); + return { valid: errors.length === 0, errors }; } @@ -95,6 +110,28 @@ export function validateSchedulerConfigV3(config: unknown): { valid: boolean; er } const c = config as any; + if (!c.generationDefaults || typeof c.generationDefaults.balanceWeeklyTargetsForMonth !== 'boolean') errors.push('Απαιτείται έγκυρη ρύθμιση εξισορρόπησης στόχων.'); + + // Structural guards precede all nested access, including data read from storage. + const object = (v: unknown): boolean => Boolean(v && typeof v === 'object' && !Array.isArray(v)); + if (!Array.isArray(c.operatingDays) || c.operatingDays.some(d => !object(d) || !Array.isArray(d.windows) || d.windows.some(w => !object(w))) || + !Array.isArray(c.shiftTemplates) || c.shiftTemplates.some(t => !object(t)) || + !Array.isArray(c.coverageRequirements) || c.coverageRequirements.some(p => !object(p) || !Array.isArray(p.slots) || p.slots.some(s => !object(s)))) { + return { valid: false, errors: ['Μη έγκυρη δομή ρυθμίσεων.'] }; + } + if (c.shiftTemplates.length > 50 || c.coverageRequirements.length > 7) errors.push('Υπέρβαση τεχνικού ορίου ρυθμίσεων.'); + if (new Set(c.operatingDays.map(d => d.weekday)).size !== 7 || c.operatingDays.some(d => !WEEKDAYS_V3.includes(d.weekday) || typeof d.isOpen !== 'boolean' || d.windows.length > 8)) errors.push('Απαιτούνται επτά μοναδικές έγκυρες ημέρες.'); + if (!Number.isInteger(c.weekStartDay)) errors.push('Το weekStartDay πρέπει να είναι ακέραιο.'); + for (const d of c.operatingDays) for (const w of d.windows) { + if (w.openTime === w.closeTime || Boolean(w.crossMidnight) !== (w.closeTime < w.openTime)) errors.push('Μη έγκυρο crossMidnight ωραρίου λειτουργίας.'); + } + for (const p of c.coverageRequirements) { + if (!WEEKDAYS_V3.includes(p.weekday) || new Set(p.slots.map(s => s.shiftTemplateId)).size !== p.slots.length) errors.push('Μη έγκυρη ή διπλή κάλυψη.'); + for (const s of p.slots) if (Object.keys(s).some(k => !['shiftTemplateId', 'headcount'].includes(k)) || s.headcount > 100) errors.push('Η κάλυψη δέχεται μόνο πρότυπο και πλήθος έως 100.'); + } + if (c.warningPolicies) for (const [key, value] of Object.entries(c.warningPolicies)) { + if (!['minRestIntervalHours','maxDailyHours','maxWeeklyHours','maxConsecutiveWorkingDays'].includes(key) || (value !== null && (!Number.isFinite(value) || Number(value) <= 0))) errors.push('Μη έγκυρη πολιτική προειδοποιήσεων.'); + } if (c.schemaVersion !== 3) { errors.push('Η έκδοση σχήματος (schemaVersion) πρέπει να είναι 3.'); diff --git a/src/scheduler-engine-v3/coverage.ts b/src/scheduler-engine-v3/coverage.ts index 43294bd..37a7b34 100644 --- a/src/scheduler-engine-v3/coverage.ts +++ b/src/scheduler-engine-v3/coverage.ts @@ -17,6 +17,7 @@ import type { ShiftTemplateV3, Weekday, } from './types.ts'; +import { isIsoDateV3 } from './config.ts'; export type ExpandedCoverageSlot = { date: string; @@ -44,6 +45,7 @@ export function getWeekdayForDate(dateStr: string): Weekday { * Generate all dates in [startDate, endDate] inclusive. */ export function eachDateInRange(startDate: string, endDate: string): string[] { + if (!isIsoDateV3(startDate) || !isIsoDateV3(endDate) || startDate > endDate || Date.parse(endDate) - Date.parse(startDate) > 366 * 86400000) throw new Error('Μη έγκυρη περίοδος προγραμματισμού.'); const dates: string[] = []; const current = new Date(startDate + 'T00:00:00Z'); const end = new Date(endDate + 'T00:00:00Z'); diff --git a/src/scheduler-engine-v3/eligibility.ts b/src/scheduler-engine-v3/eligibility.ts index 4133ff3..b52867f 100644 --- a/src/scheduler-engine-v3/eligibility.ts +++ b/src/scheduler-engine-v3/eligibility.ts @@ -14,7 +14,7 @@ import type { Weekday, } from './types.ts'; import { isFixedDayOff, isWithinActiveDates, resolveEffectiveStandardShift } from './employeeProfile.ts'; -import { timeToMinutesV3, calculateShiftDurationHoursV3 } from './config.ts'; +import { timeToMinutesV3, calculateShiftDurationHoursV3, shiftIntervalV3 } from './config.ts'; export type EligibilityReason = | 'ELIGIBLE' @@ -48,7 +48,7 @@ function hasAbsenceOnDate( a.employeeId === employeeId && dateStr >= a.startDate && dateStr <= a.endDate && - a.scope === 'FULL_DAY', + ['FULL_DAY', 'PARTIAL_DAY'].includes(a.scope), ); } @@ -78,7 +78,7 @@ function wouldViolateRestInterval( if (s.date === prevDate) { // End of previous shift to start of new shift const endMin = timeToMinutesV3(s.endTime); - const restMinutes = (24 * 60 - endMin) + newStartMin; + const restMinutes = (24 * 60 - endMin - (s.crossMidnight ? 1440 : 0)) + newStartMin; if (restMinutes < minRestMinutes) return true; } if (s.date === dateStr && s.endTime !== shiftStartTime) { @@ -166,9 +166,13 @@ export function evaluateEmployeeEligibilityV3( maxConsecutiveWorkingDays?: number | null; weekStartDate?: string; weekEndDate?: string; + shiftEndTime?: string; + crossMidnight?: boolean; + shiftDurationHours?: number; } = {}, ): EligibilityResult { const eid = employee.id; + const prospectiveHours = options.shiftDurationHours ?? 0; // 1. Active check if (!employee.isActive) { @@ -206,9 +210,17 @@ export function evaluateEmployeeEligibilityV3( } // 8. Daily hours + if (options.shiftEndTime) { + const candidate = shiftIntervalV3(dateStr, shiftStartTime, options.shiftEndTime, Boolean(options.crossMidnight)); + for (const shift of existingShifts.filter(s => s.employeeId === eid)) { + const other = shiftIntervalV3(shift.date, shift.startTime, shift.endTime, Boolean(shift.crossMidnight)); + const separation = Math.max(candidate.start - other.end, other.start - candidate.end) / 3600000; + if (separation < 0 || (options.minRestIntervalHours && separation < options.minRestIntervalHours)) return { eligible: false, reason: 'REST_INTERVAL', employeeId: eid }; + } + } if (options.maxDailyHours && options.maxDailyHours > 0) { const dailyHours = getEmployeeHoursInRange(eid, dateStr, dateStr, existingShifts); - if (dailyHours >= options.maxDailyHours) { + if (dailyHours + prospectiveHours > options.maxDailyHours) { return { eligible: false, reason: 'DAILY_HOURS_EXCEEDED', employeeId: eid }; } } @@ -216,14 +228,14 @@ export function evaluateEmployeeEligibilityV3( // 9. Weekly hours if (options.maxWeeklyHours && options.maxWeeklyHours > 0 && options.weekStartDate && options.weekEndDate) { const weeklyHours = getEmployeeHoursInRange(eid, options.weekStartDate, options.weekEndDate, existingShifts); - if (weeklyHours >= options.maxWeeklyHours) { + if (weeklyHours + prospectiveHours > options.maxWeeklyHours) { return { eligible: false, reason: 'WEEKLY_HOURS_EXCEEDED', employeeId: eid }; } } // 10. Consecutive days if (options.maxConsecutiveWorkingDays && options.maxConsecutiveWorkingDays > 0) { - const consecutive = getConsecutiveWorkDays(eid, dateStr, existingShifts); + const consecutive = getConsecutiveWorkDays(eid, addDaysSimple(dateStr, -1), existingShifts); if (consecutive >= options.maxConsecutiveWorkingDays) { return { eligible: false, reason: 'CONSECUTIVE_DAYS_EXCEEDED', employeeId: eid }; } diff --git a/src/scheduler-engine-v3/employeeProfile.ts b/src/scheduler-engine-v3/employeeProfile.ts index 8e79934..3cff8de 100644 --- a/src/scheduler-engine-v3/employeeProfile.ts +++ b/src/scheduler-engine-v3/employeeProfile.ts @@ -65,7 +65,7 @@ export function validateEmployeeProfileV3( } if (profile.targetWeeklyHours !== null) { - if (typeof profile.targetWeeklyHours !== 'number' || profile.targetWeeklyHours < 0) { + if (!Number.isFinite(profile.targetWeeklyHours) || profile.targetWeeklyHours < 0 || profile.targetWeeklyHours > 168) { errors.push('Ο στόχος εβδομαδιαίων ωρών πρέπει να είναι μη αρνητικός αριθμός ή null.'); } } @@ -75,12 +75,7 @@ export function validateEmployeeProfileV3( } if (profile.rotateStandardShiftWeekly) { - if (!profile.standardShiftTemplateId) { - errors.push('Η εβδομαδιαία εναλλαγή απαιτεί ορισμό τυπικής βάρδιας.'); - } - if (!profile.rotationAlternateShiftTemplateId) { - errors.push('Η εβδομαδιαία εναλλαγή απαιτεί ορισμό εναλλακτικής βάρδιας.'); - } + // Missing rotation choices produce ROTATION_CONFIGURATION_WARNING; owners may save the draft. if (profile.rotationAlternateShiftTemplateId && !availableTemplateIds.has(profile.rotationAlternateShiftTemplateId)) { errors.push(`Μη έγκυρη αναφορά εναλλακτικής βάρδιας: ${profile.rotationAlternateShiftTemplateId}.`); } diff --git a/src/scheduler-engine-v3/generator.ts b/src/scheduler-engine-v3/generator.ts index eb8cff5..dc50369 100644 --- a/src/scheduler-engine-v3/generator.ts +++ b/src/scheduler-engine-v3/generator.ts @@ -1,381 +1,67 @@ -/** - * ShiftOryx Scheduler V3 — Pure Schedule Generator - * - * Deterministic schedule generation based on: - * - Headcount-only coverage (no CORE/FLEX/EXTRA) - * - Employee eligibility - * - Standard shift matching - * - Target-hours deficit balancing - * - Round-robin with employeeId tie-breaking - * - * Same inputs → same output. No Math.random(), no localeCompare. - * Pure function — no Firestore, no Firebase. - */ - -import type { - EmployeeV3, - EmployeeAbsenceV3, - GeneratedShiftV3, - GenerateScheduleV3Input, - GenerateScheduleV3Result, - ScheduleWarningV3, - EmployeeHoursSummaryV3, - CoverageSummaryV3, - SchedulerConfigV3, - ShiftTemplateV3, - WarningCodeV3, -} from './types.ts'; +import type { EmployeeV3, GeneratedShiftV3, GenerateScheduleV3Input, GenerateScheduleV3Result, EmployeeHoursSummaryV3 } from './types.ts'; import { evaluateEmployeeEligibilityV3 } from './eligibility.ts'; import { resolveEffectiveStandardShift } from './employeeProfile.ts'; -import { expandCoverageSlots, calculateCoverageSummary, eachDateInRange, getWeekdayForDate } from './coverage.ts'; -import { getMondayOfWeek } from './employeeProfile.ts'; -import { calculateShiftDurationHoursV3 } from './config.ts'; - -let _idCounter = 0; - -/** - * Generate a deterministic unique ID. - * Uses a prefix + counter to ensure uniqueness within a single generation run. - */ -function generateId(prefix: string): string { - _idCounter++; - return `${prefix}-${_idCounter.toString().padStart(6, '0')}`; +import { expandCoverageSlots, calculateCoverageSummary } from './coverage.ts'; +import { analyzeScheduleWarningsV3, getWeekStartV3 } from './warnings.ts'; +import { assertV3Input } from './validation.ts'; + +const compare = (a:string,b:string) => a < b ? -1 : a > b ? 1 : 0; +function hashInput(value:unknown):string { + let hash=0x811c9dc5; + for(const char of JSON.stringify(value)){hash^=char.charCodeAt(0);hash=Math.imul(hash,0x01000193);} + return (hash>>>0).toString(16).padStart(8,'0'); } -/** - * Deterministic hash for diagnostics (FNV-1a inspired). - */ -function simpleHash(input: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < input.length; i++) { - hash ^= input.charCodeAt(i); - hash = (hash * 0x01000193) >>> 0; +/** Deterministic, pure automatic baseline; owners may edit all business decisions. */ +export function generateScheduleV3(input:GenerateScheduleV3Input):GenerateScheduleV3Result { + assertV3Input(input); + const {config,employees,absences,periodStart,periodEnd,options}=input; + const active=employees.filter(e=>e.isActive).sort((a,b)=>compare(a.id,b.id)); + const shifts:GeneratedShiftV3[]=(input.existingManualShifts||[]).map(s=>({...s})); + const ids=new Set(shifts.map(s=>s.id)); + let counter=0; + const nextId=()=>{let id;do{id='shift-v3-'+String(++counter).padStart(6,'0');}while(ids.has(id));ids.add(id);return id;}; + const rotation=new Map(); + const slots=expandCoverageSlots(config,periodStart,periodEnd).sort((a,b)=>compare(a.date,b.date)||compare(a.shiftTemplateId,b.shiftTemplateId)||a.slotIndex-b.slotIndex); + for(const slot of slots){ + const template=slot.shiftTemplate; + if(!template||!template.isActive)continue; + if(shifts.filter(s=>s.date===slot.date&&s.shiftTemplateId===template.id).length>slot.slotIndex)continue; + const weekStart=getWeekStartV3(slot.date,config.weekStartDay); + const weekEnd=new Date(Date.parse(weekStart)+6*86400000).toISOString().slice(0,10); + const eligible=active.filter(e=>evaluateEmployeeEligibilityV3(e,slot.date,absences,shifts,template.startTime,{ + ...config.warningPolicies,weekStartDate:weekStart,weekEndDate:weekEnd, + shiftEndTime:template.endTime,shiftDurationHours:template.durationHours,crossMidnight:template.crossMidnight, + }).eligible); + const cursor=rotation.get(template.id)||0; + const rank=(employee:EmployeeV3)=>{ + const target=employee.schedulerV3.targetWeeklyHours; + const worked=shifts.filter(s=>s.employeeId===employee.id&&s.date>=weekStart&&s.date<=weekEnd).reduce((sum,s)=>sum+s.durationHours,0); + return { + standard:resolveEffectiveStandardShift(employee.schedulerV3,slot.date)===template.id?1:0, + deficit:options.balanceWeeklyTargets&&target!==null?target-worked:0, + rotation:(active.indexOf(employee)-cursor+active.length)%active.length, + }; + }; + eligible.sort((a,b)=>{const x=rank(a),y=rank(b);return y.standard-x.standard||y.deficit-x.deficit||x.rotation-y.rotation||compare(a.id,b.id);}); + const selected=eligible[0]; + if(!selected)continue; + shifts.push({id:nextId(),date:slot.date,employeeId:selected.id,employeeName:selected.fullName,shiftTemplateId:template.id,startTime:template.startTime,endTime:template.endTime,durationHours:template.durationHours,crossMidnight:template.crossMidnight,source:'AUTO',isManualOverride:false,schedulerSchemaVersion:3}); + rotation.set(template.id,(active.indexOf(selected)+1)%active.length); } - return hash.toString(16).padStart(8, '0'); -} - -/** - * Get the ISO week start (Monday) and end (Sunday) for a given date. - */ -function getWeekBounds(dateStr: string): { weekStart: string; weekEnd: string } { - const monday = getMondayOfWeek(dateStr); - const d = new Date(monday + 'T00:00:00Z'); - d.setUTCDate(d.getUTCDate() + 6); - return { weekStart: monday, weekEnd: d.toISOString().slice(0, 10) }; -} - -/** - * Calculate total assigned hours for an employee within a date range. - */ -function calcHoursInRange( - employeeId: string, - startDate: string, - endDate: string, - shifts: GeneratedShiftV3[], -): number { - return shifts - .filter((s) => s.employeeId === employeeId && s.date >= startDate && s.date <= endDate) - .reduce((sum, s) => sum + s.durationHours, 0); -} - -/** - * Generate a V3 schedule. - * - * @param input - Complete generation input with config, employees, absences, and options - * @returns Deterministic schedule result with shifts, warnings, coverage summary, and diagnostics - */ -export function generateScheduleV3(input: GenerateScheduleV3Input): GenerateScheduleV3Result { - // Reset ID counter for determinism - _idCounter = 0; - - const { config, employees, absences, existingManualShifts, periodStart, periodEnd, options } = input; - - // 1. Prepare lookup structures - const templateMap = new Map(config.shiftTemplates.map((t) => [t.id, t])); - const activeEmployees = employees - .filter((e) => e.isActive) - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); // Deterministic order by ID - - const shifts: GeneratedShiftV3[] = []; - const warnings: ScheduleWarningV3[] = []; - - // 2. Include existing manual shifts - if (existingManualShifts) { - for (const manual of existingManualShifts) { - shifts.push({ ...manual }); - } - } - - // 3. Expand coverage slots - const coverageSlots = expandCoverageSlots(config, periodStart, periodEnd); - - // 4. Group coverage slots by date for ordered processing - const slotsByDate = new Map(); - for (const slot of coverageSlots) { - if (!slotsByDate.has(slot.date)) slotsByDate.set(slot.date, []); - slotsByDate.get(slot.date)!.push(slot); - } - - // Process dates in chronological order - const sortedDates = [...slotsByDate.keys()].sort(); - - // 5. Round-robin tracker per shift template - const roundRobinIndex = new Map(); - - for (const date of sortedDates) { - const dateSlots = slotsByDate.get(date) || []; - const { weekStart, weekEnd } = getWeekBounds(date); - - // Process slots ordered by shiftTemplateId for determinism - const sortedSlots = [...dateSlots].sort((a, b) => { - if (a.shiftTemplateId !== b.shiftTemplateId) { - return a.shiftTemplateId < b.shiftTemplateId ? -1 : 1; - } - return a.slotIndex - b.slotIndex; - }); - - for (const slot of sortedSlots) { - // Check if already filled by a manual shift - const alreadyFilled = shifts.filter( - (s) => s.date === date && s.shiftTemplateId === slot.shiftTemplateId, - ).length; - if (alreadyFilled > slot.slotIndex) continue; - - const template = slot.shiftTemplate; - if (!template) { - warnings.push({ - id: generateId('w'), - code: 'COVERAGE_UNDER_TARGET', - severity: 'WARNING', - blocking: false, - date, - message: `Δεν βρέθηκε πρότυπο βάρδιας: ${slot.shiftTemplateId}.`, - }); - continue; - } - - // 6. Find eligible candidates - const candidates: { - employee: EmployeeV3; - matchesStandard: boolean; - hoursDeficit: number; - }[] = []; - - for (const emp of activeEmployees) { - const eligibility = evaluateEmployeeEligibilityV3( - emp, date, absences, shifts, template.startTime, - { - minRestIntervalHours: config.warningPolicies?.minRestIntervalHours, - maxDailyHours: config.warningPolicies?.maxDailyHours, - maxWeeklyHours: config.warningPolicies?.maxWeeklyHours, - maxConsecutiveWorkingDays: config.warningPolicies?.maxConsecutiveWorkingDays, - weekStartDate: weekStart, - weekEndDate: weekEnd, - }, - ); - - if (!eligibility.eligible) continue; - - // Check standard shift match - const effectiveShift = resolveEffectiveStandardShift(emp.schedulerV3, date); - const matchesStandard = effectiveShift === template.id; - - // Calculate target-hours deficit - let hoursDeficit = 0; - if (emp.schedulerV3.targetWeeklyHours !== null) { - const currentHours = calcHoursInRange(emp.id, weekStart, weekEnd, shifts); - hoursDeficit = emp.schedulerV3.targetWeeklyHours - currentHours; - } - - candidates.push({ employee: emp, matchesStandard, hoursDeficit }); - } - - // 7. Sort candidates by priority (deterministic) - candidates.sort((a, b) => { - // Priority 1: Standard shift match - if (a.matchesStandard !== b.matchesStandard) { - return a.matchesStandard ? -1 : 1; - } - // Priority 2: Highest target-hours deficit - if (a.hoursDeficit !== b.hoursDeficit) { - return b.hoursDeficit - a.hoursDeficit; - } - // Priority 3: Round-robin (least recently assigned for this template) - const rrKey = slot.shiftTemplateId; - const rrIdx = roundRobinIndex.get(rrKey) || 0; - const aIdx = activeEmployees.indexOf(a.employee); - const bIdx = activeEmployees.indexOf(b.employee); - const aRR = (aIdx - rrIdx + activeEmployees.length) % activeEmployees.length; - const bRR = (bIdx - rrIdx + activeEmployees.length) % activeEmployees.length; - if (aRR !== bRR) return aRR - bRR; - // Priority 4: Tie-break by employeeId (lexicographic) - return a.employee.id < b.employee.id ? -1 : 1; - }); - - if (candidates.length > 0) { - const selected = candidates[0]; - const duration = calculateShiftDurationHoursV3( - template.startTime, - template.endTime, - template.crossMidnight, - ); - - shifts.push({ - id: generateId('shift-v3'), - date, - employeeId: selected.employee.id, - employeeName: selected.employee.fullName, - shiftTemplateId: template.id, - startTime: template.startTime, - endTime: template.endTime, - durationHours: duration, - crossMidnight: template.crossMidnight || undefined, - source: 'AUTO', - isManualOverride: false, - schedulerSchemaVersion: 3, - }); - - // Update round-robin index - const selIdx = activeEmployees.indexOf(selected.employee); - roundRobinIndex.set(slot.shiftTemplateId, (selIdx + 1) % activeEmployees.length); - - // Standard shift deviation warning - if (!selected.matchesStandard && selected.employee.schedulerV3.standardShiftTemplateId) { - warnings.push({ - id: generateId('w'), - code: 'STANDARD_SHIFT_DEVIATION', - severity: 'INFO', - blocking: false, - date, - employeeId: selected.employee.id, - message: `Ο ${selected.employee.fullName} δεν εργάζεται στην τυπική του βάρδια.`, - }); - } - } else { - // Coverage gap - warnings.push({ - id: generateId('w'), - code: 'COVERAGE_UNDER_TARGET', - severity: 'WARNING', - blocking: false, - date, - message: `Δεν βρέθηκε διαθέσιμος εργαζόμενος για ${template.label} (${date}).`, - details: { shiftTemplateId: template.id }, - }); - } - } - } - - // 8. Post-generation warnings: target hours - const allDates = eachDateInRange(periodStart, periodEnd); - const weekBoundsMap = new Map(); - for (const date of allDates) { - const bounds = getWeekBounds(date); - weekBoundsMap.set(bounds.weekStart, bounds); - } - - for (const emp of activeEmployees) { - if (emp.schedulerV3.targetWeeklyHours === null) continue; - for (const [, bounds] of weekBoundsMap) { - const hours = calcHoursInRange(emp.id, bounds.weekStart, bounds.weekEnd, shifts); - const target = emp.schedulerV3.targetWeeklyHours; - if (hours < target * 0.9) { - warnings.push({ - id: generateId('w'), - code: 'TARGET_HOURS_UNDER', - severity: 'INFO', - blocking: false, - employeeId: emp.id, - message: `Ο ${emp.fullName} έχει ${hours.toFixed(1)}h (στόχος: ${target}h).`, - details: { actual: hours, target, weekStart: bounds.weekStart }, - }); - } else if (hours > target * 1.1) { - warnings.push({ - id: generateId('w'), - code: 'TARGET_HOURS_OVER', - severity: 'INFO', - blocking: false, - employeeId: emp.id, - message: `Ο ${emp.fullName} έχει ${hours.toFixed(1)}h (στόχος: ${target}h).`, - details: { actual: hours, target, weekStart: bounds.weekStart }, - }); - } - } - } - - // 9. Calculate coverage summary - const coverageSummary = calculateCoverageSummary(config, periodStart, periodEnd, shifts); - - // 10. Calculate employee hours - const employeeHours = calculateEmployeeHoursV3(employees, shifts); - - // 11. Build diagnostics - const inputStr = JSON.stringify({ - periodStart, - periodEnd, - employeeIds: activeEmployees.map((e) => e.id), - templateIds: config.shiftTemplates.map((t) => t.id), - }); - - // Sort shifts deterministically - shifts.sort((a, b) => { - if (a.date !== b.date) return a.date < b.date ? -1 : 1; - if (a.startTime !== b.startTime) return a.startTime < b.startTime ? -1 : 1; - if (a.employeeId !== b.employeeId) return a.employeeId < b.employeeId ? -1 : 1; - return 0; - }); - + shifts.sort((a,b)=>compare(a.date,b.date)||compare(a.startTime,b.startTime)||compare(a.employeeId,b.employeeId)||compare(a.id,b.id)); return { - shifts, - warnings, - coverageSummary, - employeeHours, - diagnostics: { - generatedAt: new Date().toISOString(), - inputHash: simpleHash(inputStr), - deterministicSeed: `v3-${periodStart}-${periodEnd}`, - }, + shifts,warnings:analyzeScheduleWarningsV3(config,employees,absences,shifts,periodStart,periodEnd), + coverageSummary:calculateCoverageSummary(config,periodStart,periodEnd,shifts), + employeeHours:calculateEmployeeHoursV3(employees,shifts), + diagnostics:{generatedAt:'',inputHash:hashInput({...input,employees:[...employees].sort((a,b)=>compare(a.id,b.id))}),deterministicSeed:'v3-'+periodStart+'-'+periodEnd}, }; } -/** - * Calculate per-employee hours summary, including zero-hour employees. - */ -export function calculateEmployeeHoursV3( - employees: EmployeeV3[], - shifts: GeneratedShiftV3[], -): EmployeeHoursSummaryV3[] { - const hoursByEmployee = new Map(); - - // Initialize all active employees (including zero-shift ones) - for (const emp of employees) { - if (emp.isActive) { - hoursByEmployee.set(emp.id, { hours: 0, shiftCount: 0 }); - } - } - - // Accumulate shift hours - for (const shift of shifts) { - const entry = hoursByEmployee.get(shift.employeeId); - if (entry) { - entry.hours += shift.durationHours; - entry.shiftCount++; - } else { - hoursByEmployee.set(shift.employeeId, { - hours: shift.durationHours, - shiftCount: 1, - }); - } - } - - // Build result with target hours - const empMap = new Map(employees.map((e) => [e.id, e])); - return [...hoursByEmployee.entries()] - .map(([employeeId, stats]) => ({ - employeeId, - hours: Math.round(stats.hours * 4) / 4, // Quarter-hour precision - shiftCount: stats.shiftCount, - targetWeeklyHours: empMap.get(employeeId)?.schedulerV3?.targetWeeklyHours ?? null, - })) - .sort((a, b) => (a.employeeId < b.employeeId ? -1 : 1)); // Deterministic order +/** Zero-hour active employees and historical references stay visible. */ +export function calculateEmployeeHoursV3(employees:EmployeeV3[],shifts:GeneratedShiftV3[]):EmployeeHoursSummaryV3[]{ + const totals=new Map(); + employees.filter(e=>e.isActive).forEach(e=>totals.set(e.id,{hours:0,shiftCount:0})); + for(const shift of shifts){const item=totals.get(shift.employeeId)||{hours:0,shiftCount:0};item.hours+=shift.durationHours;item.shiftCount++;totals.set(shift.employeeId,item);} + return [...totals].map(([employeeId,item])=>({employeeId,hours:Math.round(item.hours*4)/4,shiftCount:item.shiftCount,targetWeeklyHours:employees.find(e=>e.id===employeeId)?.schedulerV3.targetWeeklyHours??null})).sort((a,b)=>compare(a.employeeId,b.employeeId)); } diff --git a/src/scheduler-engine-v3/index.ts b/src/scheduler-engine-v3/index.ts index b6f60e2..ed547f5 100644 --- a/src/scheduler-engine-v3/index.ts +++ b/src/scheduler-engine-v3/index.ts @@ -12,3 +12,5 @@ export * from './eligibility.ts'; export * from './coverage.ts'; export * from './generator.ts'; export * from './warnings.ts'; +export * from './validation.ts'; +export * from './migration.ts'; diff --git a/src/scheduler-engine-v3/migration.ts b/src/scheduler-engine-v3/migration.ts new file mode 100644 index 0000000..b04957f --- /dev/null +++ b/src/scheduler-engine-v3/migration.ts @@ -0,0 +1,17 @@ +import { normalizeEmployeeProfileV3 } from './employeeProfile.ts'; +import { validateSchedulerConfigV3 } from './config.ts'; +import type { SchedulerConfigV3 } from './types.ts'; + +/** Offline, preview-only conversion. Never infers workMode from a legacy role. */ +export function previewV2Migration(raw: Record, tenantId: string) { + const deferred = ['role/skill coverage','substitute strategies','Sunday special policy','special dates']; + const config: SchedulerConfigV3 = { + schemaVersion:3,tenantId,timezone:raw.timezone || 'Europe/Athens',weekStartDay:raw.weekStartDay || 1, + templateId:'scheduler-v3-migration',templateVersion:1, + operatingDays:structuredClone(raw.operatingDays || []), + shiftTemplates:(raw.shiftTemplates || []).map(t=>({id:t.id,label:t.label,shortCode:t.shortCode,shiftType:['MORNING','INTERMEDIATE','AFTERNOON','NIGHT'].includes(t.shiftType)?t.shiftType:'CUSTOM',startTime:t.startTime,endTime:t.endTime,durationHours:t.durationHours,crossMidnight:Boolean(t.crossMidnight),isActive:t.isActive!==false})), + coverageRequirements:(raw.coverageRequirements || []).map(p=>({weekday:p.weekday,slots:p.slots.map(s=>({shiftTemplateId:s.shiftTemplateId,headcount:s.targetHeadcount}))})), + generationDefaults:{balanceWeeklyTargetsForMonth:true},warningPolicies:{minRestIntervalHours:raw.complianceRules?.minRestIntervalBetweenShiftsHours ?? null,maxDailyHours:raw.complianceRules?.maxDailyWorkingHours ?? null,maxWeeklyHours:raw.complianceRules?.maxWeeklyStandardHours ?? null,maxConsecutiveWorkingDays:raw.complianceRules?.maxConsecutiveWorkingDays ?? null}, + }; + return { config, validation:validateSchedulerConfigV3(config), requiresOwnerReview:true, deferred, defaultEmployeeProfile:normalizeEmployeeProfileV3() }; +} diff --git a/src/scheduler-engine-v3/validation.ts b/src/scheduler-engine-v3/validation.ts new file mode 100644 index 0000000..c038f2f --- /dev/null +++ b/src/scheduler-engine-v3/validation.ts @@ -0,0 +1,27 @@ +import { calculateShiftDurationHoursV3, isIsoDateV3, isValidTimeV3, validateSchedulerConfigV3 } from './config.ts'; +import { validateEmployeeProfileV3 } from './employeeProfile.ts'; +import { eachDateInRange } from './coverage.ts'; +import type { GenerateScheduleV3Input, GeneratedShiftV3 } from './types.ts'; + +/** Technical errors block; business-policy deviations are handled by warnings. */ +export function assertV3Input(input: GenerateScheduleV3Input, shifts: GeneratedShiftV3[] = input.existingManualShifts || []): void { + const validation = validateSchedulerConfigV3(input.config); + if (!validation.valid) throw new Error(validation.errors.join(' ')); + eachDateInRange(input.periodStart, input.periodEnd); + if (!['WEEK', 'MONTH'].includes(input.periodType) || !Array.isArray(input.employees) || input.employees.length > 100 || !Array.isArray(input.absences) || !Array.isArray(shifts)) throw new Error('Μη έγκυρη δομή προγράμματος.'); + const templates = new Set(input.config.shiftTemplates.map(t => t.id)); + const employeeIds = new Set(); + for (const employee of input.employees) { + if (!employee || !employee.id || employeeIds.has(employee.id) || typeof employee.fullName !== 'string' || typeof employee.isActive !== 'boolean') throw new Error('Μη έγκυρος εργαζόμενος.'); + employeeIds.add(employee.id); + if (!employee.schedulerV3 || !validateEmployeeProfileV3(employee.schedulerV3, templates).valid) throw new Error('Μη έγκυρο προφίλ εργαζομένου.'); + for (const date of [employee.activeFrom, employee.activeTo, employee.schedulerV3.rotationAnchorWeekStart]) if (date && !isIsoDateV3(date)) throw new Error('Μη έγκυρη ημερομηνία προφίλ.'); + } + const shiftIds = new Set(); + for (const shift of shifts) { + if (shift && (!['AUTO','MANUAL'].includes(shift.source) || shift.schedulerSchemaVersion !== 3 || typeof shift.isManualOverride !== 'boolean')) throw new Error('Μη έγκυρη προέλευση βάρδιας.'); + if (!shift || !shift.id || shiftIds.has(shift.id) || !employeeIds.has(shift.employeeId) || !isIsoDateV3(shift.date) || shift.date < input.periodStart || shift.date > input.periodEnd || !isValidTimeV3(shift.startTime) || !isValidTimeV3(shift.endTime) || shift.startTime === shift.endTime || Boolean(shift.crossMidnight) !== (shift.endTime < shift.startTime) || !Number.isFinite(shift.durationHours) || Math.abs(shift.durationHours - calculateShiftDurationHoursV3(shift.startTime, shift.endTime, shift.crossMidnight)) > 0.001 || (shift.shiftTemplateId !== null && !templates.has(shift.shiftTemplateId))) throw new Error('Μη έγκυρη δομή βάρδιας ή αναφορά εργαζομένου.'); + shiftIds.add(shift.id); + } + for (const absence of input.absences) if (!absence || !isIsoDateV3(absence.startDate) || !isIsoDateV3(absence.endDate) || absence.startDate > absence.endDate || !['FULL_DAY','PARTIAL_DAY'].includes(absence.scope)) throw new Error('Μη έγκυρη απουσία.'); +} diff --git a/src/scheduler-engine-v3/warnings.ts b/src/scheduler-engine-v3/warnings.ts index c027931..ee03eb0 100644 --- a/src/scheduler-engine-v3/warnings.ts +++ b/src/scheduler-engine-v3/warnings.ts @@ -1,313 +1,83 @@ -/** - * ShiftOryx Scheduler V3 — Warning Engine - * - * Shared non-blocking schedule analyzer. - * Used for generation, preview edits, and publish validation. - * ALL warnings are blocking: false. - * Pure function — no Firestore, no Firebase. - */ - -import type { - EmployeeV3, - EmployeeAbsenceV3, - GeneratedShiftV3, - SchedulerConfigV3, - ScheduleWarningV3, - WarningCodeV3, -} from './types.ts'; -import { timeToMinutesV3 } from './config.ts'; -import { isFixedDayOff, isWithinActiveDates } from './employeeProfile.ts'; +import type { EmployeeV3, EmployeeAbsenceV3, GeneratedShiftV3, SchedulerConfigV3, ScheduleWarningV3, WarningCodeV3 } from './types.ts'; +import { shiftIntervalV3 } from './config.ts'; +import { isFixedDayOff, resolveEffectiveStandardShift } from './employeeProfile.ts'; import { eachDateInRange, getWeekdayForDate } from './coverage.ts'; -import { getMondayOfWeek } from './employeeProfile.ts'; - -let _warnIdCounter = 0; -function warnId(): string { - _warnIdCounter++; - return `warn-${_warnIdCounter.toString().padStart(6, '0')}`; +export function getWeekStartV3(date: string, weekStartDay = 1): string { + const d = new Date(date + 'T00:00:00Z'); + d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() - weekStartDay % 7 + 7) % 7)); + return d.toISOString().slice(0, 10); } -function makeWarning( - code: WarningCodeV3, - severity: 'INFO' | 'WARNING', - message: string, - overrides: Partial = {}, -): ScheduleWarningV3 { - return { - id: warnId(), - code, - severity, - blocking: false, - message, - ...overrides, - }; -} - -/** - * Analyze a schedule for all warning conditions. - * This is the single shared analyzer used by: - * - Generator (post-generation) - * - Preview editor (after each edit) - * - Publish gate (pre-publish review) - * - * @param config Tenant V3 configuration - * @param employees All employees - * @param absences All absences in the period - * @param shifts Current schedule shifts - * @param periodStart ISO date - * @param periodEnd ISO date - * @returns Array of non-blocking warnings - */ -export function analyzeScheduleWarningsV3( - config: SchedulerConfigV3, - employees: EmployeeV3[], - absences: EmployeeAbsenceV3[], - shifts: GeneratedShiftV3[], - periodStart: string, - periodEnd: string, -): ScheduleWarningV3[] { - _warnIdCounter = 0; +/** One pure analyzer for generated, edited and published candidates. */ +export function analyzeScheduleWarningsV3(config: SchedulerConfigV3, employees: EmployeeV3[], absences: EmployeeAbsenceV3[], shifts: GeneratedShiftV3[], periodStart: string, periodEnd: string): ScheduleWarningV3[] { const warnings: ScheduleWarningV3[] = []; - const empMap = new Map(employees.map((e) => [e.id, e])); - const policies = config.warningPolicies || {}; - - // 1. Coverage analysis - const templateMap = new Map(config.shiftTemplates.map((t) => [t.id, t])); + const add = (code: WarningCodeV3, message: string, fields: Partial = {}) => warnings.push({ + ...fields, id: 'warning-' + String(warnings.length + 1).padStart(6, '0'), code, message, severity: 'WARNING', blocking: false, + }); + const empMap = new Map(employees.map(e => [e.id, e])); + const ordered = [...shifts].sort((a,b) => { + const x = a.date + a.startTime + a.employeeId + a.id, y = b.date + b.startTime + b.employeeId + b.id; + return x < y ? -1 : x > y ? 1 : 0; + }); const dates = eachDateInRange(periodStart, periodEnd); - + const policies = config.warningPolicies || {}; for (const date of dates) { - const weekday = getWeekdayForDate(date); - const dayConfig = config.operatingDays.find((d) => d.weekday === weekday); - if (!dayConfig || !dayConfig.isOpen) continue; - - const coverage = config.coverageRequirements.find((c) => c.weekday === weekday); - if (!coverage) continue; - - for (const slot of coverage.slots) { - const assigned = shifts.filter( - (s) => s.date === date && s.shiftTemplateId === slot.shiftTemplateId, - ).length; - if (assigned < slot.headcount) { - warnings.push(makeWarning( - 'COVERAGE_UNDER_TARGET', 'WARNING', - `Ελλιπής κάλυψη: ${assigned}/${slot.headcount} εργαζόμενοι (${date}).`, - { date, details: { shiftTemplateId: slot.shiftTemplateId, assigned, required: slot.headcount } }, - )); - } else if (assigned > slot.headcount) { - warnings.push(makeWarning( - 'COVERAGE_OVER_TARGET', 'INFO', - `Υπερκάλυψη: ${assigned}/${slot.headcount} εργαζόμενοι (${date}).`, - { date, details: { shiftTemplateId: slot.shiftTemplateId, assigned, required: slot.headcount } }, - )); - } + const day = config.operatingDays.find(d => d.weekday === getWeekdayForDate(date)); + if (!day?.isOpen) continue; + for (const slot of config.coverageRequirements.find(c => c.weekday === day.weekday)?.slots || []) { + if (config.shiftTemplates.find(t => t.id === slot.shiftTemplateId)?.isActive === false) continue; + const assigned = ordered.filter(s => s.date === date && s.shiftTemplateId === slot.shiftTemplateId).length; + if (assigned !== slot.headcount) add(assigned < slot.headcount ? 'COVERAGE_UNDER_TARGET' : 'COVERAGE_OVER_TARGET', + 'Κάλυψη ' + assigned + '/' + slot.headcount + ' στις ' + date, { date, details: { shiftTemplateId: slot.shiftTemplateId, assigned, required: slot.headcount } }); } } - - // Per-employee analyses - for (const shift of shifts) { - const emp = empMap.get(shift.employeeId); - - // 2. Fixed day off override - if (emp && isFixedDayOff(shift.date, emp.schedulerV3.fixedDayOff)) { - warnings.push(makeWarning( - 'FIXED_DAY_OFF_OVERRIDE', 'WARNING', - `Ο ${emp.fullName} εργάζεται στη σταθερή ημέρα ανάπαυσής του (${shift.date}).`, - { date: shift.date, employeeId: shift.employeeId }, - )); - } - - // 3. Absence override - const hasAbsence = absences.some( - (a) => - a.employeeId === shift.employeeId && - shift.date >= a.startDate && - shift.date <= a.endDate && - a.scope === 'FULL_DAY', - ); - if (hasAbsence) { - warnings.push(makeWarning( - 'ABSENCE_OVERRIDE', 'WARNING', - `Ο ${emp?.fullName || shift.employeeName} εργάζεται ενώ έχει άδεια (${shift.date}).`, - { date: shift.date, employeeId: shift.employeeId }, - )); - } - - // 4. Outside operating window - if (shift.shiftTemplateId) { - const weekday = getWeekdayForDate(shift.date); - const dayConfig = config.operatingDays.find((d) => d.weekday === weekday); - if (dayConfig && dayConfig.isOpen && dayConfig.windows.length > 0) { - const shiftStart = timeToMinutesV3(shift.startTime); - const shiftEnd = timeToMinutesV3(shift.endTime); - const inWindow = dayConfig.windows.some((w) => { - const wStart = timeToMinutesV3(w.openTime); - const wEnd = timeToMinutesV3(w.closeTime); - if (w.crossMidnight) return true; // simplified for cross-midnight - return shiftStart >= wStart && shiftEnd <= wEnd; - }); - if (!inWindow) { - warnings.push(makeWarning( - 'OUTSIDE_OPERATING_WINDOW', 'WARNING', - `Βάρδια εκτός ωραρίου λειτουργίας (${shift.date}, ${shift.startTime}-${shift.endTime}).`, - { date: shift.date, employeeId: shift.employeeId, shiftId: shift.id }, - )); - } - } - } - - // 5. Substitute-only employee assignment - if (emp && emp.schedulerV3.workMode === 'SUBSTITUTE_ONLY' && shift.source === 'MANUAL') { - warnings.push(makeWarning( - 'SUBSTITUTE_MANUAL_ASSIGNMENT', 'INFO', - `Ο ${emp.fullName} είναι μόνο αναπληρωματικός και ανατέθηκε χειροκίνητα (${shift.date}).`, - { date: shift.date, employeeId: shift.employeeId }, - )); - } - - // 6. Deactivated employee reference - if (emp && !emp.isActive) { - warnings.push(makeWarning( - 'DEACTIVATED_EMPLOYEE_REFERENCE', 'WARNING', - `Αναφορά σε ανενεργό εργαζόμενο: ${emp.fullName} (${shift.date}).`, - { date: shift.date, employeeId: shift.employeeId }, - )); - } - } - - // 7. Shift overlaps (per employee, per date) - const shiftsByEmployeeDate = new Map(); - for (const shift of shifts) { - const key = `${shift.employeeId}:${shift.date}`; - if (!shiftsByEmployeeDate.has(key)) shiftsByEmployeeDate.set(key, []); - shiftsByEmployeeDate.get(key)!.push(shift); - } - for (const [key, dayShifts] of shiftsByEmployeeDate) { - if (dayShifts.length < 2) continue; - const sorted = [...dayShifts].sort((a, b) => - a.startTime < b.startTime ? -1 : a.startTime > b.startTime ? 1 : 0, - ); - for (let i = 0; i < sorted.length - 1; i++) { - const endMin = timeToMinutesV3(sorted[i].endTime); - const nextStartMin = timeToMinutesV3(sorted[i + 1].startTime); - if (endMin > nextStartMin) { - const emp = empMap.get(sorted[i].employeeId); - warnings.push(makeWarning( - 'SHIFT_OVERLAP', 'WARNING', - `Αλληλεπικαλυπτόμενες βάρδιες για ${emp?.fullName || sorted[i].employeeName} (${sorted[i].date}).`, - { date: sorted[i].date, employeeId: sorted[i].employeeId }, - )); - } + for (const shift of ordered) { + const employee = empMap.get(shift.employeeId); + const fields = { date: shift.date, employeeId: shift.employeeId, shiftId: shift.id }; + if (!employee?.isActive) add('DEACTIVATED_EMPLOYEE_REFERENCE', 'Αναφορά σε ανενεργό ή μη διαθέσιμο εργαζόμενο.', fields); + if (employee && isFixedDayOff(shift.date, employee.schedulerV3.fixedDayOff)) add('FIXED_DAY_OFF_OVERRIDE', 'Εργασία σε σταθερό ρεπό.', fields); + if (absences.some(a => a.employeeId === shift.employeeId && a.startDate <= shift.date && a.endDate >= shift.date)) add('ABSENCE_OVERRIDE', 'Εργασία σε ημέρα απουσίας.', fields); + if (employee?.schedulerV3.workMode === 'SUBSTITUTE_ONLY' && shift.source === 'MANUAL') add('SUBSTITUTE_MANUAL_ASSIGNMENT', 'Χειροκίνητη ανάθεση αναπληρωματικού.', fields); + if (employee) { + const effective = resolveEffectiveStandardShift(employee.schedulerV3, shift.date); + if (effective && shift.shiftTemplateId !== effective) add('STANDARD_SHIFT_DEVIATION', 'Απόκλιση από την τυπική βάρδια.', fields); } + const interval = shiftIntervalV3(shift.date, shift.startTime, shift.endTime, Boolean(shift.crossMidnight)); + const day = config.operatingDays.find(d => d.weekday === getWeekdayForDate(shift.date)); + const fits = day?.isOpen && day.windows.some(w => { + const window = shiftIntervalV3(shift.date, w.openTime, w.closeTime, Boolean(w.crossMidnight)); + return interval.start >= window.start && interval.end <= window.end; + }); + if (!fits) add('OUTSIDE_OPERATING_WINDOW', 'Βάρδια εκτός ωραρίου λειτουργίας.', fields); } - - // 8. Rest interval (between consecutive days) - if (policies.minRestIntervalHours && policies.minRestIntervalHours > 0) { - const minRestMin = policies.minRestIntervalHours * 60; - const employeeIds = [...new Set(shifts.map((s) => s.employeeId))]; - for (const eid of employeeIds) { - const empShifts = shifts - .filter((s) => s.employeeId === eid) - .sort((a, b) => { - if (a.date !== b.date) return a.date < b.date ? -1 : 1; - return a.startTime < b.startTime ? -1 : 1; - }); - for (let i = 0; i < empShifts.length - 1; i++) { - const curr = empShifts[i]; - const next = empShifts[i + 1]; - if (curr.date === next.date) continue; // same day — overlap check already done - const currEnd = timeToMinutesV3(curr.endTime); - const nextStart = timeToMinutesV3(next.startTime); - // Days between - const d1 = new Date(curr.date + 'T00:00:00Z'); - const d2 = new Date(next.date + 'T00:00:00Z'); - const daysDiff = Math.round((d2.getTime() - d1.getTime()) / (24 * 60 * 60 * 1000)); - if (daysDiff === 1) { - const restMin = (24 * 60 - currEnd) + nextStart; - if (restMin < minRestMin) { - const emp = empMap.get(eid); - warnings.push(makeWarning( - 'REST_INTERVAL_WARNING', 'WARNING', - `Ανεπαρκής ανάπαυση (${(restMin / 60).toFixed(1)}h) για ${emp?.fullName || eid} μεταξύ ${curr.date} και ${next.date}.`, - { date: next.date, employeeId: eid, details: { restHours: Math.round(restMin / 60 * 4) / 4 } }, - )); - } - } - } + const weeks = [...new Set(dates.map(d => getWeekStartV3(d, config.weekStartDay)))].sort(); + for (const employee of [...employees].sort((a,b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) { + const profile = employee.schedulerV3; + if (profile.rotateStandardShiftWeekly && (!profile.standardShiftTemplateId || !profile.rotationAlternateShiftTemplateId || !profile.rotationAnchorWeekStart || + !config.shiftTemplates.some(t => t.id === profile.standardShiftTemplateId) || !config.shiftTemplates.some(t => t.id === profile.rotationAlternateShiftTemplateId))) { + add('ROTATION_CONFIGURATION_WARNING', 'Η εβδομαδιαία εναλλαγή χρειάζεται δύο πρότυπα και εβδομάδα αναφοράς.', { employeeId: employee.id }); } - } - - // 9. Consecutive working days - if (policies.maxConsecutiveWorkingDays && policies.maxConsecutiveWorkingDays > 0) { - const maxConsec = policies.maxConsecutiveWorkingDays; - const employeeIds = [...new Set(shifts.map((s) => s.employeeId))]; - for (const eid of employeeIds) { - const workDates = [...new Set(shifts.filter((s) => s.employeeId === eid).map((s) => s.date))].sort(); - let consecutive = 1; - for (let i = 1; i < workDates.length; i++) { - const prev = new Date(workDates[i - 1] + 'T00:00:00Z'); - const curr = new Date(workDates[i] + 'T00:00:00Z'); - const diff = Math.round((curr.getTime() - prev.getTime()) / (24 * 60 * 60 * 1000)); - if (diff === 1) { - consecutive++; - if (consecutive > maxConsec) { - const emp = empMap.get(eid); - warnings.push(makeWarning( - 'CONSECUTIVE_DAYS_WARNING', 'WARNING', - `${emp?.fullName || eid}: ${consecutive} συνεχόμενες ημέρες εργασίας (μέγιστο: ${maxConsec}).`, - { date: workDates[i], employeeId: eid }, - )); - } - } else { - consecutive = 1; - } - } + const list = ordered.filter(s => s.employeeId === employee.id); + for (let a = 0; a < list.length; a++) for (let b = a + 1; b < list.length; b++) { + const x = shiftIntervalV3(list[a].date, list[a].startTime, list[a].endTime, Boolean(list[a].crossMidnight)); + const y = shiftIntervalV3(list[b].date, list[b].startTime, list[b].endTime, Boolean(list[b].crossMidnight)); + if (x.start < y.end && y.start < x.end) add('SHIFT_OVERLAP', 'Αλληλεπικαλυπτόμενες βάρδιες.', { employeeId: employee.id, date: list[b].date }); + else if (policies.minRestIntervalHours && (y.start - x.end) / 3600000 < policies.minRestIntervalHours) add('REST_INTERVAL_WARNING', 'Ανεπαρκής ανάπαυση μεταξύ βαρδιών.', { employeeId: employee.id, date: list[b].date }); } - } - - // 10. Weekly hours - if (policies.maxWeeklyHours && policies.maxWeeklyHours > 0) { - const maxWeekly = policies.maxWeeklyHours; - const employeeIds = [...new Set(shifts.map((s) => s.employeeId))]; - const weekStarts = new Set(); + let streak = 0; for (const date of dates) { - weekStarts.add(getMondayOfWeek(date)); + const daily = list.filter(s => s.date === date); + streak = daily.length ? streak + 1 : 0; + if (policies.maxConsecutiveWorkingDays && streak > policies.maxConsecutiveWorkingDays) add('CONSECUTIVE_DAYS_WARNING', 'Πολλές συνεχόμενες ημέρες εργασίας.', { employeeId: employee.id, date }); + if (policies.maxDailyHours && daily.reduce((h,s) => h + s.durationHours, 0) > policies.maxDailyHours) add('DAILY_HOURS_WARNING', 'Υπέρβαση ημερήσιων ωρών.', { employeeId: employee.id, date }); } - for (const eid of employeeIds) { - for (const ws of weekStarts) { - const weEnd = new Date(ws + 'T00:00:00Z'); - weEnd.setUTCDate(weEnd.getUTCDate() + 6); - const weekEnd = weEnd.toISOString().slice(0, 10); - const hours = shifts - .filter((s) => s.employeeId === eid && s.date >= ws && s.date <= weekEnd) - .reduce((sum, s) => sum + s.durationHours, 0); - if (hours > maxWeekly) { - const emp = empMap.get(eid); - warnings.push(makeWarning( - 'WEEKLY_HOURS_WARNING', 'WARNING', - `${emp?.fullName || eid}: ${hours.toFixed(1)}h σε εβδομάδα ${ws} (μέγιστο: ${maxWeekly}h).`, - { employeeId: eid, details: { hours, maxWeeklyHours: maxWeekly, weekStart: ws } }, - )); - } - } + for (const weekStart of weeks) { + const hours = list.filter(s => getWeekStartV3(s.date, config.weekStartDay) === weekStart).reduce((h,s) => h + s.durationHours, 0); + const target = profile.targetWeeklyHours; + if (employee.isActive && target !== null && hours !== target) add(hours < target ? 'TARGET_HOURS_UNDER' : 'TARGET_HOURS_OVER', 'Ώρες ' + hours + ' / στόχος ' + target, { employeeId: employee.id, details: { hours, target, weekStart } }); + if (policies.maxWeeklyHours && hours > policies.maxWeeklyHours) add('WEEKLY_HOURS_WARNING', 'Υπέρβαση εβδομαδιαίων ωρών.', { employeeId: employee.id, details: { hours, weekStart } }); } } - - // 11. Daily hours - if (policies.maxDailyHours && policies.maxDailyHours > 0) { - const maxDaily = policies.maxDailyHours; - for (const [key, dayShifts] of shiftsByEmployeeDate) { - const totalHours = dayShifts.reduce((sum, s) => sum + s.durationHours, 0); - if (totalHours > maxDaily) { - const [eid, date] = key.split(':'); - const emp = empMap.get(eid); - warnings.push(makeWarning( - 'DAILY_HOURS_WARNING', 'WARNING', - `${emp?.fullName || eid}: ${totalHours.toFixed(1)}h στις ${date} (μέγιστο: ${maxDaily}h).`, - { date, employeeId: eid }, - )); - } - } - } - return warnings; } diff --git a/src/services/publicationProjectionsV3.ts b/src/services/publicationProjectionsV3.ts new file mode 100644 index 0000000..55a9bf7 --- /dev/null +++ b/src/services/publicationProjectionsV3.ts @@ -0,0 +1,18 @@ +import { eachDateInRange, getWeekStartV3 } from '../scheduler-engine-v3/index.ts'; +import { publicProjectionV3 } from './schedulePublicationService.ts'; +import type { SchedulePublicationV3 } from '../scheduler-engine-v3/types.ts'; +export function projectionTargetsV3(snapshot:SchedulePublicationV3) { + const dates=eachDateInRange(snapshot.periodStart,snapshot.periodEnd); + return [ + ...[...new Set(dates.map(d=>getWeekStartV3(d,1)))].map(start=>({collection:'publicSchedules',id:start,start,end:new Date(Date.parse(start)+6*86400000).toISOString().slice(0,10)})), + ...[...new Set(dates.map(d=>d.slice(0,7)))].map(id=>{const date=new Date(id+'-01T00:00:00Z');date.setUTCMonth(date.getUTCMonth()+1);date.setUTCDate(0);return {collection:'publicMonths',id,start:id+'-01',end:date.toISOString().slice(0,10)};}), + ]; +} +export function projectionPayloadV3(snapshot:SchedulePublicationV3,target:ReturnType[number],previous:Record={}) { + const prior=Array.isArray(previous.shifts)?previous.shifts:[]; + const shifts=[...prior.filter(s=>s.datesnapshot.periodEnd),...publicProjectionV3(snapshot)] + .filter(s=>s.date>=target.start&&s.date<=target.end) + .map(s=>({employeeName:String(s.employeeName||''),date:String(s.date),startTime:String(s.startTime),endTime:String(s.endTime),type:'work',label:'ΕΡΓ',shiftType:'custom',...(s.schedulerSchemaVersion===3?{schedulerSchemaVersion:3,crossMidnight:s.endTime { + const [{jsPDF:JsPDF},font]=await Promise.all([import('jspdf'),import('../assets/fonts/robotoRegularBase64.js')]); + const pdf=new JsPDF({orientation:'landscape',unit:'pt',format:'a4'}); + pdf.addFileToVFS('Roboto.ttf',font.ROBOTO_REGULAR_BASE64);pdf.addFont('Roboto.ttf','Roboto','normal');pdf.setFont('Roboto');pdf.setFontSize(12); + let y=45; + const line=(text:string)=>{if(y>550){pdf.addPage();y=45;}for(const part of pdf.splitTextToSize(text,740)){pdf.text(part,35,y);y+=18;}}; + line(`ShiftOryx · ${snapshot.periodStart} — ${snapshot.periodEnd} · Έκδοση ${snapshot.version}`); + line('Ημερομηνία | Ονοματεπώνυμο | Ωράριο | Εργασία'); + for(const shift of snapshot.shifts)line(`${shift.date} | ${shift.employeeName} | ${shift.startTime}–${shift.endTime} | ΕΡΓ`); + line('Σύνολα ωρών'); + for(const employee of snapshot.employeeSnapshot) {const hours=snapshot.calculatedHours.find(h=>h.employeeId===employee.employeeId);line(`${employee.displayName}: ${hours?.hours||0} ώρες`);} + if(snapshot.publishedWithWarnings)line(`Δημοσιεύτηκε με ${snapshot.warningsAtPublish.length} προειδοποιήσεις μετά από έλεγχο OWNER.`); + return new Uint8Array(pdf.output('arraybuffer')); +} diff --git a/src/services/schedulePublicationService.ts b/src/services/schedulePublicationService.ts new file mode 100644 index 0000000..ee58771 --- /dev/null +++ b/src/services/schedulePublicationService.ts @@ -0,0 +1,38 @@ +import { analyzeDraftV3, type DraftV3 } from './schedulerV3Service.ts'; +import type { SchedulePublicationV3 } from '../scheduler-engine-v3/types.ts'; + +export function buildPublicationV3(draft: DraftV3, context: { tenantId: string; uid: string; id: string; version: number; timestamp: string }): SchedulePublicationV3 { + if (draft.config.tenantId !== context.tenantId || !context.uid || !/^[a-zA-Z0-9_-]{1,100}$/.test(context.id) || !Number.isInteger(context.version) || context.version < 1) throw new Error('Μη έγκυρο πλαίσιο δημοσίευσης.'); + const { warnings, employeeHours } = analyzeDraftV3(draft); + return { + id:context.id,tenantId:context.tenantId,schemaVersion:3,periodType:draft.periodType,periodStart:draft.periodStart,periodEnd:draft.periodEnd, + periodKey:`${draft.periodType}_${draft.periodStart}_${draft.periodEnd}`,version:context.version,sourceDraftId:draft.id, + templateSnapshot:structuredClone(draft.config), + employeeSnapshot:draft.employees.map(e=>({employeeId:e.id,displayName:e.fullName,...(e.color?{color:e.color}:{})})), + shifts:draft.shifts.map(s=>({id:s.id,date:s.date,employeeId:s.employeeId,employeeName:draft.employees.find(e=>e.id===s.employeeId)!.fullName,startTime:s.startTime,endTime:s.endTime,durationHours:s.durationHours,shiftTemplateId:s.shiftTemplateId,source:s.source})), + calculatedHours:employeeHours,warningsAtPublish:warnings,publishedWithWarnings:warnings.length>0, + pdfStoragePath:`tenants/${context.tenantId}/schedule-publications/${context.id}/schedule.pdf`,pdfGeneratedAt:context.timestamp,publishedAt:context.timestamp,publishedByUid:context.uid, + }; +} + +/** Allocate once, upload the exact PDF, then atomically expose snapshot + latest pointer. */ +export async function publishDraftV3(draft: DraftV3, context: { tenantId:string;uid:string;id:string;timestamp:string;acceptWarnings:boolean }, io: { + reserve: (periodKey:string,id:string)=>Promise; + renderPdf:(snapshot:SchedulePublicationV3)=>Promise; + uploadPdf:(path:string,bytes:Uint8Array)=>Promise; + finalize:(snapshot:SchedulePublicationV3)=>Promise; +}) { + const initial = buildPublicationV3(draft,{...context,version:1}); + if (initial.publishedWithWarnings && !context.acceptWarnings) throw new Error('Επιβεβαίωσε ότι διάβασες τις προειδοποιήσεις.'); + const version = await io.reserve(initial.periodKey,context.id); + const snapshot = { ...initial, version }; + const pdf = await io.renderPdf(snapshot); + if (!(pdf instanceof Uint8Array) || !pdf.length || pdf.length >= 10 * 1024 * 1024) throw new Error('Μη έγκυρο PDF.'); + await io.uploadPdf(snapshot.pdfStoragePath,pdf); + await io.finalize(snapshot); + return snapshot; +} + +export function publicProjectionV3(snapshot: SchedulePublicationV3) { + return snapshot.shifts.map(s=>({employeeName:s.employeeName,date:s.date,startTime:s.startTime,endTime:s.endTime,type:'work',label:'ΕΡΓ',shiftType:'custom',schedulerSchemaVersion:3,crossMidnight:s.endTime>): EmployeeV3[] { + return employees.map(e => ({ id: String(e.id || ''), fullName: String(e.fullName || ''), isActive: e.isActive !== false, schedulerV3: normalizeEmployeeProfileV3(e.schedulerV3 as EmployeeV3['schedulerV3']), activeFrom: e.activeFrom as string || null, activeTo: e.activeTo as string || null, color: typeof e.color === 'string' ? e.color : undefined })); +} +export function mapAbsencesV3(absences: Array>, first:string,last:string) { + return absences.filter(a=>a.status!=='CANCELLED'&&a.startDate<=last&&(a.endDate||a.startDate)>=first).map(a=>({id:a.id,employeeId:a.employeeId,type:['LEAVE','SICK'].includes(a.type)?a.type:'OTHER',startDate:a.startDate,endDate:a.endDate||a.startDate,scope:a.scope==='FULL_DAY'?'FULL_DAY':'PARTIAL_DAY'})); +} +export function refreshDraftPeopleV3(draft:DraftV3,employees:EmployeeV3[],absences:Array>):DraftV3 { + const current=new Map(employees.map(e=>[e.id,e])); + const people=draft.employees.map(e=>{const latest=current.get(e.id);return latest?{...e,isActive:latest.isActive,fullName:latest.fullName,activeFrom:latest.activeFrom,activeTo:latest.activeTo}:{...e,isActive:false};}); + const ids=new Set(people.map(e=>e.id)); + people.push(...employees.filter(e=>!ids.has(e.id)).map(e=>({...e,schedulerV3:normalizeEmployeeProfileV3()}))); + return {...draft,employees:people,absences:mapAbsencesV3(absences,draft.periodStart,draft.periodEnd)}; +} +export type DraftV3 = GenerateScheduleV3Input & { id: string; shifts: GeneratedShiftV3[]; revision?: number }; +export function createDraftV3(input: GenerateScheduleV3Input, id: string): DraftV3 { + if (!/^[a-zA-Z0-9_-]{1,100}$/.test(id)) throw new Error('Μη έγκυρο αναγνωριστικό προσχεδίου.'); + const result = generateScheduleV3(input); + return { ...structuredClone(input), id, shifts: result.shifts.map(s => ({ ...s, draftId: id })) }; +} +export function analyzeDraftV3(draft: DraftV3) { + assertV3Input(draft, draft.shifts); + return { + warnings: analyzeScheduleWarningsV3(draft.config, draft.employees, draft.absences, draft.shifts, draft.periodStart, draft.periodEnd), + employeeHours: calculateEmployeeHoursV3(draft.employees, draft.shifts), + coverageSummary: calculateCoverageSummary(draft.config, draft.periodStart, draft.periodEnd, draft.shifts), + }; +} +export function editDraftV3(draft: DraftV3, shifts: GeneratedShiftV3[]): DraftV3 { + const next = { ...draft, shifts: structuredClone(shifts) }; + analyzeDraftV3(next); + return next; +} +export function makeDefaultConfigV3(tenantId: string): SchedulerConfigV3 { + const weekdays = ['MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY','SATURDAY','SUNDAY'] as const; + return { schemaVersion:3, tenantId, timezone:'Europe/Athens', weekStartDay:1, templateId:'scheduler-standard', templateVersion:1, + operatingDays:weekdays.map(weekday=>({weekday,isOpen:weekday!=='SUNDAY',windows:weekday==='SUNDAY'?[]:[{openTime:'08:00',closeTime:'20:00',crossMidnight:false}]})), + shiftTemplates:[{id:'day',label:'Ημέρα',shortCode:'ΗΜ',shiftType:'MORNING',startTime:'08:00',endTime:'16:00',durationHours:8,crossMidnight:false,isActive:true}], + coverageRequirements:weekdays.map(weekday=>({weekday,slots:[{shiftTemplateId:'day',headcount:weekday==='SUNDAY'?0:1}]})),generationDefaults:{balanceWeeklyTargetsForMonth:true},warningPolicies:{minRestIntervalHours:11,maxDailyHours:12,maxWeeklyHours:48,maxConsecutiveWorkingDays:6} }; +} diff --git a/src/utils/analytics.js b/src/utils/analytics.js index 6d40c23..53ebec0 100644 --- a/src/utils/analytics.js +++ b/src/utils/analytics.js @@ -23,6 +23,12 @@ export function getShiftTypeLabel(type) { } export function getShiftDurationHours(shift) { + if (shift?.schedulerSchemaVersion === 3 && shift.crossMidnight === true) { + const valid = /^([01]\d|2[0-3]):[0-5]\d$/; + if (!valid.test(shift.startTime) || !valid.test(shift.endTime) || shift.endTime >= shift.startTime) throw new Error('Μη έγκυρη νυχτερινή βάρδια.'); + const minutes = value => { const [h,m]=value.split(':').map(Number);return h*60+m; }; + return minutesToHours(1440 + minutes(shift.endTime) - minutes(shift.startTime)); + } const minutes = calculateShiftDurationMinutes(shift.startTime, shift.endTime); return minutesToHours(minutes); } diff --git a/storage.rules b/storage.rules index de84a8f..f0646ed 100644 --- a/storage.rules +++ b/storage.rules @@ -37,6 +37,12 @@ service firebase.storage { request.resource.contentType == 'application/pdf'; } + match /tenants/{tenantId}/schedule-publications/{publicationId}/schedule.pdf { + allow read: if isTenantAdmin(tenantId); + allow write: if isTenantAdmin(tenantId) && resource == null && request.resource != null && + publicationId.matches('^[a-zA-Z0-9_-]{1,100}$') && request.resource.size < 10 * 1024 * 1024 && request.resource.contentType == 'application/pdf' && + firestore.exists(/databases/(default)/documents/tenants/$(tenantId)/schedulePublicationReservations/$(publicationId)); + } match /{allPaths=**} { allow read, write: if false; } diff --git a/tests/scheduler-v3.e2e.spec.js b/tests/scheduler-v3.e2e.spec.js new file mode 100644 index 0000000..f1ccff1 --- /dev/null +++ b/tests/scheduler-v3.e2e.spec.js @@ -0,0 +1,35 @@ +import { test, expect } from 'playwright/test'; +import { makeDefaultConfigV3 } from '../src/services/schedulerV3Service.ts'; +const url=process.env.E2E_BASE_URL||'http://127.0.0.1:5187'; +for (const width of [390,1440]) test(`V3 draft and settings at ${width}px`,async({page},info)=>{ + await page.setViewportSize({width,height:900}); + await page.goto(url);await page.waitForFunction(()=>window.__gasStationSchedulerStore); + await page.evaluate(config=>{ + const store=window.__gasStationSchedulerStore;store.getState().cleanupData(); + store.setState({isAdmin:true,adminUser:{uid:'test-owner',tenantId:'tenant-a'},isLoading:false,isAuthLoading:false,schedulerSchemaVersion:3,schedulerConfigV3:config,employees:[{id:'a',fullName:'Μαρία',isActive:true},{id:'b',fullName:'Νίκος',isActive:true}],absences:[]}); + },makeDefaultConfigV3('tenant-a')); + const workspace=page.getByTestId('scheduler-v3-workspace');await expect(workspace).toBeVisible(); + await workspace.getByLabel('Ημερομηνία',{exact:true}).fill('2026-09-07');await workspace.getByRole('button',{name:'Δημιουργία',exact:true}).click(); + await expect(page.getByText('Προσχέδιο — επεξεργασία πριν τη δημοσίευση')).toBeVisible(); + await expect(page.getByRole('button',{name:'Αφαίρεση βάρδιας'})).toHaveCount(6); + await page.getByRole('button',{name:'Αφαίρεση βάρδιας'}).first().click(); + await expect(page.getByRole('region', { name: 'Προειδοποιήσεις', exact: true })).toContainText('Κάλυψη 0/1'); + await expect(page.getByRole('button',{name:'Αποθήκευση προσχεδίου'})).toBeEnabled(); + await expect(page.getByRole('button',{name:'Δημοσίευση νέας έκδοσης'})).toBeDisabled(); + await page.getByLabel('Διάβασα τις προειδοποιήσεις και επιλέγω δημοσίευση.').check(); + await expect(page.getByRole('button',{name:'Δημοσίευση νέας έκδοσης'})).toBeEnabled(); + await page.getByText('Ρυθμίσεις και προφίλ εργαζομένων',{exact:true}).click(); + await expect(page.getByRole('form',{name:'Ρυθμίσεις προγράμματος V3'})).toBeVisible(); + await page.getByRole('button',{name:'Προσθήκη βάρδιας',exact:true}).click(); + await expect(page.getByRole('button',{name:'Διαγραφή Νέα βάρδια'})).toBeVisible(); + expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); + await workspace.screenshot({path:info.outputPath(`v3-${width}.png`)}); + await page.getByText('Προηγούμενα προγράμματα και εργαλεία',{exact:true}).click(); + await expect(page.getByText('Πίνακας Ανακοινώσεων',{exact:true})).toBeVisible(); + await expect(workspace.getByRole('button',{name:'Αφαίρεση βάρδιας'})).toHaveCount(5); + const legacyWriteResults=await page.evaluate(async()=>{ + const state=window.__gasStationSchedulerStore.getState(); + return [await state.refreshPublicWeekSnapshot(),await state.refreshPublicMonthSnapshot({year:2026,month:8}),await state.clearMonthShifts({year:2026,month:8})]; + }); + expect(legacyWriteResults).toEqual([false,false,false]); +}); From 1050b38fbee280a5e4be130c35a5765df3609b64 Mon Sep 17 00:00:00 2001 From: commanderxgr Date: Wed, 9 Sep 2026 15:22:33 +0300 Subject: [PATCH 3/4] fix(scheduler-v3): close PR57 profile history and validation gaps --- docs/SCHEDULER_CONTRACT_V3.md | 13 +++- docs/SCHEDULER_V3_CONTINUATION_REPORT.md | 20 ++++++ firestore.rules | 29 +++++++- package.json | 2 +- scripts/test-pr57-corrective.mjs | 70 ++++++++++++++++++ scripts/test-scheduler-v3-emulator.mjs | 33 ++++++++- .../scheduler/PublicationHistoryV3.jsx | 27 ++++++- .../scheduler/SchedulePreviewV3.jsx | 49 +++++++++++-- .../scheduler/SchedulerSettingsV3.jsx | 13 ++-- .../scheduler/SchedulerWorkspaceV3.jsx | 11 +-- .../schedulePublicationsRepository.ts | 4 +- src/scheduler-engine-v3/employeeProfile.ts | 11 +++ src/services/schedulePublicationService.ts | 1 + src/services/schedulerV3Service.ts | 20 +++++- tests/scheduler-v3.e2e.spec.js | 72 ++++++++++++++++++- 15 files changed, 341 insertions(+), 34 deletions(-) create mode 100644 scripts/test-pr57-corrective.mjs diff --git a/docs/SCHEDULER_CONTRACT_V3.md b/docs/SCHEDULER_CONTRACT_V3.md index 9d152c2..5122798 100644 --- a/docs/SCHEDULER_CONTRACT_V3.md +++ b/docs/SCHEDULER_CONTRACT_V3.md @@ -234,13 +234,13 @@ No permanent public URL. Download through authorized Firebase Storage SDK. ## 9. Implemented Integration And Verification (8 September 2026) - Activation requires `VITE_ENABLE_SCHEDULER_V3=true` and tenant settings `schedulerSchemaVersion: 3`. The default is off. Repository operations also enforce the global flag. Existing broker/gate flags are unchanged. -- V3 settings and employee profiles are saved atomically. The V3 workspace includes employee creation/deactivation and the existing absence editor. +- V3 settings and employee profiles are saved atomically without changing `schedulerSchemaVersion`. Tenant activation is a separate, explicitly approved operation; there is no production activation control in the settings form. The V3 workspace includes employee creation/deactivation and the existing absence editor. - The primary scheduling workspace is V3. A separate disclosure retains previous schedules, exports and tools; legacy public refresh/month-clear actions are gated for a V3 tenant and cannot replace the new publication projections. - Drafts use `shifts` with `schedulerSchemaVersion: 3` and `draftId`, plus private `scheduleDrafts` metadata. Revision checks reject stale saves. Reads reconstruct one consistent transaction snapshot. Legacy bulk reads/clear operations exclude V3 draft rows. - Publication version reservation is transactional and bound to the publication ID. PDF upload precedes publication visibility. Snapshot, latest-period pointer and intersecting public week/month projections commit together. Published snapshots and PDFs deny overwrite/delete. - Public projections contain only display names, dates, times, work labels and non-sensitive V3/cross-midnight markers; internal IDs, warning details and profile data stay private. Public overnight analytics use the explicit V3 marker. - `previewV2Migration` is an offline preview function. Role/skill demand, replacement strategies, special dates and special Sunday policies are reported for owner review rather than silently migrated. It performs no writes. -- `npm run test:scheduler-contract-v3`: 304 engine cases and 17 service cases. The service cases include real PDF generation, privacy allowlists and publication failure ordering. +- `npm run test:scheduler-contract-v3`: 304 engine cases, 17 service cases and 8 PR #57 corrective cases. The service cases include real PDF generation, privacy allowlists and publication failure ordering. - V2 contract suite: 2,118 assertions unchanged. Browser coverage: three existing V2 tests plus V3 mobile and desktop tests. Emulator coverage exercises the actual V3 repository, concurrent reservations, draft roundtrips, stale saves, immutable snapshot/PDF denial and tenant/identity boundaries. Technical limits and follow-up before production activation: @@ -255,3 +255,12 @@ Technical limits and follow-up before production activation: Partial-day absences currently have no time bounds and conservatively exclude their full date from automatic assignment. V2 break-adjusted durations require review during preview migration because V3 templates represent gross time spans. Emulator testing uses `scripts/run-scheduler-v3-emulator.mjs` inside Firebase `emulators:exec` for demo project `demo-shiftoryx-v3`. Run the emulator from a temporary directory with a config referencing this checkout's Rules; debug logs must remain outside the checkout. + +## 10. PR #57 corrective contract (9 September 2026) + +- Newly discovered employees keep all seven current normalized V3 profile fields when appended to an open draft; zero-hour statistics, warnings and subsequent generation use that profile. +- Normal profile controls expose standard shift and weekly-rotation toggle only. A unique active opposite MORNING/AFTERNOON template is selected automatically. Missing, ambiguous, INTERMEDIATE and CUSTOM patterns warn without guessing. Existing anchors are preserved; a new anchor uses the deterministic Monday `2026-01-05`. +- Adding an assignment opens an inline form and creates no shift until confirmation. Replacing the draft resets pending form state. New choices are active-only. Existing inactive assignments remain visible with warnings. Time choices are quarter-hour selects, not text inputs. +- History includes period, type, version, publication time, employee/hour totals and warning status, plus read-only view, authorized stored-PDF download and create-new-draft actions. Restoration clones shifts and historical config, records `sourcePublicationId`, reconciles current employee profiles and starts a fresh draft revision. Republishing reserves the current period counter, so restoring v1 after v3 produces v4. Original snapshots and PDF bytes remain unchanged. +- Unknown template references are technical errors, not permission to silently rewrite current profiles or historical templates. Incompatible historical/current configuration requires explicit reconciliation before restoration can proceed. +- Firestore validates every employee profile field and the draft metadata allowlist, identity/tenant/config binding, calendar-valid ISO dates (including leap years), date order and revision type/range. Existing OWNER/tenant authorization and publication/PDF immutability remain enforced. diff --git a/docs/SCHEDULER_V3_CONTINUATION_REPORT.md b/docs/SCHEDULER_V3_CONTINUATION_REPORT.md index 61ae9a3..b3cdba9 100644 --- a/docs/SCHEDULER_V3_CONTINUATION_REPORT.md +++ b/docs/SCHEDULER_V3_CONTINUATION_REPORT.md @@ -51,3 +51,23 @@ Draft transaction size is bounded to 450 operations. Interrupted publication can New dependencies: 0. Lockfiles and GitHub Actions unchanged. No credentials were read or modified. No production data, Firebase deployment, DNS, IAM or Vercel production configuration was changed. The original protected log and `.serena/` were not inspected. The delivery is a Draft PR for review, not production activation. Rollback is to leave/restore the global V3 flag off; V2 data and code remain available. Any future production Rules rollout and tenant activation require separate approval. Preserve publication history and PDFs during rollback. + +## PR #57 corrective pass — 9 September 2026 + +Reviewed starting head: `ece9f8f13fed17d5e0b393511c9ce799b98242b1`; same branch/base as above. Goal: close the supplied profile, activation, history, preview, rotation and Rules gaps without replacing V3 architecture. + +Changes: preserve newly appended profiles; separate config save from activation; add immutable history view/download/restore with source linkage and current version allocation; require inline assignment confirmation and active-only new choices; derive simple MORNING/AFTERNOON rotation; validate all profile fields and draft metadata in Firestore. Changes are confined to V3 services/repository/components/profile helper, Rules, tests, package test command and these documents. No migration or production activation was performed. + +Regression evidence: the initial focused suite failed all 7 cases before fixes. It now passes 8 cases including NORMAL/SUBSTITUTE_ONLY, null/32-hour targets, fixed Tuesday off, zero-hour stats, warning/generation consumers, both rotation directions, ambiguous/missing/inactive/intermediate/custom templates, stable anchor/year boundary and independent publication restoration. Full V3 suite: 304 engine + 17 service + 8 corrective cases. V2: 2,118 assertions. + +All required local commands were rerun successfully: `test:scheduler-contract-v3`, `test:scheduler-contract-v2`, `qa:scheduler` (including build), `qa:scheduler-engine`, `qa:repositories`, `qa:public-readonly`, `qa:tenant-authorization`, `qa:auth-broker`, `qa:export-security`, `security:hardening`, `security:integrity`, and `build`, each through `npm run`. + +The demo-only V3 emulator suite passed with actual repository operations: save-config preserves tenant version 2; explicit fixture activation sets 3; malformed optional profile fields and draft periods/revisions/config bindings return `permission-denied`; restoring v1 after v3 publishes v4; original publication data and PDF bytes are unchanged. Existing cross-tenant, anonymous, Platform Admin, immutability and public projection checks also passed. The known Storage teardown exception occurs after successful test exit 0, outside the checkout. + +Final browser verification: 5/5 passed (three V2 regressions plus V3 at 390×900 and 1440×900), using `$env:E2E_BASE_URL='http://127.0.0.1:5187'; node node_modules/playwright/cli.js test tests/scheduler-v3.e2e.spec.js tests/scheduler-roles.e2e.spec.js --workers=1 --reporter=line`. V3 asserts no pre-confirmation shift, all 96 quarter-hour choices, no time text entry, active-only new choices, inactive historical assignments and warnings, automatically derived profile values, history view/download/restore, private-field non-display and unchanged source snapshot. Viewport overflow checks pass. Browser repository calls use local test seams; actual persistence/authorization is covered separately by the emulator. Earlier locator and unavailable-dev-server failures were corrected and the complete run repeated successfully. + +Security review: no dependencies, lockfiles or GitHub Actions changed; credentials/secrets were not inspected or modified. Higher-risk changes are `firestore.rules`, `schedulePublicationsRepository.ts`, and the V3 publication/draft services. Existing tenant authorization remains intact, private snapshot fields remain allowlisted, and downloads use authorized bytes with short-lived local blob URLs rather than public Storage URLs. No new external data transmission was introduced. Security results refer to repository checks, focused code review and emulator enforcement, not an exhaustive security certification. + +Limits retained: no local standalone TypeScript compiler in PATH or root dependencies (none installed); existing build chunk warnings; no production browser persistence verification. A current profile referring to a template absent from the historical config fails restoration explicitly instead of silently changing the profile or historical data. Rules now enforce calendar-valid ISO dates, including Gregorian century/leap-year handling. Deployment status: none. Rollback: keep the global flag OFF and revert the corrective commit if required, without deleting publications/PDFs. Next step: human review of this Draft PR; production activation remains separately approved. + +Independent corrective review identified two additional gaps: pending assignment state surviving draft replacement and calendar-invalid ISO-shaped dates passing Rules. Both were reproduced with failing browser/emulator assertions and fixed. The final browser suite passed 5/5 with uncaught-page-error checks; the final emulator passed impossible-date denials and valid 2000/2024/2400 leap-day controls. A local cross-check of the exact Rules regexes against calendar round-trips passed 334,800 dates (years 1600–2499). Security hardening, integrity and tenant-authorization checks were rerun after the final Rules change. diff --git a/firestore.rules b/firestore.rules index c58e5d8..ecd312a 100644 --- a/firestore.rules +++ b/firestore.rules @@ -122,10 +122,33 @@ service cloud.firestore { isOptionalIntOrNull('fixedDayOff'); } + function safeV3Id(value) { + return value is string && value.size() > 0 && value.size() <= 100 && value.matches('^[a-zA-Z0-9_-]+$'); + } + function v3Date(value) { + // Calendar-valid ISO date, including Gregorian leap years (centuries divide by 400). + return value is string && ( + value.matches('^[0-9]{4}-((01|03|05|07|08|10|12)-(0[1-9]|[12][0-9]|3[01])|(04|06|09|11)-(0[1-9]|[12][0-9]|30)|02-(0[1-9]|1[0-9]|2[0-8]))$') || + value.matches('^([0-9]{2}(0[48]|[2468][048]|[13579][26])|([02468][048]|[13579][26])00)-02-29$') + ); + } function validEmployeeProfileV3(p) { - return p is map && p.keys().hasOnly(['workMode','fixedDayOff','targetWeeklyHours','standardShiftTemplateId','rotateStandardShiftWeekly','rotationAlternateShiftTemplateId','rotationAnchorWeekStart']) && + return p is map && p.keys().hasAll(['workMode','fixedDayOff','targetWeeklyHours','standardShiftTemplateId','rotateStandardShiftWeekly','rotationAlternateShiftTemplateId','rotationAnchorWeekStart']) && p.keys().hasOnly(['workMode','fixedDayOff','targetWeeklyHours','standardShiftTemplateId','rotateStandardShiftWeekly','rotationAlternateShiftTemplateId','rotationAnchorWeekStart']) && p.workMode in ['NORMAL','SUBSTITUTE_ONLY'] && (p.fixedDayOff == null || (p.fixedDayOff is int && p.fixedDayOff >= 0 && p.fixedDayOff <= 6)) && - (p.targetWeeklyHours == null || (p.targetWeeklyHours is number && p.targetWeeklyHours >= 0 && p.targetWeeklyHours <= 168)) && p.rotateStandardShiftWeekly is bool; + (p.targetWeeklyHours == null || (p.targetWeeklyHours is number && p.targetWeeklyHours >= 0 && p.targetWeeklyHours <= 168)) && p.rotateStandardShiftWeekly is bool && + (p.standardShiftTemplateId == null || safeV3Id(p.standardShiftTemplateId)) && + (p.rotationAlternateShiftTemplateId == null || safeV3Id(p.rotationAlternateShiftTemplateId)) && + (p.rotationAnchorWeekStart == null || v3Date(p.rotationAnchorWeekStart)); + } + function validScheduleDraftV3(tenantId, draftId) { + let d = request.resource.data; + return d.keys().hasAll(['id','tenantId','schemaVersion','periodType','periodStart','periodEnd','config','employees','absences','options','updatedBy','revision','shiftDocumentIds']) && + d.keys().hasOnly(['id','tenantId','schemaVersion','periodType','periodStart','periodEnd','config','employees','absences','options','updatedBy','revision','shiftDocumentIds','sourcePublicationId']) && + d.tenantId == tenantId && d.schemaVersion == 3 && d.id == draftId && safeV3Id(d.id) && d.periodType in ['WEEK','MONTH'] && + v3Date(d.periodStart) && v3Date(d.periodEnd) && d.periodStart <= d.periodEnd && d.revision is int && d.revision >= 0 && + d.updatedBy == request.auth.uid && d.config is map && d.config.tenantId == tenantId && d.config.schemaVersion == 3 && + d.employees is list && d.absences is list && d.options is map && d.shiftDocumentIds is list && d.shiftDocumentIds.size() <= 450 && + (!('sourcePublicationId' in d) || safeV3Id(d.sourcePublicationId)); } function validDraftShiftV3() { let d = request.resource.data; @@ -674,7 +697,7 @@ service cloud.firestore { match /scheduleDrafts/{draftId} { allow read: if isTenantAdmin(tenantId); - allow create, update: if isTenantAdmin(tenantId) && request.resource.data.tenantId == tenantId && request.resource.data.schemaVersion == 3 && request.resource.data.id == draftId && request.resource.data.config.tenantId == tenantId && request.resource.data.updatedBy == request.auth.uid; + allow create, update: if isTenantAdmin(tenantId) && validScheduleDraftV3(tenantId, draftId); allow delete: if false; } match /schedulePublicationCounters/{periodKey} { diff --git a/package.json b/package.json index 176bbbb..4bbd68e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "qa:scheduler": "node scripts/run-validate-scheduler-rules.mjs && node scripts/test-new-employee-scheduler-regression.mjs && npm run build", "test:new-employee-scheduler": "node scripts/test-new-employee-scheduler-regression.mjs", "test:scheduler-contract-v2": "node scripts/test-scheduler-contract-v2.mjs", - "test:scheduler-contract-v3": "node scripts/test-scheduler-contract-v3.mjs && node scripts/test-scheduler-v3-services.mjs", + "test:scheduler-contract-v3": "node scripts/test-scheduler-contract-v3.mjs && node scripts/test-scheduler-v3-services.mjs && node scripts/test-pr57-corrective.mjs", "qa:export-security": "node scripts/validate-export-audit-security.mjs", "qa:public-readonly": "node scripts/validate-public-readonly.mjs", "qa:tenant-authorization": "node scripts/validate-tenant-authorization.mjs", diff --git a/scripts/test-pr57-corrective.mjs b/scripts/test-pr57-corrective.mjs new file mode 100644 index 0000000..e83e99b --- /dev/null +++ b/scripts/test-pr57-corrective.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import * as service from '../src/services/schedulerV3Service.ts'; +import * as profiles from '../src/scheduler-engine-v3/employeeProfile.ts'; +import { buildPublicationV3 } from '../src/services/schedulePublicationService.ts'; +const config=service.makeDefaultConfigV3('tenant-a'); +config.shiftTemplates.push({...config.shiftTemplates[0],id:'afternoon',shiftType:'AFTERNOON',startTime:'16:00',endTime:'20:00',durationHours:4}); +const employees=service.mapEmployeesV3([{id:'a',fullName:'Μαρία',isActive:true}]); +const input={config,employees,absences:[],periodType:'WEEK',periodStart:'2026-09-07',periodEnd:'2026-09-13',options:{balanceWeeklyTargets:true}}; +const draft=service.createDraftV3(input,'base-draft'); +let passed=0;const failures=[]; +function test(name,fn){try{fn();passed++;}catch(error){failures.push(name+': '+error.message);}} +for(const workMode of ['NORMAL','SUBSTITUTE_ONLY'])for(const targetWeeklyHours of [32,null]){ + test(`new employee preserves ${workMode}/${targetWeeklyHours}`,()=>{ + const profile=profiles.normalizeEmployeeProfileV3({workMode,targetWeeklyHours,fixedDayOff:2,standardShiftTemplateId:'day',rotateStandardShiftWeekly:true,rotationAlternateShiftTemplateId:'afternoon',rotationAnchorWeekStart:'2026-09-07'}); + const added={id:'new',fullName:'Νέος',isActive:true,schedulerV3:profile}; + const result=service.refreshDraftPeopleV3(draft,[...employees,added],[]); + assert.deepEqual(result.employees.find(e=>e.id==='new').schedulerV3,profile); + assert.equal(service.analyzeDraftV3(result).employeeHours.find(h=>h.employeeId==='new').hours,0); + const targetWarnings=service.analyzeDraftV3(result).warnings.filter(w=>w.employeeId==='new'&&w.code==='TARGET_HOURS_UNDER'); + assert.equal(targetWarnings.length,targetWeeklyHours===null?0:1); + if(targetWeeklyHours!==null)assert.equal(targetWarnings[0].details.target,32); + const generated=service.createDraftV3(result,'regenerated').shifts.filter(s=>s.employeeId==='new'); + assert.equal(generated.some(s=>s.date==='2026-09-08'),false,'fixed Tuesday off remains enforced'); + if(workMode==='SUBSTITUTE_ONLY')assert.equal(generated.length,0); + else assert.ok(generated.length>0,'normal new employee participates in generation'); + result.employees.find(e=>e.id==='new').schedulerV3.fixedDayOff=3; + assert.equal(profile.fixedDayOff,2,'profile snapshot must not alias current data'); + }); +} +test('simple rotation chooses opposite and stable anchor',()=>{ + assert.equal(typeof profiles.applySimpleRotationV3,'function'); + const p=profiles.normalizeEmployeeProfileV3({standardShiftTemplateId:'day',rotateStandardShiftWeekly:true}); + const result=profiles.applySimpleRotationV3(p,config.shiftTemplates); + assert.equal(result.profile.rotationAlternateShiftTemplateId,'afternoon'); + assert.equal(result.profile.rotationAnchorWeekStart,'2026-01-05'); + assert.deepEqual(profiles.applySimpleRotationV3(result.profile,config.shiftTemplates),result); + assert.equal(profiles.resolveEffectiveStandardShift(result.profile,'2026-12-28'),'afternoon'); + assert.equal(profiles.resolveEffectiveStandardShift(result.profile,'2027-01-04'),'day'); +}); +test('ambiguous or custom rotation never guesses',()=>{ + assert.equal(typeof profiles.applySimpleRotationV3,'function'); + const p=profiles.normalizeEmployeeProfileV3({standardShiftTemplateId:'day',rotateStandardShiftWeekly:true}); + const ambiguous=profiles.applySimpleRotationV3(p,[...config.shiftTemplates,{...config.shiftTemplates[1],id:'other'}]); + assert.equal(ambiguous.profile.rotationAlternateShiftTemplateId,null);assert.ok(ambiguous.warning); + const custom=profiles.applySimpleRotationV3(p,[{...config.shiftTemplates[0],shiftType:'CUSTOM'},config.shiftTemplates[1]]); + assert.equal(custom.profile.rotationAlternateShiftTemplateId,null);assert.ok(custom.warning); +}); +test('reverse rotation and missing/intermediate patterns are explicit',()=>{ + const reverse=profiles.applySimpleRotationV3(profiles.normalizeEmployeeProfileV3({standardShiftTemplateId:'afternoon',rotateStandardShiftWeekly:true,rotationAnchorWeekStart:'2026-08-31'}),config.shiftTemplates); + assert.equal(reverse.profile.rotationAlternateShiftTemplateId,'day'); + assert.equal(reverse.profile.rotationAnchorWeekStart,'2026-08-31'); + assert.equal(profiles.resolveEffectiveStandardShift(reverse.profile,'2026-09-07'),'day'); + const p=profiles.normalizeEmployeeProfileV3({standardShiftTemplateId:'day',rotateStandardShiftWeekly:true}); + for(const templates of [[config.shiftTemplates[0]],[config.shiftTemplates[0],{...config.shiftTemplates[1],isActive:false}],[{...config.shiftTemplates[0],shiftType:'INTERMEDIATE'},config.shiftTemplates[1]]]){ + const result=profiles.applySimpleRotationV3(p,templates); + assert.equal(result.profile.rotationAlternateShiftTemplateId,null);assert.ok(result.warning); + } +}); +test('publication clone is new editable draft with immutable origin',()=>{ + assert.equal(typeof service.createDraftFromPublicationV3,'function'); + const pub=buildPublicationV3(draft,{tenantId:'tenant-a',uid:'owner',id:'v1',version:1,timestamp:'2026-09-07T10:00:00Z'}); + const before=structuredClone(pub); + const restored=service.createDraftFromPublicationV3(pub,{id:'restored',tenantId:'tenant-a',employees,absences:[]}); + assert.equal(restored.sourcePublicationId,'v1');assert.equal(restored.id,'restored');assert.equal(restored.revision,0); + assert.equal(restored.shifts.length,pub.shifts.length);assert.equal(restored.shifts[0].draftId,'restored'); + restored.shifts[0].startTime='09:00';assert.deepEqual(pub,before); + assert.throws(()=>service.createDraftFromPublicationV3(pub,{id:'bad',tenantId:'tenant-b',employees,absences:[]})); +}); +console.log(`PR57 corrective tests PASS=${passed} FAIL=${failures.length}`); +if(failures.length){console.error(failures.join('\n'));process.exitCode=1;} diff --git a/scripts/test-scheduler-v3-emulator.mjs b/scripts/test-scheduler-v3-emulator.mjs index 09fb1e0..bbfcdc9 100644 --- a/scripts/test-scheduler-v3-emulator.mjs +++ b/scripts/test-scheduler-v3-emulator.mjs @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import { connectAuthEmulator, createUserWithEmailAndPassword, signOut } from 'firebase/auth'; -import { connectFirestoreEmulator, doc, getDoc, updateDoc, deleteDoc } from 'firebase/firestore'; +import { connectFirestoreEmulator, doc, getDoc, updateDoc, deleteDoc, setDoc } from 'firebase/firestore'; import { connectStorageEmulator, ref, deleteObject } from 'firebase/storage'; import { auth, db, storage } from '../src/firebase/config.js'; import { schedulePublicationsRepository as repository } from '../src/repositories/schedulePublicationsRepository.ts'; -import { makeDefaultConfigV3, mapEmployeesV3, createDraftV3 } from '../src/services/schedulerV3Service.ts'; +import { makeDefaultConfigV3, mapEmployeesV3, createDraftV3, createDraftFromPublicationV3 } from '../src/services/schedulerV3Service.ts'; import { buildPublicationV3 } from '../src/services/schedulePublicationService.ts'; async function main() { @@ -23,13 +23,28 @@ async function main() { await seed('tenants/tenant-a/employees/a',{fullName:'Μαρία'}); const employees=mapEmployeesV3([{id:'a',fullName:'Μαρία',isActive:true}]);const config=makeDefaultConfigV3('tenant-a'); console.log('CHECK settings'); + const settingsRef=doc(db,'tenants/tenant-a/settings/scheduler'); + await setDoc(settingsRef,{schedulerSchemaVersion:2}); await repository.saveSettings(config,employees); - assert.equal((await getDoc(doc(db,'tenants/tenant-a/settings/scheduler'))).data().schedulerSchemaVersion,3); + assert.equal((await getDoc(settingsRef)).data().schedulerSchemaVersion,2,'save must not activate V3'); + await updateDoc(settingsRef,{schedulerSchemaVersion:3}); // Explicit test-fixture activation only. + assert.equal((await getDoc(settingsRef)).data().schedulerSchemaVersion,3); + await repository.saveSettings(config,employees); + assert.equal((await getDoc(settingsRef)).data().schedulerSchemaVersion,3,'save must preserve active V3'); + const denied=async(operation,label)=>assert.rejects(operation,error=>error.code==='permission-denied',label); + console.log('CHECK malformed profile fields denied'); + for(const field of ['standardShiftTemplateId','rotationAlternateShiftTemplateId'])for(const value of [{},[],12,'','../unsafe','x'.repeat(101)])await denied(()=>updateDoc(doc(db,'tenants/tenant-a/employees/a'),{['schedulerV3.'+field]:value}),field); + for(const value of [{},[],12,'','not-a-date','2026-99-99','2026-02-31','2026-04-31','2026-02-29','1900-02-29','2100-02-29'])await denied(()=>updateDoc(doc(db,'tenants/tenant-a/employees/a'),{'schedulerV3.rotationAnchorWeekStart':value}),'anchor'); + for(const value of ['2024-02-29','2000-02-29','2400-02-29'])await updateDoc(doc(db,'tenants/tenant-a/employees/a'),{'schedulerV3.rotationAnchorWeekStart':value}); + await updateDoc(doc(db,'tenants/tenant-a/employees/a'),{'schedulerV3.rotationAnchorWeekStart':null}); const draft=createDraftV3({config,employees,absences:[],periodStart:'2026-09-07',periodEnd:'2026-09-13',periodType:'WEEK',options:{balanceWeeklyTargets:true}},'emulator-draft'); console.log('CHECK draft roundtrip'); await repository.saveDraft(draft);const loaded=await repository.loadDraft('tenant-a',draft.id);assert.equal(loaded.shifts.length,draft.shifts.length); loaded.shifts.pop();await repository.saveDraft(loaded);assert.equal((await repository.loadDraft('tenant-a',draft.id)).shifts.length,loaded.shifts.length); await assert.rejects(()=>repository.saveDraft(loaded),'stale draft revision rejected'); + console.log('CHECK malformed draft fields denied'); + const draftRef=doc(db,'tenants/tenant-a/scheduleDrafts',draft.id); + for(const patch of [{periodType:'YEAR'},{periodStart:'bad'},{periodStart:'2026-02-31',periodEnd:'2026-03-01'},{periodStart:'2100-02-29',periodEnd:'2100-03-01'},{periodEnd:'2026-09-31'},{periodEnd:[]},{periodEnd:'2026-09-01'},{revision:-1},{revision:1.5},{revision:'1'},{'config.tenantId':'tenant-b'},{id:'other'},{unexpected:'field'}])await denied(()=>updateDoc(draftRef,patch),'malformed draft'); console.log('CHECK concurrent reservations'); const key='WEEK_2026-09-07_2026-09-13';const versions=await Promise.all([repository.reserve('tenant-a',key,'pub-a'),repository.reserve('tenant-a',key,'pub-b')]); assert.deepEqual([...versions].sort(),[1,2]); @@ -41,6 +56,18 @@ async function main() { for(const snapshot of [...snapshots].sort((a,b)=>b.version-a.version))await repository.finalize(snapshot); assert.equal((await getDoc(doc(db,'tenants/tenant-a/schedulePublicationPeriods',key))).data().latestVersion,2); assert.equal((await repository.list('tenant-a')).length,2); + console.log('CHECK draft from v1 publishes at current counter v4'); + const old= snapshots.find(s=>s.version===1);const oldBytes=new Uint8Array(await repository.download('tenant-a',old.id)); + const third={...old,id:'pub-third',version:await repository.reserve('tenant-a',key,'pub-third')}; + await repository.uploadPdf('tenant-a',`tenants/tenant-a/schedule-publications/pub-third/schedule.pdf`,oldBytes); + third.pdfStoragePath='tenants/tenant-a/schedule-publications/pub-third/schedule.pdf';await repository.finalize(third); + const restored=createDraftFromPublicationV3(old,{id:'from-v1',tenantId:'tenant-a',employees,absences:[]}); + await repository.saveDraft(restored);assert.equal((await repository.loadDraft('tenant-a','from-v1')).sourcePublicationId,old.id); + const version4=await repository.reserve('tenant-a',key,'pub-fourth');assert.equal(version4,4); + const fourth=buildPublicationV3(restored,{tenantId:'tenant-a',uid:user.uid,id:'pub-fourth',version:version4,timestamp:'2026-09-09T00:00:00Z'}); + await repository.uploadPdf('tenant-a',fourth.pdfStoragePath,oldBytes);await repository.finalize(fourth); + assert.deepEqual(new Uint8Array(await repository.download('tenant-a',old.id)),oldBytes); + assert.deepEqual((await getDoc(doc(db,'tenants/tenant-a/schedulePublications',old.id))).data(),JSON.parse(JSON.stringify(old))); assert.equal((await getDoc(doc(db,'tenants/tenant-a/publicMonths/2026-09'))).data().shiftCount,draft.shifts.length); console.log('CHECK month clears stale weekly projections'); const monthDraft={...draft,periodType:'MONTH',periodStart:'2026-09-01',periodEnd:'2026-09-30',shifts:[]}; diff --git a/src/components/scheduler/PublicationHistoryV3.jsx b/src/components/scheduler/PublicationHistoryV3.jsx index ab3fa12..c847284 100644 --- a/src/components/scheduler/PublicationHistoryV3.jsx +++ b/src/components/scheduler/PublicationHistoryV3.jsx @@ -1 +1,26 @@ -export default function PublicationHistoryV3({publications,onDownload,busy=false}) { return

Ιστορικό δημοσιεύσεων

{!publications.length&&

Δεν υπάρχουν δημοσιεύσεις.

}{publications.map(p=>
{p.periodStart} – {p.periodEnd} · Έκδοση {p.version}{p.publishedWithWarnings?'Με προειδοποιήσεις':'Χωρίς προειδοποιήσεις'}
)}
; } +import { useState } from 'react'; +export default function PublicationHistoryV3({publications,onDownload,onCreateDraft,busy=false}) { + const [selected,setSelected]=useState(null); + return
+

Ιστορικό δημοσιεύσεων

+ {!publications.length&&

Δεν υπάρχουν δημοσιεύσεις.

} + {publications.map(p=>
+

{p.periodStart} – {p.periodEnd} · {p.periodType} · Έκδοση {p.version}

+

Δημοσιεύτηκε:

+

{p.employeeSnapshot.length} εργαζόμενοι · {p.calculatedHours.reduce((sum,e)=>sum+e.hours,0)} ώρες · {p.warningsAtPublish.length} προειδοποιήσεις

+

{p.publishedWithWarnings?'Με προειδοποιήσεις':'Χωρίς προειδοποιήσεις'}

+
+ + + +
+
)} + {selected&&
+

Έκδοση {selected.version} · {selected.periodStart} – {selected.periodEnd}

+

Ιστορική προβολή — οι αλλαγές γίνονται σε νέο προσχέδιο.

+
{selected.shifts.map(s=>)}
ΗμέραΕργαζόμενοςΑπόΈωςΏρες
{s.date}{s.employeeName}{s.startTime}{s.endTime}{s.durationHours}
+
    {selected.employeeSnapshot.map(e=>
  • {e.displayName}: {selected.calculatedHours.find(h=>h.employeeId===e.employeeId)?.hours??0} ώρες
  • )}
+ +
} +
; +} diff --git a/src/components/scheduler/SchedulePreviewV3.jsx b/src/components/scheduler/SchedulePreviewV3.jsx index eac362e..9d8827d 100644 --- a/src/components/scheduler/SchedulePreviewV3.jsx +++ b/src/components/scheduler/SchedulePreviewV3.jsx @@ -1,14 +1,51 @@ +import { useState } from 'react'; import { calculateShiftDurationHoursV3, eachDateInRange } from '../../scheduler-engine-v3/index.ts'; import QuarterHourTimePicker from './QuarterHourTimePicker'; + export default function SchedulePreviewV3({draft,onChange,disabled=false}) { - const update=(id,patch)=>onChange(draft.shifts.map(s=>{if(s.id!==id)return s;const next={...s,...patch,source:'MANUAL',isManualOverride:true};return {...next,durationHours:calculateShiftDurationHoursV3(next.startTime,next.endTime,next.crossMidnight)};})); - return
Προσχέδιο — επεξεργασία πριν τη δημοσίευση + const [pending,setPending]=useState(null); + const [error,setError]=useState(''); + const activeEmployees=draft.employees.filter(e=>e.isActive); + const update=(id,patch)=>onChange(draft.shifts.map(s=>{ + if(s.id!==id)return s; + const next={...s,...patch,source:'MANUAL',isManualOverride:true}; + return {...next,durationHours:calculateShiftDurationHoursV3(next.startTime,next.endTime,next.crossMidnight)}; + })); + function confirm(){ + const employee=activeEmployees.find(e=>e.id===pending?.employeeId); + if(!employee || pending.startTime===pending.endTime){setError('Επίλεξε ενεργό εργαζόμενο και διαφορετικές ώρες έναρξης/λήξης.');return;} + const crossMidnight=pending.endTime + Προσχέδιο — επεξεργασία πριν τη δημοσίευση {draft.shifts.map(s=>
- - update(s.id,{startTime,shiftTemplateId:null,crossMidnight:s.endTimeupdate(s.id,{endTime,shiftTemplateId:null,crossMidnight:endTime - {s.durationHours} ώρες{s.crossMidnight?' · επόμενη ημέρα':''} + + update(s.id,{startTime,shiftTemplateId:null,crossMidnight:s.endTime + update(s.id,{endTime,shiftTemplateId:null,crossMidnight:endTime + {s.durationHours} ώρες{s.crossMidnight?' · επόμενη ημέρα':''} +
)} - + {!pending?: +
+ Νέα ανάθεση + + + setPending(p=>({...p,startTime}))}/> + setPending(p=>({...p,endTime}))}/> + + + {error&&

{error}

} +
}
; } diff --git a/src/components/scheduler/SchedulerSettingsV3.jsx b/src/components/scheduler/SchedulerSettingsV3.jsx index 82be9a9..02aad80 100644 --- a/src/components/scheduler/SchedulerSettingsV3.jsx +++ b/src/components/scheduler/SchedulerSettingsV3.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { calculateShiftDurationHoursV3, validateSchedulerConfigV3 } from '../../scheduler-engine-v3/index.ts'; +import { calculateShiftDurationHoursV3, validateSchedulerConfigV3, applySimpleRotationV3 } from '../../scheduler-engine-v3/index.ts'; import QuarterHourTimePicker from './QuarterHourTimePicker'; const days={MONDAY:'Δευτέρα',TUESDAY:'Τρίτη',WEDNESDAY:'Τετάρτη',THURSDAY:'Πέμπτη',FRIDAY:'Παρασκευή',SATURDAY:'Σάββατο',SUNDAY:'Κυριακή'}; @@ -14,8 +14,9 @@ export default function SchedulerSettingsV3({ config, employees, onSave, busy=fa const validation=validateSchedulerConfigV3(draft); const updateDay=(index,patch)=>setDraft(c=>({...c,operatingDays:c.operatingDays.map((d,n)=>n===index?{...d,...patch}:d)})); const updateTemplate=(index,patch)=>setDraft(c=>({...c,shiftTemplates:c.shiftTemplates.map((t,n)=>{if(n!==index)return t;const next={...t,...patch};return {...next,durationHours:calculateShiftDurationHoursV3(next.startTime,next.endTime,next.crossMidnight)};})})); - const updateProfile=(index,patch)=>setProfiles(list=>list.map((e,n)=>n===index?{...e,schedulerV3:{...e.schedulerV3,...patch}}:e)); - return
{e.preventDefault();if(validation.valid)onSave(draft,profiles);}} className="space-y-5" aria-label="Ρυθμίσεις προγράμματος V3"> + const updateProfile=(index,patch)=>setProfiles(list=>list.map((e,n)=>n===index?{...e,schedulerV3:applySimpleRotationV3({...e.schedulerV3,...patch},draft.shiftTemplates).profile}:e)); + const rotationResults=profiles.map(e=>applySimpleRotationV3(e.schedulerV3,draft.shiftTemplates)); + return {e.preventDefault();if(validation.valid)onSave(draft,profiles.map((p,n)=>({...p,schedulerV3:rotationResults[n].profile})));}} className="space-y-5" aria-label="Ρυθμίσεις προγράμματος V3">
Ρυθμίσεις προγράμματος
{draft.operatingDays.map((day,index)=>
{days[day.weekday]} @@ -47,9 +48,9 @@ export default function SchedulerSettingsV3({ config, employees, onSave, busy=fa - {['standardShiftTemplateId','rotationAlternateShiftTemplateId'].map((key,n)=>)} - - + + + {rotationResults[index].warning&&

{rotationResults[index].warning}

}
)} {!validation.valid&&
{validation.errors.join(' ')}
} diff --git a/src/components/scheduler/SchedulerWorkspaceV3.jsx b/src/components/scheduler/SchedulerWorkspaceV3.jsx index 2955193..fb572fb 100644 --- a/src/components/scheduler/SchedulerWorkspaceV3.jsx +++ b/src/components/scheduler/SchedulerWorkspaceV3.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { createDraftV3, analyzeDraftV3, editDraftV3, mapEmployeesV3, makeDefaultConfigV3 } from '../../services/schedulerV3Service.ts'; -import { refreshDraftPeopleV3 } from '../../services/schedulerV3Service.ts'; +import { refreshDraftPeopleV3, createDraftFromPublicationV3 } from '../../services/schedulerV3Service.ts'; import { publishDraftV3 } from '../../services/schedulePublicationService.ts'; import { renderPublicationPdfV3 } from '../../services/schedulePublicationPdf.ts'; import { schedulePublicationsRepository as repository } from '../../repositories/schedulePublicationsRepository.ts'; @@ -13,8 +13,9 @@ import AbsencesPanel from './AbsencesPanel'; export function SchedulerSetupV3({tenantId,employees}) { const [message,setMessage]=useState('');const [busy,setBusy]=useState(false); - const config=makeDefaultConfigV3(tenantId); - return
Ρυθμίσεις νέου προγράμματος V3

Η αποθήκευση ενεργοποιεί το νέο πρόγραμμα για αυτό το κατάστημα. Τα παλιά προγράμματα διατηρούνται.

{setBusy(true);try{await repository.saveSettings(c,e);setMessage('Αποθηκεύτηκαν.');}catch{setMessage('Η αποθήκευση απέτυχε. Έλεγξε τις ρυθμίσεις και την πρόσβασή σου.');}finally{setBusy(false);}}}/>

{message}

; + const savedConfig=useSchedulerStore(state=>state.schedulerConfigV3); + const config=savedConfig?.tenantId===tenantId?savedConfig:makeDefaultConfigV3(tenantId); + return
Ρυθμίσεις νέου προγράμματος V3

Η αποθήκευση προετοιμάζει τις ρυθμίσεις V3 χωρίς να αλλάζει το ενεργό πρόγραμμα. Η ενεργοποίηση του καταστήματος γίνεται ξεχωριστά μετά από έγκριση.

{setBusy(true);try{await repository.saveSettings(c,e);setMessage('Αποθηκεύτηκαν.');}catch{setMessage('Η αποθήκευση απέτυχε. Έλεγξε τις ρυθμίσεις και την πρόσβασή σου.');}finally{setBusy(false);}}}/>

{message}

; } export default function SchedulerWorkspaceV3({config,employees,absences,uid,onLogout}) { const [draft,setDraft]=useState(null),[start,setStart]=useState(()=>new Date().toISOString().slice(0,10)),[periodType,setPeriodType]=useState('WEEK'),[busy,setBusy]=useState(false),[message,setMessage]=useState(''),[publications,setPublications]=useState([]),[drafts,setDrafts]=useState([]),[acceptWarnings,setAcceptWarnings]=useState(false); @@ -39,11 +40,11 @@ export default function SchedulerWorkspaceV3({config,employees,absences,uid,onLo

{busy?'Επεξεργασία…':message}

{drafts.map(d=>)} - {draft&&<>

Σύνολα

{report.employeeHours.map(h=>

{draft.employees.find(e=>e.id===h.employeeId)?.fullName}: {h.hours} ώρες · {h.shiftCount} βάρδιες

)}
+{draft&&<>

Σύνολα

{report.employeeHours.map(h=>

{draft.employees.find(e=>e.id===h.employeeId)?.fullName}: {h.hours} ώρες · {h.shiftCount} βάρδιες

)}
{report.warnings.length>0&&} } - + run(async()=>{const current=useSchedulerStore.getState();setDraft(createDraftFromPublicationV3(publication,{tenantId:config.tenantId,employees:mapEmployeesV3(current.employees),absences:current.absences}));setAcceptWarnings(false);setMessage(`Δημιουργήθηκε νέο προσχέδιο από την έκδοση ${publication.version}.`);})}/>
; } diff --git a/src/repositories/schedulePublicationsRepository.ts b/src/repositories/schedulePublicationsRepository.ts index 0a01adf..404a9ed 100644 --- a/src/repositories/schedulePublicationsRepository.ts +++ b/src/repositories/schedulePublicationsRepository.ts @@ -29,7 +29,7 @@ export const schedulePublicationsRepository={ if(!validateEmployeeProfileV3(employee.schedulerV3,ids).valid)throw new Error('Μη έγκυρο προφίλ.'); batch.update(doc(db,c.root,'employees',idCheck(employee.id)),{schedulerV3:clean(employee.schedulerV3)}); } - batch.set(doc(db,c.root,'settings','scheduler'),{schedulerSchemaVersion:3,schedulerConfigV3:clean(config)},{merge:true}); + batch.set(doc(db,c.root,'settings','scheduler'),{schedulerConfigV3:clean(config)},{merge:true}); await batch.commit(); }, async saveDraft(draft:DraftV3) { @@ -45,7 +45,7 @@ export const schedulePublicationsRepository={ removedIds.forEach(id=>tx.delete(doc(db,c.root,'shifts',idCheck(id)))); draft.shifts.forEach((s,n)=>tx.set(doc(db,c.root,'shifts',shiftDocumentIds[n]),clean({...s,type:'work',schedulerSchemaVersion:3,draftId:draft.id}))); const revision=(draft.revision||0)+1; - tx.set(metadata,clean({id:draft.id,tenantId:c.tenant,schemaVersion:3,periodType:draft.periodType,periodStart:draft.periodStart,periodEnd:draft.periodEnd,config:draft.config,employees:draft.employees,absences:draft.absences,options:draft.options,updatedBy:c.uid,revision,shiftDocumentIds})); + tx.set(metadata,clean({id:draft.id,tenantId:c.tenant,schemaVersion:3,periodType:draft.periodType,periodStart:draft.periodStart,periodEnd:draft.periodEnd,config:draft.config,employees:draft.employees,absences:draft.absences,options:draft.options,updatedBy:c.uid,revision,shiftDocumentIds,...(draft.sourcePublicationId?{sourcePublicationId:draft.sourcePublicationId}:{})})); return revision; }); }, diff --git a/src/scheduler-engine-v3/employeeProfile.ts b/src/scheduler-engine-v3/employeeProfile.ts index 3cff8de..7b324c4 100644 --- a/src/scheduler-engine-v3/employeeProfile.ts +++ b/src/scheduler-engine-v3/employeeProfile.ts @@ -177,6 +177,17 @@ export function resolveRotationAlternateTemplate( return { alternateId: null, ambiguous: true }; } +/** Normal OWNER controls only choose a standard shift and toggle weekly rotation. */ +export function applySimpleRotationV3(profile:EmployeeSchedulingProfileV3,templates:ShiftTemplateV3[]):{profile:EmployeeSchedulingProfileV3;warning:string|null} { + const next=normalizeEmployeeProfileV3(profile); + if(!next.rotateStandardShiftWeekly)return {profile:next,warning:null}; + const standard=templates.find(t=>t.id===next.standardShiftTemplateId&&t.isActive); + const alternate=standard?resolveRotationAlternateTemplate(standard.id,templates):{alternateId:null,ambiguous:false}; + next.rotationAlternateShiftTemplateId=alternate.alternateId; + next.rotationAnchorWeekStart=next.rotationAnchorWeekStart||'2026-01-05'; + return {profile:next,warning:alternate.alternateId?null:alternate.ambiguous?'Υπάρχουν περισσότερες από μία αντίθετες βάρδιες. Διόρθωσε τα ενεργά πρότυπα.':'Η εβδομαδιαία αλλαγή χρειάζεται μία ενεργή πρωινή και μία απογευματινή βάρδια.'}; +} + /** * Convert a fixedDayOff number (0=Sunday..6=Saturday) to a Weekday string. */ diff --git a/src/services/schedulePublicationService.ts b/src/services/schedulePublicationService.ts index ee58771..fedb5ca 100644 --- a/src/services/schedulePublicationService.ts +++ b/src/services/schedulePublicationService.ts @@ -7,6 +7,7 @@ export function buildPublicationV3(draft: DraftV3, context: { tenantId: string; return { id:context.id,tenantId:context.tenantId,schemaVersion:3,periodType:draft.periodType,periodStart:draft.periodStart,periodEnd:draft.periodEnd, periodKey:`${draft.periodType}_${draft.periodStart}_${draft.periodEnd}`,version:context.version,sourceDraftId:draft.id, + ...(draft.sourcePublicationId?{sourcePublicationId:draft.sourcePublicationId}:{}), templateSnapshot:structuredClone(draft.config), employeeSnapshot:draft.employees.map(e=>({employeeId:e.id,displayName:e.fullName,...(e.color?{color:e.color}:{})})), shifts:draft.shifts.map(s=>({id:s.id,date:s.date,employeeId:s.employeeId,employeeName:draft.employees.find(e=>e.id===s.employeeId)!.fullName,startTime:s.startTime,endTime:s.endTime,durationHours:s.durationHours,shiftTemplateId:s.shiftTemplateId,source:s.source})), diff --git a/src/services/schedulerV3Service.ts b/src/services/schedulerV3Service.ts index 7de5646..c1e8ec4 100644 --- a/src/services/schedulerV3Service.ts +++ b/src/services/schedulerV3Service.ts @@ -1,5 +1,5 @@ import { analyzeScheduleWarningsV3, assertV3Input, calculateEmployeeHoursV3, calculateCoverageSummary, generateScheduleV3, normalizeEmployeeProfileV3 } from '../scheduler-engine-v3/index.ts'; -import type { GenerateScheduleV3Input, GeneratedShiftV3, SchedulerConfigV3, EmployeeV3 } from '../scheduler-engine-v3/types.ts'; +import type { GenerateScheduleV3Input, GeneratedShiftV3, SchedulerConfigV3, EmployeeV3, SchedulePublicationV3 } from '../scheduler-engine-v3/types.ts'; export function isSchedulerV3Active(globalFlag: unknown, tenantVersion: unknown): boolean { return (globalFlag === true || globalFlag === 'true') && tenantVersion === 3; @@ -14,10 +14,24 @@ export function refreshDraftPeopleV3(draft:DraftV3,employees:EmployeeV3[],absenc const current=new Map(employees.map(e=>[e.id,e])); const people=draft.employees.map(e=>{const latest=current.get(e.id);return latest?{...e,isActive:latest.isActive,fullName:latest.fullName,activeFrom:latest.activeFrom,activeTo:latest.activeTo}:{...e,isActive:false};}); const ids=new Set(people.map(e=>e.id)); - people.push(...employees.filter(e=>!ids.has(e.id)).map(e=>({...e,schedulerV3:normalizeEmployeeProfileV3()}))); + people.push(...employees.filter(e=>!ids.has(e.id)).map(e=>({...e,schedulerV3:normalizeEmployeeProfileV3(e.schedulerV3)}))); return {...draft,employees:people,absences:mapAbsencesV3(absences,draft.periodStart,draft.periodEnd)}; } -export type DraftV3 = GenerateScheduleV3Input & { id: string; shifts: GeneratedShiftV3[]; revision?: number }; +export type DraftV3 = GenerateScheduleV3Input & { id: string; shifts: GeneratedShiftV3[]; revision?: number; sourcePublicationId?: string }; +export function createDraftFromPublicationV3(publication:SchedulePublicationV3, options:{id?:string;tenantId?:string;employees?:EmployeeV3[];absences?:Array>}={}):DraftV3 { + if(publication.tenantId !== (options.tenantId ?? publication.tenantId) || publication.templateSnapshot.tenantId !== publication.tenantId) throw new Error('Μη έγκυρο κατάστημα δημοσίευσης.'); + const id=options.id ?? globalThis.crypto.randomUUID(); + if(!/^[a-zA-Z0-9_-]{1,100}$/.test(id) || id===publication.sourceDraftId)throw new Error('Απαιτείται νέο αναγνωριστικό προσχεδίου.'); + const current=new Map((options.employees||[]).map(e=>[e.id,e])); + const people:EmployeeV3[]=publication.employeeSnapshot.map(e=>{ + const live=current.get(e.employeeId); + return live?{...live,schedulerV3:normalizeEmployeeProfileV3(live.schedulerV3)}:{id:e.employeeId,fullName:e.displayName,isActive:false,color:e.color,schedulerV3:normalizeEmployeeProfileV3()}; + }); + const existing=new Set(people.map(e=>e.id)); + people.push(...(options.employees||[]).filter(e=>!existing.has(e.id)).map(e=>({...e,schedulerV3:normalizeEmployeeProfileV3(e.schedulerV3)}))); + const draft:DraftV3={id,revision:0,sourcePublicationId:publication.id,config:structuredClone(publication.templateSnapshot),employees:people,absences:mapAbsencesV3(options.absences||[],publication.periodStart,publication.periodEnd),periodType:publication.periodType,periodStart:publication.periodStart,periodEnd:publication.periodEnd,options:{balanceWeeklyTargets:publication.templateSnapshot.generationDefaults.balanceWeeklyTargetsForMonth},shifts:publication.shifts.map(s=>({...structuredClone(s),shiftTemplateId:s.shiftTemplateId??null,crossMidnight:s.endTime{ + const pageErrors=[];page.on('pageerror',error=>pageErrors.push(error.message)); await page.setViewportSize({width,height:900}); await page.goto(url);await page.waitForFunction(()=>window.__gasStationSchedulerStore); + const config=makeDefaultConfigV3('tenant-a'); + config.shiftTemplates.push({...config.shiftTemplates[0],id:'afternoon',label:'Απόγευμα',shiftType:'AFTERNOON',startTime:'16:00',endTime:'20:00',durationHours:4}); await page.evaluate(config=>{ const store=window.__gasStationSchedulerStore;store.getState().cleanupData(); - store.setState({isAdmin:true,adminUser:{uid:'test-owner',tenantId:'tenant-a'},isLoading:false,isAuthLoading:false,schedulerSchemaVersion:3,schedulerConfigV3:config,employees:[{id:'a',fullName:'Μαρία',isActive:true},{id:'b',fullName:'Νίκος',isActive:true}],absences:[]}); - },makeDefaultConfigV3('tenant-a')); + store.setState({isAdmin:true,adminUser:{uid:'test-owner',tenantId:'tenant-a'},isLoading:false,isAuthLoading:false,schedulerSchemaVersion:3,schedulerConfigV3:config,employees:[{id:'a',fullName:'Μαρία',isActive:true},{id:'b',fullName:'Νίκος',isActive:true},{id:'inactive',fullName:'Ανενεργός',isActive:false,email:'PRIVATE_EMAIL',phone:'PRIVATE_PHONE',afm:'PRIVATE_AFM'}],absences:[]}); + },config); const workspace=page.getByTestId('scheduler-v3-workspace');await expect(workspace).toBeVisible(); await workspace.getByLabel('Ημερομηνία',{exact:true}).fill('2026-09-07');await workspace.getByRole('button',{name:'Δημιουργία',exact:true}).click(); await expect(page.getByText('Προσχέδιο — επεξεργασία πριν τη δημοσίευση')).toBeVisible(); @@ -32,4 +36,68 @@ for (const width of [390,1440]) test(`V3 draft and settings at ${width}px`,async return [await state.refreshPublicWeekSnapshot(),await state.refreshPublicMonthSnapshot({year:2026,month:8}),await state.clearMonthShifts({year:2026,month:8})]; }); expect(legacyWriteResults).toEqual([false,false,false]); + await workspace.getByRole('button',{name:'+ Προσθήκη εργαζομένου',exact:true}).click(); + await expect(workspace.getByRole('button',{name:'Αφαίρεση βάρδιας'})).toHaveCount(5); + const add=workspace.getByRole('group',{name:'Νέα ανάθεση',exact:true}); + const employeePicker=add.getByRole('combobox',{name:'Εργαζόμενος',exact:true}); + const startPicker=add.getByRole('combobox',{name:'Από',exact:true}); + await expect(employeePicker).toBeVisible(); + await expect(employeePicker.locator('option[value="inactive"]')).toHaveCount(0); + await expect(startPicker.locator('option')).toHaveCount(96); + expect(await startPicker.locator('option').allTextContents()).toEqual(Array.from({length:96},(_,i)=>`${String(Math.floor(i/4)).padStart(2,'0')}:${String(i%4*15).padStart(2,'0')}`)); + await expect(add.getByRole('textbox')).toHaveCount(0); + await employeePicker.selectOption('b'); + await startPicker.selectOption('09:15'); + await add.getByRole('combobox',{name:'Έως',exact:true}).selectOption('17:15'); + await add.getByRole('button',{name:'Προσθήκη',exact:true}).click(); + await expect(workspace.getByRole('button',{name:'Αφαίρεση βάρδιας'})).toHaveCount(6); + await expect(workspace.getByLabel('Εναλλακτική βάρδια',{exact:true})).toHaveCount(0); + await expect(workspace.getByLabel('Εβδομάδα αναφοράς',{exact:true})).toHaveCount(0); + await page.evaluate(async()=>{ + const {schedulePublicationsRepository:repo}=await import('/src/repositories/schedulePublicationsRepository.ts'); + repo.saveSettings=async(config,profiles)=>{window.__savedV3Profiles=profiles;}; + }); + const maria=workspace.getByRole('group',{name:'Μαρία',exact:true}); + await maria.getByRole('combobox',{name:'Τυπική βάρδια',exact:true}).selectOption('day'); + await maria.getByLabel('Αλλαγή βάρδιας κάθε εβδομάδα').check(); + await workspace.getByRole('button',{name:'Αποθήκευση ρυθμίσεων',exact:true}).click(); + await expect.poll(()=>page.evaluate(()=>window.__savedV3Profiles?.find(e=>e.id==='a')?.schedulerV3.rotationAlternateShiftTemplateId)).toBe('afternoon'); + expect(await page.evaluate(()=>window.__savedV3Profiles.find(e=>e.id==='a').schedulerV3.rotationAnchorWeekStart)).toBe('2026-01-05'); + await page.evaluate(async()=>{ + const service=await import('/src/services/schedulerV3Service.ts');const publication=await import('/src/services/schedulePublicationService.ts'); + const {schedulePublicationsRepository:repo}=await import('/src/repositories/schedulePublicationsRepository.ts'); + const state=window.__gasStationSchedulerStore.getState(); + const d=service.createDraftV3({config:state.schedulerConfigV3,employees:service.mapEmployeesV3(state.employees),absences:[],periodType:'WEEK',periodStart:'2026-09-07',periodEnd:'2026-09-13',options:{balanceWeeklyTargets:true}},'history-fixture'); + window.__historyV3=publication.buildPublicationV3(d,{tenantId:'tenant-a',uid:'PRIVATE_AUTH_UID',id:'v1',version:1,timestamp:'2026-09-09T10:00:00Z'}); + window.__originalHistory=JSON.stringify(window.__historyV3); + repo.list=async()=>[window.__historyV3];repo.listDrafts=async()=>[]; + repo.download=async()=>new TextEncoder().encode('%PDF-1.4 test history').buffer; + }); + await workspace.getByRole('button',{name:'Φόρτωση ιστορικού'}).click(); + const history=workspace.getByRole('region',{name:'Ιστορικό δημοσιεύσεων',exact:true}); + await history.getByRole('button',{name:'Προβολή',exact:true}).click(); + await expect(history.getByRole('region',{name:'Προβολή δημοσίευσης'})).toContainText('Μαρία'); + for(const secret of ['PRIVATE_EMAIL','PRIVATE_PHONE','PRIVATE_AFM','PRIVATE_AUTH_UID'])await expect(workspace).not.toContainText(secret); + const download=page.waitForEvent('download');await history.getByRole('button',{name:'Λήψη PDF',exact:true}).click();await download; + await history.getByRole('button',{name:'Δημιουργία draft από αυτή την έκδοση'}).click(); + await expect(workspace.getByRole('button',{name:'Αφαίρεση βάρδιας'})).toHaveCount(6); + expect(await page.evaluate(()=>JSON.stringify(window.__historyV3)===window.__originalHistory)).toBe(true); + await page.evaluate(()=>{ + const store=window.__gasStationSchedulerStore; + store.setState({employees:store.getState().employees.map(e=>e.id==='a'?{...e,isActive:false}:e)}); + }); + await expect(workspace.getByRole('region',{name:'Προειδοποιήσεις',exact:true})).toContainText('ανενεργό'); + const historicalAssignments=workspace.locator('select option[value="a"]:checked:disabled'); + expect(await historicalAssignments.count()).toBeGreaterThan(0); + await workspace.getByRole('button',{name:'+ Προσθήκη εργαζομένου',exact:true}).click(); + const pending=workspace.getByRole('group',{name:'Νέα ανάθεση',exact:true}); + await expect(pending.getByRole('combobox',{name:'Εργαζόμενος',exact:true}).locator('option[value="a"]')).toHaveCount(0); + await pending.getByRole('button',{name:'Ακύρωση',exact:true}).click(); + await expect(workspace.getByRole('button',{name:'Αφαίρεση βάρδιας'})).toHaveCount(6); + expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); + await workspace.screenshot({path:info.outputPath(`v3-history-${width}.png`)}); + await workspace.getByRole('button',{name:'+ Προσθήκη εργαζομένου',exact:true}).click(); + await workspace.getByRole('button',{name:'Δημιουργία',exact:true}).click(); + await expect(workspace.getByRole('group',{name:'Νέα ανάθεση',exact:true})).toHaveCount(0); + expect(pageErrors).toEqual([]); }); From f3991b257998fe8307690dc53d26b4d9bcdef513 Mon Sep 17 00:00:00 2001 From: commanderxgr Date: Wed, 9 Sep 2026 15:42:03 +0300 Subject: [PATCH 4/4] fix(scheduler-v3): enforce draft shift data integrity --- docs/SCHEDULER_V3_CONTINUATION_REPORT.md | 12 +++++++++ firestore.rules | 12 +++++++-- scripts/test-scheduler-v3-emulator.mjs | 33 ++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/docs/SCHEDULER_V3_CONTINUATION_REPORT.md b/docs/SCHEDULER_V3_CONTINUATION_REPORT.md index b3cdba9..3d5401f 100644 --- a/docs/SCHEDULER_V3_CONTINUATION_REPORT.md +++ b/docs/SCHEDULER_V3_CONTINUATION_REPORT.md @@ -71,3 +71,15 @@ Security review: no dependencies, lockfiles or GitHub Actions changed; credentia Limits retained: no local standalone TypeScript compiler in PATH or root dependencies (none installed); existing build chunk warnings; no production browser persistence verification. A current profile referring to a template absent from the historical config fails restoration explicitly instead of silently changing the profile or historical data. Rules now enforce calendar-valid ISO dates, including Gregorian century/leap-year handling. Deployment status: none. Rollback: keep the global flag OFF and revert the corrective commit if required, without deleting publications/PDFs. Next step: human review of this Draft PR; production activation remains separately approved. Independent corrective review identified two additional gaps: pending assignment state surviving draft replacement and calendar-invalid ISO-shaped dates passing Rules. Both were reproduced with failing browser/emulator assertions and fixed. The final browser suite passed 5/5 with uncaught-page-error checks; the final emulator passed impossible-date denials and valid 2000/2024/2400 leap-day controls. A local cross-check of the exact Rules regexes against calendar round-trips passed 334,800 dates (years 1600–2499). Security hardening, integrity and tenant-authorization checks were rerun after the final Rules change. + +## Final V3 draft-shift data-integrity pass — 9 September 2026 + +Starting head: `1050b38fbee280a5e4be130c35a5765df3609b64`, same branch/base and Draft PR #57. Scope: `firestore.rules`, `scripts/test-scheduler-v3-emulator.mjs` and this report only. + +`validDraftShiftV3()` now requires all 14 supported fields and rejects unknown keys. Dates use calendar-valid `v3Date`; V3-only `v3Time` permits `00:00` through `23:59`. IDs/references are bounded safe strings (1–100 characters), nullable template references are checked, employee names are non-empty strings bounded to 200 characters, override/cross-midnight fields are booleans, durations remain numeric in `(0,24]`, and source/schema/type remain constrained. Legacy `isTime`, `isIsoDate`, `validShift`, V2 code and authorization expressions were not modified. + +Tests: new emulator assertions failed before the Rules fix and passed afterward. The full suite includes 182 negative create/update writes covering invalid dates/times, wrong field types, unsafe/empty/oversized strings, each missing required field and extra keys. Three positive read-back controls cover leap days in 2024, 2000 and 2400 with `00:00`/`23:59`, nullable/non-null template references, AUTO/MANUAL records and the 200-character name boundary. Existing repository roundtrip, version allocation, immutable history/PDF and authorization tests also pass. + +Every requested regression command passed: `npm run test:scheduler-contract-v3` (304+17+8), `npm run test:scheduler-contract-v2` (2,118), `npm run qa:scheduler`, `npm run qa:scheduler-engine`, `npm run qa:repositories`, `npm run qa:public-readonly`, `npm run qa:tenant-authorization`, `npm run qa:auth-broker`, `npm run qa:export-security`, `npm run security:hardening`, `npm run security:integrity`, `npm run build`, plus the complete demo-only V3 emulator suite using the command above. Browser tests were not rerun for this Rules-only runtime delta. Existing build chunk-size and post-success emulator teardown warnings remain. + +Security/rollback: only V3 structural validation was tightened; tenant/OWNER access is unchanged. No dependency, lockfile, GitHub Actions, credential, production data, deployment or migration change. No production readiness claim: Rules deployment still requires separate approval. Existing malformed V3 rows would need correction before a future update under these Rules; no production inventory or repair was attempted. Duration/time arithmetic and business warnings remain the existing application concerns. Rollback is to revert this scoped commit if needed while keeping V3 activation OFF; no publication/PDF deletion. Next action: human review of the same Draft PR, not merge/deploy. diff --git a/firestore.rules b/firestore.rules index ecd312a..6631006 100644 --- a/firestore.rules +++ b/firestore.rules @@ -150,10 +150,18 @@ service cloud.firestore { d.employees is list && d.absences is list && d.options is map && d.shiftDocumentIds is list && d.shiftDocumentIds.size() <= 450 && (!('sourcePublicationId' in d) || safeV3Id(d.sourcePublicationId)); } + function v3Time(value) { + return value is string && value.matches('^([01][0-9]|2[0-3]):[0-5][0-9]$'); + } function validDraftShiftV3() { let d = request.resource.data; - return d.keys().hasOnly(['id','date','employeeId','employeeName','shiftTemplateId','startTime','endTime','durationHours','crossMidnight','source','isManualOverride','schedulerSchemaVersion','draftId','type']) && - d.schedulerSchemaVersion == 3 && d.draftId is string && d.draftId.matches('^[a-zA-Z0-9_-]{1,100}$') && d.employeeId is string && isIsoDate(d.date) && isTime(d.startTime) && isTime(d.endTime) && + let fields = ['id','date','employeeId','employeeName','shiftTemplateId','startTime','endTime','durationHours','crossMidnight','source','isManualOverride','schedulerSchemaVersion','draftId','type']; + return d.keys().hasAll(fields) && d.keys().hasOnly(fields) && + d.schedulerSchemaVersion == 3 && safeV3Id(d.id) && safeV3Id(d.draftId) && safeV3Id(d.employeeId) && + d.employeeName is string && d.employeeName.size() > 0 && d.employeeName.size() <= 200 && + (d.shiftTemplateId == null || safeV3Id(d.shiftTemplateId)) && + v3Date(d.date) && v3Time(d.startTime) && v3Time(d.endTime) && + d.crossMidnight is bool && d.isManualOverride is bool && d.durationHours is number && d.durationHours > 0 && d.durationHours <= 24 && d.source in ['AUTO','MANUAL'] && d.type == 'work'; } diff --git a/scripts/test-scheduler-v3-emulator.mjs b/scripts/test-scheduler-v3-emulator.mjs index bbfcdc9..ffd25db 100644 --- a/scripts/test-scheduler-v3-emulator.mjs +++ b/scripts/test-scheduler-v3-emulator.mjs @@ -46,6 +46,39 @@ async function main() { const draftRef=doc(db,'tenants/tenant-a/scheduleDrafts',draft.id); for(const patch of [{periodType:'YEAR'},{periodStart:'bad'},{periodStart:'2026-02-31',periodEnd:'2026-03-01'},{periodStart:'2100-02-29',periodEnd:'2100-03-01'},{periodEnd:'2026-09-31'},{periodEnd:[]},{periodEnd:'2026-09-01'},{revision:-1},{revision:1.5},{revision:'1'},{'config.tenantId':'tenant-b'},{id:'other'},{unexpected:'field'}])await denied(()=>updateDoc(draftRef,patch),'malformed draft'); console.log('CHECK concurrent reservations'); + console.log('CHECK V3 draft shift data integrity'); + const validShift={id:'integrity-shift',date:'2026-09-07',employeeId:'a',employeeName:'Μαρία',shiftTemplateId:null,startTime:'08:00',endTime:'16:00',durationHours:8,crossMidnight:false,source:'AUTO',isManualOverride:false,schedulerSchemaVersion:3,draftId:draft.id,type:'work'}; + const shiftRef=doc(db,'tenants/tenant-a/shifts/integrity-probe'); + const invalidShifts=[]; + const invalidFields={ + date:['2026-02-31','2025-02-29','2026-13-01',{},[],1], + startTime:['24:00','25:00','99:99','12:60','-1:00','abc',{},[],1], + endTime:['12:60','24:00','99:99',{},[],1], + id:['','../bad','x'.repeat(101),{},[],1], + employeeId:['','../bad','x'.repeat(101),{},[],1], + employeeName:['','x'.repeat(201),{},[],1], + shiftTemplateId:['','../bad','x'.repeat(101),{},[],1], + draftId:['','../bad','x'.repeat(101),{},[],1], + crossMidnight:['false',{},[],1],isManualOverride:[1,'false',{},[]], + durationHours:[0,-1,25,'8',{},[]],source:['INVALID',{},[],1], + schedulerSchemaVersion:[2,'3',{},[]],type:['leave',{},[],1],unexpected:['field'], + }; + for(const [field,values] of Object.entries(invalidFields))values.forEach((value,n)=>invalidShifts.push({label:`${field}/${n}`,data:{...validShift,[field]:value}})); + for(const field of Object.keys(validShift)){const data={...validShift};delete data[field];invalidShifts.push({label:`missing/${field}`,data});} + const failures=[]; + for(const [n,candidate] of invalidShifts.entries()){ + await setDoc(shiftRef,validShift); + for(const [mode,target] of [['create',doc(db,`tenants/tenant-a/shifts/integrity-invalid-${n}`)],['update',shiftRef]]){ + try{await denied(()=>setDoc(target,candidate.data),`${mode}/${candidate.label}`);}catch(error){failures.push(`${mode}/${candidate.label}`);} + } + } + for(const date of ['2024-02-29','2000-02-29','2400-02-29']){ + const control={...validShift,date,startTime:'00:00',endTime:'23:59',durationHours:23+59/60,employeeName:'Ω'.repeat(200),shiftTemplateId:'day',source:'MANUAL',isManualOverride:true}; + await setDoc(shiftRef,control);assert.deepEqual((await getDoc(shiftRef)).data(),control); + } + assert.deepEqual(failures,[],'V3 malformed shifts must return permission-denied'); + await deleteDoc(shiftRef); + console.log(`V3 shift integrity PASS: ${invalidShifts.length*2} negative writes; 3 leap-day/time-boundary controls.`); const key='WEEK_2026-09-07_2026-09-13';const versions=await Promise.all([repository.reserve('tenant-a',key,'pub-a'),repository.reserve('tenant-a',key,'pub-b')]); assert.deepEqual([...versions].sort(),[1,2]); const snapshots=versions.map((version,n)=>buildPublicationV3(draft,{tenantId:'tenant-a',uid:user.uid,id:n?'pub-b':'pub-a',version,timestamp:'2026-09-08T00:00:00Z'}));