feat: dochazka Excel export + auto-reload + 162 filter

- Add dochazka Excel export using direct ZIP/XML manipulation of template
  (preserves styles.xml byte-for-byte to avoid Excel "repaired styles" warning)
- Calculate per-person stravné doplatek, transport (AUV), and indiv1
  (closure count + Janouš internet) per sichtovnice.py logic
- Filter exported people to TEMPLATE_NAMES (12 fixed template rows)
- Add server version polling + auto-reload on deploy
- Add FPD check modal for monthly hour validation
- Add "162" filter button to hide first 5 TKB people from view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Docker Config Backup
2026-04-13 14:39:04 +02:00
parent 3cd05ce0e2
commit db56403f7c
13 changed files with 1079 additions and 155 deletions

505
web/export_dochazka.py Normal file
View File

@@ -0,0 +1,505 @@
#!/usr/bin/env python3
"""
export_dochazka.py
Reads JSON from stdin: {"schedule": {...}, "month": N, "year": N}
Directly manipulates dochazka_template.xlsx XML — styles.xml is never touched,
so Excel never shows a "repaired styles" warning.
"""
import sys, json, io, zipfile, re
import xml.etree.ElementTree as ET
from datetime import date as Date, timedelta
TEMPLATE_PATH = __file__.replace('export_dochazka.py', 'dochazka_template.xlsx')
MONTH_NAMES = {
1:'Leden', 2:'Únor', 3:'Březen', 4:'Duben', 5:'Květen', 6:'Červen',
7:'Červenec', 8:'Srpen', 9:'Září', 10:'Říjen', 11:'Listopad', 12:'Prosinec',
}
OP_CODE = 'OP24146101755'
NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
ET.register_namespace('', NS)
# Financial constants (from sichtovnice.py)
KC_KM = 4 # Kč per km
STRAVNE_12H = 236 # meal allowance for a 12+h shift
STRAVNE_8H = 155 # meal allowance for an 8h workday
DAN = 0.15 # tax factor → 1.15 multiplier
INTERNET = 500 # Janouš Petr monthly internet reimbursement
# Template name list — rows 8-19 of dochazka_template.xlsx.
# Only these people are exported, in this order.
TEMPLATE_NAMES = [
'Dittrich Vladimír',
'Teslík Hynek',
'Kohl David',
'Vörös Pavel',
'Janouš Petr',
'Dvořák Václav',
'Toman Milan',
'Hanzlík Marek',
'Vondrák Pavel',
'Čeleda Olda',
'Žemlička Miroslav',
'Franek Lukáš',
]
# Per-person static data from lidijson.py (auto type, km per shift)
PERSON_DATA = {
'Žemlička Miroslav': {'auto': 'AUS', 'km_tkb': 40, 'km_sat': 0},
'Franek Lukáš': {'auto': 'AUS', 'km_tkb': 40, 'km_sat': 0},
'Dvořák Václav': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Toman Milan': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Kohl David': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Milisavljevič Jovica':{'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Hanzlík Marek': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Pauzer Libor': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Chudoba Jan': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Jór Leoš': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Gschray Jiří': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Rozman František': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Vörös Pavel': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Janouš Petr': {'auto': 'AUS', 'km_tkb': 70, 'km_sat': 60},
'Strach Jiří': {'auto': 'AUV', 'km_tkb': 30, 'km_sat': 0},
'Dittrich Vladimír': {'auto': 'AUV', 'km_tkb': 30, 'km_sat': 0},
'Schwarz Richard': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Glaser Ondřej': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Herbst David': {'auto': 'AUV', 'km_tkb': 76, 'km_sat': 66},
'Ryba Ondřej': {'auto': 'AUV', 'km_tkb': 100, 'km_sat': 80},
'Zábranský Petr': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Šůna Jiří': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Čeleda Olda': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Vondrák Pavel': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Teslík Hynek': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
'Štefan Robert': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
}
# Template layout (1-based row numbers)
TPL_FIRST = 8 # first data row
TPL_LAST = 19 # last data row (12 people)
TPL_CELKEM = 20
TPL_HIDDEN1 = 22
TPL_HIDDEN2 = 23
TPL_IT_HDR = 24
TPL_IT1 = 25 # first IT person
# ──────────────────────────────────────────────────────────────
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))
fixed.add(easter + timedelta(days=1))
return fixed
def split_day(hours):
r=o=0; z=hours
while z>0:
if z>=6: r+=6; z-=6
else: r+=z; z=0
if z>=6: o+=6; z-=6
else: o+=z; z=0
return r, o
def split_night(hours):
r=o=n=0; z=hours
while z>0:
if z>=2: o+=2; z-=2
else: o+=z; z=0
if z>=8: n+=8; z-=8
else: n+=z; z=0
if z>=2: r+=2; z-=2
else: r+=z; z=0
return r, o, n
# ──────────────────────────────────────────────────────────────
def compute(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]
work_days = sum(1 for d in month_days
if not d['weekend']
and Date(d['year'],d['month'],d['day']) not in holidays)
fpd = work_days * 8
tkb_results = []
# Only include people that are in the template — in template order
tkb_by_name = {p['name']: p for p in people if p.get('group')=='TKB'}
tkb_people = [tkb_by_name[name] for name in TEMPLATE_NAMES if name in tkb_by_name]
for person in tkb_people:
r=o=n=0; dov=nem=otc=0; minus_fpd=0
sichet_12h = 0 # shifts with ≥12 h
sichet_under_12h = 0 # shifts with <12 h
uzavery = 0 # 'U' days (closures)
for d in month_days:
v = (person['data'].get(str(d['idx'])) or {}).get('v', '')
is_w = (not d['weekend'] and
Date(d['year'],d['month'],d['day']) not in holidays)
if v=='A':
dr,do=split_day(12); r+=dr; o+=do
sichet_12h += 1
elif v=='B':
nr,no,nn=split_night(12); r+=nr; o+=no; n+=nn
sichet_12h += 1
elif v=='D': dov+=1; minus_fpd += 8 if is_w else 0
elif v=='N': nem+=1; minus_fpd += 8 if is_w else 0
elif v=='O': otc+=1; minus_fpd += 8 if is_w else 0
elif v=='U': uzavery += 1
elif v not in ('x','U','') and v:
try:
h=float(v)
if h>0:
dr,do=split_day(h); r+=dr; o+=do
if h >= 12: sichet_12h += 1
else: sichet_under_12h += 1
except (ValueError,TypeError): pass
# PMS stored at negative dayIdx = -month
# PMS = práce mimo směnu: hours added to R/O but NOT counted as shifts
pms = (person['data'].get(str(-month)) or {}).get('v','')
if pms=='A': pr,po=split_day(12); r+=pr; o+=po
elif pms=='B': pr,po,pn=split_night(12); r+=pr; o+=po; n+=pn
elif pms:
try:
ph=float(pms)
if ph>0: pr,po=split_day(ph); r+=pr; o+=po
except (ValueError,TypeError): pass
total = r+o+n
o_navic = total - fpd + minus_fpd
r_fpd, o_fpd, n_fpd = r, o, n
if o_navic > 0:
pom = o_navic
if r_fpd >= pom: r_fpd -= pom; pom = 0
else: pom -= r_fpd; r_fpd = 0
o_fpd = max(0, o_fpd - pom)
absence = ' '.join(filter(None, [
f'{dov}D' if dov else '',
f'{nem}N' if nem else '',
f'{otc}O' if otc else '',
])) + (' ' if (dov or nem or otc) else '')
# --- Financial calculations (sichtovnice.py) ---
# Per-person FPD days (adjusted for D/N/O on working days)
person_fpd_dny = work_days - (minus_fpd / 8)
# R (Jine1): meal allowance supplement
# 1.15 × (sichet_12h × 236 + sichet_<12h × 155 fpd_per_person × 155)
stravne_doplatek = round(
(1 + DAN) * (
sichet_12h * STRAVNE_12H
+ sichet_under_12h * STRAVNE_8H
- person_fpd_dny * STRAVNE_8H
), 2)
# S (Jine2): transport money — only AUV drivers
pdata = PERSON_DATA.get(person['name'], {'auto':'AUS','km_tkb':0,'km_sat':0})
kc_doprava = 0
if pdata['auto'] == 'AUV':
sichet_tkb = sichet_12h + sichet_under_12h # no SAT tracking
kc_tkb = pdata['km_tkb'] * KC_KM * sichet_tkb
kc_uzavera = uzavery * pdata['km_tkb'] * KC_KM
kc_doprava = kc_tkb + kc_uzavera
# P (Indiv1): closure count text (+ Internet reimbursement for Janouš Petr)
# indiv1_type: 'str', 'num', or None (empty)
if person['name'] == 'Janouš Petr':
if uzavery:
indiv1_value = f' {uzavery}U+{INTERNET}'
indiv1_type = 'str'
else:
indiv1_value = INTERNET
indiv1_type = 'num'
elif uzavery:
indiv1_value = f' {uzavery}U'
indiv1_type = 'str'
else:
indiv1_value = None
indiv1_type = None
tkb_results.append({
'name': person['name'],
'r': r_fpd, 'o': o_fpd, 'n': n_fpd,
'o_navic': o_navic,
'absence': absence.strip(),
'indiv1_value': indiv1_value,
'indiv1_type': indiv1_type,
'jine1': stravne_doplatek,
'jine2': kc_doprava,
})
it_order = ['it-glaser','it-janous','it-stefan','it-voros']
it_map = {p['id']:p for p in people if p.get('group')=='IT'}
it_results = []
for pid in it_order:
person = it_map.get(pid)
if not person:
it_results.append({'name':pid.split('-')[1].capitalize(),'hours':0,'prace':0})
continue
hours = 0
for d in month_days:
v = (person['data'].get(str(d['idx'])) or {}).get('v','')
if v in ('A','B'): hours += 12
elif v:
try: h=float(v); hours += h if h>0 else 0
except (ValueError,TypeError): pass
pms = (person['data'].get(str(-month)) or {}).get('v','')
prace = 0
try: prace = float(pms) if pms else 0
except (ValueError,TypeError): pass
it_results.append({'name':person['name'],'hours':hours,'prace':prace})
return {'month':month,'year':year,'work_days':work_days,'fpd':fpd,
'tkb':tkb_results,'it':it_results}
# ──────────────────────────────────────────────────────────────
# Shared strings helpers
# ──────────────────────────────────────────────────────────────
def parse_ss(xml_bytes):
root = ET.fromstring(xml_bytes)
strings = []
for si in root.findall(f'{{{NS}}}si'):
t = si.find(f'.//{{{NS}}}t')
strings.append(t.text or '' if t is not None else '')
return root, strings
def get_or_add(root, strings, text):
if text in strings:
return strings.index(text)
strings.append(text)
si = ET.SubElement(root, f'{{{NS}}}si')
t = ET.SubElement(si, f'{{{NS}}}t')
t.text = text
root.set('count', str(len(strings)))
root.set('uniqueCount', str(len(strings)))
return len(strings) - 1
def serialize_ss(root):
return b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + ET.tostring(root, encoding='unicode').encode('utf-8')
# ──────────────────────────────────────────────────────────────
# Sheet XML helpers
# ──────────────────────────────────────────────────────────────
def col_letter(col_idx): # 1-based
letters = ''
while col_idx > 0:
col_idx, rem = divmod(col_idx - 1, 26)
letters = chr(65 + rem) + letters
return letters
def cell_ref(col, row):
return f'{col_letter(col)}{row}'
def set_cell_num(row_el, col, row, value):
"""Set a numeric cell value; creates or updates the cell."""
ref = cell_ref(col, row)
for c in list(row_el):
if c.get('r') == ref:
c.set('t', 'n')
v = c.find(f'{{{NS}}}v')
if v is None: v = ET.SubElement(c, f'{{{NS}}}v')
v.text = str(value)
# Remove formula if any
f = c.find(f'{{{NS}}}f')
if f is not None: row_el.remove(f)
return
# Cell doesn't exist — shouldn't happen for template cells, but handle gracefully
c = ET.SubElement(row_el, f'{{{NS}}}c')
c.set('r', ref); c.set('t', 'n')
v = ET.SubElement(c, f'{{{NS}}}v'); v.text = str(value)
def set_cell_ss(row_el, col, row, ss_idx):
"""Set a shared-string cell."""
ref = cell_ref(col, row)
for c in list(row_el):
if c.get('r') == ref:
c.set('t', 's')
v = c.find(f'{{{NS}}}v')
if v is None: v = ET.SubElement(c, f'{{{NS}}}v')
v.text = str(ss_idx)
f = c.find(f'{{{NS}}}f')
if f is not None: row_el.remove(f)
return
c = ET.SubElement(row_el, f'{{{NS}}}c')
c.set('r', ref); c.set('t', 's')
v = ET.SubElement(c, f'{{{NS}}}v'); v.text = str(ss_idx)
def set_cell_formula(row_el, col, row, formula):
ref = cell_ref(col, row)
for c in list(row_el):
if c.get('r') == ref:
c.attrib.pop('t', None)
# Remove existing v
v = c.find(f'{{{NS}}}v')
if v is not None: c.remove(v)
f = c.find(f'{{{NS}}}f')
if f is None: f = ET.SubElement(c, f'{{{NS}}}f')
f.text = formula
return
c = ET.SubElement(row_el, f'{{{NS}}}c')
c.set('r', ref)
f = ET.SubElement(c, f'{{{NS}}}f'); f.text = formula
def clear_cell(row_el, col, row):
"""Remove value from a cell (keep style/element, clear v and t)."""
ref = cell_ref(col, row)
for c in list(row_el):
if c.get('r') == ref:
c.attrib.pop('t', None)
v = c.find(f'{{{NS}}}v')
if v is not None: c.remove(v)
f = c.find(f'{{{NS}}}f')
if f is not None: c.remove(f)
return
# ──────────────────────────────────────────────────────────────
def generate_excel(data):
with open(TEMPLATE_PATH, 'rb') as f:
tpl_bytes = f.read()
tpl_zip = io.BytesIO(tpl_bytes)
with zipfile.ZipFile(tpl_zip) as zin:
filenames = zin.namelist()
ss_bytes = zin.read('xl/sharedStrings.xml') if 'xl/sharedStrings.xml' in filenames else None
sheet_bytes = zin.read('xl/worksheets/sheet1.xml')
wb_bytes = zin.read('xl/workbook.xml')
all_bytes = {f: zin.read(f) for f in filenames}
# ── Shared strings ──
ss_root, ss_list = parse_ss(ss_bytes) if ss_bytes else (None, [])
def ss(text):
return get_or_add(ss_root, ss_list, text)
month_name = MONTH_NAMES[data['month']]
month_idx = ss(month_name)
op_idx = ss(OP_CODE)
# ── Sheet XML ──
sheet_root = ET.fromstring(sheet_bytes)
sd = sheet_root.find(f'{{{NS}}}sheetData')
# Build row map
rows = {int(r.get('r')): r for r in sd.findall(f'{{{NS}}}row')}
n_people = len(data['tkb'])
n_tmpl = TPL_LAST - TPL_FIRST + 1 # 12
# ── ROW 3: month metadata ──
r3 = rows[3]
set_cell_ss (r3, 4, 3, month_idx) # D3 = month name
set_cell_num(r3, 6, 3, 8) # F3 = direction (always 8)
set_cell_num(r3, 7, 3, data['work_days']) # G3
set_cell_num(r3, 8, 3, data['fpd']) # H3
# ── DATA ROWS 819 ──
for i in range(n_tmpl):
row_num = TPL_FIRST + i
r = rows[row_num]
if i < n_people:
p = data['tkb'][i]
# Clear all value-bearing cells first
for col in range(2, 26):
clear_cell(r, col, row_num)
# Write our values
set_cell_ss (r, 2, row_num, op_idx)
set_cell_ss (r, 3, row_num, ss(p['name']))
set_cell_num(r, 4, row_num, p['r'])
set_cell_num(r, 6, row_num, p['o'])
set_cell_num(r, 8, row_num, p['n'])
set_cell_num(r,10, row_num, p['o_navic'])
# P (indiv1): closure text/internet — string OR numeric OR blank
if p['indiv1_type'] == 'str':
set_cell_ss(r, 16, row_num, ss(p['indiv1_value']))
elif p['indiv1_type'] == 'num':
set_cell_num(r, 16, row_num, p['indiv1_value'])
set_cell_num(r,18, row_num, p['jine1']) # R: stravné doplatek
set_cell_num(r,19, row_num, p['jine2']) # S: transport (AUV)
set_cell_num(r,23, row_num, 0)
if p['absence']:
set_cell_ss(r, 24, row_num, ss(p['absence']))
else:
# Fewer people than template: clear the row
for col in range(2, 26):
clear_cell(r, col, row_num)
# Mark as hidden
r.set('hidden', '1')
# n_people ≤ n_tmpl always (filtered to TEMPLATE_NAMES in compute()).
# Unused template rows are cleared and hidden above. Celkem row stays at TPL_CELKEM.
celkem_row = TPL_CELKEM
it_first = TPL_IT1
# ── CELKEM row ──
ck = rows[celkem_row]
for col in range(4, 24):
clear_cell(ck, col, celkem_row)
for col in [4, 6, 8, 10]:
cl = col_letter(col)
set_cell_formula(ck, col, celkem_row,
f'SUBTOTAL(9,{cl}{TPL_FIRST}:{cl}{TPL_LAST})')
for col in [5, 7, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]:
set_cell_num(ck, col, celkem_row, 0)
# ── IT section (hidden rows already shifted) ──
# IT people (rows it_first to it_first+3)
it_names = ['Glaser', 'Janouš', 'Štefan', 'Vörös']
for j, it in enumerate(data['it']):
rn = it_first + j
r = rows.get(rn)
if r is None: continue
set_cell_ss (r, 2, rn, ss(it_names[j] if j < len(it_names) else it['name']))
set_cell_num(r, 3, rn, it['hours'])
if it.get('prace', 0):
set_cell_num(r, 4, rn, it['prace'])
else:
clear_cell(r, 4, rn)
# ── Rebuild sheetData in row order ──
for old_row in list(sd):
sd.remove(old_row)
for rn in sorted(rows):
sd.append(rows[rn])
# ── Update sheet name in workbook.xml ──
new_sheet_name = f"{data['month']:02d}_{data['year']}"
wb_text = wb_bytes.decode('utf-8')
wb_text = re.sub(r'name="[^"]*"', f'name="{new_sheet_name}"', wb_text, count=1)
# ── Serialize ──
sheet_out = (b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
+ ET.tostring(sheet_root, encoding='unicode').encode('utf-8'))
ss_out = serialize_ss(ss_root) if ss_root is not None else ss_bytes
# ── Repack zip ──
out_buf = io.BytesIO()
with zipfile.ZipFile(out_buf, 'w', zipfile.ZIP_DEFLATED) as zout:
for fname, fbytes in all_bytes.items():
if fname == 'xl/worksheets/sheet1.xml':
zout.writestr(fname, sheet_out)
elif fname == 'xl/sharedStrings.xml':
zout.writestr(fname, ss_out)
elif fname == 'xl/workbook.xml':
zout.writestr(fname, wb_text.encode('utf-8'))
else:
zout.writestr(fname, fbytes)
out_buf.seek(0)
return out_buf.read()
# ──────────────────────────────────────────────────────────────
if __name__ == '__main__':
inp = json.loads(sys.stdin.buffer.read())
result = compute(inp['schedule'], inp['month'], inp['year'])
sys.stdout.buffer.write(generate_excel(result))