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>
240 lines
9.4 KiB
Python
240 lines
9.4 KiB
Python
#!/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()
|