Files
TKB_plan/web/export_harmonogram.py
Docker Config Backup 707e18c625 fix: correct name to Kópáček Ondřej; O (otcovská) looks like D, counts like N
The new worker's surname was misheard: Kropáček → Kópáček everywhere
(default data, both Excel templates, export rosters). The person id
tkb-kropacek is kept. The server now normalises the name in stored
files at startup and in every incoming save, so clients that still
hold the old name cannot write it back.

"O" (otcovská dovolená) is now rendered in the same yellow as "D" in
the app and the month Excel, labelled "Otcovská dovolená", and for the
FPD balance a working-day O is credited like N (a normal worked day)
both in the web FPD check and in the month Excel's FPD column, which
previously counted N and O as zero and disagreed with the web.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:28:48 +02:00

240 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
export_harmonogram.py
Reads JSON from stdin: {"schedule": {...}, "month": N, "year": N}
Writes an XLSX (to stdout) in the import format expected by the internal
attendance system. Layout matches samples/import_harmonogram.xlsx:
- Sheet "Harmonogram":
Row 1: dates as 'YYYY-MM-DD' starting at column C
Col A: person name (with "(VN)"/"(NN)" suffix where applicable)
Col B: personal ID number
Cells: comma-separated shift codes from the Definice sheet
- Sheet "Definice": copy of the canonical key/time table.
App → attendance code mapping:
A → R12 (12h day shift)
B → N12 (12h night shift)
8 → R (8h day shift)
12 → R12
D → D12 (vacation, paid as 12h day shift in CIS)
U → skipped (closure work; handled elsewhere)
N/O / x → skipped (absences; handled elsewhere)
IT 16 → "POH_B1,R,POH_B2" (weekday on-call)
IT 24 → "POH_B3" (weekend full-day on-call)
"""
import sys
import json
import io
from datetime import date as Date, time as Time, timedelta
from openpyxl import Workbook
from openpyxl.utils import get_column_letter
# ── Fixed roster — TKB shift workers only ──
# Output rows ALWAYS appear in this exact order, regardless of whether
# the person has any data this month. Format: (display_name, group, person_id,
# data_lookup_name) where data_lookup_name is how the person is named in the
# schedule data (may differ from display_name).
# Note: IT on-call people (Glaser, Janouš, Štefan, Vörös) are intentionally
# excluded — they're handled outside the CIS attendance import.
# Part-time people: half the monthly FPD (4h/day instead of 8h).
HALF_TIME_NAMES = {'Dittrich Vladimír'}
ROSTER = [
('Dvořák Václav (VN)', 'TKB', 1247, 'Dvořák Václav'),
# Kópáček Ondřej — part-time student, not in CIS (no personal number); add here
# if he joins full time: ('Kópáček Ondřej', 'TKB', <osobní číslo>, 'Kópáček Ondřej'),
('Vondrák Pavel (NN)', 'TKB', 454, 'Vondrák Pavel'),
('Čeleda Olda (NN)', 'TKB', 463, 'Čeleda Olda'),
('Hanzlík Marek (VN)', 'TKB', 1366, 'Hanzlík Marek'),
('Kohl David (VN)', 'TKB', 1007, 'Kohl David'),
('Dittrich Vladimír (VN)', 'TKB', 373, 'Dittrich Vladimír'),
('Toman Milan (VN)', 'TKB', 157, 'Toman Milan'),
('Žemlička Miroslav (NN)', 'TKB', 1827, 'Žemlička Miroslav'),
('Teslík Hynek (NN)', 'TKB', 738, 'Teslík Hynek'),
]
# ── Definice sheet (verbatim from sample) ──
# (key, key_k2, time_from, from_24plus, time_to, to_24plus, typ)
DEFINICE_ROWS = [
('R', 184730838379022, Time(7, 0), 0, Time(15, 30), 0, 0),
('R12', 184997126340609, Time(7, 30), 0, Time(20, 0), 0, 0),
('D12', 184997126340609, Time(7, 30), 0, Time(20, 0), 0, 0),
('N12', 184997126340610, Time(20, 0), 0, Time(7, 30), 1, 0),
('POH_B1', 184936996798976, Time(0, 0), 0, Time(7, 0), 0, 2),
('POH_B2', 184936996798976, Time(15, 30), 0, Time(0, 0), 1, 2),
('POH_B3', 184936996798976, Time(0, 0), 0, Time(0, 0), 1, 2),
]
def get_czech_holidays(year):
fixed = {
Date(year, 1, 1), Date(year, 5, 1), Date(year, 5, 8),
Date(year, 7, 5), Date(year, 7, 6), Date(year, 9, 28),
Date(year, 10, 28), Date(year, 11, 17),
Date(year, 12, 24), Date(year, 12, 25), Date(year, 12, 26),
}
a = year % 19; b = year // 100; c = year % 100
d = b // 4; e = b % 4
f = (b + 8) // 25; g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4; k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
mo = (h + l - 7 * m + 114) // 31
dy = ((h + l - 7 * m + 114) % 31) + 1
easter = Date(year, mo, dy)
fixed.add(easter - timedelta(days=2)) # Good Friday
fixed.add(easter + timedelta(days=1)) # Easter Monday
return fixed
def map_tkb_code(value):
"""Return attendance code(s) for a TKB person's cell value, or None to skip."""
v = (value or '').strip()
if not v:
return None
if v == 'A':
return 'R12'
if v == 'B':
return 'N12'
if v == '8':
return 'R'
if v == '12':
return 'R12'
if v == 'D':
return 'D12' # vacation → 12h day-shift code in CIS
# U (closure), N/O (absences), x (no-experience), other letters → skip.
if v in ('U', 'N', 'O', 'x'):
return None
# Unknown numeric or letter — skip silently to avoid breaking import.
return None
def map_it_code(value, is_weekend_or_holiday):
"""Return attendance code(s) for an IT person's cell value, or None to skip.
Only the explicit on-call indicators map; everything else (U, D, N, O, x,
A, B, …) is skipped — those are absences or shift markers handled outside
the attendance import."""
v = (value or '').strip()
if v == '24':
return 'POH_B3'
if v == '16':
return 'POH_B1,R,POH_B2'
return None
def compute_person_fpd(person, month_days, holidays, daily_hours):
"""Per-person FPD target — mirrors the web Kontrola FPD check:
workDays × daily_hours, minus D/D2 vacation that falls on a working day.
(N sick is credited as a worked day in the check, so it does NOT reduce
the target here.)"""
work_days = sum(1 for d in month_days
if not d.get('weekend')
and Date(d['year'], d['month'], d['day']) not in holidays)
vacation = 0.0
for d in month_days:
cell = person['data'].get(str(d['idx'])) or {}
is_work_day = (not d.get('weekend')
and Date(d['year'], d['month'], d['day']) not in holidays)
if not is_work_day:
continue
for v in (cell.get('dayValue'), cell.get('nightValue')) if (
cell.get('dayValue') or cell.get('nightValue')
or cell.get('dayColor') or cell.get('nightColor')) else (cell.get('v'),):
if v == 'D':
vacation += daily_hours
elif v == 'D/2':
vacation += daily_hours / 2
fpd = work_days * daily_hours - vacation
return int(fpd) if float(fpd).is_integer() else fpd
def build_workbook(schedule, month, year):
holidays = get_czech_holidays(year)
day_index = schedule['dayIndex']
people = schedule['people']
month_days = [d for d in day_index if d['month'] == month and d['year'] == year]
month_days.sort(key=lambda d: d['day'])
wb = Workbook()
ws = wb.active
ws.title = 'Harmonogram'
# ── Header row: dates in YYYY-MM-DD as strings (matches sample) ──
for col_offset, d in enumerate(month_days):
col = 3 + col_offset # C, D, E, …
ws.cell(row=1, column=col,
value=f"{d['year']:04d}-{d['month']:02d}-{d['day']:02d}")
# ── Trailing FPD column (right after the last day) ──
fpd_col = 3 + len(month_days)
ws.cell(row=1, column=fpd_col, value='FPD')
work_days = sum(1 for d in month_days
if not d.get('weekend')
and Date(d['year'], d['month'], d['day']) not in holidays)
month_fpd = work_days * 8 # flat monthly work-time fund, same for every row
# ── Build name → person lookup from schedule data ──
by_name = {p['name']: p for p in people}
def person_has_month_data(p):
if not p: return False
for d in month_days:
v = (p['data'].get(str(d['idx'])) or {}).get('v', '')
if v: return True
return False
# ── Write roster rows in fixed sample order ──
# Retired people are skipped for months where they have no data (past-month
# exports still include them because their cells are preserved).
row = 2
for display, group, pid, lookup_name in ROSTER:
person = by_name.get(lookup_name)
if person and person.get('retired') and not person_has_month_data(person):
continue # retired + no data this month → omit row entirely
ws.cell(row=row, column=1, value=display)
ws.cell(row=row, column=2, value=str(pid)) # MUST be string — attendance import does getString()
ws.cell(row=row, column=fpd_col, value=month_fpd)
if not person:
row += 1
continue # roster name not found in this schedule — leave row empty
for col_offset, d in enumerate(month_days):
col = 3 + col_offset
raw = (person['data'].get(str(d['idx'])) or {}).get('v', '')
if group == 'TKB':
code = map_tkb_code(raw)
else: # IT
is_we = d.get('weekend') or Date(d['year'], d['month'], d['day']) in holidays
code = map_it_code(raw, is_we)
if code:
ws.cell(row=row, column=col, value=code)
row += 1
# Set column A wider for readability
ws.column_dimensions['A'].width = 28
ws.column_dimensions['B'].width = 8
# ── Definice sheet ──
df = wb.create_sheet('Definice')
df.append(['Klíč', 'Klíč K2', 'Od', '24+', 'Do', '24+', 'Typ'])
for r in DEFINICE_ROWS:
df.append(list(r))
df.column_dimensions['A'].width = 10
df.column_dimensions['B'].width = 18
return wb
def main():
inp = json.loads(sys.stdin.buffer.read())
wb = build_workbook(inp['schedule'], inp['month'], inp['year'])
buf = io.BytesIO()
wb.save(buf)
sys.stdout.buffer.write(buf.getvalue())
if __name__ == '__main__':
main()