Files
TKB_plan/web/export_full_excel.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

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()