Web FPD check: PMS hours keep their sign (getPmsHours) instead of being dropped when negative. Month Excel FPD column: PMS was not counted at all; it now adds the signed PMS hours like the web. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
507 lines
20 KiB
Python
507 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,
|
|
'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 pms_hours(value):
|
|
"""PMS (práce mimo směnu) hours — like hours_for, but negative values keep
|
|
their sign (hours taken off). Matches getPmsHours in FpdCheck.tsx."""
|
|
if value in ('A', 'B'):
|
|
return 12
|
|
try:
|
|
return float(value)
|
|
except (ValueError, TypeError):
|
|
return 0
|
|
|
|
|
|
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)
|
|
# PMS (práce mimo směnu) counts as worked hours, positive or negative (as in FpdCheck.tsx)
|
|
pms_v = (person['data'].get(str(-month)) or {}).get('v')
|
|
if pms_v:
|
|
worked += pms_hours(pms_v)
|
|
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()
|