Rename Kópáček → Kopáček in default data, both Excel templates and the export rosters; the server fix-up map now maps both earlier spellings to the correct one. 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'),
|
||
# Kopáček Ondřej — part-time student, not in CIS (no personal number); add here
|
||
# if he joins full time: ('Kopáček Ondřej', 'TKB', <osobní číslo>, 'Kopáč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()
|