977 lines
38 KiB
PHP
977 lines
38 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
const DB_DIR = __DIR__ . '/data';
|
|
const DB_FILE = DB_DIR . '/go.sqlite';
|
|
|
|
if (PHP_SAPI === 'cli') {
|
|
try {
|
|
run_cli($argv ?? []);
|
|
} catch (Throwable $e) {
|
|
fwrite(STDERR, $e->getMessage() . PHP_EOL);
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
try {
|
|
$db = db();
|
|
migrate($db);
|
|
|
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
|
$action = $_GET['action'] ?? null;
|
|
|
|
if ($method === 'GET') {
|
|
if ($action === null || $action === 'state') {
|
|
respond([
|
|
'settings' => get_settings($db),
|
|
'entries' => get_entries($db),
|
|
'corrections' => get_corrections($db),
|
|
'feriefridage_overrides' => get_feriefridage_overrides($db),
|
|
'flex_overrides' => get_flex_overrides($db),
|
|
]);
|
|
}
|
|
fail('Unknown GET action', 404);
|
|
}
|
|
|
|
if ($method === 'POST') {
|
|
$payload = json_decode(file_get_contents('php://input') ?: '{}', true);
|
|
if (!is_array($payload)) {
|
|
fail('Invalid JSON payload', 400);
|
|
}
|
|
$action = $payload['action'] ?? $action;
|
|
|
|
switch ($action) {
|
|
case 'saveSettings':
|
|
save_settings($db, $payload['settings'] ?? []);
|
|
respond(['ok' => true, 'settings' => get_settings($db)]);
|
|
break;
|
|
|
|
case 'publishHa':
|
|
publish_ha_state($payload['state'] ?? []);
|
|
respond(['ok' => true]);
|
|
break;
|
|
|
|
case 'saveCorrection':
|
|
save_correction($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), $payload['value'] ?? null);
|
|
respond(['ok' => true, 'corrections' => get_corrections($db)]);
|
|
break;
|
|
|
|
case 'saveFeriefridageOverride':
|
|
save_feriefridage_override($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), $payload['value'] ?? null);
|
|
respond(['ok' => true, 'feriefridage_overrides' => get_feriefridage_overrides($db)]);
|
|
break;
|
|
|
|
case 'saveFlexOverride':
|
|
save_flex_override($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), $payload['value'] ?? null);
|
|
respond(['ok' => true, 'flex_overrides' => get_flex_overrides($db)]);
|
|
break;
|
|
|
|
case 'createEntry':
|
|
$id = save_entry($db, $payload['entry'] ?? []);
|
|
respond(['ok' => true, 'id' => $id, 'entries' => get_entries($db)]);
|
|
break;
|
|
|
|
case 'updateEntry':
|
|
$id = (int)($payload['id'] ?? 0);
|
|
if ($id <= 0) {
|
|
fail('Missing entry id', 400);
|
|
}
|
|
save_entry($db, $payload['entry'] ?? [], $id);
|
|
respond(['ok' => true, 'entries' => get_entries($db)]);
|
|
break;
|
|
|
|
case 'deleteEntry':
|
|
$id = (int)($payload['id'] ?? 0);
|
|
if ($id <= 0) {
|
|
fail('Missing entry id', 400);
|
|
}
|
|
$stmt = $db->prepare('DELETE FROM entries WHERE id = :id');
|
|
$stmt->execute([':id' => $id]);
|
|
respond(['ok' => true, 'entries' => get_entries($db)]);
|
|
break;
|
|
|
|
default:
|
|
fail('Unknown POST action', 404);
|
|
}
|
|
}
|
|
|
|
fail('Unsupported method', 405);
|
|
} catch (Throwable $e) {
|
|
fail($e->getMessage(), 500);
|
|
}
|
|
|
|
function app_config(): array
|
|
{
|
|
$defaults = [
|
|
'name' => 'GO Vacation Planner',
|
|
'topic_prefix' => 'go',
|
|
'mqtt_host' => '192.168.0.203',
|
|
'mqtt_port' => 1883,
|
|
];
|
|
$file = __DIR__ . '/config.php';
|
|
if (is_file($file)) {
|
|
$config = require $file;
|
|
if (is_array($config)) return array_replace($defaults, $config);
|
|
}
|
|
return $defaults;
|
|
}
|
|
|
|
function mqtt_pub(array $config, string $topic, string $payload, bool $retain = true): void
|
|
{
|
|
$host = $config['mqtt_host'];
|
|
$port = (int)($config['mqtt_port'] ?? 1883);
|
|
$cmd = 'mosquitto_pub -h ' . escapeshellarg($host)
|
|
. ' -p ' . escapeshellarg((string)$port)
|
|
. ' -t ' . escapeshellarg($topic)
|
|
. ' -m ' . escapeshellarg($payload)
|
|
. ($retain ? ' -r' : '');
|
|
@shell_exec($cmd . ' 2>/dev/null');
|
|
}
|
|
|
|
function publish_ha_discovery(array $config): void
|
|
{
|
|
$p = trim($config['topic_prefix'], '/');
|
|
$dev = ['identifiers' => ['go_vacation_planner'], 'name' => $config['name']];
|
|
$sensors = [
|
|
'feriefridage' => ['name' => 'Feriefridage', 'unit' => 'd', 'topic' => "$p/feriefridage"],
|
|
'vacation_days' => ['name' => 'Vacation days', 'unit' => 'd', 'topic' => "$p/vacation_days"],
|
|
'flex_hours' => ['name' => 'FLEX hours', 'unit' => 'h', 'topic' => "$p/flex_hours"],
|
|
'total_days' => ['name' => 'Total available days', 'unit' => 'd', 'topic' => "$p/total_days"],
|
|
'upcoming_vacations' => ['name' => 'Upcoming vacations', 'unit' => '', 'topic' => "$p/upcoming_vacations", 'attributes_topic' => "$p/upcoming_vacations/attributes"],
|
|
];
|
|
|
|
foreach ($sensors as $id => $sensor) {
|
|
$payload = [
|
|
'name' => $sensor['name'],
|
|
'state_topic' => $sensor['topic'],
|
|
'unique_id' => "go_$id",
|
|
'device' => $dev,
|
|
];
|
|
if ($sensor['unit'] !== '') $payload['unit_of_measurement'] = $sensor['unit'];
|
|
if (isset($sensor['attributes_topic'])) $payload['json_attributes_topic'] = $sensor['attributes_topic'];
|
|
mqtt_pub($config, "homeassistant/sensor/go_$id/config", json_encode($payload, JSON_UNESCAPED_SLASHES));
|
|
}
|
|
}
|
|
|
|
function publish_ha_state(array $state): void
|
|
{
|
|
$config = app_config();
|
|
$p = trim($config['topic_prefix'], '/');
|
|
publish_ha_discovery($config);
|
|
|
|
$numeric = ['feriefridage', 'vacation_days', 'flex_hours', 'total_days'];
|
|
foreach ($numeric as $key) {
|
|
$value = isset($state[$key]) && is_numeric($state[$key]) ? round((float)$state[$key], 2) : 0;
|
|
mqtt_pub($config, "$p/$key", (string)$value);
|
|
}
|
|
|
|
$upcoming = is_array($state['upcoming_vacations'] ?? null) ? $state['upcoming_vacations'] : [];
|
|
$first = $upcoming[0] ?? null;
|
|
$summary = is_array($first)
|
|
? (($first['title'] ?? 'Vacation') . ' ' . ($first['start'] ?? '') . ' → ' . ($first['end'] ?? ''))
|
|
: 'none';
|
|
mqtt_pub($config, "$p/upcoming_vacations", $summary);
|
|
mqtt_pub($config, "$p/upcoming_vacations/attributes", json_encode(['vacations' => $upcoming], JSON_UNESCAPED_SLASHES));
|
|
mqtt_pub($config, "$p/state", json_encode($state, JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
function db(): PDO
|
|
{
|
|
if (!is_dir(DB_DIR) && !mkdir(DB_DIR, 0775, true) && !is_dir(DB_DIR)) {
|
|
throw new RuntimeException('Could not create data directory');
|
|
}
|
|
|
|
$db = new PDO('sqlite:' . DB_FILE);
|
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
|
$db->exec('PRAGMA foreign_keys = ON');
|
|
return $db;
|
|
}
|
|
|
|
function migrate(PDO $db): void
|
|
{
|
|
$db->exec("CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
)");
|
|
|
|
$db->exec("CREATE TABLE IF NOT EXISTS entries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
start_date TEXT NOT NULL,
|
|
end_date TEXT NOT NULL,
|
|
source TEXT NOT NULL DEFAULT 'auto',
|
|
status TEXT NOT NULL DEFAULT 'planned',
|
|
allow_unpaid INTEGER NOT NULL DEFAULT 0,
|
|
non_working_days REAL NOT NULL DEFAULT 0,
|
|
note TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)");
|
|
|
|
$columns = array_column($db->query('PRAGMA table_info(entries)')->fetchAll(), 'name');
|
|
if (!in_array('non_working_days', $columns, true)) {
|
|
$db->exec('ALTER TABLE entries ADD COLUMN non_working_days REAL NOT NULL DEFAULT 0');
|
|
}
|
|
|
|
$db->exec("CREATE TABLE IF NOT EXISTS monthly_corrections (
|
|
year INTEGER NOT NULL,
|
|
month INTEGER NOT NULL,
|
|
vacation_days REAL NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (year, month)
|
|
)");
|
|
|
|
$db->exec("CREATE TABLE IF NOT EXISTS monthly_feriefridage_overrides (
|
|
year INTEGER NOT NULL,
|
|
month INTEGER NOT NULL,
|
|
feriefridage_days REAL NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (year, month)
|
|
)");
|
|
|
|
$db->exec("CREATE TABLE IF NOT EXISTS monthly_flex_overrides (
|
|
year INTEGER NOT NULL,
|
|
month INTEGER NOT NULL,
|
|
flex_hours REAL NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (year, month)
|
|
)");
|
|
|
|
$defaults = [
|
|
'planning_start_year' => '2026',
|
|
'planning_start_month' => '1',
|
|
'planning_years' => '3',
|
|
'planning_months' => '36',
|
|
'previous_months' => '5',
|
|
'future_months' => '30',
|
|
'vacation_days_per_month' => '2.08',
|
|
'feriefridage_per_year' => '5',
|
|
'feriefridage_expiry_month' => '8',
|
|
'normal_day_hours' => '7.5',
|
|
'friday_hours' => '7',
|
|
'opening_vacation_days' => '-1.97',
|
|
'opening_feriefridage_days' => '0',
|
|
'opening_flex_hours' => '0',
|
|
'current_flex_hours' => '46.5',
|
|
];
|
|
|
|
$stmt = $db->prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)');
|
|
foreach ($defaults as $key => $value) {
|
|
$stmt->execute([':key' => $key, ':value' => $value]);
|
|
}
|
|
}
|
|
|
|
function get_settings(PDO $db): array
|
|
{
|
|
$rows = $db->query('SELECT key, value FROM settings')->fetchAll();
|
|
$settings = [];
|
|
foreach ($rows as $row) {
|
|
$settings[$row['key']] = numeric_value($row['value']);
|
|
}
|
|
return $settings;
|
|
}
|
|
|
|
function save_settings(PDO $db, array $settings): void
|
|
{
|
|
$allowed = [
|
|
'planning_start_year',
|
|
'planning_start_month',
|
|
'planning_years',
|
|
'planning_months',
|
|
'previous_months',
|
|
'future_months',
|
|
'vacation_days_per_month',
|
|
'feriefridage_per_year',
|
|
'feriefridage_expiry_month',
|
|
'normal_day_hours',
|
|
'friday_hours',
|
|
'opening_vacation_days',
|
|
'opening_feriefridage_days',
|
|
'opening_flex_hours',
|
|
'current_flex_hours',
|
|
];
|
|
|
|
$stmt = $db->prepare('INSERT INTO settings (key, value) VALUES (:key, :value)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
|
|
|
foreach ($allowed as $key) {
|
|
if (array_key_exists($key, $settings)) {
|
|
$value = $settings[$key];
|
|
if ($key === 'feriefridage_per_year' && floor((float)$value) !== (float)$value) {
|
|
fail('Feriefridage/year must be a whole number', 400);
|
|
}
|
|
$stmt->execute([':key' => $key, ':value' => (string)$value]);
|
|
}
|
|
}
|
|
}
|
|
|
|
function get_corrections(PDO $db): array
|
|
{
|
|
$rows = $db->query('SELECT year, month, vacation_days FROM monthly_corrections ORDER BY year, month')->fetchAll();
|
|
$corrections = [];
|
|
foreach ($rows as $row) {
|
|
$corrections[$row['year'] . '-' . str_pad((string)$row['month'], 2, '0', STR_PAD_LEFT)] = (float)$row['vacation_days'];
|
|
}
|
|
return $corrections;
|
|
}
|
|
|
|
function save_correction(PDO $db, int $year, int $month, mixed $value): void
|
|
{
|
|
if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) {
|
|
fail('Invalid correction year/month', 400);
|
|
}
|
|
|
|
if ($value === null || $value === '') {
|
|
$stmt = $db->prepare('DELETE FROM monthly_corrections WHERE year = :year AND month = :month');
|
|
$stmt->execute([':year' => $year, ':month' => $month]);
|
|
return;
|
|
}
|
|
|
|
$stmt = $db->prepare('INSERT INTO monthly_corrections (year, month, vacation_days)
|
|
VALUES (:year, :month, :value)
|
|
ON CONFLICT(year, month) DO UPDATE SET vacation_days = excluded.vacation_days');
|
|
$stmt->execute([':year' => $year, ':month' => $month, ':value' => (float)$value]);
|
|
}
|
|
|
|
function get_feriefridage_overrides(PDO $db): array
|
|
{
|
|
$rows = $db->query('SELECT year, month, feriefridage_days FROM monthly_feriefridage_overrides ORDER BY year, month')->fetchAll();
|
|
$overrides = [];
|
|
foreach ($rows as $row) {
|
|
$overrides[$row['year'] . '-' . str_pad((string)$row['month'], 2, '0', STR_PAD_LEFT)] = (float)$row['feriefridage_days'];
|
|
}
|
|
return $overrides;
|
|
}
|
|
|
|
function save_feriefridage_override(PDO $db, int $year, int $month, mixed $value): void
|
|
{
|
|
if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) {
|
|
fail('Invalid feriefridage override year/month', 400);
|
|
}
|
|
|
|
if ($value === null || $value === '') {
|
|
$stmt = $db->prepare('DELETE FROM monthly_feriefridage_overrides WHERE year = :year AND month = :month');
|
|
$stmt->execute([':year' => $year, ':month' => $month]);
|
|
return;
|
|
}
|
|
|
|
if (floor((float)$value) !== (float)$value) {
|
|
fail('Feriefridage must be whole days', 400);
|
|
}
|
|
|
|
$stmt = $db->prepare('INSERT INTO monthly_feriefridage_overrides (year, month, feriefridage_days)
|
|
VALUES (:year, :month, :value)
|
|
ON CONFLICT(year, month) DO UPDATE SET feriefridage_days = excluded.feriefridage_days');
|
|
$stmt->execute([':year' => $year, ':month' => $month, ':value' => (float)$value]);
|
|
}
|
|
|
|
function get_flex_overrides(PDO $db): array
|
|
{
|
|
$rows = $db->query('SELECT year, month, flex_hours FROM monthly_flex_overrides ORDER BY year, month')->fetchAll();
|
|
$overrides = [];
|
|
foreach ($rows as $row) {
|
|
$overrides[$row['year'] . '-' . str_pad((string)$row['month'], 2, '0', STR_PAD_LEFT)] = (float)$row['flex_hours'];
|
|
}
|
|
return $overrides;
|
|
}
|
|
|
|
function save_flex_override(PDO $db, int $year, int $month, mixed $value): void
|
|
{
|
|
if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) {
|
|
fail('Invalid FLEX override year/month', 400);
|
|
}
|
|
|
|
if ($value === null || $value === '') {
|
|
$stmt = $db->prepare('DELETE FROM monthly_flex_overrides WHERE year = :year AND month = :month');
|
|
$stmt->execute([':year' => $year, ':month' => $month]);
|
|
return;
|
|
}
|
|
|
|
$stmt = $db->prepare('INSERT INTO monthly_flex_overrides (year, month, flex_hours)
|
|
VALUES (:year, :month, :value)
|
|
ON CONFLICT(year, month) DO UPDATE SET flex_hours = excluded.flex_hours');
|
|
$stmt->execute([':year' => $year, ':month' => $month, ':value' => (float)$value]);
|
|
}
|
|
|
|
function get_entries(PDO $db): array
|
|
{
|
|
return $db->query('SELECT id, title, start_date, end_date, source, status, allow_unpaid, non_working_days, note
|
|
FROM entries ORDER BY start_date, end_date, id')->fetchAll();
|
|
}
|
|
|
|
function save_entry(PDO $db, array $entry, ?int $id = null): int
|
|
{
|
|
$title = trim((string)($entry['title'] ?? ''));
|
|
$start = trim((string)($entry['start_date'] ?? ''));
|
|
$end = trim((string)($entry['end_date'] ?? ''));
|
|
$source = (string)($entry['source'] ?? 'auto');
|
|
$status = (string)($entry['status'] ?? 'planned');
|
|
$allowUnpaid = !empty($entry['allow_unpaid']) ? 1 : 0;
|
|
$nonWorkingDays = (float)($entry['non_working_days'] ?? 0);
|
|
$note = trim((string)($entry['note'] ?? ''));
|
|
|
|
if ($title === '') {
|
|
fail('Title is required', 400);
|
|
}
|
|
if (!valid_date($start) || !valid_date($end)) {
|
|
fail('Start/end date must be YYYY-MM-DD', 400);
|
|
}
|
|
if ($start > $end) {
|
|
fail('Start date must be before end date', 400);
|
|
}
|
|
if (!in_array($source, ['auto', 'vacation', 'feriefridag', 'flex', 'unpaid'], true)) {
|
|
fail('Invalid source', 400);
|
|
}
|
|
if ($nonWorkingDays < 0 || fmod($nonWorkingDays * 2, 1.0) !== 0.0) {
|
|
fail('Additional non-working days must be in steps of 0.5', 400);
|
|
}
|
|
if ($status === 'confirmed') {
|
|
$status = 'approved';
|
|
}
|
|
if (!in_array($status, ['planned', 'approved'], true)) {
|
|
fail('Invalid status', 400);
|
|
}
|
|
|
|
$params = [
|
|
':title' => $title,
|
|
':start_date' => $start,
|
|
':end_date' => $end,
|
|
':source' => $source,
|
|
':status' => $status,
|
|
':allow_unpaid' => $allowUnpaid,
|
|
':non_working_days' => $nonWorkingDays,
|
|
':note' => $note,
|
|
];
|
|
|
|
if ($id === null) {
|
|
$stmt = $db->prepare('INSERT INTO entries
|
|
(title, start_date, end_date, source, status, allow_unpaid, non_working_days, note)
|
|
VALUES (:title, :start_date, :end_date, :source, :status, :allow_unpaid, :non_working_days, :note)');
|
|
$stmt->execute($params);
|
|
return (int)$db->lastInsertId();
|
|
}
|
|
|
|
$params[':id'] = $id;
|
|
$stmt = $db->prepare('UPDATE entries SET
|
|
title = :title,
|
|
start_date = :start_date,
|
|
end_date = :end_date,
|
|
source = :source,
|
|
status = :status,
|
|
allow_unpaid = :allow_unpaid,
|
|
non_working_days = :non_working_days,
|
|
note = :note,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = :id');
|
|
$stmt->execute($params);
|
|
return $id;
|
|
}
|
|
|
|
function build_ha_state(PDO $db): array
|
|
{
|
|
$settings = get_settings($db);
|
|
$entries = array_map('normalize_entry_for_plan', get_entries($db));
|
|
$corrections = get_corrections($db);
|
|
$feriefridageOverrides = get_feriefridage_overrides($db);
|
|
$flexOverrides = get_flex_overrides($db);
|
|
|
|
$current = calculate_current_snapshot($settings, $entries, $corrections, $feriefridageOverrides, $flexOverrides);
|
|
$entrySummaries = calculate_entry_summaries($settings, $entries, $corrections, $feriefridageOverrides, $flexOverrides);
|
|
|
|
return [
|
|
'feriefridage' => (float)$current['feriefridage'],
|
|
'vacation_days' => (float)$current['vacation'],
|
|
'flex_hours' => (float)$current['flex'],
|
|
'total_days' => (float)$current['totalDays'],
|
|
'upcoming_vacations' => upcoming_vacations_for_ha($entries, $entrySummaries),
|
|
'updated_at' => gmdate('Y-m-d\\TH:i:s\\Z'),
|
|
];
|
|
}
|
|
|
|
function run_cli(array $argv): never
|
|
{
|
|
$command = $argv[1] ?? '';
|
|
if (!in_array($command, ['ha-state', 'publish-ha', 'publishHaNow', 'publishHa'], true)) {
|
|
fwrite(STDERR, "Usage: php api.php ha-state|publish-ha\n");
|
|
exit(1);
|
|
}
|
|
|
|
$db = db();
|
|
migrate($db);
|
|
$state = build_ha_state($db);
|
|
|
|
if ($command !== 'ha-state') {
|
|
publish_ha_state($state);
|
|
}
|
|
|
|
echo json_encode(['ok' => true, 'published' => $command !== 'ha-state', 'state' => $state], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
|
exit(0);
|
|
}
|
|
|
|
function normalize_entry_for_plan(array $entry): array
|
|
{
|
|
$entry['id'] = (int)$entry['id'];
|
|
$entry['allow_unpaid'] = (int)$entry['allow_unpaid'];
|
|
$entry['non_working_days'] = (float)($entry['non_working_days'] ?? 0);
|
|
if (($entry['status'] ?? '') === 'confirmed') {
|
|
$entry['status'] = 'approved';
|
|
}
|
|
return $entry;
|
|
}
|
|
|
|
function calculate_entry_summaries(array $settings, array $entries, array $corrections = [], array $feriefridageOverrides = [], array $flexOverrides = []): array
|
|
{
|
|
$today = date_from_parts((int)date('Y'), (int)date('n'), (int)date('j'));
|
|
$previousMonths = (int)($settings['previous_months'] ?? 5);
|
|
$futureMonths = (int)($settings['future_months'] ?? 30);
|
|
$startCursor = date_from_parts((int)$today->format('Y'), (int)$today->format('n') + 1 - $previousMonths, 1);
|
|
$startYear = (int)$startCursor->format('Y');
|
|
$startMonth = (int)$startCursor->format('n');
|
|
$monthCount = $previousMonths + $futureMonths + 1;
|
|
$startDate = date_from_parts($startYear, $startMonth, 1);
|
|
$endDate = date_from_parts($startYear, $startMonth + $monthCount, 0);
|
|
|
|
$entrySummaries = [];
|
|
$entriesById = [];
|
|
$allWorkdays = [];
|
|
foreach ($entries as $entry) {
|
|
$entriesById[(int)$entry['id']] = $entry;
|
|
$grossDates = workdays_between($entry['start_date'], $entry['end_date']);
|
|
$units = workday_units_for_entry($entry);
|
|
$entrySummaries[(int)$entry['id']] = [
|
|
'workdays' => sum_fractions($units),
|
|
'grossWorkdays' => count($grossDates),
|
|
'nonWorkingDays' => (float)($entry['non_working_days'] ?? 0),
|
|
'usedVacation' => 0.0,
|
|
'usedFeriefridage' => 0.0,
|
|
'usedFlex' => 0.0,
|
|
'usedFlexDays' => 0.0,
|
|
'usedUnpaid' => 0.0,
|
|
];
|
|
foreach ($units as $unit) {
|
|
if ($unit['date'] < $startDate || $unit['date'] > $endDate) continue;
|
|
$allWorkdays[] = ['date' => $unit['date'], 'fraction' => $unit['fraction'], 'entryId' => (int)$entry['id']];
|
|
}
|
|
}
|
|
|
|
usort($allWorkdays, fn($a, $b) => ($a['date'] <=> $b['date']) ?: ($a['entryId'] <=> $b['entryId']));
|
|
|
|
$workdaysByMonth = [];
|
|
foreach ($allWorkdays as $item) {
|
|
$key = month_key((int)$item['date']->format('Y'), (int)$item['date']->format('n'));
|
|
$workdaysByMonth[$key][] = $item;
|
|
}
|
|
|
|
$vacationBalance = (float)($settings['opening_vacation_days'] ?? 0);
|
|
$feriefridageBalance = (float)($settings['opening_feriefridage_days'] ?? 0);
|
|
$flexBalance = (float)($settings['opening_flex_hours'] ?? 0);
|
|
$currentFlexHours = (float)($settings['current_flex_hours'] ?? $flexBalance);
|
|
|
|
for ($i = 0; $i < $monthCount; $i++) {
|
|
$cursor = date_from_parts($startYear, $startMonth + $i, 1);
|
|
$year = (int)$cursor->format('Y');
|
|
$month = (int)$cursor->format('n');
|
|
$current = ['usedVacation' => 0.0, 'usedFeriefridage' => 0.0, 'usedFlex' => 0.0, 'usedUnpaid' => 0.0, 'entries' => []];
|
|
|
|
if ($month === 1) {
|
|
$feriefridageBalance += (float)($settings['feriefridage_per_year'] ?? 0);
|
|
}
|
|
|
|
if ($month === (int)($settings['feriefridage_expiry_month'] ?? 8) + 1 && $feriefridageBalance > 0.001) {
|
|
$feriefridageBalance = 0.0;
|
|
}
|
|
|
|
$key = month_key($year, $month);
|
|
$monthItems = $workdaysByMonth[$key] ?? [];
|
|
$isThisMonth = $year === (int)$today->format('Y') && $month === (int)$today->format('n');
|
|
|
|
$applyWorkday = function (array $item) use (&$vacationBalance, &$feriefridageBalance, &$flexBalance, &$current, &$entrySummaries, $entriesById, $settings): void {
|
|
$entry = $entriesById[$item['entryId']];
|
|
$allocation = allocate_day($entry, $item['date'], [
|
|
'vacationBalance' => $vacationBalance,
|
|
'feriefridageBalance' => $feriefridageBalance,
|
|
'flexBalance' => $flexBalance,
|
|
], $settings, (float)$item['fraction']);
|
|
|
|
$vacationBalance = $allocation['balances']['vacationBalance'];
|
|
$feriefridageBalance = $allocation['balances']['feriefridageBalance'];
|
|
$flexBalance = $allocation['balances']['flexBalance'];
|
|
|
|
$current['usedVacation'] += $allocation['usedVacation'];
|
|
$current['usedFeriefridage'] += $allocation['usedFeriefridage'];
|
|
$current['usedFlex'] += $allocation['usedFlex'];
|
|
$current['usedUnpaid'] += $allocation['usedUnpaid'];
|
|
|
|
$id = (int)$entry['id'];
|
|
$entrySummaries[$id]['usedVacation'] += $allocation['usedVacation'];
|
|
$entrySummaries[$id]['usedFeriefridage'] += $allocation['usedFeriefridage'];
|
|
$entrySummaries[$id]['usedFlex'] += $allocation['usedFlex'];
|
|
$entrySummaries[$id]['usedFlexDays'] += $allocation['usedFlexDays'];
|
|
$entrySummaries[$id]['usedUnpaid'] += $allocation['usedUnpaid'];
|
|
|
|
$monthEntryIndex = null;
|
|
foreach ($current['entries'] as $index => $row) {
|
|
if ($row['id'] === $id) {
|
|
$monthEntryIndex = $index;
|
|
break;
|
|
}
|
|
}
|
|
if ($monthEntryIndex === null) {
|
|
$current['entries'][] = ['id' => $id, 'usedVacation' => 0.0, 'usedUnpaid' => 0.0, 'allowUnpaid' => (bool)$entry['allow_unpaid']];
|
|
$monthEntryIndex = count($current['entries']) - 1;
|
|
}
|
|
$current['entries'][$monthEntryIndex]['usedVacation'] += $allocation['usedVacation'];
|
|
$current['entries'][$monthEntryIndex]['usedUnpaid'] += $allocation['usedUnpaid'];
|
|
};
|
|
|
|
if ($isThisMonth) {
|
|
foreach ($monthItems as $item) {
|
|
if ($item['date'] <= $today) $applyWorkday($item);
|
|
}
|
|
$flexBalance = $currentFlexHours;
|
|
foreach ($monthItems as $item) {
|
|
if ($item['date'] > $today) $applyWorkday($item);
|
|
}
|
|
} else {
|
|
foreach ($monthItems as $item) $applyWorkday($item);
|
|
}
|
|
|
|
$vacationBalance += (float)($settings['vacation_days_per_month'] ?? 0);
|
|
|
|
if ($vacationBalance < -0.001) {
|
|
$vacationBalance = convert_negative_vacation_to_unpaid($current, $entrySummaries, $vacationBalance);
|
|
}
|
|
|
|
if (has_correction($corrections, $year, $month)) $vacationBalance = correction_for($corrections, $year, $month);
|
|
if (has_correction($feriefridageOverrides, $year, $month)) $feriefridageBalance = correction_for($feriefridageOverrides, $year, $month);
|
|
if (has_correction($flexOverrides, $year, $month)) $flexBalance = correction_for($flexOverrides, $year, $month);
|
|
}
|
|
|
|
return $entrySummaries;
|
|
}
|
|
|
|
function convert_negative_vacation_to_unpaid(array &$month, array &$entrySummaries, float $vacationBalance): float
|
|
{
|
|
$remaining = -$vacationBalance;
|
|
for ($i = count($month['entries']) - 1; $i >= 0 && $remaining > 0.001; $i--) {
|
|
if (!$month['entries'][$i]['allowUnpaid'] || $month['entries'][$i]['usedVacation'] <= 0) continue;
|
|
$convert = min($month['entries'][$i]['usedVacation'], $remaining);
|
|
$month['entries'][$i]['usedVacation'] -= $convert;
|
|
$month['entries'][$i]['usedUnpaid'] += $convert;
|
|
$month['usedVacation'] -= $convert;
|
|
$month['usedUnpaid'] += $convert;
|
|
$id = (int)$month['entries'][$i]['id'];
|
|
if (isset($entrySummaries[$id])) {
|
|
$entrySummaries[$id]['usedVacation'] -= $convert;
|
|
$entrySummaries[$id]['usedUnpaid'] += $convert;
|
|
}
|
|
$vacationBalance += $convert;
|
|
$remaining -= $convert;
|
|
}
|
|
return $vacationBalance;
|
|
}
|
|
|
|
function calculate_current_snapshot(array $settings, array $entries, array $corrections = [], array $feriefridageOverrides = [], array $flexOverrides = []): array
|
|
{
|
|
$today = date_from_parts((int)date('Y'), (int)date('n'), (int)date('j'));
|
|
$previousMonths = (int)($settings['previous_months'] ?? 5);
|
|
$startCursor = date_from_parts((int)$today->format('Y'), (int)$today->format('n') + 1 - $previousMonths, 1);
|
|
$startYear = (int)$startCursor->format('Y');
|
|
$startMonth = (int)$startCursor->format('n');
|
|
$startDate = date_from_parts($startYear, $startMonth, 1);
|
|
$avgDayHours = average_day_hours($settings);
|
|
|
|
$vacationBalance = (float)($settings['opening_vacation_days'] ?? 0);
|
|
$feriefridageBalance = (float)($settings['opening_feriefridage_days'] ?? 0);
|
|
$flexBalance = (float)($settings['opening_flex_hours'] ?? 0);
|
|
$currentFlexHours = (float)($settings['current_flex_hours'] ?? $flexBalance);
|
|
|
|
if ($today < $startDate) {
|
|
$flexBalance = $currentFlexHours;
|
|
return current_snapshot_result($vacationBalance, $feriefridageBalance, $flexBalance, $avgDayHours);
|
|
}
|
|
|
|
$workdays = [];
|
|
foreach ($entries as $entry) {
|
|
foreach (workday_units_for_entry($entry) as $unit) {
|
|
if ($unit['date'] >= $startDate && $unit['date'] <= $today) {
|
|
$workdays[] = ['date' => $unit['date'], 'fraction' => $unit['fraction'], 'entry' => $entry];
|
|
}
|
|
}
|
|
}
|
|
usort($workdays, fn($a, $b) => ($a['date'] <=> $b['date']) ?: ((int)$a['entry']['id'] <=> (int)$b['entry']['id']));
|
|
|
|
$workdayIndex = 0;
|
|
$monthCount = (((int)$today->format('Y') - $startYear) * 12) + ((int)$today->format('n') - $startMonth) + 1;
|
|
for ($i = 0; $i < $monthCount; $i++) {
|
|
$cursor = date_from_parts($startYear, $startMonth + $i, 1);
|
|
$year = (int)$cursor->format('Y');
|
|
$month = (int)$cursor->format('n');
|
|
|
|
if ($month === 1) $feriefridageBalance += (float)($settings['feriefridage_per_year'] ?? 0);
|
|
|
|
if ($month === (int)($settings['feriefridage_expiry_month'] ?? 8) + 1 && $feriefridageBalance > 0.001) {
|
|
$feriefridageBalance = 0.0;
|
|
}
|
|
|
|
$monthStart = date_from_parts($year, $month, 1);
|
|
$monthEnd = date_from_parts($year, $month + 1, 0);
|
|
$limit = $today < $monthEnd ? $today : $monthEnd;
|
|
while ($workdayIndex < count($workdays) && $workdays[$workdayIndex]['date'] >= $monthStart && $workdays[$workdayIndex]['date'] <= $limit) {
|
|
$item = $workdays[$workdayIndex++];
|
|
$allocation = allocate_day($item['entry'], $item['date'], [
|
|
'vacationBalance' => $vacationBalance,
|
|
'feriefridageBalance' => $feriefridageBalance,
|
|
'flexBalance' => $flexBalance,
|
|
], $settings, (float)$item['fraction']);
|
|
$vacationBalance = $allocation['balances']['vacationBalance'];
|
|
$feriefridageBalance = $allocation['balances']['feriefridageBalance'];
|
|
$flexBalance = $allocation['balances']['flexBalance'];
|
|
}
|
|
|
|
if ($today >= $monthEnd) {
|
|
$vacationBalance += (float)($settings['vacation_days_per_month'] ?? 0);
|
|
}
|
|
|
|
if ($year === (int)$today->format('Y') && $month === (int)$today->format('n')) {
|
|
$flexBalance = $currentFlexHours;
|
|
}
|
|
|
|
if (has_correction($corrections, $year, $month)) $vacationBalance = correction_for($corrections, $year, $month);
|
|
if (has_correction($feriefridageOverrides, $year, $month)) $feriefridageBalance = correction_for($feriefridageOverrides, $year, $month);
|
|
if (has_correction($flexOverrides, $year, $month)) $flexBalance = correction_for($flexOverrides, $year, $month);
|
|
}
|
|
|
|
if (!has_correction($flexOverrides, (int)$today->format('Y'), (int)$today->format('n'))) {
|
|
$flexBalance = $currentFlexHours;
|
|
}
|
|
|
|
return current_snapshot_result($vacationBalance, $feriefridageBalance, $flexBalance, $avgDayHours);
|
|
}
|
|
|
|
function current_snapshot_result(float $vacationBalance, float $feriefridageBalance, float $flexBalance, float $avgDayHours): array
|
|
{
|
|
return [
|
|
'vacation' => $vacationBalance,
|
|
'feriefridage' => $feriefridageBalance,
|
|
'flex' => $flexBalance,
|
|
'totalDays' => $vacationBalance + $feriefridageBalance + ($flexBalance / $avgDayHours),
|
|
];
|
|
}
|
|
|
|
function allocate_day(array $entry, DateTimeImmutable $date, array $balances, array $settings, float $fraction = 1.0): array
|
|
{
|
|
$source = (string)($entry['source'] ?? 'auto');
|
|
$allowUnpaid = (bool)($entry['allow_unpaid'] ?? false);
|
|
$baseDayHours = hours_for_date($date, $settings);
|
|
$dayHours = $baseDayHours * $fraction;
|
|
$vacationBalance = (float)$balances['vacationBalance'];
|
|
$feriefridageBalance = (float)$balances['feriefridageBalance'];
|
|
$flexBalance = (float)$balances['flexBalance'];
|
|
$result = ['usedVacation' => 0.0, 'usedFeriefridage' => 0.0, 'usedFlex' => 0.0, 'usedFlexDays' => 0.0, 'usedUnpaid' => 0.0];
|
|
|
|
$useVacation = function () use (&$result, &$vacationBalance, $allowUnpaid, $fraction): void {
|
|
if ($allowUnpaid && $vacationBalance < $fraction) {
|
|
$use = max(0.0, $vacationBalance);
|
|
$result['usedVacation'] += $use;
|
|
$result['usedUnpaid'] += $fraction - $use;
|
|
$vacationBalance = 0.0;
|
|
} else {
|
|
$result['usedVacation'] += $fraction;
|
|
$vacationBalance -= $fraction;
|
|
}
|
|
};
|
|
$useFeriefridag = function () use (&$result, &$feriefridageBalance, $allowUnpaid, $fraction): void {
|
|
if ($allowUnpaid && $feriefridageBalance < $fraction) {
|
|
$use = max(0.0, $feriefridageBalance);
|
|
$result['usedFeriefridage'] += $use;
|
|
$result['usedUnpaid'] += $fraction - $use;
|
|
$feriefridageBalance = 0.0;
|
|
} else {
|
|
$result['usedFeriefridage'] += $fraction;
|
|
$feriefridageBalance -= $fraction;
|
|
}
|
|
};
|
|
$useFlex = function () use (&$result, &$flexBalance, $allowUnpaid, $dayHours, $baseDayHours, $fraction): void {
|
|
if ($allowUnpaid && $flexBalance < $dayHours) {
|
|
$useHours = max(0.0, $flexBalance);
|
|
$result['usedFlex'] += $useHours;
|
|
$result['usedFlexDays'] += $useHours / $baseDayHours;
|
|
$result['usedUnpaid'] += $fraction - ($useHours / $baseDayHours);
|
|
$flexBalance = 0.0;
|
|
} else {
|
|
$result['usedFlex'] += $dayHours;
|
|
$result['usedFlexDays'] += $fraction;
|
|
$flexBalance -= $dayHours;
|
|
}
|
|
};
|
|
|
|
if ($source === 'unpaid') {
|
|
$result['usedUnpaid'] = $fraction;
|
|
} elseif ($source === 'vacation') {
|
|
$useVacation();
|
|
} elseif ($source === 'feriefridag') {
|
|
$useFeriefridag();
|
|
} elseif ($source === 'flex') {
|
|
$useFlex();
|
|
} else {
|
|
$remaining = $fraction;
|
|
if ($remaining > 0 && $feriefridageBalance > 0 && is_before_feriefridage_expiry($date, $settings)) {
|
|
$use = min($feriefridageBalance, $remaining);
|
|
$result['usedFeriefridage'] += $use;
|
|
$feriefridageBalance -= $use;
|
|
$remaining -= $use;
|
|
}
|
|
if ($remaining > 0 && $vacationBalance > 0) {
|
|
$use = min($vacationBalance, $remaining);
|
|
$result['usedVacation'] += $use;
|
|
$vacationBalance -= $use;
|
|
$remaining -= $use;
|
|
}
|
|
if ($remaining > 0 && $flexBalance > 0) {
|
|
$use = min($flexBalance / $baseDayHours, $remaining);
|
|
$result['usedFlex'] += $use * $baseDayHours;
|
|
$result['usedFlexDays'] += $use;
|
|
$flexBalance -= $use * $baseDayHours;
|
|
$remaining -= $use;
|
|
}
|
|
if ($remaining > 0) {
|
|
$result['usedVacation'] += $remaining;
|
|
$vacationBalance -= $remaining;
|
|
}
|
|
}
|
|
|
|
$result['balances'] = ['vacationBalance' => $vacationBalance, 'feriefridageBalance' => $feriefridageBalance, 'flexBalance' => $flexBalance];
|
|
return $result;
|
|
}
|
|
|
|
function upcoming_vacations_for_ha(array $entries, array $entrySummaries): array
|
|
{
|
|
$today = date('Y-m-d');
|
|
$upcoming = array_values(array_filter($entries, fn($entry) => (string)$entry['end_date'] >= $today));
|
|
usort($upcoming, fn($a, $b) => strcmp((string)$a['start_date'], (string)$b['start_date']) ?: ((int)$a['id'] <=> (int)$b['id']));
|
|
|
|
return array_map(function (array $entry) use ($entrySummaries): array {
|
|
$summary = $entrySummaries[(int)$entry['id']] ?? ['workdays' => 0, 'usedVacation' => 0, 'usedFeriefridage' => 0, 'usedFlex' => 0, 'usedFlexDays' => 0];
|
|
return [
|
|
'title' => $entry['title'],
|
|
'start' => $entry['start_date'],
|
|
'end' => $entry['end_date'],
|
|
'start_display' => format_date_for_ha((string)$entry['start_date']),
|
|
'end_display' => format_date_for_ha((string)$entry['end_date']),
|
|
'status' => $entry['status'],
|
|
'source' => $entry['source'],
|
|
'workdays' => (float)($summary['workdays'] ?? 0),
|
|
'vacation_days' => (float)($summary['usedVacation'] ?? 0),
|
|
'feriefridage' => (float)($summary['usedFeriefridage'] ?? 0),
|
|
'flex_hours' => (float)($summary['usedFlex'] ?? 0),
|
|
'flex_days' => (float)($summary['usedFlexDays'] ?? 0),
|
|
];
|
|
}, $upcoming);
|
|
}
|
|
|
|
function workday_units_for_entry(array $entry): array
|
|
{
|
|
$units = array_map(fn($date) => ['date' => $date, 'fraction' => 1.0], workdays_between((string)$entry['start_date'], (string)$entry['end_date']));
|
|
$remaining = max(0.0, (float)($entry['non_working_days'] ?? 0));
|
|
for ($i = count($units) - 1; $i >= 0 && $remaining > 0; $i--) {
|
|
$reduction = min($units[$i]['fraction'], $remaining);
|
|
$units[$i]['fraction'] -= $reduction;
|
|
$remaining -= $reduction;
|
|
}
|
|
return array_values(array_filter($units, fn($unit) => $unit['fraction'] > 0.0001));
|
|
}
|
|
|
|
function workdays_between(string $start, string $end): array
|
|
{
|
|
$days = [];
|
|
$current = parse_date($start);
|
|
$stop = parse_date($end);
|
|
while ($current <= $stop) {
|
|
$dow = (int)$current->format('N');
|
|
if ($dow >= 1 && $dow <= 5) $days[] = $current;
|
|
$current = $current->modify('+1 day');
|
|
}
|
|
return $days;
|
|
}
|
|
|
|
function sum_fractions(array $units): float
|
|
{
|
|
return array_reduce($units, fn($sum, $unit) => $sum + (float)$unit['fraction'], 0.0);
|
|
}
|
|
|
|
function hours_for_date(DateTimeImmutable $date, array $settings): float
|
|
{
|
|
return (int)$date->format('N') === 5 ? (float)($settings['friday_hours'] ?? 7) : (float)($settings['normal_day_hours'] ?? 7.5);
|
|
}
|
|
|
|
function average_day_hours(array $settings): float
|
|
{
|
|
return ((((float)($settings['normal_day_hours'] ?? 7.5)) * 4) + (float)($settings['friday_hours'] ?? 7)) / 5;
|
|
}
|
|
|
|
function is_before_feriefridage_expiry(DateTimeImmutable $date, array $settings): bool
|
|
{
|
|
return (int)$date->format('n') <= (int)($settings['feriefridage_expiry_month'] ?? 8);
|
|
}
|
|
|
|
function date_from_parts(int $year, int $month, int $day): DateTimeImmutable
|
|
{
|
|
return (new DateTimeImmutable('now'))->setDate($year, $month, $day)->setTime(12, 0, 0);
|
|
}
|
|
|
|
function parse_date(string $value): DateTimeImmutable
|
|
{
|
|
[$year, $month, $day] = array_map('intval', explode('-', $value));
|
|
return date_from_parts($year, $month, $day);
|
|
}
|
|
|
|
function month_key(int $year, int $month): string
|
|
{
|
|
return $year . '-' . str_pad((string)$month, 2, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
function correction_for(array $corrections, int $year, int $month): float
|
|
{
|
|
return (float)($corrections[month_key($year, $month)] ?? 0);
|
|
}
|
|
|
|
function has_correction(array $corrections, int $year, int $month): bool
|
|
{
|
|
return array_key_exists(month_key($year, $month), $corrections);
|
|
}
|
|
|
|
function format_date_for_ha(string $value): string
|
|
{
|
|
$parts = explode('-', $value);
|
|
if (count($parts) !== 3) return $value;
|
|
return $parts[2] . '/' . $parts[1] . '/' . $parts[0];
|
|
}
|
|
|
|
function valid_date(string $date): bool
|
|
{
|
|
$d = DateTime::createFromFormat('Y-m-d', $date);
|
|
return $d && $d->format('Y-m-d') === $date;
|
|
}
|
|
|
|
function numeric_value(string $value): int|float|string
|
|
{
|
|
if (is_numeric($value)) {
|
|
return str_contains($value, '.') ? (float)$value : (int)$value;
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
function respond(array $data): never
|
|
{
|
|
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
function fail(string $message, int $status): never
|
|
{
|
|
http_response_code($status);
|
|
echo json_encode(['error' => $message], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|