"""Monthly Excel export matching the TKB sample format. Reads JSON {schedule, month, year} from stdin, writes xlsx bytes to stdout. Layout (preserved from samples/Pohotovost_TKB+IT_směny_2026_03_Březen.xlsx): - 34 day-pair columns (cols 2-69, even=day half, odd=night half) - Each export starts 3 days before the 1st of the requested month - Persons matched by name to fixed template rows - TKB closures → merged R3:R5 cell across day-pair - SAZLT + D8 closures → row 1, comma-separated - METRO closures → ignored """ import sys import json from copy import copy from io import BytesIO from datetime import date, timedelta from openpyxl import load_workbook from openpyxl.styles import PatternFill, Alignment, Border, Side, Font from pathlib import Path CELL_COLORS = { '4': 'F1F8E9', '6': 'E8F5E9', '8': 'CCFFCC', '12': 'A3D977', '16': '66BB6A', '24': '2E7D32', 'A': 'FFE082', 'B': 'B0BEC5', 'D': 'FFFF00', 'D/2': 'FFF9C4', 'N': 'FF4444', 'U': '92D050', 'O': 'FFFF00', 'x': 'E0E0E0', # O = otcovská: same look as D } # Czech weekday names indexed by Python's weekday() (Monday=0) CZECH_DAYS = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota', 'Neděle'] # Person name → row in template (must match template content) TKB_ROWS = { 'Pauzer Libor': 7, 'Vörös Pavel': 8, 'Janouš Petr': 9, 'Franek Lukáš': 10, 'Svoboda Daniel': 11, 'Dvořák Václav': 12, 'Kopáček Ondřej': 13, 'Vondrák Pavel': 14, 'Čeleda Olda': 15, 'Hanzlík Marek': 16, 'Kohl David': 17, 'Dittrich Vladimír': 18, 'Toman Milan': 19, 'Zábranský Petr': 20, 'Žemlička Miroslav': 21, 'Teslík Hynek': 22, } IT_ROWS = { 'Vörös Pavel': 24, 'Janouš Petr': 25, 'Glaser Ondřej': 26, 'Robert Štefan': 27, 'Franek Lukáš': 28, } # Template layout below the person rows IT_HEADER_ROW = 23 # 'Pohotovost IT' label row IT_FIRST_ROW, IT_LAST_ROW = 24, 28 BELOW_IT_ROW = 29 # first row under the IT section # Day-pair start columns: 2, 4, 6, ..., 68 (34 day-pairs) DAY_COLS = list(range(2, 70, 2)) # 162 group — excluded from FPD calculation (matches FpdCheck.tsx) FPD_EXCLUDED = {'Pauzer Libor', 'Vörös Pavel', 'Janouš Petr', 'Franek Lukáš', 'Svoboda Daniel'} def _easter_monday(year): 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 month = (h + l - 7 * m + 114) // 31 day = ((h + l - 7 * m + 114) % 31) + 1 sunday = date(year, month, day) return sunday + timedelta(days=1) def czech_holidays(year): em = _easter_monday(year) gf = em - timedelta(days=3) fixed = [(1, 1), (5, 1), (5, 8), (7, 5), (7, 6), (9, 28), (10, 28), (11, 17), (12, 24), (12, 25), (12, 26)] out = {(em.month, em.day), (gf.month, gf.day)} for m, d in fixed: out.add((m, d)) return out def hours_for(value): """Match webapp's getHoursForValue (FpdCheck.tsx): A and B count as 12 hours; numeric values as themselves; everything else (U, D, N, x, O) counts as 0.""" if value in ('A', 'B'): return 12 try: n = float(value) return n if n > 0 else 0 except (ValueError, TypeError): return 0 def main(): payload = json.loads(sys.stdin.read()) schedule = payload['schedule'] month = int(payload['month']) year = int(payload['year']) # Show 3 days before the 1st of the month, then 34 days total start = date(year, month, 1) - timedelta(days=3) days = [start + timedelta(days=i) for i in range(34)] template_path = Path(__file__).parent / 'public' / 'full_template.xlsx' wb = load_workbook(str(template_path)) ws = wb['Plán Služeb'] # ---- A1: replace template "01.03.2026 v.1" with the export's month ---- ws.cell(1, 1).value = f'01.{month:02d}.{year} v.1' # ---- Step 0: Strip ALL leftover cell comments from template ---- for row in ws.iter_rows(): for cell in row: if cell.comment: cell.comment = None # Snapshot original PMS column (BU) fills+borders BEFORE any modifications. # These represent the row's "default" formatting for PMS, used later for FPD column. pms_original_styles = {} for r in range(7, BELOW_IT_ROW): cell = ws.cell(r, 71) pms_original_styles[r] = (copy(cell.fill), copy(cell.border)) # ---- Step 1: Unmerge ALL cells in R3:R5 across day-pair cols (clean slate) ---- for m in list(ws.merged_cells.ranges): if m.min_row >= 3 and m.max_row <= 5 and 2 <= m.min_col < 70: ws.unmerge_cells(str(m)) # Clear values + fills in R3:R5 cols 2-69. Restore consistent borders: # - no border between day half and night half within a day-pair # - month boundary marked with thicker (medium) left border on the day half # We compute month-boundary based on the requested month dates set later. # For now, just clear and apply baseline thin borders; medium override happens after dates are computed. for r in (3, 4, 5): for c in range(2, 70): cell = ws.cell(r, c) cell.value = None cell.fill = PatternFill(fill_type=None) # ---- Step 2: Clear values in person rows. Replace RGB fills (sample shifts) with the # row's "default" fill (taken from the col-1 label cell) so empty cells inherit the # row's background instead of going white. ---- person_rows = list(TKB_ROWS.values()) + list(IT_ROWS.values()) for r in person_rows: # Default row fill: from col-1 label cell (typically theme-colored) try: row_default_fill = copy(ws.cell(r, 1).fill) except Exception: row_default_fill = None for c in range(2, 72): # Cols 71 (PMS) and 72 (FPD) keep their template fills — handled separately if c >= 71: continue cell = ws.cell(r, c) if cell.__class__.__name__ == 'MergedCell': continue cell.value = None # Col 70 (BR) is the separator column before "Práce mimo směnu" — keep white if c == 70: cell.fill = PatternFill(fill_type=None) continue try: fg = cell.fill.fgColor # If cell has an RGB fill (sample shift highlight), replace with row default if fg and getattr(fg, 'type', None) == 'rgb': if row_default_fill is not None: cell.fill = copy(row_default_fill) else: cell.fill = PatternFill(fill_type=None) except Exception: pass # ---- Step 3: Clear row 1 closure labels in day-pair cols ---- for m in list(ws.merged_cells.ranges): if m.min_row == 1 and m.max_row == 1 and 2 <= m.min_col < 70: ws.unmerge_cells(str(m)) for c in range(2, 70): cell = ws.cell(1, c) cell.value = None # ---- Step 4: Update headers (R2 weekday, R3 year, R4 month, R5 day) + borders + weekend fills ---- thin = Side(style='thin', color='000000') medium = Side(style='medium', color='000000') WEEKEND_FILL = PatternFill(start_color='FCD5B4', end_color='FCD5B4', fill_type='solid') # peach NO_FILL = PatternFill(fill_type=None) for i, day in enumerate(days): col = DAY_COLS[i] ws.cell(2, col).value = CZECH_DAYS[day.weekday()] ws.cell(3, col).value = day.year ws.cell(4, col).value = day.month ws.cell(5, col).value = day.day is_month_start = (day.day == 1) is_weekend = day.weekday() >= 5 # Sat=5, Sun=6 for r in (3, 4, 5): # Day half (left): left border (medium at month boundary, else thin), no right border ws.cell(r, col).border = Border( left=medium if is_month_start else thin, right=None, top=thin if r == 3 else None, bottom=thin if r == 5 else None, ) # Night half (right): no left border, thin right border ws.cell(r, col + 1).border = Border( left=None, right=thin, top=thin if r == 3 else None, bottom=thin if r == 5 else None, ) # Apply weekend fill (or clear for weekday) ws.cell(r, col).fill = WEEKEND_FILL if is_weekend else NO_FILL ws.cell(r, col + 1).fill = WEEKEND_FILL if is_weekend else NO_FILL # R2 (weekday name) — same peach as R3-R5 for weekend ws.cell(2, col).fill = WEEKEND_FILL if is_weekend else NO_FILL ws.cell(2, col + 1).fill = WEEKEND_FILL if is_weekend else NO_FILL # Build dayIndex lookup by date by_date = {} for d in schedule['dayIndex']: by_date[(d['year'], d['month'], d['day'])] = d # ---- Step 5: SAZLT + D8 closures into row 1 (comma-separated) ---- info_rows = schedule.get('infoRows') or {} sazlt_by_idx = {x['dayIdx']: x['text'] for x in (info_rows.get('sazlt') or [])} d8_by_idx = {x['dayIdx']: x['text'] for x in (info_rows.get('d8') or [])} for i, day in enumerate(days): di = by_date.get((day.year, day.month, day.day)) if not di: continue parts = [] s = sazlt_by_idx.get(di['idx']) d = d8_by_idx.get(di['idx']) if s: parts.append(s) if d: parts.append(d) if parts: col = DAY_COLS[i] ws.cell(1, col).value = ', '.join(parts) # Merge across the day-pair so the label spans both day + night cols ws.merge_cells(start_row=1, start_column=col, end_row=1, end_column=col + 1) # ---- Step 6: TKB closures: merged R3:R5 of NIGHT half (odd col), rotated text + bg color. Date in DAY half stays. ---- tunnel_closures = {tc['dayIdx']: tc['text'] for tc in (schedule.get('tunnelClosures') or [])} tunnel_colors = {tc['dayIdx']: tc.get('color') for tc in (schedule.get('tunnelClosures') or [])} DEFAULT_TKB_BG = 'FFC000' # orange — matches predominant style in sample for i, day in enumerate(days): di = by_date.get((day.year, day.month, day.day)) if not di: continue tkb = tunnel_closures.get(di['idx']) if tkb: night_col = DAY_COLS[i] + 1 ws.merge_cells(start_row=3, start_column=night_col, end_row=5, end_column=night_col) cell = ws.cell(3, night_col) cell.value = tkb cell.alignment = Alignment(text_rotation=90, horizontal='center', vertical='center') color = tunnel_colors.get(di['idx']) or DEFAULT_TKB_BG color = color.lstrip('#').upper() cell.fill = PatternFill(start_color=color, end_color=color, fill_type='solid') cell.border = Border(left=None, right=thin, top=thin, bottom=thin) # ---- Step 7: Fill person values ---- # Translation rules per group: # ALL groups: A → 12 (day half), B → 12 (night half) # IT only: 16 → day=4, night=12 ; 24 → day=12, night=12 def translate(value, is_it): """Convert app shift code → (day_value, night_value) for excel cells. Returns (day, night) where each is the value to write or None.""" if value is None or value == '': return (None, None) if value == 'A': return ('12', None) if value == 'B': return (None, '12') if value == 'U': return (None, 'U') # uzávěra is always at night if is_it: if value == '16': return ('4', '12') if value == '24': return ('12', '12') return (value, None) # default: in day half def write_cell(row, col, val, text_color=None, custom_bg=None): if val in (None, '') and not custom_bg: return cell = ws.cell(row, col) is_numeric = False if val not in (None, ''): try: n = int(val) cell.value = n is_numeric = True except (ValueError, TypeError): cell.value = val # Background priority: custom color from app > shift color (only for letter codes). bg = None if custom_bg: bg = custom_bg.lstrip('#').upper() elif val not in (None, '') and not is_numeric: bg = CELL_COLORS.get(str(val)) if bg: cell.fill = PatternFill(start_color=bg, end_color=bg, fill_type='solid') # Apply text color (red for VN persons, blue for NN persons) if text_color: base = cell.font cell.font = Font( name=base.name, size=base.size, bold=base.bold, italic=base.italic, color=text_color, ) for person in schedule['people']: is_tkb = person.get('group') == 'TKB' is_it = not is_tkb rows_map = TKB_ROWS if is_tkb else IT_ROWS row = rows_map.get(person['name']) if not row: continue # Text color based on qualification: VN→red, NN→blue note = (person.get('note') or '').strip() text_color = None if note == 'VN': text_color = 'FFD32F2F' elif note == 'NN': text_color = 'FF1565C0' for i, day in enumerate(days): di = by_date.get((day.year, day.month, day.day)) if not di: continue cd = person['data'].get(str(di['idx'])) if not cd: continue day_col = DAY_COLS[i] night_col = day_col + 1 # Apply translations to whole-cell value, half values are explicit day_v = cd.get('dayValue') night_v = cd.get('nightValue') if day_v is None and night_v is None and cd.get('v'): # Legacy whole-cell value — translate day_v, night_v = translate(cd.get('v'), is_it) else: # Explicit halves — translate each independently if needed if day_v in ('A',): day_v = '12' if night_v in ('B',): night_v = '12' if is_it: if day_v == '16': day_v, _ = ('4', None); night_v = night_v or '12' if night_v == '16': night_v, _ = ('12', None); day_v = day_v or '4' if day_v == '24': day_v, _ = ('12', None); night_v = night_v or '12' if night_v == '24': night_v = '12'; day_v = day_v or '12' # Custom background colors from webapp (per-half override > whole-cell) day_bg = cd.get('dayColor') or cd.get('color') night_bg = cd.get('nightColor') or cd.get('color') write_cell(row, day_col, day_v, text_color, custom_bg=day_bg) write_cell(row, night_col, night_v, text_color, custom_bg=night_bg) # PMS: data is stored at person.data[String(-month)] for the requested month pms = person['data'].get(str(-month)) if pms and pms.get('v'): write_cell(row, 71, pms['v'], text_color) # ---- Step 8: FPD column at C72 — header + per-person diff (workedHours - FPD) ---- holidays = czech_holidays(year) work_days = sum( 1 for d in schedule['dayIndex'] if d['year'] == year and d['month'] == month and not d.get('weekend') and (d['month'], d['day']) not in holidays ) month_fpd = work_days * 8 # Unmerge any leftover merges in col 72 for m in list(ws.merged_cells.ranges): if m.min_col <= 72 <= m.max_col and m.max_row >= 7: ws.unmerge_cells(str(m)) # Match col 72 width to col 71 (PMS) pms_col_dim = ws.column_dimensions.get('BS') if pms_col_dim and pms_col_dim.width: ws.column_dimensions['BT'].width = pms_col_dim.width # Strip formatting on the IT section of PMS/FPD cols and adjacent boundaries NO_BORDER = Border() for r in range(IT_FIRST_ROW, BELOW_IT_ROW): for c in (70, 71, 72, 73): cell = ws.cell(r, c) if c in (71, 72): cell.fill = PatternFill(fill_type=None) cell.border = NO_BORDER else: # Adjacent cols: only clear the side facing PMS/FPD b = cell.border if c == 70: cell.border = Border(left=b.left, right=Side(), top=b.top, bottom=b.bottom) elif c == 73: cell.border = Border(left=Side(), right=b.right, top=b.top, bottom=b.bottom) # Clear bleed-in from rows 22 (above IT) and 28 (below IT) on BS/BT for c in (71, 72): b = ws.cell(IT_HEADER_ROW, c).border ws.cell(IT_HEADER_ROW, c).border = Border(left=b.left, right=b.right, top=b.top, bottom=Side()) ws.cell(BELOW_IT_ROW, c).border = NO_BORDER # Header at C72 (BT) — copy style from C73 (PMS), set text "FPD", merge R2:R6 src_header = ws.cell(2, 71) dst_header = ws.cell(2, 72) dst_header.value = 'FPD' dst_header.alignment = copy(src_header.alignment) dst_header.font = copy(src_header.font) dst_header.fill = copy(src_header.fill) dst_header.border = copy(src_header.border) try: ws.merge_cells(start_row=2, start_column=72, end_row=6, end_column=72) except Exception: pass # Per-person FPD diff (calculate for ALL TKB persons, including 162 group) for person in schedule['people']: if person.get('group') != 'TKB': continue row = TKB_ROWS.get(person['name']) if not row: continue worked = 0.0 vacation = 0.0 # D = 8h, D/2 = 4h — reduces person's FPD target def collect(v, is_workday): nonlocal worked, vacation if not v: return if v == 'D': vacation += 8 elif v == 'D/2': vacation += 4 elif v in ('N', 'O'): # sick / paternity leave on a working day is credited as a normal # worked day, so it neither helps nor hurts the balance (as in FpdCheck.tsx) if is_workday: worked += 8 else: worked += hours_for(v) for d in schedule['dayIndex']: if d['year'] != year or d['month'] != month: continue cd = person['data'].get(str(d['idx'])) if not cd: continue is_workday = not d.get('weekend') and (d['month'], d['day']) not in holidays is_split = bool(cd.get('dayColor') or cd.get('nightColor') or cd.get('dayValue') or cd.get('nightValue')) if is_split: collect(cd.get('dayValue'), is_workday) collect(cd.get('nightValue'), is_workday) else: collect(cd.get('v'), is_workday) person_fpd = month_fpd - vacation diff = worked - person_fpd dst = ws.cell(row, 72) src_pms = ws.cell(row, 71) dst.value = int(diff) if diff == int(diff) else round(diff, 1) dst.alignment = Alignment(horizontal='center', vertical='center') # Center PMS values too src_pms.alignment = Alignment(horizontal='center', vertical='center') # Copy ORIGINAL PMS fill + border (snapshot before U/PMS values were written) orig_fill, orig_border = pms_original_styles.get(row, (None, None)) if orig_fill is not None: dst.fill = orig_fill if orig_border is not None: dst.border = orig_border # Force black text (no VN/NN red/blue in FPD column) base = dst.font dst.font = Font(name=base.name, size=base.size, bold=base.bold, italic=base.italic, color='FF000000') out = BytesIO() wb.save(out) sys.stdout.buffer.write(out.getvalue()) if __name__ == '__main__': main()