diff --git a/_audit/audit_20260505_023639/shots/anon__public_admin.png b/_audit/audit_20260505_023639/shots/anon__public_admin.png new file mode 100644 index 0000000..9d294f1 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/anon__public_admin.png differ diff --git a/_audit/audit_20260505_023639/shots/anon__public_admin_users.png b/_audit/audit_20260505_023639/shots/anon__public_admin_users.png new file mode 100644 index 0000000..a77ca2c Binary files /dev/null and b/_audit/audit_20260505_023639/shots/anon__public_admin_users.png differ diff --git a/_audit/audit_20260505_023639/shots/anon__public_app.png b/_audit/audit_20260505_023639/shots/anon__public_app.png new file mode 100644 index 0000000..b0a7c5e Binary files /dev/null and b/_audit/audit_20260505_023639/shots/anon__public_app.png differ diff --git a/_audit/audit_20260505_023639/shots/anon__public_audit.png b/_audit/audit_20260505_023639/shots/anon__public_audit.png new file mode 100644 index 0000000..c96eabb Binary files /dev/null and b/_audit/audit_20260505_023639/shots/anon__public_audit.png differ diff --git a/_audit/audit_20260505_023639/shots/anon__public_crm.png b/_audit/audit_20260505_023639/shots/anon__public_crm.png new file mode 100644 index 0000000..a7159f3 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/anon__public_crm.png differ diff --git a/_audit/audit_20260505_023639/shots/anon__public_erp.png b/_audit/audit_20260505_023639/shots/anon__public_erp.png new file mode 100644 index 0000000..0b08a6b Binary files /dev/null and b/_audit/audit_20260505_023639/shots/anon__public_erp.png differ diff --git a/_audit/audit_20260505_023639/shots/anon__public_home.png b/_audit/audit_20260505_023639/shots/anon__public_home.png new file mode 100644 index 0000000..a84022c Binary files /dev/null and b/_audit/audit_20260505_023639/shots/anon__public_home.png differ diff --git a/_audit/audit_20260505_023639/shots/anon__public_kpi.png b/_audit/audit_20260505_023639/shots/anon__public_kpi.png new file mode 100644 index 0000000..c5c9cba Binary files /dev/null and b/_audit/audit_20260505_023639/shots/anon__public_kpi.png differ diff --git a/_audit/audit_20260505_023639/shots/anon__public_login.png b/_audit/audit_20260505_023639/shots/anon__public_login.png new file mode 100644 index 0000000..a77ca2c Binary files /dev/null and b/_audit/audit_20260505_023639/shots/anon__public_login.png differ diff --git a/_audit/audit_20260505_023639/shots/anon__public_sport2.png b/_audit/audit_20260505_023639/shots/anon__public_sport2.png new file mode 100644 index 0000000..4a205a0 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/anon__public_sport2.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_audit.png b/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_audit.png new file mode 100644 index 0000000..ac669c1 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_audit.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_financije.png b/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_financije.png new file mode 100644 index 0000000..2b7795c Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_financije.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_forenzika.png b/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_forenzika.png new file mode 100644 index 0000000..f0fbba5 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_forenzika.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_kpi.png b/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_kpi.png new file mode 100644 index 0000000..423f960 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_kpi.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_mreza.png b/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_mreza.png new file mode 100644 index 0000000..bb3b415 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__ANALITIKA_an_mreza.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__CRM_crm_clanarine.png b/_audit/audit_20260505_023639/shots/klub_admin__CRM_crm_clanarine.png new file mode 100644 index 0000000..a7159f3 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__CRM_crm_clanarine.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__CRM_crm_dokumenti.png b/_audit/audit_20260505_023639/shots/klub_admin__CRM_crm_dokumenti.png new file mode 100644 index 0000000..5e6b8fd Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__CRM_crm_dokumenti.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__CRM_crm_lijecnicki.png b/_audit/audit_20260505_023639/shots/klub_admin__CRM_crm_lijecnicki.png new file mode 100644 index 0000000..64063bd Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__CRM_crm_lijecnicki.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__CRM_crm_obrasci.png b/_audit/audit_20260505_023639/shots/klub_admin__CRM_crm_obrasci.png new file mode 100644 index 0000000..b4d5949 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__CRM_crm_obrasci.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__ERP_erp_placanja.png b/_audit/audit_20260505_023639/shots/klub_admin__ERP_erp_placanja.png new file mode 100644 index 0000000..d2b3935 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__ERP_erp_placanja.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__ERP_erp_putni.png b/_audit/audit_20260505_023639/shots/klub_admin__ERP_erp_putni.png new file mode 100644 index 0000000..c3630ce Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__ERP_erp_putni.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__ERP_erp_racuni.png b/_audit/audit_20260505_023639/shots/klub_admin__ERP_erp_racuni.png new file mode 100644 index 0000000..23df24f Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__ERP_erp_racuni.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__ERP_erp_xlsx.png b/_audit/audit_20260505_023639/shots/klub_admin__ERP_erp_xlsx.png new file mode 100644 index 0000000..46d391b Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__ERP_erp_xlsx.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__OPERATIVA_app_kalendar.png b/_audit/audit_20260505_023639/shots/klub_admin__OPERATIVA_app_kalendar.png new file mode 100644 index 0000000..8bc3427 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__OPERATIVA_app_kalendar.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__OPERATIVA_app_notif.png b/_audit/audit_20260505_023639/shots/klub_admin__OPERATIVA_app_notif.png new file mode 100644 index 0000000..da504e2 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__OPERATIVA_app_notif.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__OPERATIVA_app_profil.png b/_audit/audit_20260505_023639/shots/klub_admin__OPERATIVA_app_profil.png new file mode 100644 index 0000000..6e0ebbd Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__OPERATIVA_app_profil.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_dashboard.png b/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_dashboard.png new file mode 100644 index 0000000..d5f18fb Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_dashboard.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_klubovi.png b/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_klubovi.png new file mode 100644 index 0000000..062180b Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_klubovi.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_manifestacije.png b/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_manifestacije.png new file mode 100644 index 0000000..1335a06 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_manifestacije.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_objekti.png b/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_objekti.png new file mode 100644 index 0000000..cede892 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_objekti.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_savezi.png b/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_savezi.png new file mode 100644 index 0000000..f38908c Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_savezi.png differ diff --git a/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_sportasi.png b/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_sportasi.png new file mode 100644 index 0000000..ed75f51 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/klub_admin__PORTAL_portal_sportasi.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ADMIN_adm_korisnici.png b/_audit/audit_20260505_023639/shots/pgz_admin__ADMIN_adm_korisnici.png new file mode 100644 index 0000000..3c48ef9 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ADMIN_adm_korisnici.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ADMIN_adm_sigurnost.png b/_audit/audit_20260505_023639/shots/pgz_admin__ADMIN_adm_sigurnost.png new file mode 100644 index 0000000..83948f3 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ADMIN_adm_sigurnost.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ADMIN_adm_sustav.png b/_audit/audit_20260505_023639/shots/pgz_admin__ADMIN_adm_sustav.png new file mode 100644 index 0000000..83948f3 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ADMIN_adm_sustav.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ADMIN_adm_tenanti.png b/_audit/audit_20260505_023639/shots/pgz_admin__ADMIN_adm_tenanti.png new file mode 100644 index 0000000..83948f3 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ADMIN_adm_tenanti.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_audit.png b/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_audit.png new file mode 100644 index 0000000..ac669c1 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_audit.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_financije.png b/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_financije.png new file mode 100644 index 0000000..d5f18fb Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_financije.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_forenzika.png b/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_forenzika.png new file mode 100644 index 0000000..f0fbba5 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_forenzika.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_kpi.png b/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_kpi.png new file mode 100644 index 0000000..464377e Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_kpi.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_mreza.png b/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_mreza.png new file mode 100644 index 0000000..bb3b415 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ANALITIKA_an_mreza.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__CRM_crm_clanarine.png b/_audit/audit_20260505_023639/shots/pgz_admin__CRM_crm_clanarine.png new file mode 100644 index 0000000..a7159f3 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__CRM_crm_clanarine.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__CRM_crm_dokumenti.png b/_audit/audit_20260505_023639/shots/pgz_admin__CRM_crm_dokumenti.png new file mode 100644 index 0000000..5e6b8fd Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__CRM_crm_dokumenti.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__CRM_crm_lijecnicki.png b/_audit/audit_20260505_023639/shots/pgz_admin__CRM_crm_lijecnicki.png new file mode 100644 index 0000000..64063bd Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__CRM_crm_lijecnicki.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__CRM_crm_obrasci.png b/_audit/audit_20260505_023639/shots/pgz_admin__CRM_crm_obrasci.png new file mode 100644 index 0000000..b4d5949 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__CRM_crm_obrasci.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ERP_erp_placanja.png b/_audit/audit_20260505_023639/shots/pgz_admin__ERP_erp_placanja.png new file mode 100644 index 0000000..05d74f6 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ERP_erp_placanja.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ERP_erp_putni.png b/_audit/audit_20260505_023639/shots/pgz_admin__ERP_erp_putni.png new file mode 100644 index 0000000..a23f7d3 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ERP_erp_putni.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ERP_erp_racuni.png b/_audit/audit_20260505_023639/shots/pgz_admin__ERP_erp_racuni.png new file mode 100644 index 0000000..d0be37e Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ERP_erp_racuni.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__ERP_erp_xlsx.png b/_audit/audit_20260505_023639/shots/pgz_admin__ERP_erp_xlsx.png new file mode 100644 index 0000000..fe6a56a Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__ERP_erp_xlsx.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__OPERATIVA_app_kalendar.png b/_audit/audit_20260505_023639/shots/pgz_admin__OPERATIVA_app_kalendar.png new file mode 100644 index 0000000..7f4cb25 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__OPERATIVA_app_kalendar.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__OPERATIVA_app_notif.png b/_audit/audit_20260505_023639/shots/pgz_admin__OPERATIVA_app_notif.png new file mode 100644 index 0000000..087601e Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__OPERATIVA_app_notif.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__OPERATIVA_app_profil.png b/_audit/audit_20260505_023639/shots/pgz_admin__OPERATIVA_app_profil.png new file mode 100644 index 0000000..3377b1c Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__OPERATIVA_app_profil.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_dashboard.png b/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_dashboard.png new file mode 100644 index 0000000..4c30e69 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_dashboard.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_klubovi.png b/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_klubovi.png new file mode 100644 index 0000000..e59be7c Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_klubovi.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_manifestacije.png b/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_manifestacije.png new file mode 100644 index 0000000..1335a06 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_manifestacije.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_objekti.png b/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_objekti.png new file mode 100644 index 0000000..c88207d Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_objekti.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_savezi.png b/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_savezi.png new file mode 100644 index 0000000..f38908c Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_savezi.png differ diff --git a/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_sportasi.png b/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_sportasi.png new file mode 100644 index 0000000..bce55ee Binary files /dev/null and b/_audit/audit_20260505_023639/shots/pgz_admin__PORTAL_portal_sportasi.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_audit.png b/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_audit.png new file mode 100644 index 0000000..ac669c1 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_audit.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_financije.png b/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_financije.png new file mode 100644 index 0000000..2b7795c Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_financije.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_forenzika.png b/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_forenzika.png new file mode 100644 index 0000000..f0fbba5 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_forenzika.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_kpi.png b/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_kpi.png new file mode 100644 index 0000000..d71c5b3 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_kpi.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_mreza.png b/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_mreza.png new file mode 100644 index 0000000..f19aa73 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__ANALITIKA_an_mreza.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__CRM_crm_clanarine.png b/_audit/audit_20260505_023639/shots/savez_admin__CRM_crm_clanarine.png new file mode 100644 index 0000000..a7159f3 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__CRM_crm_clanarine.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__CRM_crm_dokumenti.png b/_audit/audit_20260505_023639/shots/savez_admin__CRM_crm_dokumenti.png new file mode 100644 index 0000000..5e6b8fd Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__CRM_crm_dokumenti.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__CRM_crm_lijecnicki.png b/_audit/audit_20260505_023639/shots/savez_admin__CRM_crm_lijecnicki.png new file mode 100644 index 0000000..64063bd Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__CRM_crm_lijecnicki.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__CRM_crm_obrasci.png b/_audit/audit_20260505_023639/shots/savez_admin__CRM_crm_obrasci.png new file mode 100644 index 0000000..b4d5949 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__CRM_crm_obrasci.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__ERP_erp_placanja.png b/_audit/audit_20260505_023639/shots/savez_admin__ERP_erp_placanja.png new file mode 100644 index 0000000..d2b3935 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__ERP_erp_placanja.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__ERP_erp_putni.png b/_audit/audit_20260505_023639/shots/savez_admin__ERP_erp_putni.png new file mode 100644 index 0000000..c3630ce Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__ERP_erp_putni.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__ERP_erp_racuni.png b/_audit/audit_20260505_023639/shots/savez_admin__ERP_erp_racuni.png new file mode 100644 index 0000000..23df24f Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__ERP_erp_racuni.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__ERP_erp_xlsx.png b/_audit/audit_20260505_023639/shots/savez_admin__ERP_erp_xlsx.png new file mode 100644 index 0000000..46d391b Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__ERP_erp_xlsx.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__OPERATIVA_app_kalendar.png b/_audit/audit_20260505_023639/shots/savez_admin__OPERATIVA_app_kalendar.png new file mode 100644 index 0000000..7e35adb Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__OPERATIVA_app_kalendar.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__OPERATIVA_app_notif.png b/_audit/audit_20260505_023639/shots/savez_admin__OPERATIVA_app_notif.png new file mode 100644 index 0000000..2a0dad0 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__OPERATIVA_app_notif.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__OPERATIVA_app_profil.png b/_audit/audit_20260505_023639/shots/savez_admin__OPERATIVA_app_profil.png new file mode 100644 index 0000000..50193ac Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__OPERATIVA_app_profil.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_dashboard.png b/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_dashboard.png new file mode 100644 index 0000000..b290cab Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_dashboard.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_klubovi.png b/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_klubovi.png new file mode 100644 index 0000000..a2eaadd Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_klubovi.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_manifestacije.png b/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_manifestacije.png new file mode 100644 index 0000000..1335a06 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_manifestacije.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_objekti.png b/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_objekti.png new file mode 100644 index 0000000..cede892 Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_objekti.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_savezi.png b/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_savezi.png new file mode 100644 index 0000000..f38908c Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_savezi.png differ diff --git a/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_sportasi.png b/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_sportasi.png new file mode 100644 index 0000000..bce55ee Binary files /dev/null and b/_audit/audit_20260505_023639/shots/savez_admin__PORTAL_portal_sportasi.png differ diff --git a/_backups/admin.html.cc3_pre_logo.1777941424 b/_backups/admin.html.cc3_pre_logo.1777941424 new file mode 100644 index 0000000..5ee00b1 --- /dev/null +++ b/_backups/admin.html.cc3_pre_logo.1777941424 @@ -0,0 +1,769 @@ + + + + + +PGŽ Sport · Admin Dashboard + + + + + + + + +
+ + + +
+
+

Dashboard

+ učitavam… +
+ + +
+
+
+

Top Klubovi (po aktivnosti)

+
NazivSportGradČlanoviRačuni
+
+
+ + +
+
+ + +
+

📷 OCR — Skeniraj račun (gorivo, cestarina, hotel…)

+
+
+
Povuci PDF/JPG/PNG ovdje ili klikni za odabir
+
Tesseract OCR + Ri.NET AI Engine izvuče izdavatelja, OIB, datum, iznos, PDV, IBAN, stavke
+ +
+
+ +
+ + +
+

🚗 Novi putni nalog (HR pravilnik 2025)

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ Unesi datume za live obračun dnevnica… +
+
+ + +
+
+ +
+

Računi

+
BrojDobavljačKlubIznosStatusDatum
+
+
+

Putni nalozi / izdaci

+
BrojKlubDestinacijaIznosStatusDatum
+
+
+ + +
+ +
+

Klubovi

+
NazivOIBSportGradEmailČlanoviRačuni
+
+
+ + +
+ +
+

Kontakti / Članovi

+
ImePrezimeOIBKlubPozicijaEmailStatus
+
+
+ + +
+
+

3D Sport Graph

+

Interaktivni 3D prikaz svih klubova, saveza i osoba s drill-down na detalje.

+
+ +
+
+
+ + +
+
+

Multi-tenant Management

+

Tenants u sustavu. Svaki tenant ima vlastiti scope klubova, financija i konfiguracije.

+
+
+
+ + +
+
+

Top 10 Klubova (po dokumentima i računima)

+
NazivSportGradRačuniČlanovi
+
+
+ +
+
+ + + + diff --git a/_backups/admin_users.html.cc3_pre_logo.1777941424 b/_backups/admin_users.html.cc3_pre_logo.1777941424 new file mode 100644 index 0000000..a956fcb --- /dev/null +++ b/_backups/admin_users.html.cc3_pre_logo.1777941424 @@ -0,0 +1,825 @@ + + + + + +PGŽ Sport · Admin · Korisnici + + + + + + +
+ + +
+
+ +
+
+

Najnovije akcije zadnjih 10

+
VrijemeKorisnikAkcijaResursIP
+
+
+ +
+ +
+ + + + + +
+
+

Lista korisnika

+ + + +
IDE-mailImeUlogaKlub / SavezStatusZadnja prijavaAkcije
+
+
+ +
+ +

Hijerarhija

+
IDSlugNazivTipOIBStatus
+
+

Savezi

+
IDNazivSportPredsjednikTajnik
+
+

Klubovi

+
IDNazivSportGradOIBSavez ID
+
+
+ +
+ +
+ + + + +
+

Događaji

+
VrijemeUserAkcijaResursIPUAMeta
+
+
+ +
+ +
+
+

Two-factor authentication (2FA) moj račun

+
+ Učitavam… + + +
+ +
+

Zaključani / failed-login računi

+
E-mailUlogaPokušajaZaključan doAkcije
+
+

Sesije

+
Sesije se prate per-user kroz audit log (login.ok / logout / auth.refresh)
+
+
+ +
+ +
+

Zahtjevi za brisanje Art. 17

+
IDKorisnikE-mailRazlogStatusZatraženoAkcije
+
+

Pristanak na kolačiće moja povijest

+
VrijemeSessionNužniAnalitičkiMarketingIPVerzija
+
+
+
+
+ + + + + + + +
+ + + + diff --git a/_backups/app.html.cc3_pre_logo.1777941424 b/_backups/app.html.cc3_pre_logo.1777941424 new file mode 100644 index 0000000..9f7ae72 --- /dev/null +++ b/_backups/app.html.cc3_pre_logo.1777941424 @@ -0,0 +1,1928 @@ + + + + + +PGŽ SPORT — Operativna aplikacija + + + + + + + + + + + +
+ + +
+
+
+
Dashboard
+
Pregled stanja
+
+
+
+
+
DR
+
+
Damir Radulićpgz admin
+
Primorsko-goranska županija
+
+
+
+
+ +
+
Učitavanje...
+
+
+
+ + +
+ + + + + + + diff --git a/_backups/audit.html.cc3_pre_logo.1777941424 b/_backups/audit.html.cc3_pre_logo.1777941424 new file mode 100644 index 0000000..186b72a --- /dev/null +++ b/_backups/audit.html.cc3_pre_logo.1777941424 @@ -0,0 +1,153 @@ + + + + +Audit Log — PGŽ Sport + + + + + + + +

📜 Audit Log

+
Kompletna povijest izmjena s blockchain pečatima na Polygon PoS
+ +
+
Ukupno akcija
+
Danas
+
Polygon zapečaćeno
+
Aktivni korisnici
+
+ +
+ + + + + +
+ + + + + + + + + + + + + + + +
VrijemeKorisnikAkcijaResursDetaljiPolygon Tx
⏳ Učitavam...
+ + + + diff --git a/_backups/crm.html.cc3_pre_logo.1777941424 b/_backups/crm.html.cc3_pre_logo.1777941424 new file mode 100644 index 0000000..0fcb5f4 --- /dev/null +++ b/_backups/crm.html.cc3_pre_logo.1777941424 @@ -0,0 +1,1855 @@ + + + + + +PGŽ Sport — CRM (Članarine • Liječnički • Obrasci) + + + + + + + +
+ +
·
+
CRM — Članarine • Liječnički • Obrasci
+
+ Round 3 / CC5 + ← portal + app → +
+
+ +
+
👤 Članovi
+
€ Članarine
+
⚕ Liječnički pregledi
+
📝 Obrasci
+
📊 Statistika
+
🔔 Notifikacije
+
📨 E-mail templates
+
+ ROLA: + +
+
+ +
+
+ + + + + + +
+ + + +
+ + + + + diff --git a/_backups/erp.html.cc3_pre_logo.1777941424 b/_backups/erp.html.cc3_pre_logo.1777941424 new file mode 100644 index 0000000..a7ca7d2 --- /dev/null +++ b/_backups/erp.html.cc3_pre_logo.1777941424 @@ -0,0 +1,1009 @@ + + + + + +PGŽ Sport · ERP — OCR + Putni nalozi + + + + + + + + +
+ +
+
+

Skeniraj račun (OCR)

+ Tesseract + Ri.NET AI Engine · /api/erp +
+ + + +
+
+

📊 ERP statistika — mjesec / kvartal / godina

+
+
+ + 📥 Export XLSX +
+
+
+

Top klubovi (godina)

+
KlubBr. računaTotal
+
+
+

Putni nalozi

+
+
+
+
+ +
+
+

📷 Drag-and-drop OCR (PDF / JPG / PNG)

+
+
+
Povuci datoteku ovdje ili klikni za odabir
+
Tesseract OCR (hrv+eng) + Ri.NET AI Engine LLM ekstrakcija polja
+ +
+
+ + +
+
+ + +
+
+

Računi (svi klubovi)

+
+ Označeno: 0 + + + + 📥 Export XLSX (svi) +
+
#VrstaBrojDobavljačOIBKlubBruttoStatusDatum
+
+
+ + +
+
+

🚗 Novi putni nalog (HR pravilnik 2025)

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ Unesi datume za live obračun dnevnica… +
+
+ + +
+

+ HR pravilnik 2025: domaće 26.54 € (>8h), 13.27 € (5–8h), 0 € (<5h). Inozemne dnevnice po zemlji + (Italija/Austrija 35 €, Slovenija/Mađarska/BiH/Srbija 30 €). Kilometrina vlastitim automobilom 0.50 €/km. +

+
+
+ + +
+
+

Lista putnih naloga

+
#KlubDestinacijaPolazakPovratakDnevniceTransportTotalStatus
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/_backups/kpi.html.cc3_pre_logo.1777941424 b/_backups/kpi.html.cc3_pre_logo.1777941424 new file mode 100644 index 0000000..7a57008 --- /dev/null +++ b/_backups/kpi.html.cc3_pre_logo.1777941424 @@ -0,0 +1,102 @@ + + + + +RINET KPI Dashboard + + + + + +

RINET KPI Dashboard

+
Loading...
+ + + + diff --git a/_backups/login.html.cc3_pre_logo.1777941424 b/_backups/login.html.cc3_pre_logo.1777941424 new file mode 100644 index 0000000..ba91812 --- /dev/null +++ b/_backups/login.html.cc3_pre_logo.1777941424 @@ -0,0 +1,564 @@ + + + + + +PGŽ Sport · Prijava + + + + + + + + + +
+
+
P
+
+

PGŽ Sport

+
ERP/CRM Platforma
+
+
+
+

Operativna platforma za sport u Primorsko-goranskoj županiji.

+

Jedinstvena baza klubova, saveza i sportaša. Računovodstvo, članarine, liječnički pregledi, sufinanciranja — sve na jednom mjestu.

+
+
Multi-tenant arhitektura — PGŽ, savezi, klubovi sa svojim view-om
+
OCR za račune, automatska ekstrakcija polja, putni nalozi
+
Članarine s HUB-3 uplatnicama i blockchain audit log
+
GDPR-compliant (Art. 17, 20) · 2FA · audit svih akcija
+
+
+ +
+ +
+
+

Prijava

