Cronjob for daily MQTT publish

This commit is contained in:
marijo 2026-06-10 11:16:02 +02:00
parent 46f3020fec
commit be3652fbb1

490
api.php
View file

@ -5,6 +5,15 @@ 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 {
@ -451,6 +460,487 @@ function save_entry(PDO $db, array $entry, ?int $id = null): int
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);