CC5 R5: fix bulk-uplatnice + xlsx + notify-scan extended (incl. expired)

- /api/crm/clanovi/export.xlsx: fix col_letters list construction (str+list bug)
- /api/crm/lijecnicki/notify-scan: dodan include_expired=True bucket, jasniji
  subject za already-expired vs uskoro istek

CC2 commit 0046b8d je već unio crm_extras_router.py na master; ovaj commit
samo sređuje bugove i extends scan logiku.
This commit is contained in:
Damir Radulić
2026-05-05 01:31:00 +02:00
parent faf6beb536
commit d45fbca4b3
+27 -26
View File
@@ -275,7 +275,8 @@ def export_clanovi_xlsx(
ws.cell(row=ridx, column=cidx, value=v) ws.cell(row=ridx, column=cidx, value=v)
# Auto column widths # Auto column widths
for col_letter, h in zip("ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "AA AB AC AD".split(), headers): col_letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + ["AA", "AB", "AC", "AD", "AE", "AF"]
for col_letter, h in zip(col_letters, headers):
ws.column_dimensions[col_letter].width = max(10, min(28, len(h) + 4)) ws.column_dimensions[col_letter].width = max(10, min(28, len(h) + 4))
ws.freeze_panes = "A2" ws.freeze_panes = "A2"
@@ -406,22 +407,35 @@ def crm_stats(klub_id: Optional[int] = Query(None)):
class NotifScanIn(BaseModel): class NotifScanIn(BaseModel):
klub_id: Optional[int] = None klub_id: Optional[int] = None
thresholds: Optional[list[int]] = None # default = LIJEC_THRESHOLDS thresholds: Optional[list[int]] = None # default = LIJEC_THRESHOLDS
include_expired: bool = True # uključi i one koji su već istekli
@router.post("/lijecnicki/notify-scan") @router.post("/lijecnicki/notify-scan")
def lijecnicki_notify_scan(body: NotifScanIn): def lijecnicki_notify_scan(body: NotifScanIn):
""" """
Skenira nadolazeće isteke i kreira notifikacije (InApp + Email mock) Skenira nadolazeće isteke i kreira notifikacije (InApp + Email mock)
za pragove 30/15/7 dana. Ne duplicira: gleda meta.lijecnicki_id+threshold za pragove 30/15/7 dana. Ako include_expired=True, isto kreira jednu
u zadnjih 7 dana. notifikaciju (threshold=0) za već istekle.
Ne duplicira: gleda meta.lijecnicki_id+threshold u zadnjih 7 dana.
""" """
thresholds = sorted(set(body.thresholds or LIJEC_THRESHOLDS), reverse=True) thresholds = sorted(set(body.thresholds or LIJEC_THRESHOLDS), reverse=True)
klub_filter = "AND l.klub_id = %s" if body.klub_id else "" klub_filter = "AND l.klub_id = %s" if body.klub_id else ""
klub_params = [body.klub_id] if body.klub_id else [] klub_params = [body.klub_id] if body.klub_id else []
# threshold=0 → već istekli (poseban "expired" bucket)
scan_buckets = [(thr, "uskoro") for thr in thresholds]
if body.include_expired:
scan_buckets.append((0, "expired"))
created = [] created = []
with _conn() as conn, conn.cursor() as cur: with _conn() as conn, conn.cursor() as cur:
for thr in thresholds: for thr, kind in scan_buckets:
if kind == "expired":
where_window = "(l.vrijedi_do - CURRENT_DATE) < 0"
where_params = []
else:
where_window = "(l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s"
where_params = [thr]
cur.execute(f""" cur.execute(f"""
SELECT l.id, l.vrijedi_do, l.clan_id, SELECT l.id, l.vrijedi_do, l.clan_id,
(l.vrijedi_do - CURRENT_DATE)::int AS dana, (l.vrijedi_do - CURRENT_DATE)::int AS dana,
@@ -432,27 +446,9 @@ def lijecnicki_notify_scan(body: NotifScanIn):
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
WHERE l.vrijedi_do IS NOT NULL WHERE l.vrijedi_do IS NOT NULL
AND (l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s AND {where_window}
AND (l.vrijedi_do - CURRENT_DATE) > %s
{klub_filter} {klub_filter}
""", [thr, thr - 1] + klub_params if False else """, where_params + klub_params)
([thr - (thresholds[thresholds.index(thr)+1] if thresholds.index(thr)+1 < len(thresholds) else 0),
-1] + klub_params))
# Pojednostavljen scan: samo "≤ thr & > prev_thr" dovodi do duplika;
# umjesto toga samo gledamo "u prozoru ≤ thr".
cur.execute(f"""
SELECT l.id, l.vrijedi_do, l.clan_id,
(l.vrijedi_do - CURRENT_DATE)::int AS dana,
cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email,
k.naziv AS klub
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
WHERE l.vrijedi_do IS NOT NULL
AND (l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s
{klub_filter}
""", [thr] + klub_params)
kandidati = [_row(r) for r in cur.fetchall()] kandidati = [_row(r) for r in cur.fetchall()]
for r in kandidati: for r in kandidati:
@@ -467,11 +463,16 @@ def lijecnicki_notify_scan(body: NotifScanIn):
if cur.fetchone(): if cur.fetchone():
continue continue
if r['dana'] is not None and r['dana'] < 0:
subject = f"⚠ Liječnički pregled ISTEKAO ({-r['dana']} dana): {r['clan']}"
msg_dana = f"istekao prije {-r['dana']} dana"
else:
subject = f"⚕ Liječnički pregled ističe za {r['dana']} dana: {r['clan']}" subject = f"⚕ Liječnički pregled ističe za {r['dana']} dana: {r['clan']}"
msg_dana = f"{r['dana']} dana ostalo"
body_txt = ( body_txt = (
f"Liječnički pregled za sportaša {r['clan']} " f"Liječnički pregled za sportaša {r['clan']} "
f"({r.get('klub') or '(bez kluba)'}) ističe {r['vrijedi_do']} " f"({r.get('klub') or '(bez kluba)'}) — vrijedi do {r['vrijedi_do']} "
f"{r['dana']} dana ostalo.\n\n" f"{msg_dana}.\n\n"
f"Molimo zakažite novi termin u ZZJZ PGŽ " f"Molimo zakažite novi termin u ZZJZ PGŽ "
f"(ili koristite /sport/api/crm/lijecnicki/{r['id']}/zakazi).\n\n" f"(ili koristite /sport/api/crm/lijecnicki/{r['id']}/zakazi).\n\n"
f"PGŽ Sport ERP/CRM" f"PGŽ Sport ERP/CRM"