+
Unesite svoje podatke za pristup platformi.
+ +
+ +
+
+ + +
+
+ + +
+ +
+ + Zaboravljena lozinka? +
+ +
+ +
Demo računi
+
+
+ PGŽ admin · damir@pgz.hr / PGZ2026! +
+
+ Savez admin · pero@atletika.pgz.hr +
+
+ Klub admin · ana@akkvarner.hr +
+
+ + +
+
+ + + + + + + diff --git a/_backups/r3_cc4/ocr.py.pre_S2.1777941414 b/_backups/r3_cc4/ocr.py.pre_S2.1777941414 new file mode 100644 index 0000000..12433fe --- /dev/null +++ b/_backups/r3_cc4/ocr.py.pre_S2.1777941414 @@ -0,0 +1,1177 @@ +#!/usr/bin/env python3 +# erp/ocr.py — PGŽ Sport ERP OCR router (M5) +# Author: Damir Radulić / dradulic@outlook.com +# Date: 2026-05-04 +# Description: /api/erp/ocr/upload + /parse — Tesseract OCR + DeepSeek V3 LLM extraction +# Persists into pgz_sport.invoice_uploads, then offers structured invoice parse. + +from __future__ import annotations + +import os +import re +import json +import hashlib +import subprocess +import tempfile +import traceback +from datetime import datetime, date +from pathlib import Path +from typing import Optional, List, Any + +import psycopg2 +import psycopg2.extras +import requests +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Header, Query, Body +from fastapi.responses import JSONResponse, FileResponse + +try: + from erp.permissions import ( + can_view_invoice, can_edit_invoice, can_pay_invoice, can_comment_invoice, + invoice_actions, audit_invoice, fetch_audit, is_pgz_admin, + ) +except Exception: + # Fallback (always-allow) for unauth dev + def can_view_invoice(u, i): return True + def can_edit_invoice(u, i): return True + def can_pay_invoice(u, i): return True + def can_comment_invoice(u, i): return True + def invoice_actions(u, i): return {"view": True, "edit": True, "pay": True, "comment": True, "delete": False} + def audit_invoice(u, iid, op, field=None, old=None, new=None): pass + def fetch_audit(t, r, limit=50): return [] + def is_pgz_admin(u): return False + +try: + from auth.auth_v2 import get_current_user as _auth_user +except Exception: + _auth_user = None + +try: + from erp.notifications import ( + notify_invoice_created, notify_invoice_paid, notify_invoice_cancelled, + ) +except Exception: + def notify_invoice_created(*a, **k): return {} + def notify_invoice_paid(*a, **k): return {} + def notify_invoice_cancelled(*a, **k): return {} + +router = APIRouter(prefix="/api/erp", tags=["erp-ocr"]) + +# === Config === +DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", + password="R1net2026!SecureDB#v7") +UPLOAD_DIR = Path("/opt/pgz-sport/_data/uploads/invoices") +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + +DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "sk-33d29054d1ab4377b7d1a84bc0a423c7") +DEEPSEEK_URL = "https://api.deepseek.com/v1/chat/completions" +DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat") + +ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"} +MAX_BYTES = 12 * 1024 * 1024 # 12 MB + +ADMIN_TOKEN = "admin-pgz-2026" + + +def _db(): + c = psycopg2.connect(**DB) + c.autocommit = True + return c + + +def _is_admin(authorization: Optional[str]) -> bool: + if not authorization: + return False + t = authorization.replace("Bearer ", "").strip() + return t == ADMIN_TOKEN + + +def _resolve_user(authorization: Optional[str]) -> Optional[dict]: + """Resolve current user via auth_v2 JWT, fallback to admin token (returns synthetic pgz_admin).""" + if _auth_user: + try: + u = _auth_user(authorization) + if u: return u + except Exception: + pass + if _is_admin(authorization): + return {"id": 0, "email": "admin@token", "user_type": "pgz_admin", + "klub_id": None, "savez_id": None, "_synthetic": True} + return None + + +def _safe_filename(orig: str) -> str: + base = re.sub(r"[^A-Za-z0-9._-]+", "_", (orig or "upload").strip())[:120] + if not base: + base = "upload" + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + return f"{ts}_{base}" + + +def _extract_text(path: Path) -> tuple[str, str]: + """Return (text, method). Tries pdftotext first, falls back to tesseract.""" + suf = path.suffix.lower() + if suf == ".pdf": + try: + r = subprocess.run( + ["pdftotext", "-layout", "-q", str(path), "-"], + capture_output=True, timeout=45, + ) + txt = r.stdout.decode("utf-8", "ignore") + if len(txt.strip()) > 80: + return txt, "pdftotext" + except Exception: + pass + # Rasterize + tesseract + try: + with tempfile.TemporaryDirectory(prefix="ocr_") as td: + subprocess.run( + ["pdftoppm", "-r", "200", str(path), f"{td}/page"], + timeout=120, check=True, + ) + chunks = [] + for img in sorted(Path(td).glob("page-*.ppm"))[:5]: + r = subprocess.run( + ["tesseract", str(img), "-", "-l", "hrv+eng", "--psm", "6"], + capture_output=True, timeout=90, + ) + chunks.append(r.stdout.decode("utf-8", "ignore")) + return "\n".join(chunks), "tesseract" + except Exception as e: + return "", f"pdf_err:{e}" + if suf in {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}: + try: + r = subprocess.run( + ["tesseract", str(path), "-", "-l", "hrv+eng", "--psm", "6"], + capture_output=True, timeout=120, + ) + return r.stdout.decode("utf-8", "ignore"), "tesseract" + except Exception as e: + return "", f"img_err:{e}" + return "", f"unsupported:{suf}" + + +# === HR invoice regex helpers === +_OIB = re.compile(r"\b(\d{11})\b") +_IBAN = re.compile(r"\b(HR\d{19})\b") +_DATE_DOT = re.compile(r"\b(\d{1,2})[.\s\-/]+(\d{1,2})[.\s\-/]+(20\d{2})\b") +_DATE_ISO = re.compile(r"\b(20\d{2})[\-/](\d{1,2})[\-/](\d{1,2})\b") +_AMOUNT_TOTAL = re.compile( + r"(?i)(?:UKUPNO|TOTAL|SVEUKUPNO|ZA NAPLATU|ZA PLATITI|ZA UPLATU|IZNOS\s+UKUPNO)[\s:€]*([\d.\s]{1,12}[,.]\d{2})" +) +_AMOUNT_VAT = re.compile(r"(?i)(?:PDV|VAT)[\s:%]*?([\d.\s]{1,8}[,.]\d{2})") +_INVOICE_NO = re.compile(r"(?i)(?:ra[čc]un|invoice|broj|fakture|br\.)\s*[:#]?\s*([A-Z0-9\-/.]{3,30})") + + +def _parse_amount(s: str) -> Optional[float]: + if not s: + return None + s = s.replace(" ", "").replace("\xa0", "") + # Croatian style "1.234,56" → 1234.56 + if "," in s and "." in s: + s = s.replace(".", "").replace(",", ".") + elif "," in s: + s = s.replace(",", ".") + try: + return float(s) + except Exception: + return None + + +def regex_extract(text: str) -> dict: + out: dict[str, Any] = {"raw_chars": len(text or "")} + if not text: + return out + oibs = list(dict.fromkeys(_OIB.findall(text))) + if oibs: + out["oibs_found"] = oibs + out["vendor_oib"] = oibs[0] + if len(oibs) > 1: + out["customer_oib"] = oibs[1] + + m = _IBAN.search(text.replace(" ", "")) + if m: + out["iban"] = m.group(1) + + m = _INVOICE_NO.search(text) + if m: + out["invoice_no"] = m.group(1).strip().rstrip(".,;") + + for rx, order in [(_DATE_DOT, "dmy"), (_DATE_ISO, "ymd")]: + m = rx.search(text) + if m: + g = m.groups() + try: + if order == "dmy": + out["invoice_date"] = f"{g[2]}-{int(g[1]):02d}-{int(g[0]):02d}" + else: + out["invoice_date"] = f"{g[0]}-{int(g[1]):02d}-{int(g[2]):02d}" + # validate + date.fromisoformat(out["invoice_date"]) + break + except Exception: + out.pop("invoice_date", None) + + totals = [_parse_amount(x) for x in _AMOUNT_TOTAL.findall(text)] + totals = [t for t in totals if t and t > 0.01] + if totals: + out["amount_gross"] = max(totals) + out["amounts_found"] = totals[:6] + + vats = [_parse_amount(x) for x in _AMOUNT_VAT.findall(text)] + vats = [v for v in vats if v and v > 0.01] + if vats: + # smallest plausible PDV (less than gross) + if "amount_gross" in out: + cand = [v for v in vats if v < out["amount_gross"]] + if cand: + out["amount_vat"] = max(cand) + else: + out["amount_vat"] = max(vats) + + if "amount_gross" in out and "amount_vat" in out: + out["amount_net"] = round(out["amount_gross"] - out["amount_vat"], 2) + + # Vendor name guess: first non-numeric, non-OIB line in header + for line in text.split("\n")[:12]: + ln = line.strip() + if 4 < len(ln) < 80 and not _OIB.search(ln) and not re.match(r"^[\d\s.,\-/€:]+$", ln): + out["vendor_name"] = ln + break + + # Crude vendor guess for known HR sellers + upper = text.upper() + for keyword, label in [ + ("INA d.d.", "INA"), ("INA-MAZIVA", "INA"), ("TIFON", "TIFON"), + ("PETROL", "PETROL"), ("HAC", "HAC"), ("BINA-ISTRA", "BINA-ISTRA"), + ("HRVATSKE AUTOCESTE", "HAC"), + ]: + if keyword in upper: + out.setdefault("vendor_brand", label) + break + + return out + + +# === DeepSeek V3 LLM extraction === +SYSTEM_PROMPT = ( + "Ti si stručnjak za hrvatske račune (R-1, fiskalne, HUB-3). " + "Korisnik daje tekst računa izvučen OCR-om. Vrati ISKLJUČIVO valjani JSON, bez markdowna i komentara. " + "Ako neko polje nije sigurno - vrati null. Iznosi su brojevi (decimal s točkom). Datum je 'YYYY-MM-DD'." +) + +LLM_SCHEMA_HINT = """{ + "izdavatelj_naziv": str|null, + "izdavatelj_oib": str|null, + "izdavatelj_adresa": str|null, + "kupac_naziv": str|null, + "kupac_oib": str|null, + "datum": "YYYY-MM-DD"|null, + "broj_racuna": str|null, + "iznos_neto": float|null, + "iznos_pdv": float|null, + "iznos_brutto": float|null, + "stopa_pdv": float|null, + "valuta": "EUR"|"HRK"|null, + "nacin_placanja": str|null, + "IBAN": str|null, + "opis_svrhe": str|null, + "vrsta_troska": "gorivo"|"cestarina"|"hotel"|"restoran"|"oprema"|"ostalo"|null, + "stavke": [ + {"opis": str, "kolicina": float, "jedinica": str, "cijena": float, "ukupno": float} + ] +}""" + + +def deepseek_extract(text: str, hint: dict | None = None) -> dict: + """Call DeepSeek chat completions for structured JSON extraction.""" + if not DEEPSEEK_API_KEY: + return {"error": "no_api_key"} + if not text or len(text.strip()) < 20: + return {"error": "empty_text"} + + user_msg = ( + f"Iz teksta računa ispod izvuci polja po shemi:\n{LLM_SCHEMA_HINT}\n\n" + f"REGEX hint (može biti nepotpun ili netočan): {json.dumps(hint or {}, ensure_ascii=False)}\n\n" + f"--- TEKST RAČUNA ---\n{text[:8000]}\n--- KRAJ ---" + ) + payload = { + "model": DEEPSEEK_MODEL, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ], + "response_format": {"type": "json_object"}, + "temperature": 0.0, + "max_tokens": 1200, + } + headers = { + "Authorization": f"Bearer {DEEPSEEK_API_KEY}", + "Content-Type": "application/json", + } + try: + r = requests.post(DEEPSEEK_URL, headers=headers, json=payload, timeout=60) + except Exception as e: + return {"error": f"net:{e}"} + if r.status_code != 200: + return {"error": f"http_{r.status_code}", "detail": r.text[:300]} + try: + body = r.json() + content = body["choices"][0]["message"]["content"] + return json.loads(content) + except Exception as e: + return {"error": f"parse:{e}", "raw": (r.text[:500] if r else "")} + + +# === Endpoints === + +@router.post("/ocr/upload") +async def ocr_upload( + file: UploadFile = File(...), + klub_id: Optional[int] = Form(None), + tenant_id: int = Form(1), + invoice_kind: str = Form("ostalo"), + authorization: Optional[str] = Header(None), +): + """Upload an invoice file (PDF/image) → store on disk + insert pgz_sport.invoice_uploads.""" + user = _resolve_user(authorization) + # Permission: pgz_admin uvijek; klub_admin/klub_user samo za vlastiti klub (ako je naveden) + if user and not is_pgz_admin(user): + if klub_id and user.get("klub_id") != klub_id: + raise HTTPException(403, "Nemate ovlasti uploadati za ovaj klub") + suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower() + if suffix not in ALLOWED_EXT: + raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}. Dozvoljeno: {sorted(ALLOWED_EXT)}") + + raw = await file.read() + if not raw: + raise HTTPException(400, "Prazna datoteka") + if len(raw) > MAX_BYTES: + raise HTTPException(400, f"Datoteka prevelika ({len(raw)} > {MAX_BYTES} bajtova)") + + sha256 = hashlib.sha256(raw).hexdigest() + fname = _safe_filename(file.filename or "upload") + if not fname.endswith(suffix): + fname += suffix + path = UPLOAD_DIR / fname + path.write_bytes(raw) + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """ + INSERT INTO pgz_sport.invoice_uploads + (klub_id, file_name, file_path, file_size, mime, sha256, ocr_status, meta) + VALUES (%s, %s, %s, %s, %s, %s, 'pending', %s) + RETURNING id, klub_id, file_name, ocr_status, uploaded_at + """, + (klub_id, file.filename, str(path), len(raw), file.content_type or "", + sha256, json.dumps({"tenant_id": tenant_id, "invoice_kind": invoice_kind})), + ) + row = cur.fetchone() + # Audit log za OCR upload + try: + with _db() as c: + c.cursor().execute( + """INSERT INTO pgz_sport.audit_log + (tablica, operacija, record_id, korisnik, promijenjeno_polje, nova_vrijednost) + VALUES ('pgz_sport.invoice_uploads','create',%s,%s,'file_name',%s)""", + (row["id"], (user.get("email") if user else "anon"), + f"{file.filename} ({len(raw)} B, sha={sha256[:12]})"), + ) + except Exception: + pass + return {"ok": True, "upload_id": row["id"], "file_name": row["file_name"], + "size": len(raw), "sha256": sha256, "status": row["ocr_status"]} + + +@router.post("/ocr/parse") +async def ocr_parse( + upload_id: Optional[int] = Form(None), + file: Optional[UploadFile] = File(None), + use_llm: bool = Form(True), + authorization: Optional[str] = Header(None), +): + """Run OCR + (optional) DeepSeek LLM extraction. + Either pass upload_id (parse a previously uploaded file) or send file directly (one-shot).""" + tmp_to_clean: Optional[Path] = None + upload_row = None + try: + if upload_id: + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.invoice_uploads WHERE id=%s", (upload_id,)) + upload_row = cur.fetchone() + if not upload_row: + raise HTTPException(404, f"Upload id={upload_id} ne postoji") + target = Path(upload_row["file_path"]) + if not target.exists(): + raise HTTPException(404, f"Datoteka ne postoji na disku: {target}") + elif file: + suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower() + if suffix not in ALLOWED_EXT: + raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}") + raw = await file.read() + if not raw: + raise HTTPException(400, "Prazna datoteka") + tmp = tempfile.NamedTemporaryFile(prefix="parse_", suffix=suffix, delete=False) + tmp.write(raw); tmp.close() + target = Path(tmp.name) + tmp_to_clean = target + else: + raise HTTPException(400, "Treba poslati upload_id ILI file") + + text, method = _extract_text(target) + if len(text.strip()) < 20: + return {"ok": False, "ocr_method": method, "raw_chars": len(text), + "error": "OCR nije uspio izvući dovoljno teksta"} + + regex_fields = regex_extract(text) + regex_fields["ocr_method"] = method + + llm_fields: dict = {} + if use_llm: + llm_fields = deepseek_extract(text, hint=regex_fields) + + # Merge: LLM overrides regex when valid + merged = dict(regex_fields) + for k in ("izdavatelj_naziv", "izdavatelj_oib", "kupac_oib", "datum", + "broj_racuna", "iznos_neto", "iznos_pdv", "iznos_brutto", + "stopa_pdv", "valuta", "IBAN", "opis_svrhe", "vrsta_troska", + "izdavatelj_adresa", "nacin_placanja"): + v = llm_fields.get(k) if isinstance(llm_fields, dict) else None + if v not in (None, "", "null"): + merged[k] = v + + # Normalize aliases for UI / DB + if "izdavatelj_naziv" in merged: merged.setdefault("vendor_name", merged["izdavatelj_naziv"]) + if "izdavatelj_oib" in merged: merged.setdefault("vendor_oib", merged["izdavatelj_oib"]) + if "izdavatelj_adresa" in merged: merged.setdefault("vendor_address", merged["izdavatelj_adresa"]) + if "kupac_oib" in merged: merged.setdefault("customer_oib", merged["kupac_oib"]) + if "datum" in merged: merged.setdefault("invoice_date", merged["datum"]) + if "broj_racuna" in merged: merged.setdefault("invoice_no", merged["broj_racuna"]) + if "iznos_brutto" in merged: merged.setdefault("amount_gross", merged["iznos_brutto"]) + if "iznos_neto" in merged: merged.setdefault("amount_net", merged["iznos_neto"]) + if "iznos_pdv" in merged: merged.setdefault("amount_vat", merged["iznos_pdv"]) + if "stopa_pdv" in merged: merged.setdefault("vat_rate", merged["stopa_pdv"]) + if "valuta" in merged: merged.setdefault("currency", merged["valuta"]) + if "IBAN" in merged: merged.setdefault("iban", merged["IBAN"]) + if "opis_svrhe" in merged: merged.setdefault("description", merged["opis_svrhe"]) + if "vrsta_troska" in merged: merged.setdefault("category", merged["vrsta_troska"]) + + # Persist back to invoice_uploads when we have upload_row + if upload_row: + try: + with _db() as c: + c.cursor().execute( + """UPDATE pgz_sport.invoice_uploads + SET ocr_status='done', processed_at=NOW(), + ocr_engine=%s, ocr_text=%s, + ai_invoice_no=%s, ai_invoice_date=%s, + ai_vendor_name=%s, ai_vendor_oib=%s, + ai_amount_gross=%s, ai_currency=%s, ai_iban=%s, + ai_extracted=%s, ai_engine=%s + WHERE id=%s""", + ( + method, text[:50000], + merged.get("invoice_no"), + merged.get("invoice_date") if isinstance(merged.get("invoice_date"), str) else None, + merged.get("vendor_name"), + merged.get("vendor_oib"), + merged.get("amount_gross"), + merged.get("currency", "EUR"), + merged.get("iban"), + json.dumps({"regex": regex_fields, "llm": llm_fields, "merged": merged}, + ensure_ascii=False, default=str), + ("deepseek-v3" if use_llm and "error" not in (llm_fields or {}) else "regex"), + upload_row["id"], + ), + ) + except Exception as e: + merged["_persist_warn"] = str(e)[:200] + + return { + "ok": True, + "upload_id": (upload_row["id"] if upload_row else None), + "ocr_method": method, + "raw_chars": len(text), + "regex": regex_fields, + "llm": llm_fields, + "extracted": merged, + "raw_text_preview": text[:1500], + } + finally: + if tmp_to_clean and tmp_to_clean.exists(): + try: + tmp_to_clean.unlink() + except Exception: + pass + + +# === Invoices CRUD (M5) === + +@router.get("/invoices") +def invoices_list( + tenant_id: Optional[int] = Query(None), + klub_id: Optional[int] = Query(None), + status: Optional[str] = Query(None), + kind: Optional[str] = Query(None), + limit: int = Query(100, le=500), + offset: int = Query(0), +): + sql = """SELECT i.id, i.klub_id, k.naziv AS klub_naziv, + i.invoice_kind, i.invoice_no, i.internal_no, + i.vendor_name, i.vendor_oib, i.customer_name, i.customer_oib, + i.invoice_date, i.due_date, i.paid_date, i.currency, + i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate, + i.payment_status, i.payment_method, i.iban_to, + i.description, i.category, i.tenant_id, + i.created_at, i.approved_at + FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id + WHERE 1=1""" + args: list = [] + if tenant_id is not None: + sql += " AND i.tenant_id=%s"; args.append(tenant_id) + if klub_id is not None: + sql += " AND i.klub_id=%s"; args.append(klub_id) + if status: + sql += " AND i.payment_status=%s"; args.append(status) + if kind: + sql += " AND i.invoice_kind=%s"; args.append(kind) + sql += " ORDER BY i.invoice_date DESC NULLS LAST, i.id DESC LIMIT %s OFFSET %s" + args += [limit, offset] + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, args) + rows = cur.fetchall() + return {"ok": True, "rows": rows, "count": len(rows)} + + +@router.get("/invoices/{invoice_id}") +def invoices_get(invoice_id: int, authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """SELECT i.*, k.naziv AS klub_naziv, k.savez_id + FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id + WHERE i.id=%s""", (invoice_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Račun ne postoji") + if user and not can_view_invoice(user, row): + raise HTTPException(403, "Nemate ovlasti vidjeti ovaj račun") + cur.execute("SELECT * FROM pgz_sport.invoice_lines WHERE invoice_id=%s ORDER BY line_no, id", + (invoice_id,)) + lines = cur.fetchall() + cur.execute( + """SELECT id, file_name, file_size, mime, sha256, ocr_status, ocr_engine, + ai_extracted, uploaded_at, processed_at + FROM pgz_sport.invoice_uploads WHERE invoice_id=%s + ORDER BY uploaded_at DESC""", (invoice_id,)) + uploads = cur.fetchall() + cur.execute( + """SELECT id, payment_date, amount, currency, payment_method, iban_from, + iban_to, reference, bank_transaction_id, matched_status, created_at + FROM pgz_sport.payments WHERE invoice_id=%s ORDER BY payment_date DESC""", + (invoice_id,)) + payments = cur.fetchall() + audit = fetch_audit("pgz_sport.invoices", invoice_id, 50) + actions = invoice_actions(user, row) if user else {"view": True, "edit": False, "pay": False, "comment": False, "delete": False} + return {"ok": True, "invoice": row, "lines": lines, "uploads": uploads, + "payments": payments, "audit": audit, "actions": actions} + + +@router.get("/invoices/{invoice_id}/file") +def invoices_file(invoice_id: int, authorization: Optional[str] = Header(None)): + """Streamira originalnu datoteku skena/računa (slika ili PDF).""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.id, i.klub_id FROM pgz_sport.invoices i WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_view_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti") + cur.execute( + """SELECT file_path, file_name, mime FROM pgz_sport.invoice_uploads + WHERE invoice_id=%s ORDER BY uploaded_at DESC LIMIT 1""", (invoice_id,)) + up = cur.fetchone() + if not up: + raise HTTPException(404, "Datoteka skena ne postoji za ovaj račun") + p = Path(up["file_path"]) + if not p.exists(): + raise HTTPException(404, f"Datoteka ne postoji na disku") + return FileResponse(str(p), media_type=up.get("mime") or "application/octet-stream", + filename=up.get("file_name") or p.name) + + +@router.get("/invoices/uploads/{upload_id}/file") +def upload_file(upload_id: int, authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.invoice_uploads WHERE id=%s", (upload_id,)) + up = cur.fetchone() + if not up: + raise HTTPException(404, "Upload ne postoji") + if user and not is_pgz_admin(user) and user.get("klub_id") != up.get("klub_id"): + raise HTTPException(403, "Nemate ovlasti") + p = Path(up["file_path"]) + if not p.exists(): + raise HTTPException(404, "Datoteka ne postoji") + return FileResponse(str(p), media_type=up.get("mime") or "application/octet-stream", + filename=up.get("file_name") or p.name) + + +@router.post("/invoices/{invoice_id}/comment") +def invoices_comment(invoice_id: int, body: dict = Body(...), + authorization: Optional[str] = Header(None)): + """Savez admin / klub admin / pgz admin može dodati komentar (audit log entry).""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_comment_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti komentirati") + txt = (body.get("comment") or "").strip() + if not txt: + raise HTTPException(400, "Komentar je prazan") + audit_invoice(user, invoice_id, "comment", field="komentar", old=None, new=txt[:500]) + return {"ok": True, "invoice_id": invoice_id, "comment": txt} + + +@router.get("/invoices/{invoice_id}/audit") +def invoices_audit(invoice_id: int, limit: int = 100, + authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.id, i.klub_id FROM pgz_sport.invoices i WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_view_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti") + return {"ok": True, "audit": fetch_audit("pgz_sport.invoices", invoice_id, limit)} + + +@router.post("/invoices") +def invoices_create(body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Create an invoice from parsed OCR result. + Body: {klub_id, tenant_id, invoice_kind, invoice_no, vendor_name, vendor_oib, + invoice_date, amount_gross, amount_net, amount_vat, vat_rate, currency, + iban_to, description, category, lines:[{...}], upload_id?}""" + required = ["invoice_kind", "invoice_no", "invoice_date", "amount_gross"] + for k in required: + if body.get(k) in (None, ""): + raise HTTPException(400, f"Nedostaje polje: {k}") + + user = _resolve_user(authorization) + klub_id = body.get("klub_id") + tenant_id = body.get("tenant_id", 1) + upload_id = body.get("upload_id") + lines = body.get("lines") or [] + + # Permission: pgz_admin uvijek; klub_admin samo za vlastiti klub + if user and not is_pgz_admin(user): + if not (user.get("user_type") == "klub_admin" and klub_id == user.get("klub_id")): + raise HTTPException(403, "Nemate ovlasti kreirati račun za ovaj klub") + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """INSERT INTO pgz_sport.invoices + (klub_id, invoice_kind, invoice_no, internal_no, + vendor_oib, vendor_name, vendor_address, + customer_oib, customer_name, + invoice_date, due_date, currency, + amount_net, amount_vat, amount_gross, vat_rate, + payment_status, payment_method, iban_to, + description, category, account_code, tenant_id, meta) + VALUES (%s,%s,%s,%s, %s,%s,%s, %s,%s, + %s,%s,COALESCE(%s,'EUR'), + %s,%s,%s,%s, + COALESCE(%s,'unpaid'),%s,%s, + %s,%s,%s,%s,%s) + ON CONFLICT (klub_id, invoice_kind, invoice_no, vendor_oib) + DO UPDATE SET amount_gross=EXCLUDED.amount_gross, + amount_net=EXCLUDED.amount_net, + amount_vat=EXCLUDED.amount_vat, + updated_at=NOW() + RETURNING id, invoice_no, amount_gross, payment_status""", + ( + klub_id, body["invoice_kind"], body["invoice_no"], body.get("internal_no"), + body.get("vendor_oib"), body.get("vendor_name"), body.get("vendor_address"), + body.get("customer_oib"), body.get("customer_name"), + body["invoice_date"], body.get("due_date"), body.get("currency"), + body.get("amount_net"), body.get("amount_vat"), body["amount_gross"], body.get("vat_rate"), + body.get("payment_status"), body.get("payment_method"), body.get("iban_to"), + body.get("description"), body.get("category"), body.get("account_code"), + tenant_id, json.dumps(body.get("meta", {})), + ), + ) + inv = cur.fetchone() + inv_id = inv["id"] + + # Replace lines + cur.execute("DELETE FROM pgz_sport.invoice_lines WHERE invoice_id=%s", (inv_id,)) + for i, ln in enumerate(lines, start=1): + cur.execute( + """INSERT INTO pgz_sport.invoice_lines + (invoice_id, line_no, description, quantity, unit, unit_price, + vat_rate, line_net, line_vat, line_gross, account_code, cost_center, meta) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + ( + inv_id, ln.get("line_no", i), ln.get("description") or ln.get("opis") or "", + ln.get("quantity") or ln.get("kolicina") or 1, + ln.get("unit") or ln.get("jedinica") or "kom", + ln.get("unit_price") or ln.get("cijena"), + ln.get("vat_rate", 25), + ln.get("line_net"), ln.get("line_vat"), + ln.get("line_gross") or ln.get("ukupno"), + ln.get("account_code"), ln.get("cost_center"), + json.dumps(ln.get("meta", {})), + ), + ) + + # Link upload to invoice + if upload_id: + cur.execute( + "UPDATE pgz_sport.invoice_uploads SET invoice_id=%s WHERE id=%s", + (inv_id, upload_id), + ) + + audit_invoice(user, inv_id, "create", field="invoice_no", + new=f"{body.get('invoice_no')} €{body.get('amount_gross')}") + notif = notify_invoice_created({**body, "id": inv_id, "klub_id": klub_id}) + return {"ok": True, "invoice": inv, "notification": notif} + + +@router.put("/invoices/{invoice_id}") +def invoices_update(invoice_id: int, body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Update / approve invoice. Body may include any of: payment_status, paid_date, + approved (bool), notes, category, account_code, due_date.""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_edit_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti uređivati ovaj račun") + + fields = [] + args: list = [] + changes = [] + for col in ("payment_status", "paid_date", "due_date", "category", + "account_code", "notes", "vat_rate", "amount_net", "amount_vat", + "amount_gross", "payment_method", "iban_to"): + if col in body: + fields.append(f"{col}=%s") + args.append(body[col]) + changes.append((col, inv.get(col), body[col])) + if body.get("approved"): + fields.append("approved_at=NOW()") + changes.append(("approved_at", inv.get("approved_at"), "now")) + if not fields: + raise HTTPException(400, "Nema polja za izmjenu") + fields.append("updated_at=NOW()") + args.append(invoice_id) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"UPDATE pgz_sport.invoices SET {','.join(fields)} WHERE id=%s RETURNING *", args) + row = cur.fetchone() + for f, o, n in changes: + audit_invoice(user, invoice_id, "update", field=f, old=o, new=n) + return {"ok": True, "invoice": row} + + +@router.post("/invoices/{invoice_id}/pay") +def invoices_pay(invoice_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + """Označi račun kao plaćen + insert payment record. + Body: {iban_to, iban_from, paid_date, reference, bank_transaction_id, payment_method, amount} + """ + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_pay_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti označiti račun kao plaćen") + if (inv.get("payment_status") or "").lower() == "paid": + raise HTTPException(409, "Račun je već označen kao plaćen") + + paid_date = body.get("paid_date") or date.today().isoformat() + payment_method = body.get("payment_method") or "transfer" + iban_from = body.get("iban_from") + iban_to = body.get("iban_to") or inv.get("iban_to") + reference = body.get("reference") + tx_id = body.get("bank_transaction_id") or body.get("tx_id") + amount = body.get("amount") or inv.get("amount_gross") + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.invoices + SET payment_status='paid', paid_date=%s, + payment_method=COALESCE(%s,payment_method), + iban_from=COALESCE(%s,iban_from), + iban_to=COALESCE(%s,iban_to), + updated_at=NOW() + WHERE id=%s + RETURNING id, invoice_no, paid_date, amount_gross, payment_status, + iban_from, iban_to, payment_method""", + (paid_date, payment_method, iban_from, iban_to, invoice_id), + ) + row = cur.fetchone() + # Insert payment record + cur.execute( + """INSERT INTO pgz_sport.payments + (klub_id, invoice_id, payment_date, amount, currency, payment_method, + iban_from, iban_to, reference, bank_transaction_id, matched_status) + VALUES (%s,%s,%s,%s,COALESCE(%s,'EUR'),%s,%s,%s,%s,%s,'matched') + RETURNING id""", + (inv.get("klub_id"), invoice_id, paid_date, amount, + inv.get("currency"), payment_method, iban_from, iban_to, + reference, tx_id), + ) + pay = cur.fetchone() + audit_invoice(user, invoice_id, "pay", field="payment_status", + old=inv.get("payment_status"), new="paid") + notif = notify_invoice_paid( + {**inv, **(row or {}), "id": invoice_id}, + {"iban_to": iban_to, "iban_from": iban_from, "reference": reference, + "payment_date": paid_date, "amount": amount}, + ) + return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None, + "notification": notif} + + +# ── R5.3 BULK OPERATIONS ────────────────────────────────────────────── +@router.post("/invoices/bulk-pay") +def invoices_bulk_pay(body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Bulk označi listu računa kao plaćene. + Body: {ids: [int], paid_date?, payment_method?, iban_from?, iban_to?, reference?, tx_id?}""" + user = _resolve_user(authorization) + ids = body.get("ids") or [] + if not ids or not isinstance(ids, list): + raise HTTPException(400, "ids je obavezna ne-prazna lista") + paid_date = body.get("paid_date") or date.today().isoformat() + payment_method = body.get("payment_method") or "transfer" + iban_from = body.get("iban_from") + iban_to = body.get("iban_to") + reference = body.get("reference") + tx_id = body.get("bank_transaction_id") or body.get("tx_id") + + results = {"paid": [], "skipped": [], "forbidden": [], "errors": []} + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """SELECT i.*, k.savez_id FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id + WHERE i.id = ANY(%s)""", (ids,)) + rows = cur.fetchall() + for inv in rows: + if (inv.get("payment_status") or "").lower() == "paid": + results["skipped"].append(inv["id"]); continue + if user and not can_pay_invoice(user, inv): + results["forbidden"].append(inv["id"]); continue + try: + with _db() as c: + cur = c.cursor() + cur.execute( + """UPDATE pgz_sport.invoices + SET payment_status='paid', paid_date=%s, + payment_method=COALESCE(%s,payment_method), + iban_from=COALESCE(%s,iban_from), + iban_to=COALESCE(%s,iban_to), + updated_at=NOW() + WHERE id=%s""", + (paid_date, payment_method, iban_from, iban_to, inv["id"]), + ) + cur.execute( + """INSERT INTO pgz_sport.payments + (klub_id, invoice_id, payment_date, amount, currency, payment_method, + iban_from, iban_to, reference, bank_transaction_id, matched_status) + VALUES (%s,%s,%s,%s,COALESCE(%s,'EUR'),%s,%s,%s,%s,%s,'matched')""", + (inv.get("klub_id"), inv["id"], paid_date, inv.get("amount_gross"), + inv.get("currency"), payment_method, iban_from, iban_to, reference, tx_id), + ) + audit_invoice(user, inv["id"], "bulk_pay", + field="payment_status", old=inv.get("payment_status"), new="paid") + try: + notify_invoice_paid( + {**inv, "paid_date": paid_date}, + {"iban_to": iban_to, "iban_from": iban_from, "reference": reference, + "payment_date": paid_date, "amount": inv.get("amount_gross")}, + ) + except Exception: + pass + results["paid"].append(inv["id"]) + except Exception as e: + results["errors"].append({"id": inv["id"], "err": str(e)[:200]}) + return {"ok": True, "summary": {k: len(v) for k, v in results.items()}, "details": results} + + +@router.post("/invoices/bulk-cancel") +def invoices_bulk_cancel(body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Bulk otkaži (status='cancelled') — samo pgz_admin ili klub_admin svog kluba.""" + user = _resolve_user(authorization) + ids = body.get("ids") or [] + razlog = body.get("razlog") or body.get("reason") or "(bulk cancel)" + if not ids: + raise HTTPException(400, "ids je obavezna ne-prazna lista") + results = {"cancelled": [], "skipped": [], "forbidden": [], "errors": []} + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """SELECT i.*, k.savez_id FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id + WHERE i.id = ANY(%s)""", (ids,)) + rows = cur.fetchall() + for inv in rows: + if (inv.get("payment_status") or "").lower() in ("paid", "cancelled"): + results["skipped"].append(inv["id"]); continue + if user and not can_edit_invoice(user, inv): + results["forbidden"].append(inv["id"]); continue + try: + with _db() as c: + c.cursor().execute( + """UPDATE pgz_sport.invoices + SET payment_status='cancelled', + notes = COALESCE(notes,'') || E'\n[CANCEL] ' || %s, + updated_at=NOW() WHERE id=%s""", + (razlog, inv["id"]), + ) + audit_invoice(user, inv["id"], "bulk_cancel", + field="payment_status", old=inv.get("payment_status"), + new=f"cancelled: {razlog}") + try: notify_invoice_cancelled(inv, razlog) + except Exception: pass + results["cancelled"].append(inv["id"]) + except Exception as e: + results["errors"].append({"id": inv["id"], "err": str(e)[:200]}) + return {"ok": True, "summary": {k: len(v) for k, v in results.items()}, "details": results} + + +# ── R5.4 XLSX EXPORT ─────────────────────────────────────────────────── +@router.get("/export/invoices.xlsx") +def invoices_export_xlsx( + tenant_id: Optional[int] = Query(None), + klub_id: Optional[int] = Query(None), + od: Optional[str] = Query(None, description="datum od YYYY-MM-DD"), + do: Optional[str] = Query(None, description="datum do YYYY-MM-DD"), + status: Optional[str] = None, + kind: Optional[str] = None, + authorization: Optional[str] = Header(None), +): + """XLSX export računa za knjigovodstvo. Stupci: ID, datum, vrsta, broj, + izdavatelj, OIB, klub, neto, PDV, brutto, valuta, status, IBAN, opis.""" + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment + from io import BytesIO + from fastapi.responses import StreamingResponse + + user = _resolve_user(authorization) + sql = """SELECT i.id, i.invoice_date, i.invoice_kind, i.invoice_no, + i.vendor_name, i.vendor_oib, i.customer_oib, + i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate, + i.currency, i.payment_status, i.payment_method, + i.iban_to, i.description, i.category, + i.paid_date, i.tenant_id, i.klub_id, + k.naziv AS klub_naziv, k.savez_id + FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id + WHERE 1=1""" + args: list = [] + if tenant_id is not None: sql += " AND i.tenant_id=%s"; args.append(tenant_id) + if klub_id is not None: sql += " AND i.klub_id=%s"; args.append(klub_id) + if od: sql += " AND i.invoice_date >= %s"; args.append(od) + if do: sql += " AND i.invoice_date <= %s"; args.append(do) + if status: sql += " AND i.payment_status=%s"; args.append(status) + if kind: sql += " AND i.invoice_kind=%s"; args.append(kind) + sql += " ORDER BY i.invoice_date DESC, i.id DESC" + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, args) + rows = cur.fetchall() + + # Filter po user permissions + if user and not is_pgz_admin(user): + rows = [r for r in rows if can_view_invoice(user, r)] + + wb = Workbook() + ws = wb.active + ws.title = "Računi" + headers = ["ID", "Datum", "Vrsta", "Broj računa", "Izdavatelj", "OIB", + "Klub", "Iznos neto", "PDV", "Brutto", "Stopa PDV", + "Valuta", "Status", "Datum uplate", "IBAN primatelja", + "Opis", "Kategorija"] + bold = Font(bold=True, color="FFFFFF") + fill = PatternFill("solid", fgColor="003087") + for col_idx, h in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_idx, value=h) + cell.font = bold; cell.fill = fill + cell.alignment = Alignment(horizontal="center") + for r_idx, r in enumerate(rows, 2): + ws.cell(row=r_idx, column=1, value=r.get("id")) + ws.cell(row=r_idx, column=2, value=str(r.get("invoice_date") or "")) + ws.cell(row=r_idx, column=3, value=r.get("invoice_kind")) + ws.cell(row=r_idx, column=4, value=r.get("invoice_no")) + ws.cell(row=r_idx, column=5, value=r.get("vendor_name")) + ws.cell(row=r_idx, column=6, value=r.get("vendor_oib")) + ws.cell(row=r_idx, column=7, value=r.get("klub_naziv")) + ws.cell(row=r_idx, column=8, value=float(r["amount_net"]) if r.get("amount_net") is not None else None) + ws.cell(row=r_idx, column=9, value=float(r["amount_vat"]) if r.get("amount_vat") is not None else None) + ws.cell(row=r_idx, column=10, value=float(r["amount_gross"]) if r.get("amount_gross") is not None else None) + ws.cell(row=r_idx, column=11, value=float(r["vat_rate"]) if r.get("vat_rate") is not None else None) + ws.cell(row=r_idx, column=12, value=r.get("currency")) + ws.cell(row=r_idx, column=13, value=r.get("payment_status")) + ws.cell(row=r_idx, column=14, value=str(r.get("paid_date") or "")) + ws.cell(row=r_idx, column=15, value=r.get("iban_to")) + ws.cell(row=r_idx, column=16, value=r.get("description")) + ws.cell(row=r_idx, column=17, value=r.get("category")) + # Auto width + widths = [6, 12, 12, 18, 28, 14, 24, 12, 12, 12, 8, 6, 11, 12, 22, 30, 12] + for i, w in enumerate(widths, 1): + ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = w + ws.freeze_panes = "A2" + + buf = BytesIO() + wb.save(buf); buf.seek(0) + fname = f"racuni_{date.today().isoformat()}.xlsx" + return StreamingResponse( + buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + +# ── R5.6 STATS ───────────────────────────────────────────────────────── +@router.get("/stats") +def erp_stats( + klub_id: Optional[int] = Query(None), + tenant_id: Optional[int] = Query(None), + authorization: Optional[str] = Header(None), +): + """Statistika ERP-a: ukupno troškova mjesec/kvartal/godina po klubu/savezu, + breakdown po vrstama (gorivo/cestarina/hotel/oprema/ostalo).""" + user = _resolve_user(authorization) + today = date.today() + month_start = today.replace(day=1).isoformat() + qmonth = ((today.month - 1) // 3) * 3 + 1 + quarter_start = today.replace(month=qmonth, day=1).isoformat() + year_start = today.replace(month=1, day=1).isoformat() + + where = ["1=1"]; args: list = [] + if klub_id is not None: + where.append("klub_id=%s"); args.append(klub_id) + if tenant_id is not None: + where.append("tenant_id=%s"); args.append(tenant_id) + where_sql = " AND ".join(where) + + def q_sum(date_from): + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + f"""SELECT COUNT(*) AS n, + COALESCE(SUM(amount_gross),0)::float AS total, + COALESCE(SUM(CASE WHEN payment_status='paid' THEN amount_gross END),0)::float AS paid, + COALESCE(SUM(CASE WHEN payment_status<>'paid' THEN amount_gross END),0)::float AS unpaid + FROM pgz_sport.invoices + WHERE {where_sql} AND invoice_date >= %s""", + args + [date_from], + ) + return cur.fetchone() + + def q_breakdown(date_from): + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + f"""SELECT invoice_kind, COUNT(*) AS n, + COALESCE(SUM(amount_gross),0)::float AS total + FROM pgz_sport.invoices + WHERE {where_sql} AND invoice_date >= %s + GROUP BY invoice_kind ORDER BY total DESC""", + args + [date_from], + ) + return cur.fetchall() + + def q_top(date_from): + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + f"""SELECT i.klub_id, k.naziv AS klub_naziv, + COUNT(*) AS n, COALESCE(SUM(i.amount_gross),0)::float AS total + FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id + WHERE {where_sql} AND i.invoice_date >= %s + GROUP BY i.klub_id, k.naziv ORDER BY total DESC LIMIT 10""", + args + [date_from], + ) + return cur.fetchall() + + # Putni nalozi totals + def q_pn(date_from): + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + pn_where = ["report_type='putni_nalog'"]; pn_args: list = [] + if klub_id is not None: + pn_where.append("klub_id=%s"); pn_args.append(klub_id) + if tenant_id is not None: + pn_where.append("tenant_id=%s"); pn_args.append(tenant_id) + cur.execute( + f"""SELECT COUNT(*) AS n, + COALESCE(SUM(cost_total),0)::float AS total, + COALESCE(SUM(dnevnice_amount),0)::float AS dnevnice, + COALESCE(SUM(cost_transport),0)::float AS transport + FROM pgz_sport.expense_reports + WHERE {' AND '.join(pn_where)} AND date_from >= %s""", + pn_args + [date_from], + ) + return cur.fetchone() + + return { + "ok": True, + "as_of": today.isoformat(), + "filters": {"klub_id": klub_id, "tenant_id": tenant_id}, + "invoices": { + "month": {"since": month_start, **q_sum(month_start), "by_kind": q_breakdown(month_start)}, + "quarter": {"since": quarter_start, **q_sum(quarter_start), "by_kind": q_breakdown(quarter_start)}, + "year": {"since": year_start, **q_sum(year_start), "by_kind": q_breakdown(year_start)}, + }, + "top_klubovi_godina": q_top(year_start), + "putni_nalozi": { + "month": {"since": month_start, **q_pn(month_start)}, + "quarter": {"since": quarter_start, **q_pn(quarter_start)}, + "year": {"since": year_start, **q_pn(year_start)}, + }, + } + + +@router.get("/invoices/uploads/list") +def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50): + sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine, + ai_invoice_no, ai_invoice_date, ai_vendor_name, ai_vendor_oib, + ai_amount_gross, ai_currency, invoice_id, uploaded_at, processed_at + FROM pgz_sport.invoice_uploads WHERE 1=1""" + args: list = [] + if klub_id is not None: + sql += " AND klub_id=%s"; args.append(klub_id) + if status: + sql += " AND ocr_status=%s"; args.append(status) + sql += " ORDER BY uploaded_at DESC LIMIT %s"; args.append(limit) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, args) + rows = cur.fetchall() + return {"ok": True, "rows": rows} diff --git a/_backups/r3_cc4/putni_nalozi.py.pre_S2.1777941414 b/_backups/r3_cc4/putni_nalozi.py.pre_S2.1777941414 new file mode 100644 index 0000000..f704f0f --- /dev/null +++ b/_backups/r3_cc4/putni_nalozi.py.pre_S2.1777941414 @@ -0,0 +1,772 @@ +#!/usr/bin/env python3 +# erp/putni_nalozi.py — PGŽ Sport ERP putni nalozi (M6) +# Author: Damir Radulić / dradulic@outlook.com +# Date: 2026-05-04 +# Description: CRUD putnih naloga + obračun dnevnica (HR pravilnik 2025). + +from __future__ import annotations + +import json +from datetime import datetime, date, timedelta +from typing import Optional, Any + +import psycopg2 +import psycopg2.extras +from fastapi import APIRouter, Body, HTTPException, Query, Header + +try: + from erp.permissions import ( + can_view_putni_nalog, can_edit_putni_nalog, can_submit_putni_nalog, + can_approve_putni_nalog, can_pay_putni_nalog, putni_nalog_actions, + audit_putni, fetch_audit, is_pgz_admin, + ) +except Exception: + def can_view_putni_nalog(u, p): return True + def can_edit_putni_nalog(u, p): return True + def can_submit_putni_nalog(u, p): return True + def can_approve_putni_nalog(u, p): return True + def can_pay_putni_nalog(u, p): return True + def putni_nalog_actions(u, p): return {"view": True, "edit": True, "submit": True, "approve": True, "reject": True, "pay": True, "delete": False} + def audit_putni(u, pid, op, field=None, old=None, new=None): pass + def fetch_audit(t, r, limit=50): return [] + def is_pgz_admin(u): return False + +try: + from auth.auth_v2 import get_current_user as _auth_user +except Exception: + _auth_user = None + +try: + from erp.notifications import ( + notify_pn_submitted, notify_pn_approved, notify_pn_rejected, notify_pn_paid, + ) +except Exception: + def notify_pn_submitted(*a, **k): return {} + def notify_pn_approved(*a, **k): return {} + def notify_pn_rejected(*a, **k): return {} + def notify_pn_paid(*a, **k): return {} + +ADMIN_TOKEN = "admin-pgz-2026" + +def _resolve_user(authorization): + if _auth_user: + try: + u = _auth_user(authorization) + if u: return u + except Exception: + pass + if authorization and authorization.replace("Bearer ", "").strip() == ADMIN_TOKEN: + return {"id": 0, "email": "admin@token", "user_type": "pgz_admin", + "klub_id": None, "savez_id": None, "_synthetic": True} + return None + + +router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"]) + +DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", + password="R1net2026!SecureDB#v7") + +# === HR pravilnik 2025 — dnevnice === +# Domaće: 26.54 € (puna) za put >8h, 13.27 € za 5-8h, 0 € za <5h. +# Izvor: NN — Pravilnik o porezu na dohodak, neoporezivi iznosi 2025 (200 kn ≈ 26.54 €). +DNEVNICA_DOM_FULL = 26.54 # EUR +DNEVNICA_DOM_HALF = 13.27 # EUR +KM_RATE_DEFAULT = 0.50 # EUR/km (vlastiti automobil) + +# Inozemne dnevnice (Uredba o izdacima službenih putovanja u inozemstvo). +DNEVNICE_INO = { + "Italija": 35.00, + "Italy": 35.00, + "Slovenija": 30.00, + "Slovenia": 30.00, + "Austrija": 35.00, + "Austria": 35.00, + "Mađarska": 30.00, + "Madarska": 30.00, + "Hungary": 30.00, + "Bosna i Hercegovina": 30.00, + "BiH": 30.00, + "Bosnia": 30.00, + "Srbija": 30.00, + "Serbia": 30.00, + "Crna Gora": 30.00, + "Montenegro": 30.00, + "Njemačka": 50.00, + "Germany": 50.00, + "Francuska": 50.00, + "France": 50.00, + "Švicarska": 60.00, + "Switzerland": 60.00, + "SAD": 70.00, + "USA": 70.00, +} + + +def _db(): + c = psycopg2.connect(**DB) + c.autocommit = True + return c + + +def _parse_dt(v) -> Optional[datetime]: + if v is None or v == "": + return None + if isinstance(v, datetime): + return v + s = str(v).strip().replace("Z", "+00:00") + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", "%Y-%m-%d"): + try: + return datetime.strptime(s[:len(fmt) + 5].rstrip("ZZ"), fmt) + except Exception: + continue + try: + return datetime.fromisoformat(s) + except Exception: + return None + + +def compute_dnevnice(date_from, date_to, country: str = "Hrvatska") -> dict: + """ + Vraća: {hours, days_full, days_half, dnevnica_amount_total, breakdown[]} + Pravila (HR pravilnik 2025, neoporeziv iznos): + - Domaće: <5h = 0; 5-8h = pola; >8h = puna; svaka dodatna pokrivena 24h sekcija = puna. + - Inozemne: pune dnevnice po zemlji (DNEVNICE_INO), inače fallback 50 €. + - Više dana: zaokružujemo po 24h segmentima; završetak <8h = 0, 8-12 = puna (po pravilu zaokruživanja na cijele dane), no koristimo konzervativni izračun po segmentima. + Implementacija (jednostavna, transparentna): + 1) ukupne sate računaj kao razliku. + 2) full_segments = sati // 24 + 3) ostatak_sati = sati - full_segments*24 + 4) ako ostatak >= 8 → +1 puna; ako 5 <= ostatak < 8 → +0.5; ako <5 → +0. + 5) puna dnevnica = pun_iznos po zemlji; pola = polovica. + """ + df = _parse_dt(date_from) + dt = _parse_dt(date_to) + if not df or not dt or dt < df: + return {"error": "neispravni datumi", "hours": 0, + "days_full": 0, "days_half": 0, + "dnevnica_amount_total": 0.0, "breakdown": []} + + delta = dt - df + hours = round(delta.total_seconds() / 3600, 2) + + full_segments = int(delta.total_seconds() // (24 * 3600)) + remainder_h = (delta.total_seconds() - full_segments * 24 * 3600) / 3600.0 + + days_full = full_segments + days_half = 0.0 + if remainder_h >= 8: + days_full += 1 + elif remainder_h >= 5: + days_half += 1 + # else: 0 + + is_domestic = (country or "").strip().lower() in ("hrvatska", "croatia", "hr") + if is_domestic: + full_amt = DNEVNICA_DOM_FULL + half_amt = DNEVNICA_DOM_HALF + else: + full_amt = DNEVNICE_INO.get(country.strip(), 50.00) + half_amt = full_amt / 2.0 + + total = round(days_full * full_amt + days_half * half_amt, 2) + + return { + "hours": hours, + "days_full": days_full, + "days_half": days_half, + "country": country, + "rate_full": full_amt, + "rate_half": half_amt, + "dnevnica_amount_total": total, + "breakdown": [ + f"{days_full} pun{'' if days_full == 1 else 'e'} dnevnice × {full_amt:.2f} €", + f"{days_half} pola dnevnice × {full_amt:.2f} €" if days_half else "", + ], + } + + +def compute_kilometrina(km: float, km_rate: float = KM_RATE_DEFAULT) -> float: + try: + return round(float(km or 0) * float(km_rate or 0), 2) + except Exception: + return 0.0 + + +# === Endpoints === + +@router.get("/putni-nalog/dnevnice/preview") +def preview_dnevnice(date_from: str, date_to: str, country: str = "Hrvatska", + km: float = 0.0, km_rate: float = KM_RATE_DEFAULT): + """Preview dnevnica + kilometrine bez upisa u DB. Koristi UI za live preview.""" + d = compute_dnevnice(date_from, date_to, country) + km_amt = compute_kilometrina(km, km_rate) + d["km_amount"] = km_amt + d["km_driven"] = km + d["km_rate"] = km_rate + d["total_estimated"] = round((d.get("dnevnica_amount_total") or 0) + km_amt, 2) + return {"ok": True, "preview": d} + + +@router.get("/putni-nalog") +def list_putni_nalozi(klub_id: Optional[int] = None, + status: Optional[str] = None, + limit: int = Query(100, le=500), + offset: int = 0): + sql = """SELECT er.id, er.klub_id, k.naziv AS klub_naziv, + er.user_id, er.clan_id, er.report_type, er.report_no, + er.destination, er.purpose, + er.date_from, er.date_to, + er.vehicle_type, er.vehicle_plate, + er.km_driven, er.km_rate, + er.cost_transport, er.cost_lodging, er.cost_meals, + er.cost_other, er.cost_total, + er.dnevnice_count, er.dnevnice_amount, + er.status, er.approved_at, er.paid_at, + er.created_at, er.tenant_id, er.notes + FROM pgz_sport.expense_reports er + LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id + WHERE er.report_type='putni_nalog'""" + args: list = [] + if klub_id is not None: + sql += " AND er.klub_id=%s"; args.append(klub_id) + if status: + sql += " AND er.status=%s"; args.append(status) + sql += " ORDER BY er.date_from DESC NULLS LAST, er.id DESC LIMIT %s OFFSET %s" + args += [limit, offset] + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, args) + rows = cur.fetchall() + return {"ok": True, "rows": rows, "count": len(rows)} + + +@router.get("/putni-nalog/{nalog_id}") +def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("""SELECT er.*, k.naziv AS klub_naziv, k.savez_id + FROM pgz_sport.expense_reports er + LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id + WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_view_putni_nalog(user, row): + raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog") + + # Vezani računi iz m2m tablice + cur.execute( + """SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib, + i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category, + pnr.kategorija AS attached_kategorija, pnr.attached_at + FROM pgz_sport.putni_nalog_racuni pnr + JOIN pgz_sport.invoices i ON i.id = pnr.invoice_id + WHERE pnr.putni_nalog_id=%s + ORDER BY i.invoice_date DESC""", (nalog_id,)) + invoices = cur.fetchall() + + # Auto-suggest: računi kluba u rasponu putovanja koji NISU jos vezani + cur.execute( + """SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib, + i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category + FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.putni_nalog_racuni pnr + ON pnr.invoice_id=i.id AND pnr.putni_nalog_id=%s + WHERE i.klub_id=%s + AND i.invoice_date BETWEEN %s AND %s + AND i.invoice_kind IN ('gorivo','cestarina','hotel','restoran','oprema','ostalo') + AND pnr.id IS NULL + ORDER BY i.invoice_date DESC LIMIT 50""", + (nalog_id, row.get("klub_id"), row.get("date_from"), row.get("date_to")), + ) + suggested = cur.fetchall() + + # Payments za ovaj putni nalog + cur.execute( + """SELECT id, payment_date, amount, currency, payment_method, iban_from, + iban_to, reference, bank_transaction_id, matched_status, created_at + FROM pgz_sport.payments WHERE expense_report_id=%s + ORDER BY payment_date DESC""", (nalog_id,)) + payments = cur.fetchall() + + audit = fetch_audit("pgz_sport.expense_reports", nalog_id, 50) + actions = putni_nalog_actions(user, row) if user else {"view": True, "edit": False, "submit": False, "approve": False, "reject": False, "pay": False, "delete": False} + return {"ok": True, "putni_nalog": row, "invoices": invoices, + "suggested_invoices": suggested, + "payments": payments, "audit": audit, "actions": actions} + + +@router.post("/putni-nalog/{nalog_id}/attach-invoice") +def attach_invoice(nalog_id: int, body: dict = Body(...), + authorization: Optional[str] = Header(None)): + """Veži postojeći račun na putni nalog (m2m).""" + user = _resolve_user(authorization) + inv_id = body.get("invoice_id") + kategorija = body.get("kategorija") or body.get("category") + if not inv_id: + raise HTTPException(400, "invoice_id je obavezan") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user): + raise HTTPException(403, "Nemate ovlasti za vezivanje računa") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """INSERT INTO pgz_sport.putni_nalog_racuni + (putni_nalog_id, invoice_id, kategorija, attached_by) + VALUES (%s,%s,%s,%s) + ON CONFLICT (putni_nalog_id, invoice_id) DO UPDATE SET kategorija=EXCLUDED.kategorija + RETURNING id, attached_at""", + (nalog_id, inv_id, kategorija, (user.get("id") if user else None)), + ) + link = cur.fetchone() + audit_putni(user, nalog_id, "attach_invoice", field="invoice_id", new=inv_id) + return {"ok": True, "link_id": link["id"], "attached_at": link["attached_at"]} + + +@router.delete("/putni-nalog/{nalog_id}/invoice/{invoice_id}") +def detach_invoice(nalog_id: int, invoice_id: int, + authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user): + raise HTTPException(403, "Nemate ovlasti") + with _db() as c: + cur = c.cursor() + cur.execute( + "DELETE FROM pgz_sport.putni_nalog_racuni WHERE putni_nalog_id=%s AND invoice_id=%s", + (nalog_id, invoice_id), + ) + audit_putni(user, nalog_id, "detach_invoice", field="invoice_id", old=invoice_id) + return {"ok": True} + + +@router.post("/putni-nalog/{nalog_id}/posalji") +def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)): + """Voditelj/klub_admin šalje draft → poslan.""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_submit_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti slanja na odobrenje") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.expense_reports SET status='poslan', updated_at=NOW() + WHERE id=%s RETURNING id, status""", (nalog_id,)) + row = cur.fetchone() + audit_putni(user, nalog_id, "submit", field="status", old=pn.get("status"), new="poslan") + notif = notify_pn_submitted({**pn, "status": "poslan"}) + return {"ok": True, "putni_nalog": row, "notification": notif} + + +@router.post("/putni-nalog/{nalog_id}/odbij") +def odbij_putni_nalog(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + """Klub_admin/pgz_admin odbija s razlogom.""" + user = _resolve_user(authorization) + razlog = (body.get("razlog") or body.get("reason") or "").strip() + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_approve_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti odbiti") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.expense_reports + SET status='odbijen', notes=COALESCE(notes,'') || E'\n[ODBIJEN] ' || %s, updated_at=NOW() + WHERE id=%s RETURNING id, status, notes""", + (razlog or "(bez razloga)", nalog_id), + ) + row = cur.fetchone() + audit_putni(user, nalog_id, "reject", field="status", + old=pn.get("status"), new=f"odbijen: {razlog}") + notif = notify_pn_rejected({**pn, "status": "odbijen"}, razlog=razlog) + return {"ok": True, "putni_nalog": row, "notification": notif} + + +@router.post("/putni-nalog/{nalog_id}/isplati") +def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + """Isplata putnog naloga (odobren/zatvoren → isplaćen). + Body: {iban_to, iban_from, paid_date, amount, reference, bank_transaction_id}""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_pay_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti za isplatu") + + paid_date = body.get("paid_date") or date.today().isoformat() + iban_to = body.get("iban_to") + iban_from = body.get("iban_from") + amount = body.get("amount") or pn.get("cost_total") + reference = body.get("reference") + tx_id = body.get("bank_transaction_id") or body.get("tx_id") + payment_method = body.get("payment_method") or "transfer" + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.expense_reports + SET status='isplacen', paid_at=%s, updated_at=NOW() + WHERE id=%s RETURNING id, status, paid_at, cost_total""", + (paid_date, nalog_id), + ) + row = cur.fetchone() + cur.execute( + """INSERT INTO pgz_sport.payments + (klub_id, expense_report_id, payment_date, amount, currency, + payment_method, iban_from, iban_to, reference, bank_transaction_id, + matched_status) + VALUES (%s,%s,%s,%s,'EUR',%s,%s,%s,%s,%s,'matched') + RETURNING id""", + (pn.get("klub_id"), nalog_id, paid_date, amount, payment_method, + iban_from, iban_to, reference, tx_id), + ) + pay = cur.fetchone() + audit_putni(user, nalog_id, "pay", field="status", + old=pn.get("status"), new="isplacen") + notif = notify_pn_paid( + {**pn, **(row or {}), "id": nalog_id}, + {"iban_to": iban_to, "iban_from": iban_from, "amount": amount, + "reference": reference, "payment_date": paid_date}, + ) + return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None, + "notification": notif} + + +@router.get("/putni-nalog/{nalog_id}/hub3.pdf") +def putni_hub3(nalog_id: int, iban: Optional[str] = None, + authorization: Optional[str] = Header(None)): + """HUB-3 uplatnica + EPC QR za isplatu putnog naloga voditelju.""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """SELECT er.*, k.naziv AS klub_naziv, k.savez_id, k.adresa AS klub_adresa + FROM pgz_sport.expense_reports er + LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id + WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_view_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti") + + try: + from crm.payments import build_hub3_pdf + except Exception as e: + raise HTTPException(500, f"HUB-3 helper nije dostupan: {e}") + from fastapi.responses import Response + + att = pn.get("attachments") or {} + if isinstance(att, str): + try: att = json.loads(att) + except Exception: att = {} + voditelj = att.get("voditelj") or "Voditelj putovanja" + iban_to = (iban or "").strip() or att.get("iban_voditelja") or "HR0000000000000000000" + iznos = float(pn.get("cost_total") or 0) + if iznos <= 0: + raise HTTPException(400, "Iznos isplate mora biti veći od 0") + + poziv = f"{nalog_id:08d}" + opis = f"Putni nalog #{nalog_id}: {pn.get('destination') or ''} ({pn.get('date_from')}–{pn.get('date_to')})"[:140] + + pdf = build_hub3_pdf( + platitelj_naziv=pn.get("klub_naziv") or "PGŽ Sport klub", + platitelj_adresa=pn.get("klub_adresa") or "—", + primatelj_naziv=voditelj, + primatelj_adresa="—", + iban=iban_to, + amount_eur=iznos, + model="HR99", + poziv_na_broj=poziv, + opis=opis, + sifra_namjene="SALA", + datum=date.today(), + ) + return Response(content=pdf, media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="putni-nalog-{nalog_id}-HUB3.pdf"'}) + + +@router.get("/putni-nalog/{nalog_id}/audit") +def putni_audit(nalog_id: int, limit: int = 100, + authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_view_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti") + return {"ok": True, "audit": fetch_audit("pgz_sport.expense_reports", nalog_id, limit)} + + +@router.post("/putni-nalog") +def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Kreiraj putni nalog. + Polja: klub_id, user_id, clan_id, voditelj_ime, putnici[], + svrha (purpose), od_grada, do_grada (destination), + datum_polaska (date_from), datum_povratka (date_to), + registracija_vozila (vehicle_plate), vehicle_type, + kilometara (km_driven), km_rate, + predviđeni_troškovi (cost_estimate), country, notes.""" + df = body.get("date_from") or body.get("datum_polaska") + dt = body.get("date_to") or body.get("datum_povratka") + if not df or not dt: + raise HTTPException(400, "Datum polaska i povratka su obavezni") + klub_id = body.get("klub_id") + if not klub_id: + raise HTTPException(400, "klub_id je obavezan") + + user = _resolve_user(authorization) + # Permission: pgz_admin uvijek; klub_admin/klub_user samo za vlastiti klub + if user and not is_pgz_admin(user): + if user.get("user_type") not in ("klub_admin", "klub_user") or user.get("klub_id") != klub_id: + raise HTTPException(403, "Nemate ovlasti kreirati putni nalog za ovaj klub") + + country = body.get("country", "Hrvatska") + km = body.get("km_driven", body.get("kilometara", 0)) or 0 + km_rate = body.get("km_rate") or KM_RATE_DEFAULT + dnv = compute_dnevnice(df, dt, country) + dnevnice_count = (dnv.get("days_full") or 0) + 0.5 * (dnv.get("days_half") or 0) + dnevnice_amount = dnv.get("dnevnica_amount_total") or 0 + cost_transport = compute_kilometrina(km, km_rate) + (body.get("cost_transport") or 0) + + od = body.get("od_grada") or body.get("from_city") + do = body.get("do_grada") or body.get("to_city") or body.get("destination") + destination = " → ".join([x for x in [od, do] if x]) or do + + putnici = body.get("putnici") or [] + voditelj = body.get("voditelj_ime") or body.get("voditelj") + purpose = body.get("svrha") or body.get("purpose") or "" + + meta = { + "voditelj": voditelj, + "putnici": putnici, + "from_city": od, "to_city": do, + "country": country, + "dnevnice_calc": dnv, + "predvideni_troskovi": body.get("predvideni_troskovi") or body.get("cost_estimate") or [], + } + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """INSERT INTO pgz_sport.expense_reports + (klub_id, user_id, clan_id, report_type, report_no, destination, purpose, + date_from, date_to, vehicle_type, vehicle_plate, km_driven, km_rate, + cost_transport, cost_lodging, cost_meals, cost_other, + dnevnice_count, dnevnice_amount, status, attachments, notes, tenant_id) + VALUES (%s, %s, %s, 'putni_nalog', %s, %s, %s, + %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, COALESCE(%s,'draft'), %s, %s, %s) + RETURNING id, klub_id, status, dnevnice_count, dnevnice_amount, + cost_transport, date_from, date_to, destination""", + ( + klub_id, body.get("user_id"), body.get("clan_id"), + body.get("report_no"), destination, purpose, + df, dt, body.get("vehicle_type"), body.get("vehicle_plate") or body.get("registracija_vozila"), + float(km or 0), float(km_rate or 0), + cost_transport, + body.get("cost_lodging") or 0, body.get("cost_meals") or 0, + body.get("cost_other") or 0, + dnevnice_count, dnevnice_amount, + body.get("status"), + json.dumps(meta, ensure_ascii=False, default=str), + body.get("notes"), + body.get("tenant_id", 1), + ), + ) + row = cur.fetchone() + # cost_total via trigger maybe; recompute here + cur.execute( + """UPDATE pgz_sport.expense_reports + SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0) + +COALESCE(cost_meals,0)+COALESCE(cost_other,0) + +COALESCE(dnevnice_amount,0) + WHERE id=%s + RETURNING cost_total""", (row["id"],), + ) + ct = cur.fetchone() + if ct: + row["cost_total"] = ct["cost_total"] + audit_putni(user, row["id"], "create", field="status", + new=f"draft (€{row.get('cost_total')})") + return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv} + + +@router.put("/putni-nalog/{nalog_id}") +def update_putni_nalog(nalog_id: int, body: dict = Body(...)): + """Update polja putnog naloga (osim odobrenja/zatvaranja - oni imaju vlastite endpointe).""" + cols = [] + args: list = [] + for col in ("destination", "purpose", "date_from", "date_to", "vehicle_type", + "vehicle_plate", "km_driven", "km_rate", "cost_transport", + "cost_lodging", "cost_meals", "cost_other", "notes", + "dnevnice_count", "dnevnice_amount"): + if col in body: + cols.append(f"{col}=%s"); args.append(body[col]) + # Recompute dnevnice if dates provided + if "date_from" in body or "date_to" in body or "country" in body: + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT date_from, date_to, attachments FROM pgz_sport.expense_reports WHERE id=%s", (nalog_id,)) + cur_row = cur.fetchone() + if cur_row: + df = body.get("date_from") or cur_row["date_from"] + dt = body.get("date_to") or cur_row["date_to"] + country = body.get("country") or (cur_row["attachments"] or {}).get("country", "Hrvatska") + d = compute_dnevnice(df, dt, country) + cols += ["dnevnice_count=%s", "dnevnice_amount=%s"] + args += [(d.get("days_full") or 0) + 0.5 * (d.get("days_half") or 0), + d.get("dnevnica_amount_total") or 0] + if not cols: + raise HTTPException(400, "Nema polja za izmjenu") + cols.append("updated_at=NOW()") + args.append(nalog_id) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(cols)} WHERE id=%s AND report_type='putni_nalog' RETURNING *", args) + row = cur.fetchone() + if row: + cur.execute( + """UPDATE pgz_sport.expense_reports + SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0) + +COALESCE(cost_meals,0)+COALESCE(cost_other,0) + +COALESCE(dnevnice_amount,0) + WHERE id=%s""", (nalog_id,), + ) + if not row: + raise HTTPException(404, "Putni nalog ne postoji") + return {"ok": True, "putni_nalog": row} + + +@router.post("/putni-nalog/{nalog_id}/odobriti") +def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + approved_by = body.get("approved_by") or (user.get("id") if user else None) + if approved_by == 0 or (user and user.get("_synthetic")): + approved_by = None # admin token nema realnog user_id u DB + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_approve_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti odobriti") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.expense_reports + SET status='odobren', approved_by=%s, approved_at=NOW(), updated_at=NOW() + WHERE id=%s AND report_type='putni_nalog' + RETURNING id, status, approved_at""", (approved_by, nalog_id), + ) + row = cur.fetchone() + audit_putni(user, nalog_id, "approve", field="status", + old=pn.get("status"), new="odobren") + notif = notify_pn_approved({**pn, "status": "odobren"}) + return {"ok": True, "putni_nalog": row, "notification": notif} + + +# R6.2 — PUT alias za simetriju s briefom +@router.put("/putni-nalog/{nalog_id}/odobri") +def odobri_putni_nalog_put(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + return odobriti_putni_nalog(nalog_id, body, authorization) + + +@router.put("/putni-nalog/{nalog_id}/odbij") +def odbij_putni_nalog_put(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + return odbij_putni_nalog(nalog_id, body, authorization) + + +@router.put("/putni-nalog/{nalog_id}/isplati") +def isplati_putni_nalog_put(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + return isplati_putni_nalog(nalog_id, body, authorization) + + +@router.post("/putni-nalog/{nalog_id}/zatvori") +def zatvori_putni_nalog(nalog_id: int, body: dict = Body(default={})): + """Zatvori putni nalog: priloži račune i konačan obračun.""" + invoice_ids = body.get("invoice_ids") or [] + cost_lodging = body.get("cost_lodging") + cost_meals = body.get("cost_meals") + cost_other = body.get("cost_other") + notes = body.get("notes") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,)) + cur_row = cur.fetchone() + if not cur_row: + raise HTTPException(404, "Putni nalog ne postoji") + + # Aggregiraj iznose iz računa (ako su poslani) + if invoice_ids: + cur.execute( + "SELECT COALESCE(SUM(amount_gross),0) AS total FROM pgz_sport.invoices WHERE id = ANY(%s)", + (invoice_ids,), + ) + invs_total = float(cur.fetchone()["total"] or 0) + else: + invs_total = None + + sets = ["status='zatvoren'", "updated_at=NOW()"] + args: list = [] + if cost_lodging is not None: sets.append("cost_lodging=%s"); args.append(cost_lodging) + if cost_meals is not None: sets.append("cost_meals=%s"); args.append(cost_meals) + if cost_other is not None: sets.append("cost_other=%s"); args.append(cost_other) + if notes: sets.append("notes=%s"); args.append(notes) + # Pohrani povezane račune u attachments + atts = cur_row["attachments"] or {} + if isinstance(atts, str): + try: atts = json.loads(atts) + except Exception: atts = {} + atts["invoice_ids"] = invoice_ids + if invs_total is not None: + atts["invoices_total"] = invs_total + sets.append("attachments=%s"); args.append(json.dumps(atts, ensure_ascii=False, default=str)) + args.append(nalog_id) + cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(sets)} WHERE id=%s RETURNING *", args) + row = cur.fetchone() + cur.execute( + """UPDATE pgz_sport.expense_reports + SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0) + +COALESCE(cost_meals,0)+COALESCE(cost_other,0) + +COALESCE(dnevnice_amount,0) + WHERE id=%s RETURNING cost_total""", (nalog_id,), + ) + ct = cur.fetchone() + if ct: row["cost_total"] = ct["cost_total"] + return {"ok": True, "putni_nalog": row} diff --git a/_backups/sport2.html.cc3_pre_logo.1777941424 b/_backups/sport2.html.cc3_pre_logo.1777941424 new file mode 100644 index 0000000..e9ca026 --- /dev/null +++ b/_backups/sport2.html.cc3_pre_logo.1777941424 @@ -0,0 +1,2956 @@ + + + + + +PGŽ SPORT — Platforma + + + + + + + + + + + + + +
+ + +
+
+
+
Dashboard
+
Pregled stanja
+
+
+ API live · sport.rinet.one +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
Detalji
+
×
+
+
+
+ + + + diff --git a/_data/uploads/invoices/20260505_080112_ina_racun.png b/_data/uploads/invoices/20260505_080112_ina_racun.png new file mode 100644 index 0000000..c2d76ba Binary files /dev/null and b/_data/uploads/invoices/20260505_080112_ina_racun.png differ diff --git a/_data/uploads/placanja/invoice_1_HUB3_20260505_080032.pdf b/_data/uploads/placanja/invoice_1_HUB3_20260505_080032.pdf new file mode 100644 index 0000000..2b3c4ab --- /dev/null +++ b/_data/uploads/placanja/invoice_1_HUB3_20260505_080032.pdf @@ -0,0 +1,308 @@ +%PDF-1.3 +% ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2+0 8 0 R /F3+0 12 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 318 /Length 8119 /Subtype /Image + /Type /XObject /Width 318 +>> +stream +Gb"/a;3L:]&H[C\9A9]=fPjP7Tj%&Vmk..mrqkTRX/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i:LrF]+DSm54;Vn%QDCbnnG783EqEHXOEQ+@_L'*0mR7d[="EY=O,K,/F,.TVHC%n>>3mU9eE"")O@_;(s=t"\LH+qr"bJT<`@2>\VB3^/]P(k@r@1s3krYpNRcPLBs(l)>Dobfe!QV6]i``BAkg]$4;P8g2//1_[[aIA7_%44I-9=e8UBtY>?R.*3G-If8nWgh/^ARsc'kFe^Ts+^Bouo6S?6m&\D-"jkM<42HLiXk=d'U]B$8k"B8!lKc?J-iR\VmWZB[:]O^khiX`R%BgZ!VDh5j1oo.+$JP86/jbLoL#KY&h)cF]"r#V@+[bgMu6N1%AaGaa:d(=W(2EeG2=F(XZ26H77+=?K;E.uWZi(-+^>LH2^5-6Zl2D_.J,Tp`P\3KHBXFFZSrXqPL.H]3kn+Dd,JKZ/A4WrR+WF:Vb(Q50qI2G7qhEHB9qC"ABaD"`R?*>ZC"C&_4"P\QLcBFHGc+k=hj5aO[]&b(9FK(BK8koX>c(uQh@E)'3U!,Cr)QDKU23TbBh'nF'69C4HW)-`YPYT7;+\WZE-!D3LBE>c4a?6Df!7Y>(i45,g1MOQucEFl9(1tpr4)NWC:J\:U7&\N&d6tX3MA]KPt\SFDa!"9tB@5=#h@g`H[crYI8+*2k-D9h"$Z0S.EeP4Nuti>;Vn?9i"]*q8lX^LLi1]k4>W*Ra00TV^]+Qj=9?1Z5=,Dq:-6Tk@W2lPZrTWQ1JEeN/.6,C5\s1Zfu2H/O-egq62Ei_gIn)RT\gWnc-A]Ft`!AlYcObmW2R_Be>c\cq6O)(\>ak[sWAn3A'R((uIEnr=>h0i&A3itjB$X2emojU+qmZu2e5,Vt_etVsDXn:(g0qV]pb@c$ke$:;>k/<_F2tr=/VQ!?4VUJ`(2tp&H9iIZH9W,;.Dsj+oS#/ApQqhF:hqU0h2be_k0V-eS^:A1YDjpO`@61O0I<4<jUad5/5))ht"eeOSN!?^N$NZkPG)l]78!Cs$PpYmJOuCY+FtN>O;>@Du:-6O1"Qu(V>!rcYp)c\%M5qA3SSiDd;XgCAofWD^JrX\Sl4?AQP1dCKUJo[\l>8_a`=!J2##[/N:D;)b;2$-YkDMi:RB3;10Mo(.=6-i)A>H($RTs"$db,Q*9l;]J4<_)#>!BK'^VS."-ZmMkZ_l;;9sJ$8FI'mnRTHT0Y0Y$HQSh@D#^:VMZ!Ot_QGn6?=00BDcbqi12+b7_GC)'L?Fb0D`I++UXMq?g`&X/A8[etLHSMd3\TF.VI%"-<`NIP*3L;Tp[g72)0sP&qFI'mnRTHT0Y0Y$HQSh@D#^:VMZ!Ot_QGn6?=00BDcbqi12+_^IZgO#-VHam@N;6/;fc,kFr*DCTo<[)LeeM+9M_2qr-I1t;`I*-<*7+F`CfTeY.@8!h@m1Q=p;UQ*^K6HYr%,:VFiBTg@Z)t5M)0Kg0q[>Ra)c\F>Lg;ob%7isR*W^=]s3sNn!2on5DuXY>qtHHfOE';ilp7]bie`CnMJ5!cG[bLPN-*9Q6n`F5.N2@8QKo)?0>LbaVB/dj'h2<-9TjC\LXOKLbaVB/dj'h2<-9TjC\LXOKDDn%rZ#o_]3"/KAr/Ci?!/b\FR.)47@7l%HZfX3EX^98L/B]6EP[!MPDdi/i=m=KV7eh(ZNN?EWM).f6HudNC(6"$j9"3Y2_P\o#AF8q]S6Q>ZNZS9$8!&!N8r]jZRi=4kUJjdA*7FNZ&pS85rcR&kDq%3p83_FE*/cbu5cP/R]r=1MX<$c["j\GJC$Aq\CKb/u;J?0-B8/JthEipQE.R1V1r.kA)rFd;9([:%3ZMn@;VAk'jhXK:2delAl>eUfM[/abgKR2I1>Z*JD.=]`.?S1PeZh0iCOdq<^OHt-G:SbA=5Y9pQF/KUeFI9:gEB6)OM@pLg[p6@kG-F0-n*i@bA=F/G/En,PI>qqoDPs;H!ZCEKfVVQZ&HIHMIeU1k40qJ$'5HcE>S1PeZh0iCOdq<^OHt-G:SX/sEL<41F1$S]=jYdP:K:io\>23`HHOP:M5>S]=jYdP:K:io\>23`HHOP:M5>S]=jYdP:K:io\>23`HHOP:M5>S]=jYdP:K:io\>23`HHOP:M5>S]=jYdP:K:io\>23`HHOP:M5>S]=jYdP:K:,HF(R9/,)b^SQRuu[)A?6/;n.b_]2IY=75B.T;FD;B7uQfZ^#+llu&3NC%f\t*TkgmB'G9$&nAAU/=tZ,F$.]R\Z`nuRa*IT&R&YfQqu2!V)(G2N7l@ZG]l$inMGUS^(0aK3jWotBSeHJ=Vf?Z)`G[&F1ok+lr20tT)2h-kL5m6Z'QbPN?"#@S5]$kkJ#Qdq6[#;lt+:B,KF:SkLBFt\aMCq5-=&gHWdZ*aRNBX3AnQumX%Zu:M]gH?C%ZME]fY/:eV)Dh!,n`1Hqc8cq=c!IrX9kU?PR$ga1-asq6cLG"Op<#703]!>Hb^=Fn\]'qWNlP&(=0?8dRHZP?k*+iI/]O<%4I2GLE_W%K9Q+H*?b+.WX8E/c1M^E1Z#iWtL\9A<\uXUqel@/$T:**kZ<4[a`l!,i2VhAN1$^=(J^;PD=BR32''!E6G+7nL\!V<'hnWl:/Z^LhDMdnf?FrpUQ!;JDb&?2oZPHRW9=Mia=KgPX]?2^;?0+$7R99nJMksWe4KMVj]9U[ujI3A*Q>S^hFGl7V==q6mn&[aEb7\=1/]QZ.R^+K1DE5%FMida:/\YNZaZ_9!&nB3=`nodFZ7M;Z3OS^hFGl7V==q6mn&[aEb7\=1/]QZ.R^+K1DE5%FMida:/\YNZaZ_8V.Ft6hL,$#Vk-MZqPAh`.F4rf<+%pNWFi)ANREt:b9u;aScY<:;Hn;sNeHn$)B0Ns)1^PO=5,[e3oq4+lhcH"?P=.M%OFQTpA:Qo`Ak&]6?3?Ln4>]mp-d[AGL5#&nNR*TLDjEtu_2Z3rJcq6OIq6UngM`KtK-TnpT3?RD-f!#F^o[B=+CVdJ`KTUFag/KRHmVog\M[L?Y0ZMT`luAk/Y64N8X@nTi\S,Sb,rLcG("Pk0P2ZoD"6S49sUkg';*CsY&[7OrDI^A/Cl>lgfu_G)!f-HGFbApi//+MX"pq2egner>W&u-lbs-RSY,B/M@j$m19iC]/'$FM27FFg`<^[YTC:]q&R)4jYq4HVa4soW[i"GM#^;i3n)0'8>,sE`/Cl>lgfu_G)!f-HGFbApi//+MX"pq2egner>W&u-lbs-RSY,B?QWVh7HZPhA#VK6eZ7B1.B>NDj0>+q4O(i?CMJ"$H8V0MAT_Wp4I1&KVn(Go9s*haY@c7\XR8c@a)]Jq1`&6UWR]hieSNqR>ID#8jhmI,KN:,Y['!La-C320e'tj6@4mj`[9u^uKN:,Y['!La-C320e'tj6@4mj`[9u^uKN:,Y['!La-C320e'tj6@4mj`[9u^uKN:,Y['!La-C320e'tj6@4mj`[9u^uKN:,Y['!La-C320e'tj6@4mj`[9u^uKN:,Y['!La-C320ZR]g`h,P>R[8/SoEg:FVHm[.L24D``jn>*I=`5.<[8/SoEg:FVHm[.L24D``jn>*I=`5.<[8/SoEg:FVHm[.L24D``jn>*I=`5.<[8/SoEg:FVHm[.L24D``jn>*I=`5.<[8/SoEg:FVHm[.L24D``jn>*I=`5.<[8/SoEg:FVHm[.L24D`LZ0m%ck<)cQlROo>X=K3j^%kYLVDkqk4B@A9T8@`:X=K3j^%kYLVDkqk4B@A9T8@`:X=K3j^%kYLVDkqk4B@A9T8@`:X=K3j^%kYLVDkqk4B@A9T8@`:X=K3j^%kYLVDkqk4B@A9T8@`:X=K3j^%k["M9c--N/.42KU0r[Bp16;D@p&9\R(I/XdsN93?Ie3@;q;cir2Ino4Y*qQ*Wjt'SF#2!Sb'N0\>L'cc';!uZe&U0&pVq5kAm%i2Y?Z:p3S/&`PIkT+=?G^&=O,/BX"!A"lY\`GH;/X)=%scPkM.$"\X@"dA\E-?/a$BB#uBuhl!s+NHXf&pZ`L%*=0B0sSEi&ka^$cPXIXEN/E^W2eWfaLFgml^]bo)VMIkn>=g+>OHXinY/?8s7R9VIZf%nJ`p6o6kXIM*4Q#gPUXE9b>[9u+\/*>'e>?T3WP:MraF(W++cKpOUcEaMeN+[1MS6:%dX4_9/`NjO0XB3]8]s6!gQIUTW9Wja=lY^8kHVLYF;B3A7C`\Y/7cE+XCAbE?R/EY&So1bEoElG>Q=01$A)EAl$Cl&a-C[AcL1/G&TMS:2biGV(!C>uZ!P%XjER&u,t;RMZ2:E?4Lnp(bq%@\GE!RQF10!mXYk:Z1mo^m/?='8HL7ktj'g6!(nn@Wm[GQpjGIM+Y?8!a20(h'REr>ijFQT*/g?cOH3+6^ub-f=\jK=q'=?:NK2/RNo-(HYJHf\GlG]Hm1(+c":kF+<@mh/B_S%WhoE]gn5AdapMS9%Doh00/QbfsJ)(-.uA`0Rtm]+rlV).9n2P"A]KZu%0@l6E/E,sKNp:'pga*Q3NC1UP$bq%pd2*Hq!4cOE$eTCMoqjYWPdqic1=bP$Mk$p83NGrpgFS<3@S&]j(ChdNaKZu%0@l6E/E,sKNp:'pga*Q3NC1UP$bq%pd2*Hq$*gMbM$Ug_j"pt\]Uc3Hu_r*SZR!'GZpF+FXB:G\YbRk>^F[Cf6#Wt8nWgTAQ`AQikq!r3kCW.FsaNS6IPbPNue^_7H8)ANk*)E`([0GrG>*e84\]XDgY#bhq61X9WKhqd)O@_[bK@ut=QpS3>KlBOHrpd-e4MrB%EmGiAS'tJXE9AU/S=Zb5-+p'C8IQ2L?37EZEeL53Vre@6%Q*XK3D&Q#?,r`N3CiLUCMaXn:)nYahh%<`Y7$9)jmPanN)aAoB5laVA%H[&0qV6l)KH4kK#t/8N/Y)iJ?L`>1P;.I/jR7;-hiof>=\g+`9@igF&V`D13kH;.o^>2=85cHidSbe:9+-cCW"pHMe@(L(-AWIlSXenBQ\7eb/rYb29i6a\]VUjS9(,Q1Zd&S]q2A'<#/94Bl;JCBOtOHXf)!1gt"=V)N`a>PG*JSm9b"WfsL=)p:7>#r>L4^"M`)\kco-jI543SQt:5=O&p$R$i!Z]=kAMY-+q1Y-+q1Y-+q1Y-+q1Y-+q1Y-+q1Y-+q1Y-+q1Y-+q1Y-+q1Y--p`+4$()m/~>endstream +endobj +4 0 obj +<< +/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject << +/FormXob.d16badadb909d4c48961b156b49a6d93 3 0 R +>> +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/Filter [ /FlateDecode ] /Length 684 +>> +stream +x}Kkab-빥 B. d MkjIa4|'/t@3wnuܯoXq]z'emv}&|p7v?Y,鷼x8ݛZx]˸㮿-OPkfm~٧yX鿟svy!zðZqb6[v^NjZj|w2\֦io:>kCM_4}Uӥt/4K/4K/4K/NϑMlsdӟ#Ȧ?G69ϑMlsdӟ#,~_~_~_~_~_~_~_~_~_W~_W~_W~_W~_W~_W~_W~_W~7~7~7~7~7~7~7~7~?8;;;;;;!^6v"Z?cn<-]__^T)endstream +endobj +6 0 obj +<< +/Filter [ /FlateDecode ] /Length 18282 /Length1 35284 +>> +stream +x \U8~Μ3s." >򢀢AK<4Sj{gVY>r5RԬ[mZkf[[dm?{9s/~3sfFRDPɩS'ZVe$B?+'!^T^;jnU 6D/FHCVxJzp' G r7*oiSM/@hD <Zqp֔T{bZo}h*B7:OMu"BGWr k`(LE\P`y?B5/*=x +ʳːԋ -5T3_x?av6!+5?ȀlEd@ߌ,ȊlȎP +BBP(r0"P$Bрk Eqr(B 7Q_Q +RQ4]2 4 ACѕhrL4@Y(堑hEƢ|4P!4MBhf1rC&J*]zގP <=ooEp7#/P44 ]-G9}^&>Nqke|5?|9jc_ȿ/D)f8qh-ga'⎠Z 0 9ADaGh v WB$|^1S#4rB&hoBR@v*tF z' i3O}BcN)VQ/o,/a7uodߍި7 cAmZmt) 1/o"0JH-g8ښShw"+/Vb)" #HcD %+FWa00Fc<&e*M0LU'4-er2T6*JcYϥQw{3))Qnn17>$II< vv@JpyFxwPWn8ߤ}Ixsfuf#%d6 xvmPjA]sߊ8a%ڗ`6WiOfK[;mFG,fbHmޟ pqK'^q'} ;qB7E[;ql*&,$BdN0ᐕ7f1BHs0Dќ +a361B|_ۓ&zF7ɾҶk?5rp€sJ+zp>{GY̛ǠeF#*13!Bh!`L&67 mIeb9KPx_4i#RcǴۗmߵ/ >> !Xuȕa UA ;hyuE8$XF(H, xlomY@ڤ29P7Q'%NGS?j-nu'JڸcڹLf _j䜜֧//N8 tG*Ju]&9I\aΐ.jgmzTߡB풹`=Tn̩9{2LxiEwE4m \u* +2s" F#. +DzuQ |DCghÖbV*+ZVs/j H/xLQ wVo]~Kf ?-)g܈xP^/?;)W +pVm+l܃LV?%ol5)ִyӑ{OX?/L 5Czh>M &ش!/ cg}zL]S[$=(, |\lx=xA`NqdX)sN\F rGKCdeR,AKiܠܡW N))B6޲js(y2O&/fŦ_7 + vvy JX)HO˯#q~;n8&''ċ`>}Lm1_[ئ}!kùBj퇜v+]iAeӡ",&LV947(Sfv𿩴fYts-#-X[lrlA,c_Ze7+!znpWkCcC_Qnir UZs{Ғmz )7/p0MF Yb!#'I5^ԱNٝ>3]l'ʙZ5aڜ-nܸosY?տ9px#rYڷ}|U`| AIn:$/Ç%.XAB_[ +^'$3V , +W"pzGw~^jMw]ɩ>q% GӾ4PiÈRA! w-)XA(Kc3k`Eݾ HNj9^9dmClE? XkVl[v9 {:CQ~4WsAOd3[5Ι^86zWb,w_2;Sn7' ?L>2GgfXBJw wH:ȣepn 0vZUVU*0כKo஠5W(_2oNn(['><~d-d+y/ +fORp..#t9d8$<I"0[|++ ÀXTNv$ȵ a"iq]o2!EaԵ |CvO Eil#c WțS0k K [mx., HEa4S*ʅj\a8 +mGZTS\Lж\^3PHu YG1> 3P`Br DOP٥8$`Q%aAJ?Ц^'uKD +[_˜Uќ-E7ō y$naRy ^e%wtdhRx:?; Na7)M +ԓF 9ÜvUG-7=|ӓ?$ +Xpf5qS `heJmV9x.^5,+(sv;29l/ot6ȴ90%bl5#Hg4vXuՂ |i{{+PY]VVC;aEh;v֡Ֆ́ξ|c%tl(9riTlo^l0M1N1O :Xi$7gˣZ9^gkVݫizn`l8^(ˀiOӚDEX̎@SøgdpHphdHHô,D  %x3l6HY!>|fo,w 6CÝaaa <Opq#rr1uyH` ŝZPY`8p= f6uvdv/sKn&BV@l rG{#jWQѼ;^awWRl4m/2|5ȧu$)=ZL5#z:5fN7卮PjML`^H]Ե1[[L["Emަn5E7]x;[^H~Rq4n ._V~Ve!-Z+ +I= HycsbR.'X[s"mu'5WӤ@8p(jKݝn3+Bfזh6|m!v`eHJ$u){܁I_c|qfw,h<ўu?Mݠ:Ml$֌H 88:* ly{9oY1gTJB!ZRh$EFhqLKv1 + ߅7CP4^jh1HCT^pS +!I8t:L. +QduрT~rc`Llk(?L$RTA$ ҔlJܓJla@e(P;$L\/`hJ ORC*YrI?JȖGo*7L'bP*O0N7y{RW5j !flMA{WhSzBgИacձ115p\^SzWCC1Ps@=HJU]U}sQDo3)F{Q{Ĥu{]m\ f!f1=&iBfۡ'}.it]We/ k=E6Jl.`ӑƝA W6}ukk-:8k=ΜVͮ}QcpȀyW q1ݡfaDlX0gF#fFEiwK]\U4޿gn?԰mTU{j>ы׬ ÚbPY./V5'.N{K\ ˵R\ltm-_1H]ܯ0FdECNZ=\/X fl6!c&X;:^k_j }bˑ',͹gNUDtb `^b]ьl giIc;\kJ 3Y q n#7 +w֓*A$, IYHa4.͆l#jy"V6N>AѽuxCI._hkŠ'X7᷵: >yD=(?kbVX*D/}ע6 P;! P 8ZԮ0ܠdGFE#m=J.n$ݶ!4}F;dYh*u[S kDa:fZ`~1vW}߆7ߗLsi1/rjwZ솝dKE3Q戰sooc7,c6]j3.o3g8eqie-d-8<,afL]t|E:R1%pA[⹧*VЅ "%%-OQqqֿd;GQ<7 CRl]FHFl.X`LS],,wi][dwYahX (bY\)psD'àЀuMi\F[~崽ڑO)<=iѓG L7/b:l:fj r(jf CwgS;=[RGwގ\Y#`q^}tp oļFF NGDo_Z$ꁧz"e6ViU\95C ۇl6 +!G0'~3H" ћ/oŷ[Uh^C+V?Mzt! `<[Z' ؟h}e담`[PT%KR8SR(l +0QxKyS ++!\{h0rl{p}Gat-( Aa^N)2M!rqi+g bX*S\-_/ԊZA5I˷4-/=hDz{l~/=&o1n3H=Ӧp3yU<' 礿3~b+8ƈcFi>Oӎk smo \_g 9?V_Z5<>X l^l εNT7"Hd9 G U;fn(yڟó9amrHd+3~~B/07ˢp6)eZD}xz_h]~$SbhY5pӺ]Q +L4 H!8DCa6v 0dX2(&l![y[[[̷YVRcye+l/r-aߨz|E}gV~:hL>et;fYQsH 0HA%{T=tUw<{ل5)UnzЪS |QoZX[6p Ppc怽_^JP@Pqr;9ۂ A!Y!w%Wh~>b[Yy}QQQӘ|]L6.H3ew]r36Q;_ 6fn- w-"+-!;w6ҿkumA/ ~׶#~𗈾Si`! kbʕ"%=k 5-b׶;I?F#բU٨5 B(Ω(4h͂*}P=uȃJP5J'A+UXg2YtZ3̓M(1ڌYК&FQ +}K4Q(RJ k,[ TK3u+^ jjJJ:k:SR֔&UUj!Uz=uiA sZl`X\OC벇ٿ{]$I-czW))ѧ0+( fP?ʡU峤^8v@}ſW~:c'OZf5e0C)ǦQtmӁ +|fR䧡V62&vmW3yVxz#t$vЙ< îqj?tlk;4թugY3y.3##)'@RO_9DgK]0a:YgeS]}Q'~ jJ'ǺTFs \acIOˢ}5;w#hd-QԍS?5d/S3|T㎎)iYw:-a*vtPT0>߇0u?ǥ4T|tjX Ǡ^0uu" wG0dY}:xם +*I N*t d#S>)b3]+-f^L;l$X:4L̺oG~?wsw`ӧ<;P~!uNCfh׼3 ^x$[}IλjIYkVձCk[s=J,vd&Θ^뫝x%,'vEe tp#a-,YP}}.Y ??zYi\ЕuL޵T%0'|p]V};B.*Py㵂Sa_W~R=HV4Ճփp=HY|i:k?z +_+?+)ԥYaf]Ia{u%2rٺR'EL]IAj]oYW괷u?^]z&V]RPUpʤ0a62)U&*SZ?YeRiIUʤ۪L +D:as;jGeeߪ)?ڑ򣵣пv =GV|_PZ-+>ʯp*>JO~ +MQgAaЫ$rZ6e\tjzGOHRWpIȪjeuSy:[Xi(iWVzQl:[00yK= LY%V9CqP H1V5QLW6TxJDt:SrjZa +R_eD:gNw%#)r2AY&_T u50 ,DqOiC+VQJ5eARJfyy1:bRvjL(Rfy|\4JJSu˒6,DI:RݟV,keTJ@@K:먁^U%u +S_91[UD5~|/T`ư ЫZVvQsS$KT.~yؠ޺z5cJ,5X2L^fy(F;QUr:?%zoj&6*zڒҹ%0BU_SnS=UQjθ"uIjxpܬ,56s<\&rFPB)53:&7?+Q͞\P=~2P[ rGMqԼܱEh=]8b\f-L@PT 2 rGL,T & 0l~n~N!̒=6@#L)9(D03+{lfD U% jD:x̼|j}feg t ڕ}mϸuܨ;NTxd ~5!,e{΀MqzH޲yԕx/u&+뙥C1O/`" |eI @A)`X[W CU63QKn]u0\ S:KsBZ}h,cT֔{}36 + l۠xf'2_:ߏm Eσ_)y yɗ2Hq3aQ~Ms%#WRt9r%E7_+)atJ/̕ny/ȕ˕ԟ+)]r-]xNJ_tI.[7)RUuʤ)K_2)L/I˦L꿒2)EǎG#_)H5ّ5;RQv\6;RMvDt$>ʏ&>꿐(??#QX=w M% J~ &\ؓYKbWk^?+V&W661/~Df?d[{" Bv81@p6*{cHp${p GQp cwʎ!̎,h@`G+ʮh`363C&lDS}Bz= e6RbG2#!| I "K`G""WM#m' +m>joG +-&ߎ$ߴ5F.h"5Y"|O?QOS' +{+`c|JJ9U#5FNiVrPdy?%(#w ﵒ.\_Zɻ wلw19Q8ϭ-yIx3 ፞OG?#0<>I^"F^}e3ե+/WW.F^,#؄fAs9 P+y³aTr`]8F?c3L3VDɞ^< {5[#43<$M D+d;/&kd[OFjQlY#lhdlrQ[!V0pyH#|FXN|<_%Nֺ5Y ڱZ#$U0pU"Y CW~YV!> |7c#]n,]S#whvvKM#-Y#K%7"7j,u% +^#5H# [udF"ȼ-!\hl% ᤾-&h֛(xIM+n%UdFhR#&"Hy*)G#e +)sDf)!".;LtLizjpFp2E#[$Lkʼn(2>^ +[U*') +Zɸ|0Idl$9ȘvaLkFIj%#sD[Ie%#,dxKJ2fYF]iYɕ2tYL22H#A + $Ä.?HH#YD/RMBZIs&/eO#)?e I6@7qз$:\B ҧ.#  !v!>T+E$Vc%1n^ " +"N!E"BDq/n&a1BbIcHFB$f n%p2h'õ]#2bk -6bYʛቹRH3R^14bЈ((A![ )#4^f 2ݼ o#oD>`Iendstream +endobj +7 0 obj +<< +/Ascent 759.7656 /CapHeight 759.7656 /Descent -240.2344 /Flags 262148 /FontBBox [ -1069.336 -415.0391 1975.098 1175.293 ] /FontFile2 6 0 R + /FontName /AAAAAA+DejaVuSans-Bold /ItalicAngle 0 /MissingWidth 600.0977 /StemV 165 /Type /FontDescriptor +>> +endobj +8 0 obj +<< +/BaseFont /AAAAAA+DejaVuSans-Bold /FirstChar 0 /FontDescriptor 7 0 R /LastChar 127 /Name /F2+0 /Subtype /TrueType + /ToUnicode 5 0 R /Type /Font /Widths [ 600.0977 595.2148 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 + 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 + 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 + 600.0977 600.0977 348.1445 456.0547 520.9961 837.8906 695.8008 1001.953 872.0703 306.1523 + 457.0312 457.0312 522.9492 837.8906 379.8828 415.0391 379.8828 365.2344 695.8008 695.8008 + 695.8008 695.8008 695.8008 695.8008 695.8008 695.8008 695.8008 695.8008 399.9023 399.9023 + 837.8906 837.8906 837.8906 580.0781 1000 773.9258 762.207 733.8867 830.0781 683.1055 + 683.1055 820.8008 836.9141 372.0703 372.0703 774.9023 637.207 995.1172 836.9141 850.0977 + 732.9102 850.0977 770.0195 720.2148 682.1289 812.0117 773.9258 1103.027 770.9961 724.1211 + 725.0977 457.0312 365.2344 457.0312 837.8906 500 500 674.8047 715.8203 592.7734 + 715.8203 678.2227 435.0586 715.8203 711.9141 342.7734 342.7734 665.0391 342.7734 1041.992 + 711.9141 687.0117 715.8203 715.8203 493.1641 595.2148 478.0273 711.9141 651.8555 923.8281 + 645.0195 651.8555 582.0312 711.9141 365.2344 711.9141 837.8906 600.0977 ] +>> +endobj +9 0 obj +<< +/Filter [ /FlateDecode ] /Length 695 +>> +stream +xmMkQb-]il PK:ʨ|gI.me&?LL* t <]?iS٭w;Nt~{[x:͛nu?.?L <_]]ys3,&o˿?wL9zcnr}ӱ[d>-y/&uM7<]뽳Znm,ѩt[Ex3G;{<@T}շUemթjZ꨺򷕿mo+[VD2J?~2Jӟ,e?Y$>D/4eQg/4eLB/ B/ B/ B/ B/ B/ B/ B/ J¯+J¯+J¯+J¯+J¯+J¯+J¯+J¯+FoFoFoFoFoFoFoFoN};N;N;N;N;N;N;N;AAAAAAAA_7u`W`Ͻezxrv}~ٗnendstream +endobj +10 0 obj +<< +/Filter [ /FlateDecode ] /Length 19101 /Length1 36272 +>> +stream +x |E8^GwL=}tn+0 ! 9HSK4 b@tQ*^_TɁ|~U͈0BȌ#MHH9'#pVAXo D2^->>g $=zzy%Sx"4[iqAQl^8F)_)4~>tVܯ^I~x{fV$]n +V +Eh "`VqX'ݦqMAhTVW .WBH,EX5HdqQ qFR@ *[vCʏb.I/Bnܸ!9U'ެ?G{7#&$a_-(ҡӌ,ȊlȎ9 "?P +B(0p"QF1(uCq;QO %D%dEP4hnCCP/ah8JG#H4 +eL4eq(ݎƣ (MDhw;Tt4 "TJ8Sft>/=h w^hi"rT -x% m;et +z֣th?+t砃#;qN% 'I_N +B5NON8Rk 7/=Th2=* ,=zA(?vpq +, xmOZ4]=7R5JrW 6`z`: oe&oYD7/'F(nhCph0IK"qq i>v['I"D{d+m"xl t#lu1z(%2YE&1M [c[pDfZpop+.2]rŤ%%#Şbwf l~)'I{ؾ ӯdܸtn}P">`Sa7FnCK%}`BBCP(a$<.`viuz">>2"w'kMs TBr(HbFȽpZBi/GlnszZAO_1)$3/|1:Q?_;EM1鿣Ŭ5/5 :a F881q 7ޅycJ Уh3D6\zn5u'Ac) v%ŢXgttJHdH4ڐ0.)O1&:| ;i̞Tסux#i75n6IjJڟ2M@?Y":k&Pp<#/x:(M:b*ZJ/,Ĥ?5O1qe+CݤjމUg&a/~pOu(w+!V ++e|?Z" !"HmvbX)D Z\y~K@r@&mA-f y`ʚdzk.$]pJ%r:"cH>dAe˛64(9>V|:裿nm*xaQx:G`W:$:\Nۇl/[&9.:>鸍 Q;f6"d B&n$cZ$Sd +F,9l_H:wόq!\Of=qy4}U݄TWꦁ8 +g j=wbګLωHK@~қl[!xq-vl;) +m=0h ~&-;.Om_2^ݫnoGeCCh |KqȲ sg}G0#ϝ:l~x#zDW E4l%ӄ.TȮ>3i + +,͗s>R5:V4\a}DA6Gr$,$6 mlU[{ {ؠ~s 6Im8NɸNnR:^yx>~>=nWmHD 0)'lXfpEcJF@A+ +djp7:TţAo£[G5vfQ`D.vQlכkY"9Ff.0z_O,\(/;ƃ'ǧ`؝4Qsu;@נRm`uPMRT4pi]hWa4f<;'' ,+Lv_nF&}Ss矞)v^ٳgv଍s6 {wҧܹ2D+ ȼ*ݽG3)>M.saܤ\+v=샨3 8FSgAclr9,7UHlF3rޘ;j#^~3clzFyW{'mhqQ1=t[fjF5`VĀM2 +^c7921N#7Ao2v7hʃ(pgg%ϧ˱i =&(PI%X9f]btdk. edTܑB^g l9l+ͤ -1m 2 jkŝ%cAlf@HǨM> ב 2΂ax5OwZ:=?뭟xxfժ^ԯDC[~J򑐹΄|`&ʈMP` w5Z/ty'IzR8!d*y:%zIT[ +O%Wl۶l|4SAy6AAGx~~1i_?V4#T*H/ %.A&T[ qLH_' :9s;&,@1E%N~I:8|=)%gr-Y*xq$ÆñRSh&ձO&έA0"gf] +kle[QZ=A62" +/oj͎kܡX{%?~77G]tݺ֒6o~~sr`ss(7ӳ ゚KkB=&RP@ChB+}Úlk}WG낃}BQDD P_yƷ%cAǂ8AAoswxKJD \x?sЖfYm<®>~hT ++;c_ܶw^}b4]yU5`EZ&z"I,z\т<Ob^T:=lBUXvED@xrEi FN|@$g]9ABc*Pa%m4Q t%DV_L +όEc}xUAM U0:|}3CHP$cCKR{e | sMO캼Ǔyq!mAQq @$m%Bp$B) +S:D|P=Xk|LA ;ՄCx:0#* ru>+l~kA!f(ȡ(|QLO4,&}xt"<6yå+l' 7w 0Sd ן8/Ll +zyz{LNKⵛw^aGS-sennB>Zn+>>$5M԰;DwgZ$[&}BQ7o#[Gn񷍵*tOOn{R -i ^eo&"{v,|ܚ74X²f ;,63 웬dbcoif~sV81< ookk xzA .U_AcV*ǖ|[:ezju+~4gq+$ޱ]567޸6&sY޳^*]m_fΚt{Ee  k[7㿍'EFS&`z,vN guJ`=,FrU y ʐ)h5 /::OϽH ҬJ:ފ&٨F_@rdmK\")]v3-/R&ҋ@f +į(Kp0QO|Dd {PCgN7y+X)@4Ui:<{R={_vj$'׸C|SfۻV/'%ꐊ{+w{vN#o׹~@}r? `Ձ3JtpUZ‚rO^ү~+Ɩ|R/cFK?)O\vԳ>5jކAxHnGq.h 5A9}+jsoPKl@L>^8T7՟Ofr\R@}sY䧂tKKat{z7@r-\mR{>?P/.k5h37锶Jݶ-uԞ#K; H\աF$[/T=]Z4HQ,_UqԪ7Oxal]^R?>qϮ3xζE&Mx.ǛFwߪ.⼍w;Z"K@8m px sdmqͷȟz-oB`\x▍6=`9Q +[ 6Qb*(A-p(c$H(GKL^J# Qax03 >HR8Vd=B|M]tB]"iӫzaH@o׳dwCuvVgCu$XVW&;kUsI~dn+#%D'a!M“b\&͓FYfqE۹lhQ/l _!= ?eg;QXiTc.*Wb5wjc8 +u5Vc3f#c8ڮޡne0C"A|@?!6YOY8rj^KS ^s0Pݮg\"K[שn:qax]}KLhuܻbPzxƻmĊMfͦ4k3c5Y! , +Jy[ gTJ +^:mi bʻ6x;e_~s13U}s>(= y%;a“& EWh^bD!Gr@`ꔃMBgt ])֎ܡ<E$b?B.$OF8X)F1(p?2 bP+Y!="= K}~>lu_$WX.V`!'ro`Ժmpcxцim+3ÃGȸK˖-g6$|c܃&b1аPd&CFǝB#Z YPCAgD76c.:hꡰqqTx E쭞߾{ssv'3XbϏo^[﫲?_oⰀgsboGY}a==c]} :4m4g-m/^vY}IKz֗|! lҰ`J=+Eۥm AvU0:>%Ƴ#WOF$6ZV>X5`%GbLҾBgH15%ZFx497Mֻ &mL ƛ-%d:-R.__d(s@ \:GItsEE:ROW+ 63;Xe"bR4oN9Tw%v@~`vY[lKܒYl;s"L(CiٓN ] tHAD a"S [_M$^rWڳBYN5zz,h[NCh <1&";QaOcɌDt[o3poaB2S"q$$F7qnvv}1#6_HG-ÜΥ6-Pu?Y?z b3)ifǵ9w?螦HEP9& IfU6V_-]OwVns.K-%7ԨGF_}i~q$gH%L_aC}=y@ëlzmtt{6 k'KNۃVatFԕC uT=jI|] +/zn~e5|wY -Z0$/x_ fk:8+pMtn|@dagն`]|0Ia_RN yO txp -Q :m۠cGI؛(uԛv +RkIO{n؆G`uoC/{dT}{ zNñ`;١BZxQeG>Zّa+p&T>g ),cŒ~ 'ؕ##Lȏ zc;c)odWN V]$'F uCanVó#c+cԇև+#6^ }B~h~X^ZVT/]XYy6^H RZ&/wIŦC{:&Om?SҔLKL>{0.mɞxcѪ^ƶ|j;zd5w6m @HsҤ+pD~)P~ЦP +xz_U \cЏzgnx{& '>d|QzT +E7 TW: +]8 +w3L^l$%6- -Z$NI_)O~ן~,k쎩m'{{{Ys%K4~FaQҌ7BdaL&ί.be.Ng&wqgێvCo/**ñ> T(U 7WX}u}0ݖme~!~x %\6Z2!AԛL's2sF)[5r:šm;NblI]ٮ&!XK#&3"W ;ܔ]ZК@izik5W6nGB :Vz8&97%=jrAS _y辸{SF:&a8%Ď;voѬ +~8r_)7ΟU?P? }.pHl{=G (o@ԂzٴQƐkd18OfKlW|CG5l7EBQ}5=|8ڗ_'$[m}i{[lZ\ay0.[S3gQ"bF^he[m׺ROȿ$9?x7ΩYpw%^|5=kDiMhCXCBn{=^irzڀObDL>RIp -CQ[ԣ] i^XaX.7SQksgWtsG]n|ޢ;#һ1NwA|%cu9oY26+X6zABo7 2  +Rp&w3vIp&v ąGu:S( D%#5Q3P+HhbMwwܢqk.ޛ K7>*kה+ڲ?LyufKW?~?0t_n99pKM+|o߼dG[nwKq+ +-Jw#;>e|OR<^m#g2 e c_|VY7!1)'b;~<ڗGζm^N\o[Rx[ '͇yuw͵ I!Z/Y`Q@:kX4v=S(Fչbl'z6xM< +17E@!QjW4C!\h/A% R)__j@KrZ'm@F>"<"nvOIoxkU8W]W; 9t&##;C':'z/oQOd\8w5F2bYf/2$v/#rGnr8QΖIX"idyD\/%G_bH T7dr+A 2ՋHKÅh1BEc Qr1ҔB )HF[Hj FVsdr%xi.[?#O4"\Lfba8C+7*,# \a!w4_H7W?ϰȰ@m\hgǖh#^O-£"[5٤w'l0mD;v>-<-vo7=cy( 6~ii!ҷy\'0Gqdnfٿ}ycMtC БA`G@Gx{Ȗ3;t$ +`j' v;Q1Aad=\I +bme׊p^(ȵnZY%kȂ  +.9FM-O&&%l<_ʛmFy'-<#=)72Dc u.C1ƈцF<b7'31061k%yt+rA> +',yl{ .!r̚o_kk]0,7.74n24M-ۍMO[d)Z6MzYu33sA-}cQu9BVz:S˓!:4܁zm?8K~7:JwzQw$|o:2dDjAz؏__hnc>'%4_1u)VKge b!Wuh4Ђz@X`uhCbp h7>fpy6d(">ĉaUf ڗs=5ų7b T@^Hi@(*6R\䩪=|!,W|{Cpo 0)f_lin^}졀\}p2MPC>)暎1zO<9or/ftE_Xtɪ2P;<dm6:8 eŐ" + s SA7$ g5ܒ77F5xyaH^C!xX=qJEŔG  ̧Rn-<Շ&5{rcuC/]TS9H ۂLG6vE1MR>c?_ se?luGw['zˣw>H T2vq%ɗ_ n9itT[Gv 07y}B*8 mL"Ɇ&)(*M4ٯ2m{Ԫ;W.a&WZ^]ZG`㈦!rM +11dׅ8Xt+pt,-E".6\##H.pJ+'H įի,\Cc~Y <|1*}U_sJ]6t޵ҾrǬ:$0m +GnPfzNZ3-y7ll=,5!nbNǿIT".@w +8NT/n@B d "݇^ڣیnCKH2zpl [ɇ=ñ-p4 Up,`xa(Z8ՋM\dxIqp7M)&76C?>p:Nigڀ6s oVF Ek ZaE;(]tIʍsڵ:څx# =PD>(kʅ v-$ɢFؙPBe\z,T1$d? XBpL LD1W|D-"tKu}[ zC7Caa/r׸x44״[s9<׼2Rb``%[m}}gNY|>N|߷;'`O|8?{PiО!YЯ {+eY>$|fz"鿉EE|~/(&^}'y1z $z!Ûڵ="2|ϵdsGvvmD!y͎ǺM\[PA<=㹶#ak0" Z"]cOx /<Uϵ|IZDdZBN2sG!ϵ $-ks@깶ҁW=6;hڎ^@PDP*CӁ5HAP!sJO2\M +J>5*T +,T4> +ùޙ #F>j4b|T?6pZQ} 8bFH(LeO+` **UM/Q)IʴyJZYMuMUqx%:s2VWW..%~՜ٳfTOW +J3 +rk҂JAURVTNYVU*(+̺8rIb*=pQQqϏ{ܮUp&ϓY-\Iܿ+jMiGTj˽6Ajؾ[Ѯ@\uw-]F=ǿgZ4_8 +ÅQ:Tʟ{G)H=#wMZhi/Wz,Vxṭicq>~L5^5݋%\r"~x;dn8˟鱤n8vÿW~6bOXK%"ŦSPumk1nOߥɋ졠C +~<]ǹ-#rt-~]#rs(bNw9"~-aD;7!3oed_q{kz2݂c0{ֻ>Z*rpȷR~X5n廋x$(r̯[qUĹ2Zͽ7VwXגX0=b%{tĴxȴJnNOTMH'sjJCYpw9h hS Orvp.T=8 qh"p792ᖔxuέCna$/s*|L->fpZyx^ӉNidFMT*]W*&q;$;:呮>r-2b:e=FFp|bu׎YWz w)Ӄvnh=tk|SFYcG69k;gκ_G6[bV\svwvެ#|6'\۳ +Vg&sӎ^驝Tt籑 xo:`iyehշG(;3JQOfeo {?ߕrKxiUЙU\ޕT0'{yV!L'ZmMR>m x0E2jxlL+o_uW׬Arz͙׿$߲Au ;Qq[UXZ]IN]IJJ7Jr߫+ɷ/ԕ[֕:(ԕgJ2GJNʺRu+}_6?2ꒌVn]T҉U&w|I27U:*wL$U&Ve9rhTxɷv$vjG֎:j@ڑԎ~vQ[Bŧs_Ywl?#wP_Q|74|v |۪6SU+ӊgV̉m3UV+e*+jYJjUl&0|#]01znqU־O[F. YU(%7CYe|\YRZ\U cM*(v ^UM/Wj*yJeqU5P18V,(P +iz֔{TXX15\\^ ܋,`EJAuuEaY'U*.)a!uc ʄ98IUqeUEQma1STM)f8]^1ά-b))dfyb#TiП*fT\AK;LRAл P 9[]#k) L %U0`1BWk(.a-lŠ2FG@Yp*fs +4-+AyE ZkeRR]Z0s<5@ Uʬ[̫,.)ziHu}:`X ^TVR`f \Ђ"N:fUŴ* TT\]61]UxihA!foxy$R8 +f/Nj.3reՌL.^(+/ͩ*V"0} G0,dzeZ1XZ 2`<]Q֎X̫`b@ C(5JiA5@,.u]Ԗy@UiT+f2bcB*Pf2XYPxOt 찼Bf)Ua3KRҕr FLJdLPǏ>\HʤQ&(c|jVN2nO>a'cJ&6+#kx%}l:1rTN<rcSǏW8 y»,^0*53SIș3>=u,˸32ktyĸYSs2e)i@JjZf2,35cl2,'29eMH}"4@?Q| 1g Nθ9LʘϘ$2b8@sLxY|Xwz=OO w ڕ>Ǹ5ݨ;jNTxd9/!,eyqzHޢ++3SV-B +-)3a0xY`&Vݎf ^SUVD)֪0\ S6Js*(U6x^з2IYyIE,}5B2/+Rdg\tcɃd-R~J$wAÕA'_!U{c-ԎEgr%ś+ߖ+ɚS/̕\I%/ }s%S|KI%ٓ.)T$wA)\^)/MdOʤI9eR~J$2eRII;zC;uOʎHfG?ɝ#'eG-#Ɏv1GGG$>2O|?wA^wx8x뫕ua0잲2pVs{UV&x}Ehqo}zC|:q݀ktJRNp*} d}B/MХiݦҭĭ*}D< n)n6ItQ̴^j@ׯu5v Zi*yY,4<-6L nhJW%V^ TrQ\+hp..Ul]\ҥvDUH?[PJ.9.qA4y*ksLtLkUZsV_UhJ+TZҙΰ3&2.D*-RiJ` ͿF2ѩ*CST7Y2 NJ*#OL9.: t>G]F:NYcmbJhJ1*aGЌatJGlp#=ah 4u utJ8mm:xYatJST:Spg;iF5>4Lz$6h FګAe= 4>-(bhbn46D+6(F4JÁpUh5 +$3 4 LpR"S/@]*uGPhQBj-MY&mF6jPUsR +P pQh*%pOzRlH-o#B?cI04endstream +endobj +11 0 obj +<< +/Ascent 759.7656 /CapHeight 759.7656 /Descent -240.2344 /Flags 4 /FontBBox [ -1020.508 -462.8906 1793.457 1232.422 ] /FontFile2 10 0 R + /FontName /AAAAAA+DejaVuSans /ItalicAngle 0 /MissingWidth 600.0977 /StemV 87 /Type /FontDescriptor +>> +endobj +12 0 obj +<< +/BaseFont /AAAAAA+DejaVuSans /FirstChar 0 /FontDescriptor 11 0 R /LastChar 127 /Name /F3+0 /Subtype /TrueType + /ToUnicode 9 0 R /Type /Font /Widths [ 600.0977 685.0586 589.8438 1000 634.7656 698.2422 549.8047 549.8047 600.0977 600.0977 + 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 + 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 600.0977 + 600.0977 600.0977 317.8711 400.8789 459.9609 837.8906 636.2305 950.1953 779.7852 274.9023 + 390.1367 390.1367 500 837.8906 317.8711 360.8398 317.8711 336.9141 636.2305 636.2305 + 636.2305 636.2305 636.2305 636.2305 636.2305 636.2305 636.2305 636.2305 336.9141 336.9141 + 837.8906 837.8906 837.8906 530.7617 1000 684.082 686.0352 698.2422 770.0195 631.8359 + 575.1953 774.9023 751.9531 294.9219 294.9219 655.7617 557.1289 862.793 748.0469 787.1094 + 603.0273 787.1094 694.8242 634.7656 610.8398 731.9336 684.082 988.7695 685.0586 610.8398 + 685.0586 390.1367 336.9141 390.1367 837.8906 500 500 612.793 634.7656 549.8047 + 634.7656 615.2344 352.0508 634.7656 633.7891 277.832 277.832 579.1016 277.832 974.1211 + 633.7891 611.8164 634.7656 634.7656 411.1328 520.9961 392.0898 633.7891 591.7969 817.8711 + 591.7969 591.7969 524.9023 636.2305 336.9141 636.2305 837.8906 600.0977 ] +>> +endobj +13 0 obj +<< +/PageMode /UseNone /Pages 15 0 R /Type /Catalog +>> +endobj +14 0 obj +<< +/Author (anonymous) /CreationDate (D:20260505080032+02'00') /Creator (anonymous) /Keywords () /ModDate (D:20260505080032+02'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +15 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +16 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1370 +>> +stream +GauI7>>sNR'SYH?(&QCH1NrVDnLZ/(]DrV>2+@\P[Z/Y6>*2"='GKbSWM!fdb+sFOJ:=LI]!9$0GL[93m^Vs5$8kp(Ag4=7.=KNH/F-PV_i'CM%BOZ%F^!j%U^4<9`KcbGi6iS)n1Pia]i_mVYki8\j1C>LsqXaaX!9bdk"6)TRUuL.>,HHRP!If+^Ws#ffTJoaakpn^RT==JD()tb7sD_0e$C48AYf'647mAJ=lT4_3kg$+*(kCW,lrZ[cU;q6l2H%Lk\-*7&_Rhe=%#E'guf9F2UA&(0%h=G'IU2"S=SnKt8er#n/Bt17M+:Kn$Y?6>85e(mJ_LeHH6]d8!T,-+%(./_l/LE.uA("IdE5K,f1;JC-,e_O8&d0,gmU5ZGo^mDa0c:)60W?IE[>qm3m6eBOVL;P4:Z?:-bhCuGDSDrq?=1;YiR\)sqjU_7BYY-JQY3N#uNHIPla.\QqQY2k,Vl?B<:neZaVNsq<(gd:G.U.7X,<_7@7(OgJW&(RdM`6?l(&RCV.:,+ugm#WGQ2^JLqr/L=oR=_2UF5DLX=HE:!Fg*NF_YL7ne*5/A\KFrjgH(?K#UDC`)5MM8l<[BnB"/0d-,`&:L$do\,sA6V\Y2/jM2Q5[ih;@CBYM1O2o3OLPljh_>1^!op^\p8%i6es`0e.?Pe>3^@AR9DRm`+WS'P?O>PGY;$)0X`A4=D\f!I`+"V<-G_@o:A_0pC>*ajjTh#6Z08fPM,!sSlVpQl$YUIl,+4,PKD8fo"siYVZ0@tY`6II94s>Lk9H4e9:\iX0L+l8YC1ZVjZ<[%dCYluf)HC,4`:~>endstream +endobj +xref +0 17 +0000000000 65535 f +0000000061 00000 n +0000000117 00000 n +0000000224 00000 n +0000008534 00000 n +0000008802 00000 n +0000009561 00000 n +0000027935 00000 n +0000028203 00000 n +0000029554 00000 n +0000030324 00000 n +0000049518 00000 n +0000049777 00000 n +0000051111 00000 n +0000051181 00000 n +0000051443 00000 n +0000051503 00000 n +trailer +<< +/ID +[<35a914110fb05c63867e39510b6a9d14><35a914110fb05c63867e39510b6a9d14>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 14 0 R +/Root 13 0 R +/Size 17 +>> +startxref +52965 +%%EOF diff --git a/_data/uploads/placanja/invoice_1_HUB3_20260505_080038.pdf b/_data/uploads/placanja/invoice_1_HUB3_20260505_080038.pdf new file mode 100644 index 0000000..3d05b72 --- /dev/null +++ b/_data/uploads/placanja/invoice_1_HUB3_20260505_080038.pdf @@ -0,0 +1,91 @@ +%PDF-1.3 +% ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 318 /Length 8119 /Subtype /Image + /Type /XObject /Width 318 +>> +stream +Gb"/a;3L:]&H[C\9A9]=fPjP7Tj%&Vmk..mrqkTRX/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i:LrF]+DSm54;Vn%QDCbnnG783EqEHXOEQ+@_L'*0mR7d[="EY=O,K,/F,.TVHC%n>>3mU9eE"")O@_;(s=t"\LH+qr"bJT<`@2>\VB3^/]P(k@r@1s3krYpNRcPLBs(l)>Dobfe!QV6]i``BAkg]$4;P8g2//1_[[aIA7_%44I-9=e8UBtY>?R.*3G-If8nWgh/^ARsc'kFe^Ts+^Bouo6S?6m&\D-"jkM<42HLiXk=d'U]B$8k"B8!lKc?J-iR\VmWZB[:]O^khiX`R%BgZ!VDh5j1oo.+$JP86/jbLoL#KY&h)cF]"r#V@+[bgMu6N1%AaGaa:d(=W(2EeG2=F(XZ26H77+=?K;E.uWZi(-+^>LH2^5-6Zl2D_.J,Tp`P\3KHBXFFZSrXqPL.H]3kn+Dd,JKZ/A4WrR+WF:Vb(Q50qI2G7qhEHB9qC"ABaD"`R?*>ZC"C&_4"P\QLcBFHGc+k=hj5aO[]&b(9FK(BK8koX>c(uQh@E)'3U!,Cr)QDKU23TbBh'nF'69C4HW)-`YPYT7;+\WZE-!D3LBE>c4a?6Df!7Y>(i45,g1MOQucEFl9(1tpr4)NWC:J\:U7&\N&d6tX3MA]KPt\SFDa!"9tB@5=#h@g`H[crYI8+*2k-D9h"$Z0S.EeP4Nuti>;Vn?9i"]*q8lX^LLi1]k4>W*Ra00TV^]+Qj=9?1Z5=,Dq:-6Tk@W2lPZrTWQ1JEeN/.6,C5\s1Zfu2H/O-egq62Ei_gIn)RT\gWnc-A]Ft`!AlYcObmW2R_Be>c\cq6O)(\>ak[sWAn3A'R((uIEnr=>h0i&A3itjB$X2emojU+qmZu2e5,Vt_etVsDXn:(g0qV]pb@c$ke$:;>k/<_F2tr=/VQ!?4VUJ`(2tp&H9iIZH9W,;.Dsj+oS#/ApQqhF:hqU0h2be_k0V-eS^:A1YDjpO`@61O0I<4<jUad5/5))ht"eeOSN!?^N$NZkPG)l]78!Cs$PpYmJOuCY+FtN>O;>@Du:-6O1"Qu(V>!rcYp)c\%M5qA3SSiDd;XgCAofWD^JrX\Sl4?AQP1dCKUJo[\l>8_a`=!J2##[/N:D;)b;2$-YkDMi:RB3;10Mo(.=6-i)A>H($RTs"$db,Q*9l;]J4<_)#>!BK'^VS."-ZmMkZ_l;;9sJ$8FI'mnRTHT0Y0Y$HQSh@D#^:VMZ!Ot_QGn6?=00BDcbqi12+b7_GC)'L?Fb0D`I++UXMq?g`&X/A8[etLHSMd3\TF.VI%"-<`NIP*3L;Tp[g72)0sP&qFI'mnRTHT0Y0Y$HQSh@D#^:VMZ!Ot_QGn6?=00BDcbqi12+_^IZgO#-VHam@N;6/;fc,kFr*DCTo<[)LeeM+9M_2qr-I1t;`I*-<*7+F`CfTeY.@8!h@m1Q=p;UQ*^K6HYr%,:VFiBTg@Z)t5M)0Kg0q[>Ra)c\F>Lg;ob%7isR*W^=]s3sNn!2on5DuXY>qtHHfOE';ilp7]bie`CnMJ5!cG[bLPN-*9Q6n`F5.N2@8QKo)?0>LbaVB/dj'h2<-9TjC\LXOKLbaVB/dj'h2<-9TjC\LXOKDDn%rZ#o_]3"/KAr/Ci?!/b\FR.)47@7l%HZfX3EX^98L/B]6EP[!MPDdi/i=m=KV7eh(ZNN?EWM).f6HudNC(6"$j9"3Y2_P\o#AF8q]S6Q>ZNZS9$8!&!N8r]jZRi=4kUJjdA*7FNZ&pS85rcR&kDq%3p83_FE*/cbu5cP/R]r=1MX<$c["j\GJC$Aq\CKb/u;J?0-B8/JthEipQE.R1V1r.kA)rFd;9([:%3ZMn@;VAk'jhXK:2delAl>eUfM[/abgKR2I1>Z*JD.=]`.?S1PeZh0iCOdq<^OHt-G:SbA=5Y9pQF/KUeFI9:gEB6)OM@pLg[p6@kG-F0-n*i@bA=F/G/En,PI>qqoDPs;H!ZCEKfVVQZ&HIHMIeU1k40qJ$'5HcE>S1PeZh0iCOdq<^OHt-G:SX/sEL<41F1$S]=jYdP:K:io\>23`HHOP:M5>S]=jYdP:K:io\>23`HHOP:M5>S]=jYdP:K:io\>23`HHOP:M5>S]=jYdP:K:io\>23`HHOP:M5>S]=jYdP:K:io\>23`HHOP:M5>S]=jYdP:K:,HF(R9/,)b^SQRuu[)A?6/;n.b_]2IY=75B.T;FD;B7uQfZ^#+llu&3NC%f\t*TkgmB'G9$&nAAU/=tZ,F$.]R\Z`nuRa*IT&R&YfQqu2!V)(G2N7l@ZG]l$inMGUS^(0aK3jWotBSeHJ=Vf?Z)`G[&F1ok+lr20tT)2h-kL5m6Z'QbPN?"#@S5]$kkJ#Qdq6[#;lt+:B,KF:SkLBFt\aMCq5-=&gHWdZ*aRNBX3AnQumX%Zu:M]gH?C%ZME]fY/:eV)Dh!,n`1Hqc8cq=c!IrX9kU?PR$ga1-asq6cLG"Op<#703]!>Hb^=Fn\]'qWNlP&(=0?8dRHZP?k*+iI/]O<%4I2GLE_W%K9Q+H*?b+.WX8E/c1M^E1Z#iWtL\9A<\uXUqel@/$T:**kZ<4[a`l!,i2VhAN1$^=(J^;PD=BR32''!E6G+7nL\!V<'hnWl:/Z^LhDMdnf?FrpUQ!;JDb&?2oZPHRW9=Mia=KgPX]?2^;?0+$7R99nJMksWe4KMVj]9U[ujI3A*Q>S^hFGl7V==q6mn&[aEb7\=1/]QZ.R^+K1DE5%FMida:/\YNZaZ_9!&nB3=`nodFZ7M;Z3OS^hFGl7V==q6mn&[aEb7\=1/]QZ.R^+K1DE5%FMida:/\YNZaZ_8V.Ft6hL,$#Vk-MZqPAh`.F4rf<+%pNWFi)ANREt:b9u;aScY<:;Hn;sNeHn$)B0Ns)1^PO=5,[e3oq4+lhcH"?P=.M%OFQTpA:Qo`Ak&]6?3?Ln4>]mp-d[AGL5#&nNR*TLDjEtu_2Z3rJcq6OIq6UngM`KtK-TnpT3?RD-f!#F^o[B=+CVdJ`KTUFag/KRHmVog\M[L?Y0ZMT`luAk/Y64N8X@nTi\S,Sb,rLcG("Pk0P2ZoD"6S49sUkg';*CsY&[7OrDI^A/Cl>lgfu_G)!f-HGFbApi//+MX"pq2egner>W&u-lbs-RSY,B/M@j$m19iC]/'$FM27FFg`<^[YTC:]q&R)4jYq4HVa4soW[i"GM#^;i3n)0'8>,sE`/Cl>lgfu_G)!f-HGFbApi//+MX"pq2egner>W&u-lbs-RSY,B?QWVh7HZPhA#VK6eZ7B1.B>NDj0>+q4O(i?CMJ"$H8V0MAT_Wp4I1&KVn(Go9s*haY@c7\XR8c@a)]Jq1`&6UWR]hieSNqR>ID#8jhmI,KN:,Y['!La-C320e'tj6@4mj`[9u^uKN:,Y['!La-C320e'tj6@4mj`[9u^uKN:,Y['!La-C320e'tj6@4mj`[9u^uKN:,Y['!La-C320e'tj6@4mj`[9u^uKN:,Y['!La-C320e'tj6@4mj`[9u^uKN:,Y['!La-C320ZR]g`h,P>R[8/SoEg:FVHm[.L24D``jn>*I=`5.<[8/SoEg:FVHm[.L24D``jn>*I=`5.<[8/SoEg:FVHm[.L24D``jn>*I=`5.<[8/SoEg:FVHm[.L24D``jn>*I=`5.<[8/SoEg:FVHm[.L24D``jn>*I=`5.<[8/SoEg:FVHm[.L24D`LZ0m%ck<)cQlROo>X=K3j^%kYLVDkqk4B@A9T8@`:X=K3j^%kYLVDkqk4B@A9T8@`:X=K3j^%kYLVDkqk4B@A9T8@`:X=K3j^%kYLVDkqk4B@A9T8@`:X=K3j^%kYLVDkqk4B@A9T8@`:X=K3j^%k["M9c--N/.42KU0r[Bp16;D@p&9\R(I/XdsN93?Ie3@;q;cir2Ino4Y*qQ*Wjt'SF#2!Sb'N0\>L'cc';!uZe&U0&pVq5kAm%i2Y?Z:p3S/&`PIkT+=?G^&=O,/BX"!A"lY\`GH;/X)=%scPkM.$"\X@"dA\E-?/a$BB#uBuhl!s+NHXf&pZ`L%*=0B0sSEi&ka^$cPXIXEN/E^W2eWfaLFgml^]bo)VMIkn>=g+>OHXinY/?8s7R9VIZf%nJ`p6o6kXIM*4Q#gPUXE9b>[9u+\/*>'e>?T3WP:MraF(W++cKpOUcEaMeN+[1MS6:%dX4_9/`NjO0XB3]8]s6!gQIUTW9Wja=lY^8kHVLYF;B3A7C`\Y/7cE+XCAbE?R/EY&So1bEoElG>Q=01$A)EAl$Cl&a-C[AcL1/G&TMS:2biGV(!C>uZ!P%XjER&u,t;RMZ2:E?4Lnp(bq%@\GE!RQF10!mXYk:Z1mo^m/?='8HL7ktj'g6!(nn@Wm[GQpjGIM+Y?8!a20(h'REr>ijFQT*/g?cOH3+6^ub-f=\jK=q'=?:NK2/RNo-(HYJHf\GlG]Hm1(+c":kF+<@mh/B_S%WhoE]gn5AdapMS9%Doh00/QbfsJ)(-.uA`0Rtm]+rlV).9n2P"A]KZu%0@l6E/E,sKNp:'pga*Q3NC1UP$bq%pd2*Hq!4cOE$eTCMoqjYWPdqic1=bP$Mk$p83NGrpgFS<3@S&]j(ChdNaKZu%0@l6E/E,sKNp:'pga*Q3NC1UP$bq%pd2*Hq$*gMbM$Ug_j"pt\]Uc3Hu_r*SZR!'GZpF+FXB:G\YbRk>^F[Cf6#Wt8nWgTAQ`AQikq!r3kCW.FsaNS6IPbPNue^_7H8)ANk*)E`([0GrG>*e84\]XDgY#bhq61X9WKhqd)O@_[bK@ut=QpS3>KlBOHrpd-e4MrB%EmGiAS'tJXE9AU/S=Zb5-+p'C8IQ2L?37EZEeL53Vre@6%Q*XK3D&Q#?,r`N3CiLUCMaXn:)nYahh%<`Y7$9)jmPanN)aAoB5laVA%H[&0qV6l)KH4kK#t/8N/Y)iJ?L`>1P;.I/jR7;-hiof>=\g+`9@igF&V`D13kH;.o^>2=85cHidSbe:9+-cCW"pHMe@(L(-AWIlSXenBQ\7eb/rYb29i6a\]VUjS9(,Q1Zd&S]q2A'<#/94Bl;JCBOtOHXf)!1gt"=V)N`a>PG*JSm9b"WfsL=)p:7>#r>L4^"M`)\kco-jI543SQt:5=O&p$R$i!Z]=kAMY-+q1Y-+q1Y-+q1Y-+q1Y-+q1Y-+q1Y-+q1Y-+q1Y-+q1Y-+q1Y--p`+4$()m/~>endstream +endobj +6 0 obj +<< +/Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 9 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject << +/FormXob.d16badadb909d4c48961b156b49a6d93 5 0 R +>> +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +7 0 obj +<< +/PageMode /UseNone /Pages 9 0 R /Type /Catalog +>> +endobj +8 0 obj +<< +/Author (anonymous) /CreationDate (D:20260505080038+02'00') /Creator (anonymous) /Keywords () /ModDate (D:20260505080038+02'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +9 0 obj +<< +/Count 1 /Kids [ 6 0 R ] /Type /Pages +>> +endobj +10 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1412 +>> +stream +Gaua?>uT`R'Sc)P($F#5)E[oRC%$YJiWh.\>$D\"6#[0q`/Wt[mB>E2-:"jh]/g'bN'^V2SEIQL53FL7:jb"lpMLJfN5.Md^c4aV^a,$R>sTuU\93f^DI!pj==6cBiBRO$lplL@.fr=<'XQMFZ'>#e1Pk14E.67u'QDO5$DL;m3;kJ?JMI34GG"8#,ia@b:2$GW..3LM54e:ullQjT)h\%nE+(peh],9RU_i&OBKD7qp):]oH`HOukKf+JmX;-*2%T"qCl4N)_U++9BP]YRbc9kQ78>X/t(2r5"ign'o>=TQ'CZ=3\1'X:es'O]lK=>XkXj(l"<_@0596%?6`Y>dt_G]INc`Z8d-mP&fkIQ#g]%V"r.Vad89Ga/P?.DAut+'-F*OM3!m!G'SZ(iQ%$!A\"hZoY<0:T618.)hLAU)TYY]-rWu"t,e:!hTVCbr)j5[pbQZmQ!c\bZGm`GoFuo3SWBiY3>iQ"l@4Mtr=ic4Y91/>f[@9?Cpk=V]!r>uh-;:7&rH*M`/5qf#\3@"5GueEX=h^pUeU$?FBDX$Zft>ZR]:hrq@*[DZb;J#:Cuendstream +endobj +xref +0 11 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000331 00000 n +0000000414 00000 n +0000008724 00000 n +0000008991 00000 n +0000009059 00000 n +0000009320 00000 n +0000009379 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 8 0 R +/Root 7 0 R +/Size 11 +>> +startxref +10883 +%%EOF diff --git a/_data/uploads/placanja/invoice_2_HUB3_20260505_080244.pdf b/_data/uploads/placanja/invoice_2_HUB3_20260505_080244.pdf new file mode 100644 index 0000000..66f8ea0 --- /dev/null +++ b/_data/uploads/placanja/invoice_2_HUB3_20260505_080244.pdf @@ -0,0 +1,91 @@ +%PDF-1.3 +% ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 318 /Length 8009 /Subtype /Image + /Type /XObject /Width 318 +>> +stream +Gb"/h6%T1t&-DJ':F?\*K6t`EX`;9^%2@hcmD&[-?!U`A?!U`A?!U`A?!U`A?!U`A?!U`A?!U`A?!U`A?!U`A?!U`A?!Ub'l^q0OqXc^lW[:egT3Tt=?ck3rI>atG2p>,mhOLGi=*(!=f(37k1nB&QVKOoB;,#i:VC#AcT3Tt=?ck3rI>atG2p>,mhOLGi=*(!=f(37k1nB&QVKOoB;,#i:VC#AcT3Tt=?ck3rI>atG2p>,mhOLGi=*(!=f(37k1nB&QVK*VZc7I.BKDFT6XF8rmSnlE;=ZTJ6lSOLSfXhcDfB@\cWT$(S(T;%b!S1RX^-as;T@%CH<*.';rl$SA#3ch`#/YCf:N.Q1a%i"fRd&&L,.2f=CG6+HR5N3nkH^K#VeDq`B8b:1afWB-=D-2=a.CI`3hN.7cC?Iab9sb'UL9Wq_]4"UMs3&*7V_>.81.kLc%_F([\6.+ZJO7'N>.?$g4tec`3Pg@-g>G7Mh5NRZ/HWN(oanR,FsX%2&f<=fnL7GbBd[92c;>-CEH#,&^+oIS>ca?8]on[er[#&,Q/&YMlYND=%uTdk-TkZeK!gl:fpuI5.bQmZ8\[=A?4i`ItPVtCA-=\PE`a@XW^n+8,=/=(0@igY*u0Qc"RX?W]AMiL+J7"BOuT^r(L/f`.u$7Z:NYKc=]*%I#?7bN1"\q/QqN(LfGc/qP[3BuG-fJlq#&=&.G)q0Ps4_HDjW(RG*V[PbrVKVo.OUN>XM.bqS\0&@BrGWIcAdjXXRqkjCjWP=;m[O3FVZ-9t3fW.5\Sq=_Rekcokqn:pk.^)pC$KUYVUHs]S,9)_VQ%=YIR=cH[SQ=kl,,Dq;WR\Y/@ONf;d$kgk#KlcD:kZ*ojM!>_lK]@IL49"V@VFc8Wi>VZIjn5:M'J;RTE3*[UG@Df8!qB%BlAGqB[u%RQbQ*-:P'Jb@2Ds4dG\5BO3QEfS3+X>T]9N2EY?to'C*M^"N5QQT4HK'pCQlQZ^:D=BE&Ybs%>lh=P=BcX5]2jVh4%O*-`Rh!?i-g.cL/rqn)1?`nCfqH-h0cHSP:X53D[kKdMproG&"XH.LhleLh_\_9brG5hCcmU*,:`VN\<\^&G`PH3O3B'7KIir8$X=0.I*Q0ZZ"/6RCAMco;./?;Q&h7@&03VJ;N/q(48`Pm4"9:o`tI@BEO'r/jT:RlLj]:CN+k&X`&,g5K%9Xh/A7?2fUS4hc,CiM2%e']UHbLlkAPeqJKr(Mtrm/50:,,1t/QW7Zh2Q39S_64'Pt6kHskQQIVn$DI"uS*;dZ7QYprSXr(kj+-t3BS"2.o-3\I&STF&^NMRsjmOHhV3B'$(c:nZ.bW=q8fc*RYMVM471NRGW[[`g$baEV!V6`+HRM<<5>\E3Slte[@.@;pWS4dL5/2\ks=1QW#\_5YVDGi0$eiB)BhH4MjjKcE)(>S\7$X\M\PYG.De&->PHe/SCBghD?[K=@7uskHi@>Rl+?DR;P$k/G>r4_8I6q>WJE^Y2-2$)U3BIR:WW^9aW5ud,N4DluMK*:"F!T>L"dX]kL@uZ%a#qc=`JZ2f#]g1Ym(`=mSkHJr)@l\8jdF>e=.&24Ecq1Ws6G:>:O]S6T3FX=NV]jGINRFQT)S0MTJAI>hX=NV]jGINRFQT)S0MTJAI>hX=NV]jGINRFQT)S0MTJ/,UA9O`=jG:aS?+SgUs-ApeWe%\qhB)ncHZ!OX>P$obdi#LPf:'-L2gAAc1IO-20,M:\Tu_i`O5StEEUT3oUN!?EhppiH.A.u[JP\u=jG:aS?+SgUs-ApeWe%\qhB)ncHZ!OX>P$obdi"1l*N!WNRLq9.',A<.jf$R9<9;3?ofltXd\iib#)8ZE`Yrc5*f;<7_s1M(0`ZZ_j$q#hW1i,XF2Y)L5^`KZn/Y#cA*i=@D0cg0j4BU@LX1YdqMs$eXR.KNl$++]4/)>Z,@P9$Du:XfW$qRVAt<_-?mF>m:1u1A/%&/53H;7o_Fj$rC^S`\Mk9mF.YCKELhTE'Dmco/(SA-NCD7MkD`qUk?m>hIUh`%p"=b';)J#mUDVVY=#mPC(qX7*G2HC9c$eFs`c_8\:@'pZY#q=P2\ni'T/VXISUZn?HrsG@NQ!bpAn$0`dtB7GL953%=^mqi:Mq9?NQ!bpAn$0`dtB7GL953%=^mqi:Mq9?NQ!bpAn$0`dtB7GL953%=^mqi:Mq9?NQ!bpAn$0`dtB7GL953%=^mqi:Mq9?NQ!bpAn$0`dtB7GL953%=^mqi:Mq9?NQ!bpAn$0`dtB7GL953%0j.Y.U>NlfEk//*@b)nTQ=ZUn>>rA0o46P7uoEdVN@1=gM8=RmUS\;;2Ra/I7F6ls(KUqqCL6ckU9kTd;hH3iRkM4?CFt8!Xc?r_]90(Ft1J;A!XK/4>DW`"OPs/Vh5-1G(CU'bCV+Gun4kJBXg!.Ys[%+7HQmR_oqcM.0-L!Pa>\D5m2_3M,Ejk[MROi(<@l[b^V!$BOSLpJiXB\-9g"M3qZC&%\j_SXoSn$)h[X<8)EXaU5CEKFq@b*`/Z_^6>N]l"Io%D:127/mUI@)@>=o2=HQ6tiVp0;7RB/_?mcuQSgq>Z8!A`e'f=16a5Z#jboG*RDa?2^eU(M#_\2mpAm`GUO/>Sc@/b,pe1XJsu=\V5q9cp5Z75I*Ic5N:_(u$b-D"Y'9RQaD>MIq0pSM`NH0t(N9g$3'Q21:j\'[?7i4)9)p@VirPZdm+-CAKVA.@TK]G1Q/i`Roo*B<.)9eb!9b;DcjDm&]/[Mm3_4c;l%QWp.FNUhHXgfI]8b;KL:A)iMm3T4D*h>j$s)eVA9bDTJ&HN&g?Eq!;5=%@M:?:Tj<-a6LQ'2`G9i/]O<)2gkI7q0qF3aI#Fe8A1I9>[pL.o.sR.Ka#cb?73O?)M#j>p(aX?N_,DF9`H'O0,!at;3B(-,:T_qqVP]6^p\'5`q]etb)Hh-I,X/TRZ2LV7+*GP,eB-e]pEX@[%o]";)9>NA>C%su24mcrKb:UEji=d.-iFEb"P\&a[e+81FHgsraMT4j_\ZR24\l0`$*Dil8U53r_o\>s8#5LDCBB2(ECdbVm2h]bS5I+TChE*(@$\(.:q>rA1@X?JY:*,0JRk1-WKs&(5Gg2pr4qW*$EUs1cc=1e>.9:`;%e!\>CPOlnY2V2VKmHXR%Fi(.QB-sZTc6l-9>bcSPX7*gQ$*,tTRB/S4T,_QdNTSco^Ah(YXP.c.ZMNX0Ehpr;RTDL,=1-\MI@=7#PNHDX@AU;b3?RD-db/)1B<+i"l?q$MX>R)$ehH:)S67mWbj'$;BjR?+RZk.6Q-L<1I$c]_B8]o`XZk"1o1=#/f!*S*_jh6YB'F+dX0^?SH$);b:=^=a3bt,S@EpEp/S9.6qD>D$-dBb:_b+PNF$@gYREkZ5L/p?^dqkECa^&'H0MTu>Q!9NMZTbIaQ-S5C)s2Ng=4TQ/POr/BEu%k5-1HCcJCAsk'V2NeilckoNe_%)7t$VHXj&.-g*9_Y+`hRZ6nhH`E;;.6gH5r`_s-=c=`kV6]6k6:""*@Z-Kn7elD-tV.Q?icU>7BX5"Xebs".[M/j%W/s\@dcRm-tk8IN*\MohCI<%LbjVh<-/8LE?>E!^M270N,D_*dCjEr;s270kqeg*DfQpnioR[n["9Ur5,lg$AV5(ADJr)Mkt>'f5\g$R8d-!4NP8b)ULgqR)]Du;Z.I@@nRlg$AV5(AFdQYYfE?Y?iDo9Wj5SL2>=06.#4%DD;tm.P/m'!/:cV"jj6mU*,j2h%=R50[GdolZ];T>/>Cb;D+"[FWE9BO*>7VVQYOdbf"U5#(33()(btfW?[d_mI%:7J[tKbbs\fa^S`)(;"_.I$f*OYt0A^RJA\1-Ws-efj_FnQk.[64r3C$3&@2(o-B6N3Etk$(%Ykfh8^`<%;4hPFIm"t0kZF#\mdis]DG^+^Z!CaRa1*jR:KH==dFhcF[h3Z#?)sb^Md?/'E]f27)/\DE70cai1u.Xu#tR9Wfog`mr:Z,J3'MX!SY[VRb"=pG9@cHl,p@l](7so<+Nem$m,uZes*ib48tGc)`k0>L'iqEj9POlcafBP:Oh3EAb)ZaDO$$1tfoQ51d$\Sg/)5>@JOT>g1mdPN/ss=[S)k\'Jca9kR+86]6j_>CCNs<]Y#0;<7F2NO7qdM/?pG0k[O_Nlt%-%5!Im5,XN$SLq)U/M?1%ouV2Vl)^RIOk]%Ud\a>s[\:[ZS?4t8/hRbs%raaK(Z(/&;ce5's\=+,DZ5Qo)jX=5sjilTB\8pib(#s%,m)j'V/XE/>=dCjjPN0f^>??ca8[0eHTs\!_Jn8*1lqNKjQYp(`XsV,jXG5;83=;'`3O:Y)Y$AP\`OM?N]<\p`.J%7cm5hST>l(8B)_"nPoD@TlAj4J@?"]Ig9:tj%,6dC\I\5E@bbpl:lccYTl'";LUgTb>muuLoq)#F$SQSuGIF@;<'Wgp%I<%D*T4ZZW1X!q;:\q_dJos8l:Msh-e'-NgAm)\:8Y7q>Yg1#2a8-SrB0]Mcj2HI3r+`c0c>rB0]Mcj2HI3r+`c0c>rB0]Mcj2HI3r+`c0c>rB0]Mcj2HI3r+`c0c>rB0]Mcj2HI3r+`c0c>rB0]MbU_<88;"(%@cVFJ$3*E95eW+;(b#pbd+=)c1LVB=0A6n2`@0SQ_&81=]WFPqYB#!f?hqb)buVjcT/<_X'mg_r9ondB^(Kc-d&:KS6;0.YaKt>pW>A#oYuV!jjJ4q/B_e\9L&Q(jVh;`Xl8/$'3SJKc;sf05*hXB/3W04M.KDkQn/T1Q0W7_n*X\bai2\LcB_S`KT!sH?s1%cgKrcFXN5=m,ZaDgk&i%CXTcO-S"\G8j;_cM+U#r.`iBCnFuT/"J]h2OnK\Ih9H[HX.a[dCtS+'FCn\,2mp.Bc8k98SiBp`XfH'OAtPqVbHej]`AMY\Bt*]PfkP2+2&eIN1n@d8oh@$@M/uG(SL;]`6gLc*Yh@&hT?*b2Xt8#Wc:q"o2`KNWeW?mo8^B8)'\8O<]%acp8a#5j]./,_d-CSfrC8.>XLth?!3u8SXZ'iDJls7X!(\iP+HHVb@5m?ZMNocYWb(qg`&m";G0$uAes]rPLX<4K([6Jk2'4/OBTeh2R?MXS?+od'Wb)j2###81+=;\n!'RD#lRF/1@8Eb4oP=k8l$(":J@/[h[%Q!8m;>CDn1mM-u&m:0EV4kmpk>Lg=-XhiGf50Sendstream +endobj +6 0 obj +<< +/Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 9 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject << +/FormXob.8b0211ce759ad6c4c71d89bd2c96eaf0 5 0 R +>> +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +7 0 obj +<< +/PageMode /UseNone /Pages 9 0 R /Type /Catalog +>> +endobj +8 0 obj +<< +/Author (anonymous) /CreationDate (D:20260505080244+02'00') /Creator (anonymous) /Keywords () /ModDate (D:20260505080244+02'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +9 0 obj +<< +/Count 1 /Kids [ 6 0 R ] /Type /Pages +>> +endobj +10 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1361 +>> +stream +Gaua?>uT`b&;KZL'n,=?XbYBg1WR+XW;$BeLt3?\9u+8m9&ejNKs.tm$k!X]5%!#8ED?:$#UAF"hJZ?='t@\'1q-@eYgMa*O-Kge6QQpt^.=N?hB$plq/KsuspCMdZ-U;WRI(kH@1p$+^$*7RT$'uqtNK\TA>=@]f$r4hE%1JlW`="dk7PAuM@NN?&XTbjFr>@ouS@_@k:$5O%QmDkQ?&8BG]Q]ghtp_gJrnEoQgJ3G5:mc=-toE&/F@.3?pH'FmbXrr9q7B,9NAjuarr/7Tjiqfna"_\g[`'e5Opk[+(sb<\p^$0O]_22es-jnU'Skk-6m`m6:mgp30I?.G$gTeQ.s_)mNDZlDr!pB]i8#<#CGXb!'eo'64WjemLAf9BEe>VX-uc<1+^b$6lj<6H&>IQX.iQiFADt1R'4ds^!;XtXCBA-tq_0^ZS0KRH/$)s"$1,tB$;5)SqI<)"t$H>[aRVU!--*J`[^nSP\uX@(Z8KA%gT-E#@0,,\B5r5Ln%gU@Pjc@0(`(Wb#(oMplVjX:-B?5us]J3>AKJ'Dln"UGP1U&e[-#I'=C\1KPnGQN=WAO4"nS31U;K*s[fBJqR)%gUcG-;C5DW]#>egcCsn"I+nqlin-4r>;Mr=^`HTRi3TF1i>]E,$@fkrj>@oT7JBO`bpt1t7,B_HK*%"gUT.FeaiHCSRBf\#?U`1@=uVa;hlSimc,O:/iL/nejF`kZcT;PlPPPh!gL3MhB`%s?Lm3)~>endstream +endobj +xref +0 11 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000331 00000 n +0000000414 00000 n +0000008614 00000 n +0000008881 00000 n +0000008949 00000 n +0000009210 00000 n +0000009269 00000 n +trailer +<< +/ID +[<9dd0eb826c81f841d5c469f1a057296a><9dd0eb826c81f841d5c469f1a057296a>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 8 0 R +/Root 7 0 R +/Size 11 +>> +startxref +10722 +%%EOF diff --git a/_data/uploads/placanja/putni_nalog_2_HUB3_20260505_080032.pdf b/_data/uploads/placanja/putni_nalog_2_HUB3_20260505_080032.pdf new file mode 100644 index 0000000..1d82b13 --- /dev/null +++ b/_data/uploads/placanja/putni_nalog_2_HUB3_20260505_080032.pdf @@ -0,0 +1,97 @@ +%PDF-1.3 +% ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font +>> +endobj +6 0 obj +<< +/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 294 /Length 6824 /Subtype /Image + /Type /XObject /Width 294 +>> +stream +Gb"/b;3G/"%0L\O9.'s`V+e98+Ep!DjoB'Wl+d7HH?sm`H?sm`H?sm`H?sm`H?sm`H?sm`H?sm`H?sm`H?sm`H?sm`Rd*,0I4T$NAS!So90(GX6,?*o`GuZoO6+TqX=QhU.7a4r-lAqk=H8"+Z()\uQ,Q9Gj[ak+"XCDjb>GK-Q8@R,Z5BR;*9lpb@&2@_3==?XEb8QW(*g6:,i-`!h6?.mFO$^iHc;5m\khkj?Nud\Stl(;qcnKePfpX6nb6ApQ'Ir9pkelQHc;5m\khkj?Nud\Stl(;qcnKePfpX6nb6ApQ'Ir9pkelQHc;5m\khkj?Nud\Stl(;qcnKePfoLTJ,ZRI5-/0KSMddafW1lm(UNh?)ZC5(h/q5piaT*[g0MqVa(kB2.!F'B6]6l9C2.rICEKV9n"A<-B+6FFp8Y+J`N4KhL;/@M2k;h5\CdjZFr.LYYoj7c8fMfMs3EZeaV%!^2_Hk@9H9)+4`I8Ch5?WVm%(g]$*+Y\NVR&am-c)kFAY*VY_'._bZWp`];Y+AMI>9=Qc3\Q,Rj;_J8l11>b(SqQMLtWt4NdF1/BAmSd3s5@*Cjk?K*p`FSXMSTE(W(=Q'T@-P9e3O+f%1_:!OUEafK!'UEg(Jn6\86jf6ol]VutR%pmURhRE3kpKB5oNe)Z=Ml,4jkQY>=\LhgQ=[2@?86)NSb_?b-mgkg]B.hl\jjMRXB3X!@V7kb0oF)4`GR[pZ`[00)s\5[[b.!D:5iY&A$K7`c)b[pm)F@jS1SpHP7s^YgfWp/F#3,#"hV$G0kZE8q;fIB-8u^PjkS(k7_ruD=3T?,B0J=1MTLjsDE0Y^^AfhCLloE@GLP09I@.07]UO+$^M2uZ5"sQVhT`[#h_b"=+/Wffn)iHMDjpO/&(E)S6G]el+omrN`SR;l`]3NST=?lRAOrIT]adWRWB*`bn/0ob58Kq.jg21inB[*@C)XrYq*hc9=HrdZ7'bX]DG8_kBq;/3AL54]%,Js7?L7#h.P.rkEEO"M6Z6lXj34`MHp#G5*O8PTA3.9N'3MFFgbgXjO/8HR<6aMjDK!Im(%5=ZR(MRQbL)XUiXg/GEW\Mc_Xj:/Qiduo\FpBST@!*S#3@U_`Htd-I05]=eq!#k@7AnRq>$r=HdiNeSC(GUl6M\@o9J"Aja"/PB;$!CPu>kN$@Fp;6ZO%6ag*\FEc8NMclj7,3NH5Ya]5l[TJ`8GL^Je=eq!#k@7AnRq>$r=HdiNeSC(GUl6M\@o9HL/"J,UT'Moi@dLYER^2cHQZ$c_\?(G/g$5*N@dLYER^2cHQZ$c_\?(G/g$5*N@dLYER^2cHQZ$c_\?(G/g$5*N@dLYER^2cHQZ$c_\?(G/g$5*N@dLYER^2cHQZ$c_\?(G/g$5*N@dLYER^2cHQZ$com/5O[q))8eG/O0\oGeYlc2$^<0f:n[]OK/HG3anU@Hfd)7[ir%c!kF*3-iK5F6qHelbh.l>`:N&Z^\q`gi2hA3To%s)qKe@kI;@Cc=](oWO_!K'm%[*>;rVo9@%T4d[\X$(/o:laXcMDA%->X+-rMY@\c#Vh(5Ep6UkM?go%[5LY_;XZ`GL?V+JQEKgiRGENKb`fl^n09eK)&L,fU!FI1uj+*P!Vbd*[BQ6m&^'Gfpc`O.nrB%NG^27Y84)WiG!dTq`GIFufI1WsNR=dh1k;-9X+'\E2cRpBZme`=$#CY3`@UcGTJ&'N*m=DF`56rJ2lF(V4.Y+-$Gb#)8UVZ0%^G*?uZUcGTJ&'N*m=DF`56rJ2lF(V4.Y+-$Gb#)8UVZ0%^G*?uZUcGTJ&'N*m=DF`56rJ2lF(V4.Y+-$Gb#)8UVXK])Dog@JSXGPha'+*UkH]+e9kR+Khu3N61)-#b]4km=DOSk:rkA:,a(6YM+*Q,Wb2"nF/cC+lpQ*Ko=?c)KR?bBu3a;IXmdD6aTs/ps9f";Ddr`%jpYF^k/jj(CYa]6*m\]6]S,VTOS8t/ab@::\Z/bBe;*'+2/P]BDCfFd`RJGE.G'u-=g+uB6G_5\2jQ8C@VW43Pj?Q'XB4"1TjVZ?)p=X\1%"Cm;f5UD7W(SOeDde1=Qrj%o3oqq9iJ[]Ju,f.(9,8aos)rI/EXj?Xdc22^BI8C%4tJ9bN`T6>?T1*3EnF:m=#G/c5Kl^(73Rl9"2*n%s,0sRbI]U/=eZ*81cHpO-OTo0d#Ooh))P:/Qb&LBTZJ'&"_[1[-+0/g&CUXgIB+;%A9RN;72Oq#7p9>OuZ?H,LFOoq"`PeZ'CVpI*DBGof/o.p,94n^;;+uA+Z8S5WkOu/!k-B1-Y]SIc)TBXm.;Qg-HYsq+@4l?)%ngOEY>Iu@Ek98%3TkRW2#MPY>?S%gF^r,-3O=.&cHX?-jj$mBlXSaQYB%p)T;H[NZ%'51Y_d.d,%LBf\0hqj0BK=fd[,A%g#FZ[J:fc,K\Dc;k_HA)17jRe$4Os-fbPhH\fj2.SmOqR(aSBm6gt2qRb3%@N9BIVk]mg0O(!c*_t%6fqmT?eab4Fr.c6F)<#MOSN#CfClbPh8QPQ>hb#kA3O&j[=,2lGHkpBfCiV^R?*V^D4`/SonrFaETJTl\XEuBh)A+aEOO;b%g8#\m[L,A3*YXlL:P9D>WI[IY%GRipWZW1S?;Eq_q\]]XrPD5=0pkpqrT=Sc;o7t@;`=i=,paVX4d!sIIe-dB.L[K0Xk/E.n?kfeSP$J^3PTmZ^#ENT%mFQbdki\UM/L<0eVU)h;m.kPiLJ'bY$2Y1iJ`u(N[$&Ebee"EU>AjV<97q2b]0g3EnGQ2([k!Fr.L5Etlk<-H=#pXde)aQ?$6BMco:)MWP?]j'pZ7K.L/a&uXSTB^o=0-HqSN0KOfM+!AP1A6+DJIT[[hsJ23p4qZo-B7MjK^=>SLs$%-Co49Yue#J/tG!uZIhl(bECE/EjjICA]dm:^9X!H@a:/pY3j,E/5j[3;N>5\Q)._$F.TIQD'*q2O]jr7EYQ-h/EO1)o43hrqlh#teFKl/UY(!+.@8#D-YluALYZ(.QB.j^`U56L\Lcl*=l\4"m)C6WHn=A-G$eCpAMNAi)V=[k2jRQHPMOBeA8sJFR/V3!a@^)nEa4p!>jTJQZ=Ir4Dj^NGT#aN:I<&SYRU#4!b-GPt2/el]FIpQGB74f*QC;(F`lbStEA_[%rcFaQ8nWQm>aCg`m)I2RA8sJFR/V3!a@^)nEa4p!>jTJQZ=Ir4Dj^NGT#aN:I<&SYEbed\XNiB?HGq;65#rG?cD=`nXRoc88S@@SeX9uOY8DuDa3%l:\TFm5Q(DbNl"n&=3jUC''r/#0=BWj.QYuK$dW5jFSZPsoB9m,B@MDib.uQFNd]G]6HEQ5&Zh;K+bd+SCrnpMWrTDkA$kMBBtgORTllXD"X2UZQ&3Fe#*T"eP'W$eglY8[oh,4l8*El2qBLV:D]V'REh>)F1,CXUq+I5c;pQo1V9nuj^]shZE.+@MLV$Ef/Z?0Bu8C7lcckZYe/-?$;2&8VH@KNQ$`2g=8:CWEh.7cQ6oj2Z-J:YpZg52n?6in3p.nB9t2aZFGh@;MVTthjsu?6Vb-(,[qIg,U_ZBTh7@$"buYZL:6Wu6Dp`[881f^2\okp#R]e9"S0jeJhkK@OO]iIDFR+[$2.'K#3DDQt^.$N(,./hfl.-=(C;-u%Egh*qI>au/7;>[Ve#aS/eU1q*j>?+kq\Dn=Q>XBF>e>Ec=%o7Rnf7o"F8okCMJ86mqH0qiUfJT'("6g-Hq2$LgqT8i@[^i451Zb]WR]%8`Uqh9hbgUApMVC^QmPt;O7fMECA"i<0n$LRG8..)rL:nZVF_QhA%cW?>(*JR9V:_C*&W^R;`DRUe--p1@sS]5'5B>0],\Q8S2@=mX/N@QmM!VVO+u%5$`_(X&9BYUjs-AMKgS1Qt793B+o*/9WQ";KJJcc;pR-XF2Y_VQ=]N3OCCOtp9C-WZG*="bgJ(#pW;f=ouY>0a)Z(?qPKQ79=NF+-b$G4-RW[ObIK(fRp+)gh,8!C,rVB9?hm*4'R-%b&t-nbrIRXp+#5]EOJE-)mUE`@8=@fbaI;J?Eo5ZT8BmCg>%eG=:K>o2.Y#!-MNBTD#-hq/^id9m)Ee-;m%9bAiI8%XC9RjibX`82e4jJ_jk`VR5;Is]j_G?4qhRe[(.?lYo;bhCWN%!:$ra3f^tRk>GTLQfo=O$[Do?r%BVKi3qHe&AMr.Dk8_)[CQ2Uo)Hpp\FQUK*bA+>hc8g&@ef)/h1pkhAl-+o3QdoVZSTF.`X>P/YBkXWbd[:VE0!!+>3p5-I=_mA=da20MV)'3j>ZR&ZFj@6rZIZ7ebEY`H3U_"M`;3T,mH#7WG=e9,POtC:/ltfLO4DoiY0hN][$`dCcT1H7ji%!tSaGMWSTdYp=d/KJo.F*>XH1ob?EV\$QaE/<8^BEcEok79=Inj@)P)(DRotsISF\@7:99N,fj:i/R;oK5N,//&F3\@Y)Ht9@F>s$0iT/#4d+[[^/*q61ebk%s_4m5#@?betE'@L]FYX)lRAh(UQrX&!W*M-+C;f$G%FB\cd]F$ArFM)edgEr/PK3\rYBX]=g?pQ[uA/U\kEiO+)9B1pj^RXK3chq`=]A2V$.&*n&r>#qt*:@,SscBbPdfCiXD/R?P7p;r/>GK\[&eaV9VXL"a==j9,jZ0uQj>I#/RgP9p[F=jQH-R\WD/S@^W_e].RcBbP[RTrnEmYrODB:&8E5*K'6XG4rsg%j?]-.Z^qo\)Km=-fqBbbs.ig5RE0B:&8E5*K'6XG4rsg%j?]-.Z^qo\)Km=-fqBbbs.ig5RE0B:&8E5*K'6XG4rsg%j?]-.Z^qo\)Km=-fqBbbo_BH,[(0Y8@H7Yr_&==m%,ujkOCmh+V.[XP>aBGp4a/]-`NjNf(^9N93p0%:k9cPWggnae9O>!*gabk1I&tUR;V&D#ANB4b>CBU@X#&eA)Lf-<-_r:VfYMbF=IjLBXj2mKCA?I7T9-AN>f.m)e*\,Tk?LBT1ZPbZI^[EIUb"[\YfXkk]8"irf?ig,\?(FWmICQ49@#J3E\Z[GXfP=5_L1P//%,Y'B'K&rfO?inVIgocB-ubW]jbb2B9n!2M_17T@VYVAG%P4/egn05F(X\2Ze/_I/uE5&CM,!VCqX:n0P?]#5Dq+Zj'lcsZ$'_gcIOMS#'4D@pTrL]3A.1@+)>L9k%q_?CG9CeIQr`BQK)GC1sCgLRCohqfnR_+3OtaF'@29=G[HZ7$P=CUi->''h?!AN@(+R1F@Nl.0<;('Ppo%A05`St;OFSt;OFSt;OFSt;OFSt;OFSt;OFSt;OFSt;OFSt;OFSt>*@+,$qKGl~>endstream +endobj +7 0 obj +<< +/Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject << +/FormXob.ea5e9bfb6df5bb30fd1ad25b58aab6b4 6 0 R +>> +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/PageMode /UseNone /Pages 10 0 R /Type /Catalog +>> +endobj +9 0 obj +<< +/Author (anonymous) /CreationDate (D:20260505080032+02'00') /Creator (anonymous) /Keywords () /ModDate (D:20260505080032+02'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +10 0 obj +<< +/Count 1 /Kids [ 7 0 R ] /Type /Pages +>> +endobj +11 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1365 +>> +stream +Gaua?>BAM!&BE]('Kab[-ds'\rTO#N?u4j2-pmWL(ga!\1>F,kj)h/j^5F7%MG%^iOVeJDZ+^%heR&4W",[VT4b$T9ZiXoI112)R#r"pr"F&3!)VI-kPO)NfV`6%t!9$0GLXk#$?a7J@j?t`'/Jq-;UG[[3$"qj#X\T!>]K8(9Fkc%S7rg&V5lI9/].0+>qJD\0k5_`K&<)=9n5:fPa4k?+XJF)65!o"=`fu>n6QK+BF9I_0l4)mK5r46I,pI.JS_]i#SFJa!(5T@^;&q)3_-WP1mBE-nuCmYfD[KB`XRMm8Q:6ogu.-4]#.$fKAPk+DT0(.n#_q=9C)0TEWdVsqIGj2GMll8s"_L:92Vs2eVOh#IoO;#O&k&P:7d9,5hk`nT42tZZmNXiETD8@s'35j/W(c]U)&h,5\^_\3W?fn@sAr/8?Y'--cmV@gL,tQXuq;M2LDU1a6q<@N/#c4eAT#&p,m@iL$Z:3FNch8A[gLpq2SIRo*c^U(6=8mo(X?D9]mZ:/F1"ehMRG@@9U67?T8H$BmLL(nD,R-u=43@C*:d?H:<>i&1J!48SF^!@=fm'UO3a9]5FVkf;tGkjM+;G<H8uBB(7lD(P-G?R\sY_/uJ-$`6VB7CouhuqY0V$hK"EQkmUTGn+)R93EaC4%4p%O)asK1]mOnIN\8O%B~>endstream +endobj +xref +0 12 +0000000000 65535 f +0000000061 00000 n +0000000122 00000 n +0000000229 00000 n +0000000341 00000 n +0000000424 00000 n +0000000501 00000 n +0000007516 00000 n +0000007784 00000 n +0000007853 00000 n +0000008114 00000 n +0000008174 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 9 0 R +/Root 8 0 R +/Size 12 +>> +startxref +9631 +%%EOF diff --git a/pgz_sport_api.py b/pgz_sport_api.py index 8947113..f4ae549 100644 --- a/pgz_sport_api.py +++ b/pgz_sport_api.py @@ -231,6 +231,71 @@ def dashboard(): WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} +@app.get("/api/kpi") +def api_kpi(): + """CC6 analitika — single-payload KPI for /kpi page. + + Returns top-level counts (savezi, klubovi, sportasi, members), + proracun current/trend, top10 sufinanciranje, sport distribution, + drill-down hooks, and a heartbeat so the page can refresh. + """ + counts_row = fetch("""SELECT + (SELECT COUNT(*) FROM pgz_sport.savezi) AS savezi, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE aktivan) AS klubovi, + (SELECT COUNT(*) FROM pgz_sport.clanovi WHERE aktivan) AS sportasi, + (SELECT COUNT(*) FROM pgz_sport.clanovi WHERE aktivan AND reprezentativac) AS reprezentativci, + (SELECT COUNT(*) FROM pgz_sport.sportski_objekti WHERE aktivan) AS objekti, + (SELECT COUNT(*) FROM pgz_sport.manifestacije) AS manifestacije + """) + counts = counts_row[0] if counts_row else {} + + proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina") + proracun_2026 = next((r['ukupno'] for r in proracun_trend if r.get('godina') == 2026), None) + + top_sufin = fetch("""SELECT naziv_kluba, godina, iznos + FROM pgz_sport.potpore_nositelji + WHERE godina = 2025 + ORDER BY iznos DESC NULLS LAST + LIMIT 10""") + + by_sport = fetch("""SELECT sport, COUNT(*)::int AS broj + FROM pgz_sport.klubovi + WHERE aktivan AND sport IS NOT NULL + GROUP BY sport + ORDER BY COUNT(*) DESC + LIMIT 15""") + + by_region = fetch("""SELECT COALESCE(region, 'N/A') AS region, COUNT(*)::int AS broj + FROM pgz_sport.klubovi + WHERE aktivan + GROUP BY region + ORDER BY COUNT(*) DESC""") + + # Liječnički expiring (next 30d) — ops widget + lijec_expiring = fetch("""SELECT COUNT(*)::int AS n + FROM pgz_sport.lijecnicki_pregledi + WHERE vrijedi_do BETWEEN CURRENT_DATE + AND CURRENT_DATE + INTERVAL '30 days'""") + lijec_expiring_n = lijec_expiring[0]['n'] if lijec_expiring else 0 + + return { + "as_of": datetime.now().isoformat(timespec='seconds'), + "counts": counts, + "proracun_2026": proracun_2026, + "proracun_trend": proracun_trend, + "top_sufinanciranje_2025": top_sufin, + "klubovi_by_sport": by_sport, + "klubovi_by_region": by_region, + "lijecnicki_expiring_30d": lijec_expiring_n, + "drill_down": { + "savezi": "/api/v2/savezi", + "klubovi": "/api/klubovi", + "sportasi": "/api/clanovi-full", + "objekti": "/api/sportski-objekti", + }, + } + + @app.get("/api/dashboard/ekosustav") def dashboard_ekosustav(): """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" diff --git a/routers/img_proxy_router.py b/routers/img_proxy_router.py index 46869ca..e982b10 100644 --- a/routers/img_proxy_router.py +++ b/routers/img_proxy_router.py @@ -42,7 +42,15 @@ def proxy_image(u: str): try: r = requests.get(u, timeout=10, headers={"User-Agent": "RiNET-Civic/1.0"}) if r.status_code != 200: - raise HTTPException(r.status_code, f"Origin returned {r.status_code}") + # Graceful fallback: return 1x1 transparent PNG (avoids cascading noise) + import base64 + TRANS_PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=") + return Response(content=TRANS_PNG, media_type="image/png", headers={ + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=3600", + "X-Proxy-Cache": "ORIGIN_4XX", + "X-Origin-Status": str(r.status_code), + }) ct = r.headers.get('content-type', 'image/jpeg') # Save to cache with open(cf, 'wb') as f: f.write(r.content) @@ -53,4 +61,12 @@ def proxy_image(u: str): "X-Proxy-Cache": "MISS" }) except requests.RequestException as e: - raise HTTPException(502, f"Origin fetch failed: {e}") + # Network error: return 1x1 transparent PNG instead of 502 + import base64 + TRANS_PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=") + return Response(content=TRANS_PNG, media_type="image/png", headers={ + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=300", + "X-Proxy-Cache": "ORIGIN_NET_ERROR", + "X-Origin-Error": str(e)[:100], + }) diff --git a/static/admin.html b/static/admin.html index 5ee00b1..4303970 100644 --- a/static/admin.html +++ b/static/admin.html @@ -165,7 +165,7 @@ td.num { font-family: 'JetBrains Mono', monospace; text-align: right; }