Files
TKB_plan/web/export_dochazka.py
Docker Config Backup db56403f7c 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>
2026-04-13 14:39:04 +02:00

506 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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))