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>
492 lines
20 KiB
Python
492 lines
20 KiB
Python
"""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,
|
|
'Kópáč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()
|