Compare commits

...
27 Commits
Author SHA1 Message Date
Dasko 89eb4c09a5 Merge grane develop: prava zarada u Izveštajima 2026-07-03 03:50:21 +02:00
Dasko 4a94db7684 Izveštaji: prava zarada (cena - nabavna cena) za prodaju i servis
Nabavna cena se zamrzava na stavci prodaje i ugrađenom servisnom delu
u trenutku unosa (migracija 101), umesto naknadnog join-a na trenutnu
artikli.nabavna_cena. Tabela mesečnog prihoda sad prikazuje zaradu uz
prodaju/servis/ukupno, a grafikon dobija preklopljene tamne barove
(zarada) preko svetlih (ukupan prihod), odvojene za prodaju i servis.
2026-07-03 03:49:18 +02:00
Dasko 54e9439430 Merge grane feature/storno-modal-i-fiskalni-tab: storno modal, about:blank ispravka, dnevni pazar modul, štampaj kopiju u prodaji 2026-07-03 02:25:05 +02:00
Dasko 683764fd8a Detalji prodaje: 'Štampaj kopiju' fiskalnog računa umesto nejasnog 'Štampaj'
Uklonjeno dugme 'Štampaj' na vrhu stranice (vodilo na /prodaja/{id}/stampa,
nejasna svrha). Dodato 'Štampaj kopiju' u sekciju fiskalnog računa, isti
obrazac kao u Servisu — link na /prodaja/{id}/fiskalni-racun.
2026-07-03 02:24:05 +02:00
Dasko edba23ae50 Storno kroz modal + ispravka about:blank u fiskalnom tabu Prodaje
- Servis i Prodaja: storniranje naloga sad ide kroz modal — dugme
  'Storniraj nalog' prvo traži potvrdu, tek onda otvara modal sa poljem za
  PIB/JMBG (kad je potrebno za refund); dugme 'Potvrdi storno' ostaje
  zaključano dok se polje ne popuni. Posle uspešnog storna modal se gasi i
  sadržaj stranice se osvežava (data-refresh=#sadrzaj).
- Dnevni pazar (Fiskalni presek): link u sidebaru i sve tri rute
  (FiskalniPazar, ZakljuciFiskalniDan, FiskalniIzvestaj) sad proveravaju
  modul fiskalizacije, ne samo RBAC dozvolu i tehničku dostupnost PFR-a.
- Prodaja: čekboks 'Prikaži fiskalni račun' se više ne nudi kad firma ne
  fiskalizuje, a pripremiFiskalniTab() više ne otvara prazan tab u tom
  slučaju — do sad je ostajao trajno na about:blank jer odredišna stranica
  nikad nije imala fiskalni račun da ga popuni. Dodata i odbrana za slučaj
  kad fiskalizacija jeste uključena ali PFR nije dostupan (zatvara osiroteli
  tab umesto about:blank).

Detalji u docs/Greške.md (van git-a).
2026-07-03 02:22:13 +02:00
Dasko c87dc67857 Merge grane feature/greske-pravni-status: PDV profil i šifarnik stopa 2026-07-03 01:51:27 +02:00
Dasko 524c6de6ba PDV: profil firme blokira nemoguće kombinacije, stope iz šifarnika umesto hardkoda
- Podešavanja: nemoguća kombinacija (samo evidencija + PDV/fiskalizacija=da)
  se sad odbija i na serveru, ne samo u brauzeru (validirajProfilFirme).
- Servis: fiskalni račun/refund više ne iskazuje PDV kad firma nije PDV
  obveznik (stavkeFiskalnogServisa nulira stopu, isto kao Prodaja).
- Avans: uklonjena hardkodovana konstanta 20% PDV — stopa se sad uzima iz
  šifarnika (Podešavanja → Kalkulacija i PDV) ili je 0 kad firma nije
  obveznik.
- fiskal.NapraviZahtev: uklonjen fallback koji je prepisivao namerno
  postavljenu 0% stopu nazad na 20% (poništavao je zaštitu Prodaje za
  ne-PDV firme).
- Forme (usluge, artikli) i validacija PDV stope u prodaji sad koriste
  šifarnik umesto literala 20 / 0,10,20.

Detaljna pravna analiza i status svake stavke u docs/Greške.md (van git-a).
2026-07-03 01:50:57 +02:00
Dasko 85a2ee1b64 Bezbednost: superadmin može da odblokira IP adrese zaključane bruteforce zaštitom
Nova stranica /admin/blokirane-ip — lista IP adresa sa previše neuspelih pokušaja
prijave (broj neuspeha, poslednji pokušaj, preostalo vreme zaključavanja) i dugme
za ručno odblokiranje. Nova dozvola 'bezbednost.odblokiraj', namerno rezervisana
isključivo za ulogu superadmin (admin je nema — odblokiranje zaobilazi bruteforce
zaštitu). Repo metode PokusajiPrijaveRepo.ListaBlokiranih/Odblokiraj.
2026-07-02 21:24:25 +02:00
Dasko b35b41b82e Fiskalni pazar: izveštaj po datumskom periodu, zaključenje dana postaje opciono
Novi sistem fiskalizacije izveštava SUF kontinuirano po računu, pa presek stanja
(zaključenje dana) više nije zakonska obaveza — samo opcioni reset brojača na
ekranu. Dodata polja od/do datuma (podrazumevano danas) za preuzimanje izveštaja
nezavisno od toga da li je ikad urađen presek. Dugme 'Zaključi dan' preimenovano
u 'Resetuj brojače (opciono)', manje istaknuto, sa jasnijim opisom u potvrdi.
2026-07-02 21:24:17 +02:00
Dasko 576d963f28 Fiskalni pazar: ispravan Content-Disposition header i data-full-reload na formama 2026-07-02 20:19:20 +02:00
Dasko c82b17f9c1 Mock server: dnevni izveštaj sad generiše pravi PDF (reportlab + DejaVu Sans Mono) 2026-07-02 20:19:17 +02:00
Dasko fb80ee910c Sidebar: 'Dnevni pazar' premešten iz Podešavanja u Knjigovodstvo
Logično pripada uz KIR/KPR/KPO — operativna dnevna radnja, ne konfiguracija.
2026-07-02 19:19:20 +02:00
Dasko 7c215e70e8 Fiskalni pazar: presek stanja, dnevni izveštaj i razdvajanje praznina po prodaji/servisu
- Nova stranica fiskal_pazar: presek prometa od poslednjeg zaključenja, zaključenje dana, generisanje PDF izveštaja
- fiskal.go: dodati pozivi Presek/ZakljuciDan/GenerisiIzvestaj ka /api/financial/summary i /api/financial/report/summary
- PrazninaKnjigovodstva razdvojena na Prodaja/Servis varijante sa ID listama za direktne linkove sa dashboard-a
2026-07-02 19:14:54 +02:00
Dasko 56e52036b6 Servis: razdvojeno prikazivanje avansa/konačnog/refunda na fiskalnim računima
Dashboard: 'bez fiskalnog računa' razdvojeno na prodaju i servis (posebna
dugmad koja vode na tačan spisak, ne uvek na /prodaja).

Servis detalji: kartica 'Fiskalni račun' sad jasno razlikuje avansni od
konačnog računa; dodata posebna kartica za povraćaj viška avansa. Modal
za preuzimanje više se ne zaglavljuje kad avans pokrije ceo iznos (dugme
'Preuzmi' bez obaveznog unosa u polju Primljeno).

Štampa fiskalnog računa (/servis/{id}/fiskalni-racun) sad prioritetno
vraća konačan (Normal/Sale) račun umesto poslednjeg upisanog — ranije je
avansni povraćaj (upisan posle konačnog računa) prekrivao prikaz stvarne
fakture. Kad postoji i povraćaj viška avansa, štampa se u istom prozoru
odmah posle konačnog računa (izbegnut drugi popup koji brauzer blokira).
2026-07-02 13:30:45 +02:00
Dasko fcda706edd Servis: avans se fiskalizuje kao Advance/Sale, konačan račun ga zatvara
Avans se do sada nikad nije fiskalizovao — bio je samo interno polje. Zbog
toga je konačni fiskalni račun (pri preuzimanju) uvek prikazivao povraćaj
protiv PUNOG iznosa stavki, bez znanja da je avans već delimično plaćen.

Sad:
- avans se fiskalizuje kao Advance/Sale pri kreiranju/izmeni naloga
  (fiskalizujAvansServisa — fiskalizuje samo deltu ako se avans dopunjava)
- konačan Normal/Sale račun pri preuzimanju šalje advancePaid/advanceTax/
  advanceLastInvoiceNumber/advanceLastInvoiceDateTime — PFR sad zna za avans
  i tačno računa preostali dug i kusur
- ako avans premaši punu cenu, konačan račun se zatvara do pune cene, a
  razlika se automatski vraća posebnim Advance/Refund zahtevom
- servis_detalji modal prikazuje 'Avans premašuje cenu — vratiti klijentu X'
  umesto tihog svođenja duga na 0

Uzgred ispravljena dva mesta koja su koristila DohvatiPoServisu (samo
najnoviji zapis, bilo kog tipa) umesto DohvatiPoServisuITip(Normal,Sale):
- PromeniStatus je avansni račun pogrešno tretirao kao 'već fiskalizovano'
  i preskakao konačan račun kad god je nalog imao avans
- RetryFiskalizacija i FiskalGreska detekcija na servis_detalji stranici
  su imale isti problem — nalog sa avansom je izgledao fiskalizovan i
  dugme 'Pokušaj ponovo' se nije ni prikazivalo

PorezIzBrutoAvansa pretpostavlja opštu PDV stopu (20%) jer stavke u trenutku
uplate avansa još nisu poznate — nema opšteg rešenja bez pretpostavke.
2026-07-02 12:52:04 +02:00
Dasko ca1c8c8241 Servis: Garancija i Serviser se ne mogu menjati nakon preuzimanja
Dugme 'Promeni' za oba polja se sad krije kad je nalog u statusu Preuzeto —
garantni list je već izdat na osnovu garancije, a nalog je zatvoren pa nema
razloga za promenu servisera. Dodata i server-side provera u
AzurirajGarancijaDana/AzurirajTehnicar (ne samo skrivanje dugmeta u UI).
2026-07-02 12:24:04 +02:00
Dasko 5b39dfd222 Servis: 'Primljeno' iznos se sad stvarno šalje na fiskalni račun
Input 'Primljeno (din)' u modalu za preuzimanje je bio VAN <form> koji se
submituje — koristio se samo za JS prikaz kusura na ekranu, ali server ga
nikad nije primao. Fiskalni račun je uvek prikazivao Gotovina=dug (posle
avansa), Povraćaj=0,00, bez obzira šta je kupac stvarno predao.

Premešten input u POST formu; fiskalizujServis sad prima primljeno i šalje
max(dug, primljeno) kao Payment.Amount — PFR sam računa kusur, isto kao kod
Prodaje (fiskal.NapraviZahtev).

NAPOMENA: kad nalog ima avans, PFR i dalje računa povraćaj protiv PUNOG iznosa
stavki (ne umanjenog za avans), jer se avans nikad ne fiskalizuje kao posebna
transakcija — PFR ga ne 'vidi'. Za tačan kusur i u tom slučaju bi avans morao
biti fiskalizovan kao Advance tip transakcije pri uplati; to je veći, zaseban
zahvat.
2026-07-02 12:21:39 +02:00
Dasko 1027cfa526 Garantni list: uklonjene cene, ostaje samo šta garancija pokriva
Cene rada/delova nisu relevantne za garantni list — dokument treba samo da
pokaže na šta se garancija odnosi (naziv rada/dela, šifra, količina).
2026-07-02 12:08:26 +02:00
Dasko d0b08f7013 Eskalacioni list: predviđen datum i poziv klijenta (dijagnoza) auto-popunjeni
- 'Predviđeni datum završetka' je bio prazan iako servis_detalji stranica prikazuje
  računat default (datum prijema + rok iz podešavanja) — StampaEskalacionogLista
  nije primenjivala isti default, sad je usklađeno.
- 'Poziv klijenta (obaveštenje o dijagnozi)' se sad auto-popunjava datumom odluke
  kad klijent odgovori preko javne stranice (QR kod) — nema potrebe za ručnim
  unosom poziva ako je klijent već digitalno potvrdio da je obavešten.

'Poziv klijenta (obaveštenje o završetku)' ostaje ručno polje — ne postoji
ekvivalentan trag (klijent ne potvrđuje ništa preko javne stranice posle
završetka, samo je vidi).
2026-07-02 12:03:21 +02:00
Dasko 485adffdfb Servis: ispravljen 01.01.0001. na eskalacionom listu za nedogođene korake
LogDogadjaji je bio map[string]time.Time — kad ključ ne postoji (npr. nalog još
nije preuzet), index vraća nultu time.Time, a {{with}} u šablonu tretira struct
kao uvek istinit pa je ispisivao '01.01.0001. auto' umesto praznog polja za
ručni unos. Mapa sad čuva *time.Time — nedostajući ključ daje nil.
2026-07-02 11:58:42 +02:00
Dasko a606348180 Popis: štampa na A4 uspravno umesto vodoravno
@page je bio postavljen na landscape, a fiksni min-width:780px na body je terao
sadržaj šire nego što uspravna A4 stranica ima mesta. Smanjen font/padding u
tabeli da svih 10 kolona stane u uspravan format bez sečenja.
2026-07-02 11:47:23 +02:00
Dasko 9899c280c8 Servis: KIR backfill i detekcija praznina (kao Prodaja)
Servisni KIR upis nije imao nijedan mehanizam oporavka — ako auto-upis pri
preuzimanju padne, B2B servisni promet tiho ispadne iz PDV evidencije bez traga
za korisnika. Dodato po uzoru na Prodaju:
- kirKandidatiServisa (preuzeti nalozi sa identifikovanim kupcem)
- KirBackfillServisa ruta + dugme "Uvezi iz servisa" na KIR stranici
- PraznineKnjigovodstva sad broji i servisne KIR praznine (dashboard upozorenje)
2026-07-02 11:31:30 +02:00
Dasko f74cb04024 Servis: storno naloga (kao Prodaja/Nabavka) — KIR/KPO storno stavke, fiskalni refund
Do sada je jedini način da se poništi naplaćen/preuzet servisni nalog bio
fizičko brisanje (ObrisiNalog), koje ostavlja KIR/KPO/fiskalne zapise sa mrtvim
izvor_id — prihod ostaje prijavljen za nalog koji više ne postoji.

Migracija 100: servisni_nalozi dobija stornirano/razlog_storniranja (kao
prodajni_nalozi/nabavke). ServisRepo.Storno vraća ugrađene delove na stanje
ali NE briše nalog. Novi handler StornoNaloga (dozvola servis.storno):
- KIR/KPO original ostaje, dograđuje se storno stavka (model.KirStorno/KpoStorno)
- fiskalni refund (best-effort) preko refundujServis — Refund zahtev sa
  ReferentDocumentNumber ka originalnom PFR broju, isti obrazac kao stornirajProdaju
- ObrisiNalog sad odbija brisanje ako nalog već ima fiskalni/KIR/KPO zapis i
  upućuje na Storno

stornoBezRefunda (dashboard upozorenje) prošireno da prati i servisne stornose
bez uspešnog fiskalnog refunda, ne samo prodaju.
2026-07-02 11:30:55 +02:00
Dasko 3c61063bbb Servis: sprečeno dupliranje KIR/KPO/fiskalnog pri ponovnom Preuzeto; KPO redni_broj
Ponovni ulazak naloga u status Preuzeto (dupli klik, dupli submit modala naplate,
sekvenca Preuzeto→Završeno→Preuzeto) je ponovo pokretao fiskalizaciju i upis u
KIR/KPO bez provere da li već postoje — svaki ulazak je duplirao prihod i PDV
obavezu, a dashboard/backfill duplikat nikad nije prijavljivao jer je dovoljan
bio bar jedan zapis. Dodate PostojiZaIzvor/DohvatiPoServisu provere pre svakog
upisa (isti obrazac kao RetryFiskalizacija).

KIR upis servisa refaktorisan u model.KirIzServisa (deljeno sa budućim backfill-om).

KpoRepo.Kreiraj sad dodeljuje redni_broj automatski (COALESCE(MAX,0)+1) — kolona
je postojala u šemi ali se nikad nije popunjavala, pa knjiga o ostvarenom prometu
paušalaca nije imala redni broj koji Pravilnik zahteva.
2026-07-02 11:27:19 +02:00
Dasko 2cc9c24aa9 Servis: garancija po trajanju (dana) umesto fiksnog datuma
Garancija se sad čuva kao broj dana (GarancijaDana), a konkretan datum
(GarancijaDo) se računa tek kad nalog pređe u Završeno/Preuzeto — na osnovu
stvarnog datuma završetka, ne datuma prijema. Ranije se datum računao odmah
pri prijemu naloga, pa je garancija krenula da teče pre nego što je popravka
uopšte gotova.
2026-07-02 10:42:01 +02:00
Dasko e26bda5d34 KIR: prikaz sumnjivih duplikata (double-submit) preskočenih pri backfill-u
Na KIR stranici se sad vidi kartica sa B2B prodajama koje je automatski
backfill preskočio jer izgledaju kao dupli submit forme naplate (isti kupac
i iznos u roku od 60s) — sa objašnjenjem i linkom na ručni unos ili storno.
2026-07-02 10:41:53 +02:00
Dasko 45831467e0 Knjigovodstvo: storno prodaje/nabavke više ne briše KIR/KPR/KPO
Storno je fizički brisao vezani KIR/KPR/KPO zapis — zakon o računovodstvu ne
dozvoljava brisanje poslovnih knjiga (KPO dodatno ima redni broj bez prekida).
Sad se original čuva, a storno se dograđuje kao poništavajuća stavka
(negirani iznosi, referenca na original) — model.KirStorno/KprStorno/KpoStorno.

Usput ispravljen i pravi uzrok prijavljenog bug-a: kirKandidatiProdaje je
filtrirala samo internu mapu, ne i listu naloga koju obilazi KirBackfillProdaje
— stornirani nalozi su zbog toga dobijali ponovni (pogrešan) KIR upis.
2026-07-02 10:35:24 +02:00
56 changed files with 3168 additions and 452 deletions
+42
View File
@@ -6,9 +6,24 @@ Prati izgled definisan u LPFR VM template-u i locale fajlovima.
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
import io
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
DATA_DIR = Path(__file__).parent / "data" DATA_DIR = Path(__file__).parent / "data"
_FONT_NAME = "Courier"
_FONT_PATH = Path("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf")
if _FONT_PATH.exists():
try:
pdfmetrics.registerFont(TTFont("DejaVuSansMono", str(_FONT_PATH)))
_FONT_NAME = "DejaVuSansMono"
except Exception:
pass
def load_locale(lang="latin"): def load_locale(lang="latin"):
"""Učitava lokalizacioni fajl u dict.""" """Učitava lokalizacioni fajl u dict."""
filename = f"locale_{lang}.properties" filename = f"locale_{lang}.properties"
@@ -646,3 +661,30 @@ def generate_report(report_data, lang="latin"):
lines.append(separator("=", W)) lines.append(separator("=", W))
return "\n".join(lines) return "\n".join(lines)
def render_report_pdf(report_text):
"""Renderuje monospace tekst izveštaja (48 kolona) u pravi PDF (A4, Courier).
Vraća bajtove gotovog PDF fajla."""
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
width, height = A4
font_name = _FONT_NAME
font_size = 10
line_height = font_size * 1.15
margin_left = 20 * (72 / 25.4) # 20mm u tačkama
margin_top = 15 * (72 / 25.4) # 15mm u tačkama
c.setFont(font_name, font_size)
y = height - margin_top
for line in report_text.split("\n"):
if y < margin_top:
c.showPage()
c.setFont(font_name, font_size)
y = height - margin_top
c.drawString(margin_left, y, line)
y -= line_height
c.showPage()
c.save()
return buf.getvalue()
+1
View File
@@ -1,2 +1,3 @@
qrcode qrcode
pillow pillow
reportlab
+155 -1
View File
@@ -21,7 +21,7 @@ import socket
import urllib.parse import urllib.parse
import qrcode import qrcode
from receipt import generate_receipt, generate_receipt_html, generate_report, load_locale from receipt import generate_receipt, generate_receipt_html, generate_report, render_report_pdf, load_locale
# ── Konfiguracija ────────────────────────────────────────── # ── Konfiguracija ──────────────────────────────────────────
PORT = 4566 # Teron standard port PORT = 4566 # Teron standard port
@@ -38,6 +38,7 @@ QR_DIR = DATA_DIR / "qr"
RECEIPTS_DIR = DATA_DIR / "receipts" RECEIPTS_DIR = DATA_DIR / "receipts"
LOG_FILE = DATA_DIR / "server.log" LOG_FILE = DATA_DIR / "server.log"
COUNTER_DIR = DATA_DIR / "counters" COUNTER_DIR = DATA_DIR / "counters"
PERIOD_FILE = DATA_DIR / "period_start.txt"
for d in [DATA_DIR, INVOICES_DIR, QR_DIR, RECEIPTS_DIR, COUNTER_DIR]: for d in [DATA_DIR, INVOICES_DIR, QR_DIR, RECEIPTS_DIR, COUNTER_DIR]:
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
@@ -525,6 +526,151 @@ def resp_invoice_search(request_body=None):
] ]
return "\n".join(lines) return "\n".join(lines)
# ── Dnevni pazar / presek stanja ───────────────────────────
def get_period_start():
"""Vreme poslednjeg preseka stanja; ako ga još nema, postavlja se na sada."""
if PERIOD_FILE.exists():
return PERIOD_FILE.read_text().strip()
ts = sada()
PERIOD_FILE.write_text(ts)
return ts
def reset_period():
"""Zatvara tekući period (presek stanja) — sledeći GET summary počinje od sada."""
PERIOD_FILE.write_text(sada())
def compute_summary(from_iso=None, to_iso=None):
"""Sabira promet iz sačuvanih fiskalnih računa u zadatom periodu.
Bez argumenata: od poslednjeg preseka stanja (get_period_start) do sada."""
start = from_iso or get_period_start()
end = to_iso or sada()
total = 0.0
total_cash = 0.0
count = 0
by_tax = {}
by_cashier = {}
by_payment = {}
by_article = {}
by_article_advance = {}
for f in sorted(INVOICES_DIR.glob("*.json")):
try:
inv = json.loads(f.read_text(encoding="utf-8"))
except Exception:
continue
t = inv.get("sdcDateTime", "")
if not t or not (start <= t <= end) or not inv.get("isFiscal", True):
continue
count += 1
amount = float(inv.get("totalAmount", 0))
is_refund = inv.get("transactionType") == "Refund"
znak = -1 if is_refund else 1
total += znak * amount
for p in inv.get("payments", []):
ptype = p.get("paymentType") or p.get("type") or "Other"
pamt = znak * float(p.get("amount", 0))
by_payment[ptype] = by_payment.get(ptype, 0.0) + pamt
if ptype == "Cash":
total_cash += pamt
kasir = inv.get("cashier", "Kasir")
by_cashier[kasir] = by_cashier.get(kasir, 0.0) + znak * amount
for ti in inv.get("taxItems", []):
label = ti.get("label", "")
if label not in by_tax:
by_tax[label] = {"label": label, "rate": ti.get("rate", 0),
"category": "VAT" if ti.get("rate", 0) > 0 else "N-TAX",
"amount": 0.0, "osnovica": 0.0}
by_tax[label]["amount"] += znak * float(ti.get("amount", 0))
cilj = by_article_advance if inv.get("invoiceType") == "Advance" else by_article
for item in inv.get("items", []):
naziv = item.get("name", "")
if naziv not in cilj:
cilj[naziv] = {"articleName": naziv, "gtin": None, "plu": None,
"taxLabel": (item.get("labels") or [""])[0],
"amount": 0.0, "quantity": 0.0}
cilj[naziv]["amount"] += znak * float(item.get("totalAmount", 0))
cilj[naziv]["quantity"] += znak * float(item.get("quantity", 0))
for label in item.get("labels", []):
if label in by_tax:
by_tax[label]["osnovica"] += znak * float(item.get("totalAmount", 0))
return {
"startOfPeriod": start,
"endOfPeriod": end,
"invoiceCount": count,
"total": round(total, 2),
"totalCash": round(total_cash, 2),
"totalByTax": [
{"amount": round(v["amount"], 4), "category": v["category"], "label": v["label"],
"rate": v["rate"], "osnovica": round(v["osnovica"], 2)}
for v in by_tax.values()
],
"totalByCashier": [{"amount": round(v, 2), "name": k} for k, v in by_cashier.items()],
"totalByPaymentType": [{"amount": round(v, 2), "paymentType": k} for k, v in by_payment.items()],
"totalByArticle": [
{**v, "amount": round(v["amount"], 2)} for v in by_article.values()
],
"totalByArticleAdvance": [
{**v, "amount": round(v["amount"], 2)} for v in by_article_advance.values()
],
}
def resp_financial_summary_get():
return compute_summary()
def resp_financial_summary_delete():
reset_period()
log(" 📊 Presek stanja urađen — brojači prometa resetovani")
return ("", 204)
def resp_financial_report_summary(request_body=None):
body = request_body or {}
from_date = body.get("fromDate")
to_date = body.get("toDate")
from_iso = f"{from_date}T00:00:00.000+02:00" if from_date else None
to_iso = f"{to_date}T23:59:59.999+02:00" if to_date else None
summary = compute_summary(from_iso, to_iso)
cert = be_command({"command": "certificate"})
firma = ucitaj_firmu()
lang = "cyrillic" if str(body.get("language", "")).lower().startswith("sr-cyrl") else "latin"
title = "ДНЕВНИ ИЗВЕШТАЈ" if lang == "cyrillic" else "DNEVNI IZVEŠTAJ"
report_data = {
"title": title,
"number": get_counter("report"),
"dateTime": sada(),
"tin": firma["tinPlain"],
"businessName": firma["name"],
"locationName": firma["locationName"],
"address": firma["address"],
"district": firma["district"],
"uid": cert.get("jid", ESIR_ID),
"startDate": (from_date or summary["startOfPeriod"][:10]),
"endDate": (to_date or summary["endOfPeriod"][:10]),
"total": {
"invoiceCount": summary["invoiceCount"],
"payments": [{"paymentType": p["paymentType"], "amount": p["amount"]} for p in summary["totalByPaymentType"]],
"totalPayments": summary["total"],
"taxItems": [{"label": t["label"], "rate": t["rate"], "total": t["osnovica"], "amount": t["amount"]} for t in summary["totalByTax"]],
"totalTax": round(sum(t["amount"] for t in summary["totalByTax"]), 2),
},
"perTransactionType": [],
}
tekst = generate_report(report_data, lang)
pdf_bytes = render_report_pdf(tekst)
pdf_b64 = base64.b64encode(pdf_bytes).decode("ascii")
filename = f"{title} - {report_data['startDate']} - {report_data['endDate']}.pdf"
log(f" 📄 Dnevni izveštaj #{report_data['number']} generisan ({summary['invoiceCount']} računa)")
return {"reportPdfBase64": pdf_b64, "reportName": title, "filename": filename}
# ── Rute ──────────────────────────────────────────────────── # ── Rute ────────────────────────────────────────────────────
ROUTES = [ ROUTES = [
@@ -540,6 +686,9 @@ ROUTES = [
("GET", "api/invoices/:invoiceNumber", "invoice_by_number"), ("GET", "api/invoices/:invoiceNumber", "invoice_by_number"),
("POST", "api/invoices/search", "invoice_search"), ("POST", "api/invoices/search", "invoice_search"),
("POST", "api/invoices", "invoice"), ("POST", "api/invoices", "invoice"),
("GET", "api/financial/summary", "financial_summary_get"),
("DELETE", "api/financial/summary", "financial_summary_delete"),
("POST", "api/financial/report/summary", "financial_report_summary"),
] ]
HANDLERS = { HANDLERS = {
@@ -555,6 +704,9 @@ HANDLERS = {
"invoice_by_request": resp_invoice_by_request, "invoice_by_request": resp_invoice_by_request,
"invoice_by_number": resp_invoice_by_number, "invoice_by_number": resp_invoice_by_number,
"invoice_search": resp_invoice_search, "invoice_search": resp_invoice_search,
"financial_summary_get": resp_financial_summary_get,
"financial_summary_delete": resp_financial_summary_delete,
"financial_report_summary": resp_financial_report_summary,
} }
# ── HTTP Handler ───────────────────────────────────────────── # ── HTTP Handler ─────────────────────────────────────────────
@@ -619,6 +771,8 @@ class FiscalHandler(http.server.BaseHTTPRequestHandler):
result = handler(params.get("invoiceNumber", "")) result = handler(params.get("invoiceNumber", ""))
elif r_name == "invoice_search": elif r_name == "invoice_search":
result = handler(self._read_body()) result = handler(self._read_body())
elif r_name == "financial_report_summary":
result = handler(self._read_body())
elif r_name in ("settings_post",): elif r_name in ("settings_post",):
self._read_body() self._read_body()
result = handler() result = handler()
+9
View File
@@ -291,6 +291,9 @@ func main() {
r.Get("/podesavanja/fiskalizacija/test", h.TestFiskalizacije) r.Get("/podesavanja/fiskalizacija/test", h.TestFiskalizacije)
r.Get("/podesavanja/fiskalizacija/be-status", h.BeStatus) r.Get("/podesavanja/fiskalizacija/be-status", h.BeStatus)
r.Post("/podesavanja/fiskalizacija/be-reset-audit", h.BeResetAudit) r.Post("/podesavanja/fiskalizacija/be-reset-audit", h.BeResetAudit)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Get("/fiskal/pazar", h.FiskalniPazar)
r.With(doz("fiskal.pazar")).Post("/fiskal/pazar/izvestaj", h.FiskalniIzvestaj)
r.With(doz("fiskal.zakljucenje")).Post("/fiskal/pazar/zakljuci", h.ZakljuciFiskalniDan)
r.Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope) r.Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu)
@@ -312,6 +315,7 @@ func main() {
r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/nova", h.SacuvajPdvKir) r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/nova", h.SacuvajPdvKir)
r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kir/obrisi/{id}", h.ObrisiPdvKir) r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kir/obrisi/{id}", h.ObrisiPdvKir)
r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/backfill-prodaje", h.KirBackfillProdaje) r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/backfill-prodaje", h.KirBackfillProdaje)
r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/backfill-servisa", h.KirBackfillServisa)
r.With(modul("pdv"), doz("pdv.dodaj")).Get("/pdv/kir/dnevni-pazar", h.DnevniPazarKir) r.With(modul("pdv"), doz("pdv.dodaj")).Get("/pdv/kir/dnevni-pazar", h.DnevniPazarKir)
r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/dnevni-pazar", h.SacuvajDnevniPazarKir) r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/dnevni-pazar", h.SacuvajDnevniPazarKir)
r.With(modul("pdv")).Get("/pdv/kpr", h.PdvKpr) r.With(modul("pdv")).Get("/pdv/kpr", h.PdvKpr)
@@ -380,6 +384,7 @@ func main() {
r.Get("/servis/izmeni/{id}", h.IzmeniNalog) r.Get("/servis/izmeni/{id}", h.IzmeniNalog)
r.With(doz("servis.izmeni")).Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga) r.With(doz("servis.izmeni")).Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
r.With(doz("servis.obrisi")).Post("/servis/obrisi/{id}", h.ObrisiNalog) r.With(doz("servis.obrisi")).Post("/servis/obrisi/{id}", h.ObrisiNalog)
r.With(doz("servis.storno")).Post("/servis/storno/{id}", h.StornoNaloga)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}", h.DetaljiNaloga) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}", h.DetaljiNaloga)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/radni-nalog", h.StampaRadnogNaloga) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/radni-nalog", h.StampaRadnogNaloga)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice)
@@ -390,6 +395,7 @@ func main() {
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/garantni-list", h.StampaGarantnog) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/garantni-list", h.StampaGarantnog)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/eskalacioni-list", h.StampaEskalacionogLista) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/eskalacioni-list", h.StampaEskalacionogLista)
r.With(doz("servis.izmeni")).Post("/servis/{id}/fiskalizuj", h.RetryFiskalizacija) r.With(doz("servis.izmeni")).Post("/servis/{id}/fiskalizuj", h.RetryFiskalizacija)
r.With(doz("servis.storno")).Post("/servis/{id}/refund-fiskalizuj", h.PokusajRefundServisa)
r.With(doz("servis.izmeni")).Post("/servis/{id}/status", h.PromeniStatus) r.With(doz("servis.izmeni")).Post("/servis/{id}/status", h.PromeniStatus)
r.With(doz("servis.izmeni")).Post("/servis/{id}/garancija", h.AzurirajGaranciju) r.With(doz("servis.izmeni")).Post("/servis/{id}/garancija", h.AzurirajGaranciju)
r.With(doz("servis.izmeni")).Post("/servis/{id}/garancija-dana", h.AzurirajGarancijaDana) r.With(doz("servis.izmeni")).Post("/servis/{id}/garancija-dana", h.AzurirajGarancijaDana)
@@ -418,6 +424,7 @@ func main() {
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju) r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju)
r.With(doz("prodaja.storno")).Post("/prodaja/storno/{id}", h.StornoProdaje) r.With(doz("prodaja.storno")).Post("/prodaja/storno/{id}", h.StornoProdaje)
r.With(doz("prodaja.dodaj")).Post("/prodaja/{id}/fiskalizuj", h.RetryFiskalizacijaProdaje) r.With(doz("prodaja.dodaj")).Post("/prodaja/{id}/fiskalizuj", h.RetryFiskalizacijaProdaje)
r.With(doz("prodaja.storno")).Post("/prodaja/{id}/refund-fiskalizuj", h.PokusajRefundProdaje)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}/stampa", h.StampaProdaje) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}/stampa", h.StampaProdaje)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}/fiskalni-racun", h.StampaFiskalnogProdaje) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}/fiskalni-racun", h.StampaFiskalnogProdaje)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}", h.DetaljiProdaje) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}", h.DetaljiProdaje)
@@ -450,6 +457,8 @@ func main() {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(ntechmw.RequireSuperAdmin) r.Use(ntechmw.RequireSuperAdmin)
r.Post("/admin/dozvole/uloga/{id}", h.AdminDozvolePromeniUlogu) r.Post("/admin/dozvole/uloga/{id}", h.AdminDozvolePromeniUlogu)
r.Get("/admin/blokirane-ip", h.AdminBlokiraneIP)
r.Post("/admin/blokirane-ip/odblokiraj", h.AdminOdblokirajIP)
}) })
r.Get("/admin/profil", h.AdminProfil) r.Get("/admin/profil", h.AdminProfil)
r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku) r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku)
+26 -5
View File
@@ -58,6 +58,9 @@ type PdvStopaRepository interface {
Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, error) Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, error)
Izmeni(ctx context.Context, s *model.PdvStopa) error Izmeni(ctx context.Context, s *model.PdvStopa) error
PostaviAktivnu(ctx context.Context, id int64, aktivna bool) error PostaviAktivnu(ctx context.Context, id int64, aktivna bool) error
// PodrazumevanaOpsta vraća aktivnu opštu stopu iz šifarnika (prva po redosledu),
// ili nil ako u šifarniku nema nijedne aktivne opšte stope.
PodrazumevanaOpsta(ctx context.Context) (*model.PdvStopa, error)
} }
// PdvKirRepository definiše operacije nad knjigom izdatih računa (KIR) // PdvKirRepository definiše operacije nad knjigom izdatih računa (KIR)
@@ -66,8 +69,9 @@ type PdvKirRepository interface {
DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error) DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error)
Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error) Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error)
Obrisi(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64) error
// ObrisiPoIzvoru briše zapise vezane za dati izvor (npr. pri stornu prodaje) // DohvatiPoIzvoru vraća zapise vezane za dati izvor (npr. za storno prodaje
ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error // original se ne briše, storno stavka se dograđuje na osnovu ovih zapisa)
DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKir, error)
// PostojiZaIzvor vraća true ako postoji bar jedan zapis za dati izvor i izvorID // PostojiZaIzvor vraća true ako postoji bar jedan zapis za dati izvor i izvorID
PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error) PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error)
// PostojiPoBrojuDokumenta vraća true ako postoji zapis sa datim brojem dokumenta // PostojiPoBrojuDokumenta vraća true ako postoji zapis sa datim brojem dokumenta
@@ -83,8 +87,9 @@ type PdvKprRepository interface {
DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, error) DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, error)
Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error) Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error)
Obrisi(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64) error
// ObrisiPoIzvoru briše zapise vezane za dati izvor (npr. pri brisanju nabavke) // DohvatiPoIzvoru vraća zapise vezane za dati izvor (npr. za storno nabavke
ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error // original se ne briše, storno stavka se dograđuje na osnovu ovih zapisa)
DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKpr, error)
// PostojiZaIzvor vraća true ako postoji bar jedan zapis za dati izvor i izvorID // PostojiZaIzvor vraća true ako postoji bar jedan zapis za dati izvor i izvorID
PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error) PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error)
} }
@@ -215,6 +220,9 @@ type ServisRepository interface {
SacuvajOdlukuKlijenta(ctx context.Context, id int64, odluka string, odgovor string) error SacuvajOdlukuKlijenta(ctx context.Context, id int64, odluka string, odgovor string) error
ObrisiOdlukuKlijenta(ctx context.Context, id int64) error ObrisiOdlukuKlijenta(ctx context.Context, id int64) error
Obrisi(ctx context.Context, id int64, korisnikID *int64) error Obrisi(ctx context.Context, id int64, korisnikID *int64) error
// Storno stornira nalog (vraća ugrađene delove na stanje, markira stornirano=1)
// bez brisanja — koristi se kad nalog već ima finansijske zapise (KIR/KPO/fiskalni)
Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error
SledeciBroj(ctx context.Context) (string, error) SledeciBroj(ctx context.Context) (string, error)
SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error
} }
@@ -331,6 +339,10 @@ type PokusajiPrijaveRepository interface {
BrojNeuspeha(ctx context.Context, ip string, od time.Time) (int, error) BrojNeuspeha(ctx context.Context, ip string, od time.Time) (int, error)
VremePoslednjeg(ctx context.Context, ip string, od time.Time) (time.Time, bool, error) VremePoslednjeg(ctx context.Context, ip string, od time.Time) (time.Time, bool, error)
ObrisiStare(ctx context.Context, pre time.Time) error ObrisiStare(ctx context.Context, pre time.Time) error
// ListaBlokiranih vraća IP adrese sa >= prag neuspelih pokušaja od zadatog trenutka
ListaBlokiranih(ctx context.Context, od time.Time, prag int) ([]model.BlokiranaIP, error)
// Odblokiraj briše neuspele pokušaje za IP adresu, čime se ona odmah oslobađa blokade
Odblokiraj(ctx context.Context, ip string) error
} }
// LoginIstorijsaRepository definiše operacije nad evidencijom prijava // LoginIstorijsaRepository definiše operacije nad evidencijom prijava
@@ -371,6 +383,8 @@ type IzvestajRepository interface {
// izveštaji // izveštaji
MesecniPrihodProdaja(ctx context.Context) ([]model.MesecniIznos, error) MesecniPrihodProdaja(ctx context.Context) ([]model.MesecniIznos, error)
MesecniPrihodServis(ctx context.Context) ([]model.MesecniIznos, error) MesecniPrihodServis(ctx context.Context) ([]model.MesecniIznos, error)
MesecniZaradaProdaja(ctx context.Context) ([]model.MesecniIznos, error)
MesecniZaradaServis(ctx context.Context) ([]model.MesecniIznos, error)
StariOtvoreniNalozi(ctx context.Context) ([]model.StariNalogRed, error) StariOtvoreniNalozi(ctx context.Context) ([]model.StariNalogRed, error)
TopArtikli(ctx context.Context, limit int) ([]model.TopArtikalRed, error) TopArtikli(ctx context.Context, limit int) ([]model.TopArtikalRed, error)
TopKlijenti(ctx context.Context, limit int) ([]model.TopKlijentRed, error) TopKlijenti(ctx context.Context, limit int) ([]model.TopKlijentRed, error)
@@ -396,6 +410,11 @@ type FiskalRepository interface {
Kreiraj(ctx context.Context, fr *model.FiskalniRacun) (int64, error) Kreiraj(ctx context.Context, fr *model.FiskalniRacun) (int64, error)
DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*model.FiskalniRacun, error) DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*model.FiskalniRacun, error)
DohvatiPoServisu(ctx context.Context, servisID int64) (*model.FiskalniRacun, error) DohvatiPoServisu(ctx context.Context, servisID int64) (*model.FiskalniRacun, error)
// DohvatiPoServisuITip vraća poslednji nestorniran račun tačno određenog tipa
// (npr. avansni Sale) — za razliku od DohvatiPoServisu (samo najnoviji, bilo kog tipa)
DohvatiPoServisuITip(ctx context.Context, servisID int64, tipRacuna, tipTransakcije string) (*model.FiskalniRacun, error)
// SumaAvansaPoServisu vraća neto fiskalizovan avans (Sale - Refund) za nalog
SumaAvansaPoServisu(ctx context.Context, servisID int64) (float64, error)
OznačiKaoStorniran(ctx context.Context, id int64) error OznačiKaoStorniran(ctx context.Context, id int64) error
ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, error) ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, error)
ProdajeBezFiskalnog(ctx context.Context) (map[int64]bool, error) ProdajeBezFiskalnog(ctx context.Context) (map[int64]bool, error)
@@ -413,5 +432,7 @@ type KpoRepository interface {
Kreiraj(ctx context.Context, z *model.KpoZapis) (int64, error) Kreiraj(ctx context.Context, z *model.KpoZapis) (int64, error)
Obrisi(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64) error
PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error) PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error)
ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error // DohvatiPoIzvoru vraća zapise vezane za dati izvor (npr. za storno prodaje —
// original se ne briše, storno stavka se dograđuje na osnovu ovih zapisa)
DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.KpoZapis, error)
} }
+55
View File
@@ -117,6 +117,61 @@ func (r *FiskalRepo) DohvatiPoServisu(ctx context.Context, servisID int64) (*mod
return fr, nil return fr, nil
} }
// DohvatiPoServisuITip vraća poslednji nestorniran fiskalni račun za dati servisni
// nalog sa tačno određenim tipom (npr. tip_racuna="Advance", tip_transakcije="Sale"
// za avansni račun) — za razliku od DohvatiPoServisu koji vraća samo najnoviji
// zapis bilo kog tipa. Vraća nil bez greške ako takav račun ne postoji.
func (r *FiskalRepo) DohvatiPoServisuITip(ctx context.Context, servisID int64, tipRacuna, tipTransakcije string) (*model.FiskalniRacun, error) {
fr := &model.FiskalniRacun{}
var storniran int
var sID, pID sql.NullInt64
err := r.db.QueryRowContext(ctx, `
SELECT id, prodaja_id, servis_id, tip_racuna, tip_transakcije, pfr_broj, pfr_vreme,
brojac, ekstenzija_brojaca, url_verifikacija, qr_kod,
poreske_stavke, ukupno_za_naplatu, ukupan_porez,
sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja
FROM fiskalni_racuni
WHERE servis_id = ? AND tip_racuna = ? AND tip_transakcije = ?
ORDER BY id DESC LIMIT 1
`, servisID, tipRacuna, tipTransakcije).Scan(
&fr.ID, &pID, &sID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme,
&fr.Brojac, &fr.EkstenzijaBrojaca, &fr.UrlVerifikacija, &fr.QRKod,
&fr.PoreskeStavke, &fr.UkupnoZaNaplatu, &fr.UkupanPorez,
&fr.SiroviOdgovor, &fr.Potpisao, &fr.Zatrazio, &fr.Poruka, &storniran, &fr.VremeKreiranja,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("ntech: FiskalRepo.DohvatiPoServisuITip: %w", err)
}
if pID.Valid {
fr.ProdajaID = pID.Int64
}
if sID.Valid {
fr.ServisID = sID.Int64
}
fr.Storniran = storniran != 0
return fr, nil
}
// SumaAvansaPoServisu vraća neto fiskalizovan avans za dati servisni nalog —
// zbir Advance/Sale iznosa umanjen za Advance/Refund iznose (nestornirani).
// Koristi se da se pri izmeni avansa fiskalizuje samo razlika (delta), ne ceo
// novi iznos ponovo.
func (r *FiskalRepo) SumaAvansaPoServisu(ctx context.Context, servisID int64) (float64, error) {
var suma float64
err := r.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(CASE WHEN tip_transakcije = 'Refund' THEN -ukupno_za_naplatu ELSE ukupno_za_naplatu END), 0)
FROM fiskalni_racuni
WHERE servis_id = ? AND tip_racuna = 'Advance' AND storniran = 0
`, servisID).Scan(&suma)
if err != nil {
return 0, fmt.Errorf("ntech: FiskalRepo.SumaAvansaPoServisu: %w", err)
}
return suma, nil
}
// ServisiBezFiskalnog vraća skup ID-eva servisnih naloga koji su u statusu "Preuzeto" // ServisiBezFiskalnog vraća skup ID-eva servisnih naloga koji su u statusu "Preuzeto"
// a nemaju fiskalni račun (za oznaku u listi naloga). // a nemaju fiskalni račun (za oznaku u listi naloga).
func (r *FiskalRepo) ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, error) { func (r *FiskalRepo) ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, error) {
+36
View File
@@ -165,6 +165,42 @@ func (r *sqliteIzvestajRepo) MesecniPrihodServis(ctx context.Context) ([]model.M
GROUP BY substr(datum_zavrsetka, 1, 7)`, "MesecniPrihodServis") GROUP BY substr(datum_zavrsetka, 1, 7)`, "MesecniPrihodServis")
} }
// MesecniZaradaProdaja je zarada prodaje (ukupno nabavna cena × količina),
// nabavna_cena je zamrznuta na stavci u trenutku prodaje (vidi migraciju 101).
func (r *sqliteIzvestajRepo) MesecniZaradaProdaja(ctx context.Context) ([]model.MesecniIznos, error) {
return r.mesecniPrihod(ctx, `
SELECT substr(pn.datum, 1, 7), SUM(sp.ukupno - sp.nabavna_cena * sp.kolicina)
FROM prodajni_nalozi pn
JOIN stavke_prodaje sp ON sp.nalog_id = pn.id
WHERE pn.stornirano = 0
AND substr(pn.datum, 1, 10) >= date('now', '-11 months', 'start of month')
GROUP BY substr(pn.datum, 1, 7)`, "MesecniZaradaProdaja")
}
// MesecniZaradaServis je zarada servisa: ugrađeni delovi (cena nabavna cena) +
// rad u punom iznosu (servis_radovi nema polje za trošak). Isti filter statusa/
// naplate kao MesecniPrihodServis.
func (r *sqliteIzvestajRepo) MesecniZaradaServis(ctx context.Context) ([]model.MesecniIznos, error) {
return r.mesecniPrihod(ctx, `
SELECT mesec, SUM(zarada) FROM (
SELECT substr(sn.datum_zavrsetka, 1, 7) AS mesec,
(sd.cena_komada - sd.nabavna_cena) * sd.kolicina AS zarada
FROM servisni_delovi sd
JOIN servisni_nalozi sn ON sn.id = sd.nalog_id
WHERE sn.datum_zavrsetka IS NOT NULL AND sn.status = 'Preuzeto'
AND (sn.naplaceno > 0 OR sn.avans > 0) AND sd.predlozeno = 0
UNION ALL
SELECT substr(sn.datum_zavrsetka, 1, 7) AS mesec,
sr.cena_komada * sr.kolicina AS zarada
FROM servis_radovi sr
JOIN servisni_nalozi sn ON sn.id = sr.nalog_id
WHERE sn.datum_zavrsetka IS NOT NULL AND sn.status = 'Preuzeto'
AND (sn.naplaceno > 0 OR sn.avans > 0)
)
WHERE mesec >= strftime('%Y-%m', date('now', '-11 months', 'start of month'))
GROUP BY mesec`, "MesecniZaradaServis")
}
func (r *sqliteIzvestajRepo) StariOtvoreniNalozi(ctx context.Context) ([]model.StariNalogRed, error) { func (r *sqliteIzvestajRepo) StariOtvoreniNalozi(ctx context.Context) ([]model.StariNalogRed, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT sn.id, sn.broj_naloga, sn.uredjaj, sn.status, sn.datum_prijema, SELECT sn.id, sn.broj_naloga, sn.uredjaj, sn.status, sn.datum_prijema,
+41 -8
View File
@@ -70,12 +70,14 @@ func (r *KpoRepo) Lista(ctx context.Context, od, do time.Time) ([]model.KpoZapis
return rezultat, redovi.Err() return rezultat, redovi.Err()
} }
// Kreiraj upisuje novi KPO zapis i vraća njegov ID. // Kreiraj upisuje novi KPO zapis i vraća njegov ID. redni_broj se dodeljuje
// automatski kao sledeći u nizu (Pravilnik o KPO zahteva kontinuiran redni broj
// bez prekida — knjiga se nikad ne briše, pa brojevi ostaju u nizu i posle storna).
func (r *KpoRepo) Kreiraj(ctx context.Context, z *model.KpoZapis) (int64, error) { func (r *KpoRepo) Kreiraj(ctx context.Context, z *model.KpoZapis) (int64, error) {
rez, err := r.db.ExecContext(ctx, ` rez, err := r.db.ExecContext(ctx, `
INSERT INTO kpo_unosi INSERT INTO kpo_unosi
(datum_prometa, broj_dokumenta, opis, prihod, nacin_placanja, napomena, izvor, izvor_id) (datum_prometa, redni_broj, broj_dokumenta, opis, prihod, nacin_placanja, napomena, izvor, izvor_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, (SELECT COALESCE(MAX(redni_broj), 0) + 1 FROM kpo_unosi), ?, ?, ?, ?, ?, ?, ?)`,
z.DatumPrometa.Format("2006-01-02"), z.DatumPrometa.Format("2006-01-02"),
z.BrojDokumenta, z.BrojDokumenta,
nullString(z.Opis), nullString(z.Opis),
@@ -115,10 +117,41 @@ func (r *KpoRepo) PostojiZaIzvor(ctx context.Context, izvor string, izvorID int6
return n > 0, nil return n > 0, nil
} }
// ObrisiPoIzvoru briše KPO zapise vezane za dati izvor (npr. pri stornu prodaje). // DohvatiPoIzvoru vraća sve KPO zapise vezane za dati izvor (npr. za storno prodaje).
func (r *KpoRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error { func (r *KpoRepo) DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.KpoZapis, error) {
if _, err := r.db.ExecContext(ctx, "DELETE FROM kpo_unosi WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil { redovi, err := r.db.QueryContext(ctx, `
return fmt.Errorf("ntech: KpoRepo.ObrisiPoIzvoru: %w", err) SELECT id, datum_prometa, redni_broj, broj_dokumenta, opis, prihod,
nacin_placanja, napomena, izvor, izvor_id, datum_unosa
FROM kpo_unosi WHERE izvor = ? AND izvor_id = ?`, izvor, izvorID)
if err != nil {
return nil, fmt.Errorf("ntech: KpoRepo.DohvatiPoIzvoru: %w", err)
} }
return nil defer redovi.Close()
var rezultat []model.KpoZapis
for redovi.Next() {
var z model.KpoZapis
var opis, nacinPlacanja, napomena sql.NullString
var redniBroj sql.NullInt64
var izvorIDCol sql.NullInt64
if err := redovi.Scan(
&z.ID, &z.DatumPrometa, &redniBroj, &z.BrojDokumenta, &opis, &z.Prihod,
&nacinPlacanja, &napomena, &z.Izvor, &izvorIDCol, &z.DatumUnosa,
); err != nil {
return nil, fmt.Errorf("ntech: KpoRepo.DohvatiPoIzvoru: scan: %w", err)
}
if redniBroj.Valid {
n := int(redniBroj.Int64)
z.RedniBroj = &n
}
z.Opis = opis.String
z.NacinPlacanja = nacinPlacanja.String
z.Napomena = napomena.String
if izvorIDCol.Valid {
z.IzvorID = &izvorIDCol.Int64
}
rezultat = append(rezultat, z)
}
return rezultat, redovi.Err()
} }
+40
View File
@@ -0,0 +1,40 @@
package sqlite
import (
"context"
"testing"
"time"
"ntech/internal/model"
)
// TestKpoRedniBroj: redni_broj mora biti dodeljen automatski i bez prekida —
// Pravilnik o KPO zahteva kontinuiran niz, i knjiga se nikad ne briše (storno
// se dograđuje kao nov red, ne zamenjuje stari).
func TestKpoRedniBroj(t *testing.T) {
db := testDB(t)
repo := NoviKpoRepo(db)
ctx := context.Background()
dp := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
for _, brojDok := range []string{"SN-1", "SN-2", "SN-3"} {
if _, err := repo.Kreiraj(ctx, &model.KpoZapis{
DatumPrometa: dp, BrojDokumenta: brojDok, Prihod: 100,
}); err != nil {
t.Fatalf("Kreiraj %s: %v", brojDok, err)
}
}
zapisi, err := repo.Lista(ctx, time.Time{}, time.Time{})
if err != nil {
t.Fatalf("Lista: %v", err)
}
if len(zapisi) != 3 {
t.Fatalf("očekivano 3 zapisa, dobijeno %d", len(zapisi))
}
for i, z := range zapisi {
if z.RedniBroj == nil || *z.RedniBroj != i+1 {
t.Errorf("zapis %q: redni_broj=%v, očekivano %d", z.BrojDokumenta, z.RedniBroj, i+1)
}
}
}
+50 -10
View File
@@ -128,12 +128,32 @@ func (r *PdvKirRepo) Obrisi(ctx context.Context, id int64) error {
return nil return nil
} }
// ObrisiPoIzvoru briše KIR zapise vezane za dati izvor (npr. pri stornu/brisanju prodaje) // DohvatiPoIzvoru vraća sve KIR zapise vezane za dati izvor (npr. za storno prodaje)
func (r *PdvKirRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error { func (r *PdvKirRepo) DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKir, error) {
if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kir WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil { redovi, err := r.db.QueryContext(ctx, `
return fmt.Errorf("ntech: PdvKirRepo.ObrisiPoIzvoru: %w", err) SELECT id, datum_prometa, datum_knjizenja, broj_dokumenta,
kupac_naziv, COALESCE(kupac_pib, ''), COALESCE(kupac_mesto, ''),
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
osloboden_sa_pravom, osloboden_bez_prava, ukupno,
COALESCE(napomena, ''), izvor, izvor_id, datum_unosa
FROM pdv_kir WHERE izvor = ? AND izvor_id = ?`, izvor, izvorID)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiPoIzvoru: %w", err)
} }
return nil defer redovi.Close()
var rezultat []model.PdvKir
for redovi.Next() {
k, err := skenirajKir(redovi.Scan)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiPoIzvoru: scan: %w", err)
}
rezultat = append(rezultat, k)
}
if err := redovi.Err(); err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiPoIzvoru: %w", err)
}
return rezultat, nil
} }
// PostojiZaIzvor vraća true ako postoji bar jedan KIR zapis za dati izvor i izvorID // PostojiZaIzvor vraća true ako postoji bar jedan KIR zapis za dati izvor i izvorID
@@ -318,12 +338,32 @@ func (r *PdvKprRepo) Obrisi(ctx context.Context, id int64) error {
return nil return nil
} }
// ObrisiPoIzvoru briše KPR zapise vezane za dati izvor (npr. pri brisanju nabavke) // DohvatiPoIzvoru vraća sve KPR zapise vezane za dati izvor (npr. za storno nabavke)
func (r *PdvKprRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error { func (r *PdvKprRepo) DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKpr, error) {
if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kpr WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil { redovi, err := r.db.QueryContext(ctx, `
return fmt.Errorf("ntech: PdvKprRepo.ObrisiPoIzvoru: %w", err) SELECT id, datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta,
dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''),
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
pdv_bez_odbitka, osloboden_nabavka, ukupno,
COALESCE(napomena, ''), izvor, izvor_id, uvoz, datum_unosa
FROM pdv_kpr WHERE izvor = ? AND izvor_id = ?`, izvor, izvorID)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiPoIzvoru: %w", err)
} }
return nil defer redovi.Close()
var rezultat []model.PdvKpr
for redovi.Next() {
k, err := skenirajKpr(redovi.Scan)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiPoIzvoru: scan: %w", err)
}
rezultat = append(rezultat, k)
}
if err := redovi.Err(); err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiPoIzvoru: %w", err)
}
return rezultat, nil
} }
// PostojiZaIzvor vraća true ako postoji bar jedan KPR zapis za dati izvor i izvorID // PostojiZaIzvor vraća true ako postoji bar jedan KPR zapis za dati izvor i izvorID
+6 -6
View File
@@ -162,12 +162,12 @@ func TestPdvKirIzvor(t *testing.T) {
} }
} }
// ObrisiPoIzvoru briše samo vezani auto zapis, ručni ostaje // DohvatiPoIzvoru vraća samo vezani auto zapis
if err := repo.ObrisiPoIzvoru(ctx, "prodaja", 7); err != nil { poIzvoru, err := repo.DohvatiPoIzvoru(ctx, "prodaja", 7)
t.Fatalf("ObrisiPoIzvoru: %v", err) if err != nil {
t.Fatalf("DohvatiPoIzvoru: %v", err)
} }
preostali, _ := repo.Lista(ctx, time.Time{}, time.Time{}) if len(poIzvoru) != 1 || poIzvoru[0].BrojDokumenta != "P-1" {
if len(preostali) != 1 || preostali[0].BrojDokumenta != "R-1" { t.Errorf("očekivan samo auto zapis P-1, dobijeno %d", len(poIzvoru))
t.Errorf("posle ObrisiPoIzvoru očekivan samo ručni zapis, dobijeno %d", len(preostali))
} }
} }
+22
View File
@@ -78,6 +78,28 @@ func (r *PdvStopaRepo) Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, e
return id, nil return id, nil
} }
// PodrazumevanaOpsta vraća aktivnu opštu stopu iz šifarnika (prva po redosledu),
// ili nil ako u šifarniku nema nijedne aktivne opšte stope — poziocalac tada mora
// sam odlučiti o fallback vrednosti (šifarnik nikad ne sme biti prazan u praksi,
// ali ne oslanjamo se na to).
func (r *PdvStopaRepo) PodrazumevanaOpsta(ctx context.Context) (*model.PdvStopa, error) {
var s model.PdvStopa
err := r.db.QueryRowContext(ctx, `
SELECT id, naziv, stopa, oznaka, aktivna, redosled, datum_unosa
FROM pdv_stope
WHERE oznaka = 'opsta' AND aktivna = 1
ORDER BY redosled ASC, stopa DESC
LIMIT 1`).
Scan(&s.ID, &s.Naziv, &s.Stopa, &s.Oznaka, &s.Aktivna, &s.Redosled, &s.DatumUnosa)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("ntech: PdvStopaRepo.PodrazumevanaOpsta: %w", err)
}
return &s, nil
}
// Izmeni menja podatke postojeće stope (osim datuma unosa) // Izmeni menja podatke postojeće stope (osim datuma unosa)
func (r *PdvStopaRepo) Izmeni(ctx context.Context, s *model.PdvStopa) error { func (r *PdvStopaRepo) Izmeni(ctx context.Context, s *model.PdvStopa) error {
_, err := r.db.ExecContext(ctx, ` _, err := r.db.ExecContext(ctx, `
+50
View File
@@ -5,6 +5,8 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"time" "time"
"ntech/internal/model"
) )
// PokusajiPrijaveRepo implementira db.PokusajiPrijaveRepository nad SQLite // PokusajiPrijaveRepo implementira db.PokusajiPrijaveRepository nad SQLite
@@ -77,3 +79,51 @@ func (r *PokusajiPrijaveRepo) ObrisiStare(ctx context.Context, pre time.Time) er
} }
return nil return nil
} }
// ListaBlokiranih vraća IP adrese sa najmanje prag neuspelih pokušaja prijave od
// zadatog trenutka, grupisano po IP adresi, poređano od najskorije zaključane.
func (r *PokusajiPrijaveRepo) ListaBlokiranih(ctx context.Context, od time.Time, prag int) ([]model.BlokiranaIP, error) {
redovi, err := r.db.QueryContext(ctx,
`SELECT ip, COUNT(*), MAX(vreme) FROM pokusaji_prijave
WHERE uspeh = 0 AND vreme > ?
GROUP BY ip HAVING COUNT(*) >= ?
ORDER BY MAX(vreme) DESC`,
od.UTC().Format("2006-01-02 15:04:05"), prag,
)
if err != nil {
return nil, fmt.Errorf("ntech: PokusajiPrijaveRepo.ListaBlokiranih: %w", err)
}
defer redovi.Close()
var lista []model.BlokiranaIP
for redovi.Next() {
var b model.BlokiranaIP
var poslednji string
if err := redovi.Scan(&b.IP, &b.BrojNeuspeha, &poslednji); err != nil {
return nil, fmt.Errorf("ntech: PokusajiPrijaveRepo.ListaBlokiranih: scan: %w", err)
}
t, err := time.ParseInLocation("2006-01-02 15:04:05", poslednji, time.UTC)
if err != nil {
return nil, fmt.Errorf("ntech: PokusajiPrijaveRepo.ListaBlokiranih: parse vremena: %w", err)
}
b.PoslednjiPokusaj = t
lista = append(lista, b)
}
if err := redovi.Err(); err != nil {
return nil, fmt.Errorf("ntech: PokusajiPrijaveRepo.ListaBlokiranih: %w", err)
}
return lista, nil
}
// Odblokiraj briše sve neuspele pokušaje prijave za datu IP adresu — brojač
// neuspeha odmah pada na nulu, pa IP prestaje da bude zaključana.
func (r *PokusajiPrijaveRepo) Odblokiraj(ctx context.Context, ip string) error {
_, err := r.db.ExecContext(ctx,
"DELETE FROM pokusaji_prijave WHERE ip = ? AND uspeh = 0",
ip,
)
if err != nil {
return fmt.Errorf("ntech: PokusajiPrijaveRepo.Odblokiraj: %w", err)
}
return nil
}
+6 -5
View File
@@ -204,9 +204,10 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
s := stavke[i] s := stavke[i]
var naziv, tip string var naziv, tip string
var stanjePre int var stanjePre int
var nabavnaCena float64
err := tx.QueryRowContext(ctx, err := tx.QueryRowContext(ctx,
"SELECT naziv, kolicina, tip FROM artikli WHERE id = ?", s.ArtikalID, "SELECT naziv, kolicina, tip, nabavna_cena FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&naziv, &stanjePre, &tip) ).Scan(&naziv, &stanjePre, &tip, &nabavnaCena)
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err) return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
} }
@@ -248,10 +249,10 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
stavke[i].Ukupno = ukupnoStavke stavke[i].Ukupno = ukupnoStavke
_, err = tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
INSERT INTO stavke_prodaje INSERT INTO stavke_prodaje
(nalog_id, artikal_id, kolicina, cena_po_komadu, popust_procenat, ukupno, pdv_stopa, pdv_iznos, cena_bez_pdv) (nalog_id, artikal_id, kolicina, cena_po_komadu, popust_procenat, ukupno, pdv_stopa, pdv_iznos, cena_bez_pdv, nabavna_cena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nalogID, s.ArtikalID, s.Kolicina, s.CenaPoKomadu, s.PopustProcenat, ukupnoStavke, nalogID, s.ArtikalID, s.Kolicina, s.CenaPoKomadu, s.PopustProcenat, ukupnoStavke,
s.PdvStopa, pdvIznos, cenaBezPdv, s.PdvStopa, pdvIznos, cenaBezPdv, nabavnaCena,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err) return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err)
+88 -4
View File
@@ -58,7 +58,7 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj, sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj,
sn.opis_kvara, sn.trazene_nadogradnje, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna, sn.opis_kvara, sn.trazene_nadogradnje, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna,
sn.avans, sn.napomena, sn.garancija_do, sn.garancija_dana, sn.datum_prijema, sn.datum_zavrsetka, sn.predvidjen_datum, sn.avans, sn.napomena, sn.garancija_do, sn.garancija_dana, sn.datum_prijema, sn.datum_zavrsetka, sn.predvidjen_datum,
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token, sn.komentar_klijenta, sn.odluka_klijenta, sn.datum_odluke, sn.nacin_placanja, sn.naplaceno, sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token, sn.komentar_klijenta, sn.odluka_klijenta, sn.datum_odluke, sn.nacin_placanja, sn.naplaceno, sn.stornirano, sn.razlog_storniranja,
COALESCE(kp.naziv, '') AS klijent_naziv, COALESCE(kp.naziv, '') AS klijent_naziv,
(EXISTS(SELECT 1 FROM servis_radovi sr WHERE sr.nalog_id = sn.id AND sr.predlozeno = 1) (EXISTS(SELECT 1 FROM servis_radovi sr WHERE sr.nalog_id = sn.id AND sr.predlozeno = 1)
OR EXISTS(SELECT 1 FROM servisni_delovi sd WHERE sd.nalog_id = sn.id AND sd.predlozeno = 1) OR EXISTS(SELECT 1 FROM servisni_delovi sd WHERE sd.nalog_id = sn.id AND sd.predlozeno = 1)
@@ -108,7 +108,7 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna, opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna,
avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum, avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno, stornirano, razlog_storniranja
FROM servisni_nalozi WHERE id = ?`, id) FROM servisni_nalozi WHERE id = ?`, id)
var n model.ServisniNalog var n model.ServisniNalog
@@ -160,7 +160,7 @@ func (r *ServisRepo) DohvatiJavniToken(ctx context.Context, token string) (*mode
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna, opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna,
avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum, avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno, stornirano, razlog_storniranja
FROM servisni_nalozi WHERE javni_token = ?`, token) FROM servisni_nalozi WHERE javni_token = ?`, token)
var n model.ServisniNalog var n model.ServisniNalog
@@ -461,6 +461,86 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er
return nil return nil
} }
// Storno stornira servisni nalog: vraća ugrađene delove na stanje (isto kao
// Obrisi) ali NALOG SE NE BRIŠE — samo se markira stornirano=1, po istom principu
// kao Prodaja. Finansijski zapisi (KIR/KPO/fiskalni) se ne diraju ovde — to radi
// handler posle uspešnog storna, dograđivanjem storno stavki na osnovu izvor_id.
func (r *ServisRepo) Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: begin tx: %w", err)
}
defer tx.Rollback()
var vecStornirano bool
if err := tx.QueryRowContext(ctx, "SELECT stornirano FROM servisni_nalozi WHERE id = ?", id).Scan(&vecStornirano); err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: provera: %w", err)
}
if vecStornirano {
return fmt.Errorf("ntech: ServisRepo.Storno: nalog je već storniran")
}
redovi, err := tx.QueryContext(ctx,
"SELECT artikal_id, kolicina FROM servisni_delovi WHERE nalog_id = ?", id)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: dohvati delove: %w", err)
}
type povrat struct {
artikalID int64
kolicina int
}
var delovi []povrat
for redovi.Next() {
var p povrat
if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil {
redovi.Close()
return fmt.Errorf("ntech: ServisRepo.Storno: scan dela: %w", err)
}
delovi = append(delovi, p)
}
redovi.Close()
for _, p := range delovi {
var stanjePre int
var tip string
err := tx.QueryRowContext(ctx,
"SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID,
).Scan(&stanjePre, &tip)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: dohvati stanje: %w", err)
}
if !(tip == model.TipProizvod || tip == "") {
continue
}
stanjePosle := stanjePre + p.kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: vrati stanje: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj,
p.kolicina, stanjePre, stanjePosle, id, korisnikID, "storno servisnog naloga")
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: magacin: %w", err)
}
}
if _, err := tx.ExecContext(ctx,
"UPDATE servisni_nalozi SET stornirano = 1, razlog_storniranja = ? WHERE id = ?",
nullString(razlog), id,
); err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: update: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: commit: %w", err)
}
return nil
}
// SacuvajNaplatu beleži način plaćanja i naplaćeni iznos pri preuzimanju uređaja. // SacuvajNaplatu beleži način plaćanja i naplaćeni iznos pri preuzimanju uređaja.
func (r *ServisRepo) SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error { func (r *ServisRepo) SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
@@ -485,13 +565,15 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
var nacinPlacanja sql.NullString var nacinPlacanja sql.NullString
var naplaceno sql.NullFloat64 var naplaceno sql.NullFloat64
var stornirano sql.NullInt64
var razlogStorniranja sql.NullString
args := []any{ args := []any{
&n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj, &n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
&n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna, &n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
&avans, &napomena, &garancijaDo, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum, &avans, &napomena, &garancijaDo, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum,
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta, &odlukaKlijenta, &datumOdluke, &ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta, &odlukaKlijenta, &datumOdluke,
&nacinPlacanja, &naplaceno, &nacinPlacanja, &naplaceno, &stornirano, &razlogStorniranja,
} }
if klijentNaziv != nil { if klijentNaziv != nil {
@@ -565,6 +647,8 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
if naplaceno.Valid { if naplaceno.Valid {
n.Naplaceno = naplaceno.Float64 n.Naplaceno = naplaceno.Float64
} }
n.Stornirano = stornirano.Int64 != 0
n.RazlogStorniranja = razlogStorniranja.String
return nil return nil
} }
+7 -6
View File
@@ -95,9 +95,10 @@ func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int
// Ugrađeni delovi: standardna logika — skidamo sa lagera // Ugrađeni delovi: standardna logika — skidamo sa lagera
var stanjePre int var stanjePre int
var nabavnaCena float64
if err = tx.QueryRowContext(ctx, if err = tx.QueryRowContext(ctx,
"SELECT kolicina FROM artikli WHERE id = ?", artikalID, "SELECT kolicina, nabavna_cena FROM artikli WHERE id = ?", artikalID,
).Scan(&stanjePre); err != nil { ).Scan(&stanjePre, &nabavnaCena); err != nil {
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: dohvati stanje: %w", err) return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: dohvati stanje: %w", err)
} }
@@ -119,15 +120,15 @@ func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int
).Scan(&postojeciID, &postojeciKol) ).Scan(&postojeciID, &postojeciKol)
if errRed == nil { if errRed == nil {
if _, err = tx.ExecContext(ctx, if _, err = tx.ExecContext(ctx,
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?", "UPDATE servisni_delovi SET kolicina = ?, cena_komada = ?, nabavna_cena = ? WHERE id = ?",
postojeciKol+ugradjeno, cenaKomada, postojeciID, postojeciKol+ugradjeno, cenaKomada, nabavnaCena, postojeciID,
); err != nil { ); err != nil {
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: merge: %w", err) return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: merge: %w", err)
} }
} else if errors.Is(errRed, sql.ErrNoRows) { } else if errors.Is(errRed, sql.ErrNoRows) {
if _, err = tx.ExecContext(ctx, if _, err = tx.ExecContext(ctx,
"INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 0)", "INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno, nabavna_cena) VALUES (?, ?, ?, ?, 0, ?)",
nalogID, artikalID, ugradjeno, cenaKomada, nalogID, artikalID, ugradjeno, cenaKomada, nabavnaCena,
); err != nil { ); err != nil {
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: insert: %w", err) return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: insert: %w", err)
} }
@@ -196,6 +196,13 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con
// uvećava postojeći red za isti artikal na nalogu ili kreira novi. Radi unutar // uvećava postojeći red za isti artikal na nalogu ili kreira novi. Radi unutar
// prosleđene transakcije. Ne dira magacin — to radi skiniSaMagacina zasebno. // prosleđene transakcije. Ne dira magacin — to radi skiniSaMagacina zasebno.
func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sql.Tx, nalogID, artikalID int64, kolicina int, cenaKomada float64) error { func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sql.Tx, nalogID, artikalID int64, kolicina int, cenaKomada float64) error {
var nabavnaCena float64
if err := tx.QueryRowContext(ctx,
"SELECT nabavna_cena FROM artikli WHERE id = ?", artikalID,
).Scan(&nabavnaCena); err != nil {
return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ugradiUNalog: nabavna cena: %w", err)
}
var postojeciID int64 var postojeciID int64
var postojeciKol int var postojeciKol int
err := tx.QueryRowContext(ctx, err := tx.QueryRowContext(ctx,
@@ -207,8 +214,8 @@ func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sq
// ne pregazi postojeću cenu nulom (npr. legacy potraživani red bez cene) // ne pregazi postojeću cenu nulom (npr. legacy potraživani red bez cene)
if cenaKomada > 0 { if cenaKomada > 0 {
_, err = tx.ExecContext(ctx, _, err = tx.ExecContext(ctx,
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?", "UPDATE servisni_delovi SET kolicina = ?, cena_komada = ?, nabavna_cena = ? WHERE id = ?",
postojeciKol+kolicina, cenaKomada, postojeciID, postojeciKol+kolicina, cenaKomada, nabavnaCena, postojeciID,
) )
} else { } else {
_, err = tx.ExecContext(ctx, _, err = tx.ExecContext(ctx,
@@ -223,9 +230,9 @@ func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sq
} }
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
_, err = tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada) INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, nabavna_cena)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
nalogID, artikalID, kolicina, cenaKomada, nalogID, artikalID, kolicina, cenaKomada, nabavnaCena,
) )
if err != nil { if err != nil {
return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ugradiUNalog: insert: %w", err) return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ugradiUNalog: insert: %w", err)
+6 -1
View File
@@ -20,9 +20,14 @@ func NoviServisniRadoviRepo(baza *sql.DB) *ServisniRadoviRepo {
// DohvatiZaNalog vraća sve radove (usluge) jednog naloga, redom unosa // DohvatiZaNalog vraća sve radove (usluge) jednog naloga, redom unosa
func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error) { func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error) {
// COALESCE fallback je za „ad-hoc" rad bez veze na cenovnik usluga
// (sr.usluga_id NULL) — tad se PDV stopa uzima iz šifarnika (Podešavanja →
// Kalkulacija i PDV), ne hardkodovana vrednost. 20.0 ostaje samo kao
// apsolutno poslednje utočište ako šifarnik nema nijednu aktivnu opštu stopu.
redovi, err := r.db.QueryContext(ctx, ` redovi, err := r.db.QueryContext(ctx, `
SELECT sr.id, sr.nalog_id, COALESCE(sr.usluga_id, 0), sr.naziv, sr.kolicina, sr.cena_komada, sr.datum, sr.predlozeno, SELECT sr.id, sr.nalog_id, COALESCE(sr.usluga_id, 0), sr.naziv, sr.kolicina, sr.cena_komada, sr.datum, sr.predlozeno,
COALESCE(u.sifra, ''), COALESCE(u.pdv_stopa, 20.0) COALESCE(u.sifra, ''),
COALESCE(u.pdv_stopa, (SELECT stopa FROM pdv_stope WHERE oznaka = 'opsta' AND aktivna = 1 ORDER BY redosled ASC, stopa DESC LIMIT 1), 20.0)
FROM servis_radovi sr FROM servis_radovi sr
LEFT JOIN usluge u ON u.id = sr.usluga_id LEFT JOIN usluge u ON u.id = sr.usluga_id
WHERE sr.nalog_id = ? ORDER BY sr.id`, nalogID) WHERE sr.nalog_id = ? ORDER BY sr.id`, nalogID)
+152 -1
View File
@@ -29,9 +29,15 @@ func NoviKlijent(baseURL, apiKey string) *Klijent {
} }
} }
// InvoiceRequest je omotač za Teron invoiceRequest JSON objekat. // InvoiceRequest je omotač za Teron invoiceRequest JSON objekat. Polja Advance*
// su na vrhu (van invoiceRequest) — Teron ih tako očekuje za konačni račun koji
// zatvara prethodno izdat avansni račun (v. NapraviKonacniZahtevSaAvansom).
type InvoiceRequest struct { type InvoiceRequest struct {
InvoiceRequest InvoiceRequestBody `json:"invoiceRequest"` InvoiceRequest InvoiceRequestBody `json:"invoiceRequest"`
AdvancePaid *float64 `json:"advancePaid,omitempty"`
AdvanceTax *float64 `json:"advanceTax,omitempty"`
AdvanceLastInvoiceNumber string `json:"advanceLastInvoiceNumber,omitempty"`
AdvanceLastInvoiceDateTime string `json:"advanceLastInvoiceDateTime,omitempty"`
} }
// InvoiceRequestBody su podaci koje Teron očekuje unutar invoiceRequest polja. // InvoiceRequestBody su podaci koje Teron očekuje unutar invoiceRequest polja.
@@ -147,6 +153,151 @@ func (k *Klijent) IzdajRacun(ctx context.Context, zahtev InvoiceRequest) (*Invoi
return &odgovor, nil return &odgovor, nil
} }
// FinancialSummary je odgovor na GET /api/financial/summary — promet od
// poslednjeg preseka stanja (dnevni pazar), razložen po više kategorija.
type FinancialSummary struct {
StartOfPeriod string `json:"startOfPeriod"`
EndOfPeriod string `json:"endOfPeriod"`
InvoiceCount int `json:"invoiceCount"`
Total float64 `json:"total"`
TotalCash float64 `json:"totalCash"`
TotalByTax []SummaryTaxStavka `json:"totalByTax"`
TotalByCashier []SummaryImeIznos `json:"totalByCashier"`
TotalByPaymentType []SummaryPlacanje `json:"totalByPaymentType"`
TotalByArticle []SummaryArtikal `json:"totalByArticle"`
TotalByArticleAdvance []SummaryArtikal `json:"totalByArticleAdvance"`
}
// SummaryTaxStavka je promet po jednoj poreskoj oznaci/stopi.
type SummaryTaxStavka struct {
Label string `json:"label"`
Category string `json:"category"`
Rate float64 `json:"rate"`
Amount float64 `json:"amount"`
Osnovica float64 `json:"osnovica"`
}
// SummaryImeIznos je generički par ime-iznos (kasir, i sl.).
type SummaryImeIznos struct {
Name string `json:"name"`
Amount float64 `json:"amount"`
}
// SummaryPlacanje je promet po vrsti plaćanja (Cash, Card, ...).
type SummaryPlacanje struct {
PaymentType string `json:"paymentType"`
Amount float64 `json:"amount"`
}
// SummaryArtikal je promet po artiklu.
type SummaryArtikal struct {
ArticleName string `json:"articleName"`
TaxLabel string `json:"taxLabel"`
Amount float64 `json:"amount"`
Quantity float64 `json:"quantity"`
}
// ReportSummaryZahtev je telo zahteva za POST /api/financial/report/summary.
// FromDate/ToDate su opcioni (YYYY-MM-DD) — bez njih izveštaj pokriva period
// od poslednjeg preseka stanja.
type ReportSummaryZahtev struct {
FromDate string `json:"fromDate,omitempty"`
ToDate string `json:"toDate,omitempty"`
Language string `json:"language,omitempty"`
}
// ReportSummaryOdgovor je odgovor na generisanje dnevnog/periodičnog izveštaja.
type ReportSummaryOdgovor struct {
ReportPdfBase64 string `json:"reportPdfBase64"`
ReportName string `json:"reportName"`
Filename string `json:"filename"`
}
// Presek vraća promet od poslednjeg preseka stanja (GET /api/financial/summary).
// Poziv je bezbedan (read-only), ne menja stanje na PFR-u.
func (k *Klijent) Presek(ctx context.Context) (*FinancialSummary, error) {
url := k.BaseURL + "/api/financial/summary"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("ntech: fiskal.Presek: %w", err)
}
k.dodajAuth(req)
resp, err := k.HTTPKlijent.Do(req)
if err != nil {
return nil, fmt.Errorf("ntech: fiskal.Presek: server nedostupan: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
telo, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ntech: fiskal.Presek: HTTP %d: %s", resp.StatusCode, string(telo))
}
var rezultat FinancialSummary
if err := json.NewDecoder(resp.Body).Decode(&rezultat); err != nil {
return nil, fmt.Errorf("ntech: fiskal.Presek: JSON parse: %w", err)
}
return &rezultat, nil
}
// ZakljuciDan svodi promet PFR-a na nulu — dnevni presek stanja
// (DELETE /api/financial/summary). Nepovratna akcija.
func (k *Klijent) ZakljuciDan(ctx context.Context) error {
url := k.BaseURL + "/api/financial/summary"
req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil)
if err != nil {
return fmt.Errorf("ntech: fiskal.ZakljuciDan: %w", err)
}
k.dodajAuth(req)
resp, err := k.HTTPKlijent.Do(req)
if err != nil {
return fmt.Errorf("ntech: fiskal.ZakljuciDan: server nedostupan: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
telo, _ := io.ReadAll(resp.Body)
return fmt.Errorf("ntech: fiskal.ZakljuciDan: HTTP %d: %s", resp.StatusCode, string(telo))
}
return nil
}
// GenerisiIzvestaj traži od PFR-a dnevni/periodični izveštaj u PDF formatu
// (POST /api/financial/report/summary).
func (k *Klijent) GenerisiIzvestaj(ctx context.Context, zahtev ReportSummaryZahtev) (*ReportSummaryOdgovor, error) {
telo, err := json.Marshal(zahtev)
if err != nil {
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: marshal: %w", err)
}
url := k.BaseURL + "/api/financial/report/summary"
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(telo))
if err != nil {
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: %w", err)
}
req.Header.Set("Content-Type", "application/json")
k.dodajAuth(req)
resp, err := k.HTTPKlijent.Do(req)
if err != nil {
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: server nedostupan: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
teloOdg, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: HTTP %d: %s", resp.StatusCode, string(teloOdg))
}
var odgovor ReportSummaryOdgovor
if err := json.NewDecoder(resp.Body).Decode(&odgovor); err != nil {
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: JSON parse: %w", err)
}
return &odgovor, nil
}
// dodajAuth postavlja Authorization header ako je API ključ podešen. // dodajAuth postavlja Authorization header ako je API ključ podešen.
func (k *Klijent) dodajAuth(req *http.Request) { func (k *Klijent) dodajAuth(req *http.Request) {
if k.APIKey != "" { if k.APIKey != "" {
+49 -4
View File
@@ -64,10 +64,11 @@ func NapraviZahtev(
ukupanIznos := 0.0 ukupanIznos := 0.0
for _, s := range stavke { for _, s := range stavke {
stopa := 20.0 // podrazumevano 20% ako stopa nije eksplicitno postavljena // stopa je uvek ono što je snimljeno na stavci — 0 je legitimna vrednost
if s.PdvStopa > 0 { // (van sistema PDV/neobveznik), ne „nije postavljeno". Nikad je ne prepisivati
stopa = s.PdvStopa // nazad na opštu stopu, jer bi to poništilo nuliranje PDV-a za ne-PDV obveznike
} // (v. docs/Greške.md).
stopa := s.PdvStopa
// CenaBezPdv iz baze je već diskontovana (popust primenjen pri kreiranju naloga); // CenaBezPdv iz baze je već diskontovana (popust primenjen pri kreiranju naloga);
// popust se ponovo primenjuje samo u fallback grani kad CenaBezPdv nije popunjena. // popust se ponovo primenjuje samo u fallback grani kad CenaBezPdv nije popunjena.
netoCena := s.CenaBezPdv netoCena := s.CenaBezPdv
@@ -142,3 +143,47 @@ func NapraviRefundZahtev(
zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj
return zahtev return zahtev
} }
// NazivAvansneStavke je naziv stavke na avansnim fiskalnim računima (isti naziv
// mora biti dosledan, jer ga PFR ne povezuje po ID-u već po sadržaju stavke).
const NazivAvansneStavke = "Аванс"
// NapraviAvansZahtev gradi Advance/Sale zahtev za primljeni avans — jedna stavka
// "Аванс" u punom (bruto) iznosu. stopa je PDV stopa avansa — u trenutku uplate
// stavke (radovi/delovi) obično još nisu poznate, pa pozivalac prosleđuje opštu
// stopu iz šifarnika (Podešavanja → Kalkulacija i PDV) kad je firma PDV obveznik,
// ili 0 kad nije — nikad hardkodovanu vrednost (v. docs/Greške.md §4.1).
func NapraviAvansZahtev(iznos, stopa float64, nacinPlacanja, kasir string) InvoiceRequest {
return InvoiceRequest{
InvoiceRequest: InvoiceRequestBody{
InvoiceType: "Advance",
TransactionType: "Sale",
Payment: []PaymentItem{
{Amount: iznos, PaymentType: TipPlacanja(nacinPlacanja)},
},
Items: []InvoiceItem{
{Name: NazivAvansneStavke, Labels: []string{OznakaPDV(stopa)}, TotalAmount: iznos, UnitPrice: iznos, Quantity: 1},
},
Cashier: kasir,
},
}
}
// NapraviAvansRefundZahtev gradi Advance/Refund zahtev za povraćaj dela ili celog
// avansa (npr. kad avans premaši konačnu cenu popravke). referentBroj je PfrBroj
// originalnog avansnog računa.
func NapraviAvansRefundZahtev(iznos, stopa float64, nacinPlacanja, kasir, referentBroj string) InvoiceRequest {
zahtev := NapraviAvansZahtev(iznos, stopa, nacinPlacanja, kasir)
zahtev.InvoiceRequest.TransactionType = "Refund"
zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj
return zahtev
}
// PorezIzBrutoAvansa izvlači poreski deo iz bruto iznosa avansa po datoj stopi
// (npr. 500 din avansa po 20% → porez ≈ 83.33 din). stopa=0 (ne-PDV obveznik) → 0.
func PorezIzBrutoAvansa(bruto, stopa float64) float64 {
if stopa <= 0 {
return 0
}
return math.Round(bruto*stopa/(100+stopa)*100) / 100
}
+81
View File
@@ -0,0 +1,81 @@
package handler
import (
"net/http"
"strings"
"time"
ntechsqlite "ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
)
// stavkaBlokiraneIP je jedan red na stranici blokiranih IP adresa, sa
// unapred izračunatim preostalim vremenom zaključavanja (ili "" ako je isteklo).
type stavkaBlokiraneIP struct {
model.BlokiranaIP
Preostalo string
}
type podaciBlokiraneIP struct {
model.PodaciStranice
Lista []stavkaBlokiraneIP
}
// AdminBlokiraneIP prikazuje IP adrese trenutno zaključane zbog previše neuspelih
// pokušaja prijave, sa mogućnošću ručnog odblokiranja (superadmin).
func (h *Handler) AdminBlokiraneIP(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "bezbednost.odblokiraj"); !ok {
return
}
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "blokirane-ip"
ps.NaslovStranice = "Blokirane IP adrese"
od := time.Now().Add(-prozorPrijave)
blokirane, err := h.PokusajiRepo.ListaBlokiranih(r.Context(), od, maxNeuspehaPrijave)
if err != nil {
http.Error(w, "Greška pri učitavanju blokiranih IP adresa", http.StatusInternalServerError)
return
}
lista := make([]stavkaBlokiraneIP, 0, len(blokirane))
for _, b := range blokirane {
preostalo, zakljucano := h.preostaloBruteforce(r.Context(), b.IP, od)
if !zakljucano {
continue
}
lista = append(lista, stavkaBlokiraneIP{BlokiranaIP: b, Preostalo: preostalo})
}
h.renderujTemplate(w, "blokirane_ip", podaciBlokiraneIP{PodaciStranice: ps, Lista: lista})
}
// AdminOdblokirajIP briše neuspele pokušaje prijave za datu IP adresu, čime se ona
// odmah oslobađa bruteforce blokade.
func (h *Handler) AdminOdblokirajIP(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "bezbednost.odblokiraj"); !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
ip := strings.TrimSpace(r.FormValue("ip"))
if ip == "" {
http.Redirect(w, r, "/admin/blokirane-ip", http.StatusSeeOther)
return
}
if err := h.PokusajiRepo.Odblokiraj(r.Context(), ip); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Odblokiranje nije uspelo.")
http.Redirect(w, r, "/admin/blokirane-ip", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "IP adresa "+ip+" je odblokirana.")
http.Redirect(w, r, "/admin/blokirane-ip", http.StatusSeeOther)
}
+161
View File
@@ -0,0 +1,161 @@
package handler
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"unicode"
"ntech/internal/config"
ntechsqlite "ntech/internal/db/sqlite"
"ntech/internal/fiskal"
"ntech/internal/middleware"
"ntech/internal/model"
)
// contentDispositionZaFajl gradi Content-Disposition header za dati naziv fajla.
// HTTP header vrednosti moraju biti ASCII — naziv sa slovima kao što su Š/Č/Ž
// (npr. iz PFR-a: "DNEVNI IZVEŠTAJ - ...pdf") bi inače napravio nevalidan header
// koji pregledač ne može da parsira. RFC 6266 filename* nosi pravo UTF-8 ime,
// dok filename (ASCII) služi kao fallback za starije klijente.
func contentDispositionZaFajl(ime string) string {
ascii := strings.Map(func(r rune) rune {
if r > unicode.MaxASCII {
return '_'
}
return r
}, ime)
return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, ascii, url.PathEscape(ime))
}
// PodaciFiskalniPazar su podaci za stranicu dnevnog fiskalnog preseka (pazara).
type PodaciFiskalniPazar struct {
model.PodaciStranice
Presek *fiskal.FinancialSummary
Greska string
FiskalPodesen bool // true: pfr_url je podešen
Danas string // YYYY-MM-DD, podrazumevani datum za izveštaj (od=do=danas)
}
// FiskalniPazar prikazuje trenutni promet od poslednjeg preseka stanja
// (GET /api/financial/summary na PFR-u) — read-only, ne menja stanje.
func (h *Handler) FiskalniPazar(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "fiskal.pazar"); !ok {
return
}
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "fiskal-pazar"
ps.NaslovStranice = "Dnevni fiskalni presek"
podaci := PodaciFiskalniPazar{PodaciStranice: ps, Danas: time.Now().Format("2006-01-02")}
if !h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
podaci.Greska = "Fiskalizacija nije uključena u profilu firme — Podešavanja → Opšte → Pravni i poreski status."
h.renderujTemplate(w, "fiskal_pazar", podaci)
return
}
klijent := h.fiskalKlijent()
if klijent == nil {
podaci.Greska = "Fiskalizacija nije podešena — unesi URL PFR servera u Podešavanja → Fiskalizacija."
h.renderujTemplate(w, "fiskal_pazar", podaci)
return
}
podaci.FiskalPodesen = true
presek, err := klijent.Presek(r.Context())
if err != nil {
podaci.Greska = "Fiskalni server nije dostupan — proveri da li je PFR uređaj/mock pokrenut."
} else {
podaci.Presek = presek
}
h.renderujTemplate(w, "fiskal_pazar", podaci)
}
// ZakljuciFiskalniDan svodi promet PFR-a na nulu (DELETE /api/financial/summary).
// OPCIONA radnja — samo resetuje brojače na ekranu „Promet od poslednjeg preseka";
// nije zakonska obaveza (za razliku od starih fiskalnih kasa, novi sistem izveštava
// SUF kontinuirano po računu). Pravi dnevni/periodični izveštaj (FiskalniIzvestaj)
// radi nezavisno preko datumskog opsega, bez obzira na to da li je ovo ikad urađeno.
func (h *Handler) ZakljuciFiskalniDan(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "fiskal.zakljucenje"); !ok {
return
}
if !h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije uključena u profilu firme.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return
}
klijent := h.fiskalKlijent()
if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return
}
if err := klijent.ZakljuciDan(r.Context()); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Presek stanja nije uspeo — fiskalni server nije dostupan.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Brojači prometa na ekranu su resetovani.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
}
// FiskalniIzvestaj traži od PFR-a dnevni/periodični izveštaj (POST
// /api/financial/report/summary) i vraća ga korisniku kao fajl za preuzimanje.
// FromDate/ToDate dolaze iz forme (podrazumevano danas-danas) — izveštaj radi
// nezavisno od preseka stanja, pa je tačan bez obzira na to da li je Zaključi dan
// ikad korišćeno.
func (h *Handler) FiskalniIzvestaj(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "fiskal.pazar"); !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
if !h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije uključena u profilu firme.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return
}
klijent := h.fiskalKlijent()
if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return
}
zahtev := fiskal.ReportSummaryZahtev{
FromDate: strings.TrimSpace(r.FormValue("od_datuma")),
ToDate: strings.TrimSpace(r.FormValue("do_datuma")),
}
odgovor, err := klijent.GenerisiIzvestaj(r.Context(), zahtev)
if err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Generisanje izveštaja nije uspelo — fiskalni server nije dostupan.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return
}
sadrzaj, err := base64.StdEncoding.DecodeString(odgovor.ReportPdfBase64)
if err != nil {
http.Error(w, "Neispravan sadržaj izveštaja", http.StatusBadGateway)
return
}
imeFajla := odgovor.Filename
if imeFajla == "" {
imeFajla = "dnevni-izvestaj.pdf"
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", contentDispositionZaFajl(imeFajla))
w.Write(sadrzaj)
}
+29
View File
@@ -168,6 +168,35 @@ func (h *Handler) modulUkljucen(ctx context.Context, modul string) bool {
return config.ModulUkljucen(podesavanja, modul) return config.ModulUkljucen(podesavanja, modul)
} }
// podrazumevanaPdvStopa vraća opštu PDV stopu iz šifarnika (Podešavanja → Kalkulacija
// i PDV → Šifarnik PDV stopa) — jedini izvor „koja je opšta stopa" u programu.
// 20.0 se koristi samo kao krajnji fallback ako šifarnik nema nijednu aktivnu
// opštu stopu (npr. korisnik ih sve arhivirao) — ne kao podrazumevana poslovna
// pretpostavka, već da se izbegne prekid rada.
func (h *Handler) podrazumevanaPdvStopa(ctx context.Context) float64 {
stopa, err := h.PdvStopeRepo.PodrazumevanaOpsta(ctx)
if err != nil || stopa == nil {
return 20.0
}
return stopa.Stopa
}
// validnePdvStope vraća skup PDV stopa koje korisnik sme da izabere na stavci —
// aktivne stope iz šifarnika (Podešavanja → Kalkulacija i PDV), plus 0 koje je
// uvek dozvoljeno (van sistema PDV/oslobođeno). Zamenjuje hardkodovanu proveru
// „0, 10 ili 20" — šifarnik je jedini izvor važećih stopa.
func (h *Handler) validnePdvStope(ctx context.Context) map[float64]bool {
validne := map[float64]bool{0: true}
stope, err := h.PdvStopeRepo.Lista(ctx, true)
if err != nil {
return validne
}
for _, s := range stope {
validne[s.Stopa] = true
}
return validne
}
// zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju. // zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju.
// U suprotnom šalje 403 sa srpskom porukom i vraća ok=false (handler tada return-uje). // U suprotnom šalje 403 sa srpskom porukom i vraća ok=false (handler tada return-uje).
func (h *Handler) zahtevajDozvolu(w http.ResponseWriter, r *http.Request, akcija string) (*model.Korisnik, bool) { func (h *Handler) zahtevajDozvolu(w http.ResponseWriter, r *http.Request, akcija string) (*model.Korisnik, bool) {
+36
View File
@@ -57,6 +57,9 @@ type MesecniPrihod struct {
Prodaja float64 Prodaja float64
Servis float64 Servis float64
Ukupno float64 Ukupno float64
Zarada float64
ZaradaProdaja float64
ZaradaServis float64
} }
// StariNalog je servisni nalog bez datuma završetka stariji od 14 dana // StariNalog je servisni nalog bez datuma završetka stariji od 14 dana
@@ -120,35 +123,68 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) {
} }
} }
// --- mesečna zarada: prodaja ---
zaradaProdajaPoMesecu := map[string]float64{}
if redovi, err := h.IzvestajRepo.MesecniZaradaProdaja(ctx); err != nil {
slog.Error("izvestaji: zarada prodaja", "error", err)
} else {
for _, m := range redovi {
zaradaProdajaPoMesecu[m.Mesec] = m.Iznos
}
}
// --- mesečna zarada: servis ---
zaradaServisPoMesecu := map[string]float64{}
if redovi, err := h.IzvestajRepo.MesecniZaradaServis(ctx); err != nil {
slog.Error("izvestaji: zarada servis", "error", err)
} else {
for _, m := range redovi {
zaradaServisPoMesecu[m.Mesec] = m.Iznos
}
}
// gradimo niz za poslednjih 12 meseci (hronološki) // gradimo niz za poslednjih 12 meseci (hronološki)
var mesecniPrihodi []MesecniPrihod var mesecniPrihodi []MesecniPrihod
var grafikonLabele []string var grafikonLabele []string
var grafikonProdaja []float64 var grafikonProdaja []float64
var grafikonServis []float64 var grafikonServis []float64
var grafikonZaradaProdaja []float64
var grafikonZaradaServis []float64
for _, kljuc := range kljuceviMeseci(time.Now(), 12) { for _, kljuc := range kljuceviMeseci(time.Now(), 12) {
prod := prodajaPoMesecu[kljuc] prod := prodajaPoMesecu[kljuc]
serv := servisPoMesecu[kljuc] serv := servisPoMesecu[kljuc]
zaradaProd := zaradaProdajaPoMesecu[kljuc]
zaradaServ := zaradaServisPoMesecu[kljuc]
mesecniPrihodi = append(mesecniPrihodi, MesecniPrihod{ mesecniPrihodi = append(mesecniPrihodi, MesecniPrihod{
MesecPrikaz: formatujMesec(kljuc), MesecPrikaz: formatujMesec(kljuc),
Prodaja: prod, Prodaja: prod,
Servis: serv, Servis: serv,
Ukupno: prod + serv, Ukupno: prod + serv,
Zarada: zaradaProd + zaradaServ,
ZaradaProdaja: zaradaProd,
ZaradaServis: zaradaServ,
}) })
grafikonLabele = append(grafikonLabele, formatujMesec(kljuc)) grafikonLabele = append(grafikonLabele, formatujMesec(kljuc))
grafikonProdaja = append(grafikonProdaja, prod) grafikonProdaja = append(grafikonProdaja, prod)
grafikonServis = append(grafikonServis, serv) grafikonServis = append(grafikonServis, serv)
grafikonZaradaProdaja = append(grafikonZaradaProdaja, zaradaProd)
grafikonZaradaServis = append(grafikonZaradaServis, zaradaServ)
} }
type grafikonPodaci struct { type grafikonPodaci struct {
Labele []string `json:"labele"` Labele []string `json:"labele"`
Prodaja []float64 `json:"prodaja"` Prodaja []float64 `json:"prodaja"`
Servis []float64 `json:"servis"` Servis []float64 `json:"servis"`
ZaradaProdaja []float64 `json:"zaradaProdaja"`
ZaradaServis []float64 `json:"zaradaServis"`
} }
jsonBytes, _ := json.Marshal(grafikonPodaci{ jsonBytes, _ := json.Marshal(grafikonPodaci{
Labele: grafikonLabele, Labele: grafikonLabele,
Prodaja: grafikonProdaja, Prodaja: grafikonProdaja,
Servis: grafikonServis, Servis: grafikonServis,
ZaradaProdaja: grafikonZaradaProdaja,
ZaradaServis: grafikonZaradaServis,
}) })
// --- stari otvoreni nalozi (>14 dana bez završetka) --- // --- stari otvoreni nalozi (>14 dana bez završetka) ---
+2 -1
View File
@@ -19,7 +19,7 @@ var bazniSabloni = []string{
// saSidebar su šabloni koji koriste base layout (sidebar + topbar) // saSidebar su šabloni koji koriste base layout (sidebar + topbar)
var saSidebar = []string{ var saSidebar = []string{
"admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole", "admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole", "blokirane_ip",
"dashboard", "dashboard",
"dobavljaci", "dobavljac_forma", "dobavljaci", "dobavljac_forma",
"izvestaji", "prometni_list", "stanje_zaliha", "popis", "izvestaji", "prometni_list", "stanje_zaliha", "popis",
@@ -28,6 +28,7 @@ var saSidebar = []string{
"magacin", "magacin_forma", "magacin_kartica", "magacin", "magacin_forma", "magacin_kartica",
"nabavke", "nabavka_forma", "nabavka_detalji", "nabavke", "nabavka_forma", "nabavka_detalji",
"podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", "podesavanja_servis", "podesavanja_fiskalizacija", "podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", "podesavanja_servis", "podesavanja_fiskalizacija",
"fiskal_pazar",
"pdv_stope", "pdv_stope",
"pdv_kir", "pdv_kir_forma", "pdv_kir_dnevni_pazar", "pdv_kir", "pdv_kir_forma", "pdv_kir_dnevni_pazar",
"pdv_kpr", "pdv_kpr_forma", "pdv_kpr", "pdv_kpr_forma",
+8 -6
View File
@@ -62,7 +62,7 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps, PodaciStranice: ps,
Kategorije: kategorije, Kategorije: kategorije,
Dobavljaci: dobavljaci, Dobavljaci: dobavljaci,
Artikal: model.Artikal{Sifra: predlogSifre, Tip: tip, JedinicaMere: "kom", PdvStopa: 20.0}, Artikal: model.Artikal{Sifra: predlogSifre, Tip: tip, JedinicaMere: "kom", PdvStopa: h.podrazumevanaPdvStopa(r.Context())},
Izmena: false, Izmena: false,
}) })
} }
@@ -79,7 +79,7 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
return return
} }
artikal, greska := parseFormuArtikla(r) artikal, greska := parseFormuArtikla(r, h.podrazumevanaPdvStopa(r.Context()))
if greska != "" { if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
kategorije, _ := h.KategorijeRepo.Lista(r.Context()) kategorije, _ := h.KategorijeRepo.Lista(r.Context())
@@ -210,7 +210,7 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
return return
} }
artikal, greska := parseFormuArtikla(r) artikal, greska := parseFormuArtikla(r, h.podrazumevanaPdvStopa(r.Context()))
if greska != "" { if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
kategorije, _ := h.KategorijeRepo.Lista(r.Context()) kategorije, _ := h.KategorijeRepo.Lista(r.Context())
@@ -325,7 +325,9 @@ func citajDobavljaceForme(r *http.Request) []int64 {
} }
// parseFormuArtikla čita polja iz forme i vraća artikal i eventualnu grešku // parseFormuArtikla čita polja iz forme i vraća artikal i eventualnu grešku
func parseFormuArtikla(r *http.Request) (model.Artikal, string) { // podrazumevanaStopa je opšta PDV stopa iz šifarnika (v. Handler.podrazumevanaPdvStopa) —
// koristi se samo kad korisnik nije uneo stopu, nikad hardkodovana vrednost.
func parseFormuArtikla(r *http.Request, podrazumevanaStopa float64) (model.Artikal, string) {
naziv := r.FormValue("naziv") naziv := r.FormValue("naziv")
if naziv == "" { if naziv == "" {
return model.Artikal{}, "Naziv artikla je obavezan." return model.Artikal{}, "Naziv artikla je obavezan."
@@ -389,8 +391,8 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
artikal.NabavnaCena = v artikal.NabavnaCena = v
} }
// PDV stopa — podrazumevano 20% // PDV stopa — podrazumevano opšta stopa iz šifarnika
pdvStopa := 20.0 pdvStopa := podrazumevanaStopa
if p := r.FormValue("pdv_stopa"); p != "" { if p := r.FormValue("pdv_stopa"); p != "" {
if v, err := strconv.ParseFloat(p, 64); err == nil && v >= 0 { if v, err := strconv.ParseFloat(p, 64); err == nil && v >= 0 {
pdvStopa = v pdvStopa = v
+11 -3
View File
@@ -377,9 +377,17 @@ func (h *Handler) StornoNabavke(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?greska=storno", http.StatusSeeOther) http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?greska=storno", http.StatusSeeOther)
return return
} }
// stornirana nabavka ne ulazi u PDV — ukloni vezani auto-KPR zapis // stornirana nabavka se ne briše iz KPR (zakon o računovodstvu ne dozvoljava
if err := h.PdvKprRepo.ObrisiPoIzvoru(r.Context(), "nabavka", id); err != nil { // brisanje poslovnih knjiga) — dograđuje se storno stavka koja poništava original
slog.Error("brisanje vezanog KPR zapisa nije uspelo", "nabavka_id", id, "error", err) if zapisi, err := h.PdvKprRepo.DohvatiPoIzvoru(r.Context(), "nabavka", id); err != nil {
slog.Error("čitanje vezanog KPR zapisa nije uspelo", "nabavka_id", id, "error", err)
} else {
for _, z := range zapisi {
storno := model.KprStorno(z, razlog, time.Now())
if _, e := h.PdvKprRepo.Kreiraj(r.Context(), &storno); e != nil {
slog.Error("upis storno KPR zapisa nije uspeo", "nabavka_id", id, "error", e)
}
}
} }
http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther) http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
+9 -9
View File
@@ -22,7 +22,7 @@ func TestParseFormuArtikla(t *testing.T) {
t.Run("validan", func(t *testing.T) { t.Run("validan", func(t *testing.T) {
a, greska := parseFormuArtikla(formZahtev(url.Values{ a, greska := parseFormuArtikla(formZahtev(url.Values{
"naziv": {"Pumpa"}, "kolicina": {"10"}, "prodajna_cena": {"1500.50"}, "naziv": {"Pumpa"}, "kolicina": {"10"}, "prodajna_cena": {"1500.50"},
})) }), 20.0)
if greska != "" { if greska != "" {
t.Fatalf("neočekivana greška: %q", greska) t.Fatalf("neočekivana greška: %q", greska)
} }
@@ -31,18 +31,18 @@ func TestParseFormuArtikla(t *testing.T) {
} }
}) })
t.Run("naziv obavezan", func(t *testing.T) { t.Run("naziv obavezan", func(t *testing.T) {
if _, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {""}})); greska == "" { if _, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {""}}), 20.0); greska == "" {
t.Fatal("prazan naziv mora dati grešku") t.Fatal("prazan naziv mora dati grešku")
} }
}) })
t.Run("negativna količina", func(t *testing.T) { t.Run("negativna količina", func(t *testing.T) {
_, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {"X"}, "kolicina": {"-5"}})) _, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {"X"}, "kolicina": {"-5"}}), 20.0)
if greska == "" { if greska == "" {
t.Fatal("negativna količina mora dati grešku") t.Fatal("negativna količina mora dati grešku")
} }
}) })
t.Run("neispravna cena", func(t *testing.T) { t.Run("neispravna cena", func(t *testing.T) {
_, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {"X"}, "prodajna_cena": {"abc"}})) _, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {"X"}, "prodajna_cena": {"abc"}}), 20.0)
if greska == "" { if greska == "" {
t.Fatal("neispravna cena mora dati grešku") t.Fatal("neispravna cena mora dati grešku")
} }
@@ -91,7 +91,7 @@ func TestParseFormuKlijenta(t *testing.T) {
func TestParseFormuProdaje(t *testing.T) { func TestParseFormuProdaje(t *testing.T) {
t.Run("bez stavki", func(t *testing.T) { t.Run("bez stavki", func(t *testing.T) {
_, _, greska := parseFormuProdaje(formZahtev(url.Values{})) _, _, greska := parseFormuProdaje(formZahtev(url.Values{}), map[float64]bool{0: true, 10: true, 20: true})
if greska == "" { if greska == "" {
t.Fatal("prodaja bez stavki mora dati grešku") t.Fatal("prodaja bez stavki mora dati grešku")
} }
@@ -99,7 +99,7 @@ func TestParseFormuProdaje(t *testing.T) {
t.Run("količina nula", func(t *testing.T) { t.Run("količina nula", func(t *testing.T) {
_, _, greska := parseFormuProdaje(formZahtev(url.Values{ _, _, greska := parseFormuProdaje(formZahtev(url.Values{
"artikal_id[]": {"1"}, "kolicina[]": {"0"}, "cena_po_komadu[]": {"100"}, "artikal_id[]": {"1"}, "kolicina[]": {"0"}, "cena_po_komadu[]": {"100"},
})) }), map[float64]bool{0: true, 10: true, 20: true})
if greska == "" { if greska == "" {
t.Fatal("količina 0 mora dati grešku") t.Fatal("količina 0 mora dati grešku")
} }
@@ -107,7 +107,7 @@ func TestParseFormuProdaje(t *testing.T) {
t.Run("neispravan artikal", func(t *testing.T) { t.Run("neispravan artikal", func(t *testing.T) {
_, _, greska := parseFormuProdaje(formZahtev(url.Values{ _, _, greska := parseFormuProdaje(formZahtev(url.Values{
"artikal_id[]": {"0"}, "kolicina[]": {"1"}, "cena_po_komadu[]": {"100"}, "artikal_id[]": {"0"}, "kolicina[]": {"1"}, "cena_po_komadu[]": {"100"},
})) }), map[float64]bool{0: true, 10: true, 20: true})
if greska == "" { if greska == "" {
t.Fatal("artikal_id 0 mora dati grešku") t.Fatal("artikal_id 0 mora dati grešku")
} }
@@ -115,7 +115,7 @@ func TestParseFormuProdaje(t *testing.T) {
t.Run("validna stavka", func(t *testing.T) { t.Run("validna stavka", func(t *testing.T) {
nalog, stavke, greska := parseFormuProdaje(formZahtev(url.Values{ nalog, stavke, greska := parseFormuProdaje(formZahtev(url.Values{
"artikal_id[]": {"3"}, "kolicina[]": {"2"}, "cena_po_komadu[]": {"250"}, "pdv_stopa[]": {"20"}, "artikal_id[]": {"3"}, "kolicina[]": {"2"}, "cena_po_komadu[]": {"250"}, "pdv_stopa[]": {"20"},
})) }), map[float64]bool{0: true, 10: true, 20: true})
if greska != "" { if greska != "" {
t.Fatalf("neočekivana greška: %q", greska) t.Fatalf("neočekivana greška: %q", greska)
} }
@@ -133,7 +133,7 @@ func TestParseFormuProdaje(t *testing.T) {
t.Run("nesklad broja stavki", func(t *testing.T) { t.Run("nesklad broja stavki", func(t *testing.T) {
_, _, greska := parseFormuProdaje(formZahtev(url.Values{ _, _, greska := parseFormuProdaje(formZahtev(url.Values{
"artikal_id[]": {"1", "2"}, "kolicina[]": {"1"}, "cena_po_komadu[]": {"100"}, "artikal_id[]": {"1", "2"}, "kolicina[]": {"1"}, "cena_po_komadu[]": {"100"},
})) }), map[float64]bool{0: true, 10: true, 20: true})
if greska == "" { if greska == "" {
t.Fatal("nesklad broja stavki mora dati grešku") t.Fatal("nesklad broja stavki mora dati grešku")
} }
+89 -2
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
appdb "ntech/internal/db"
"ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/middleware" "ntech/internal/middleware"
"ntech/internal/model" "ntech/internal/model"
@@ -22,9 +23,10 @@ type PodaciPdvKir struct {
model.PodaciStranice model.PodaciStranice
Zapisi []model.PdvKir Zapisi []model.PdvKir
Sume model.PdvKirSume Sume model.PdvKirSume
Od string // filter perioda (YYYY-MM-DD), prazno = bez granice Od string
Do string Do string
Zastareo map[int64]bool // ID zapisa dnevnog pazara čiji je iznos van sinhronizacije sa trenutnim prometom (npr. posle naknadnog storna) Zastareo map[int64]bool
SumnjiviDuplikati []model.ProdajniNalogSaDetaljem // B2B prodaje preskočene pri backfill-u (double-submit)
} }
// PodaciPdvKirForma su podaci za formu unosa zapisa KIR // PodaciPdvKirForma su podaci za formu unosa zapisa KIR
@@ -105,6 +107,21 @@ func (h *Handler) PdvKir(w http.ResponseWriter, r *http.Request) {
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "pdv-kir" ps.Stranica = "pdv-kir"
ps.NaslovStranice = "KIR — knjiga izdatih računa" ps.NaslovStranice = "KIR — knjiga izdatih računa"
// detektuj sumnjive duplikate (double-submit) da ih korisnik vidi
var sumnjivi []model.ProdajniNalogSaDetaljem
if _, _, kandidati, err := h.kirKandidatiProdaje(r.Context()); err == nil {
duplikati := sumnjiviDuplikati(kandidati)
if len(duplikati) > 0 {
nalozi, _ := h.ProdajaRepo.Lista(r.Context(), appdb.ProdajaFilter{})
for _, nd := range nalozi {
if duplikati[nd.ID] {
sumnjivi = append(sumnjivi, nd)
}
}
}
}
h.renderujTemplate(w, "pdv_kir", PodaciPdvKir{ h.renderujTemplate(w, "pdv_kir", PodaciPdvKir{
PodaciStranice: ps, PodaciStranice: ps,
Zapisi: zapisi, Zapisi: zapisi,
@@ -112,6 +129,7 @@ func (h *Handler) PdvKir(w http.ResponseWriter, r *http.Request) {
Od: odStr, Od: odStr,
Do: doStr, Do: doStr,
Zastareo: zastareo, Zastareo: zastareo,
SumnjiviDuplikati: sumnjivi,
}) })
} }
@@ -278,6 +296,75 @@ func (h *Handler) KirBackfillProdaje(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther) http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
} }
// KirBackfillServisa kreira KIR zapise za sve preuzete servisne naloge sa
// identifikovanim kupcem koji ih nemaju. Namenjen za jednokratno popunjavanje.
func (h *Handler) KirBackfillServisa(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "pdv.dodaj"); !ok {
return
}
ctx := r.Context()
sada := time.Now()
nalozi, postojiMap, kandidati, err := h.kirKandidatiServisa(ctx)
if err != nil {
http.Error(w, "Greška pri učitavanju servisa", http.StatusInternalServerError)
return
}
duplikati := sumnjiviDuplikati(kandidati)
var kreirano, preskoceno, sumnjivo int
for _, sn := range nalozi {
if postojiMap[sn.ID] {
preskoceno++
continue
}
if duplikati[sn.ID] {
sumnjivo++
continue
}
radovi, err := h.ServisniRadoviRepo.DohvatiZaNalog(ctx, sn.ID)
if err != nil {
slog.Error("backfill KIR: greška pri čitanju radova", "servis_id", sn.ID, "error", err)
continue
}
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(ctx, sn.ID)
if err != nil {
slog.Error("backfill KIR: greška pri čitanju delova", "servis_id", sn.ID, "error", err)
continue
}
klijent, err := h.KlijentiRepo.DohvatiID(ctx, *sn.KlijentID)
if err != nil {
slog.Error("backfill KIR: greška pri čitanju klijenta", "servis_id", sn.ID, "error", err)
continue
}
pib := klijent.PIB
if klijent.Tip != "pravno" {
pib = klijent.JMBG
}
datumPrometa := sn.DatumPrijema
if sn.DatumZavrsetka != nil {
datumPrometa = *sn.DatumZavrsetka
}
kir := model.KirIzServisa(sn.ServisniNalog, radovi, delovi, klijent.PunoIme(), pib, klijent.Mesto, datumPrometa, sada)
if kir.Ukupno <= 0 {
preskoceno++
continue
}
if _, e := h.PdvKirRepo.Kreiraj(ctx, &kir); e != nil {
slog.Error("backfill KIR: greška pri kreiranju zapisa", "servis_id", sn.ID, "error", e)
continue
}
kreirano++
}
poruka := fmt.Sprintf("Backfill završen: %d novih KIR zapisa, %d preskočeno.", kreirano, preskoceno)
if sumnjivo > 0 {
poruka += fmt.Sprintf(" %d sumnjivih duplikata NIJE upisano — proveri ručno.", sumnjivo)
}
middleware.SetFlash(w, r, h.DB, "uspeh", poruka)
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
}
// PodaciDnevniPazar su podaci za formu zbirnog KIR unosa (maloprodaja) // PodaciDnevniPazar su podaci za formu zbirnog KIR unosa (maloprodaja)
type PodaciDnevniPazar struct { type PodaciDnevniPazar struct {
model.PodaciStranice model.PodaciStranice
+27
View File
@@ -285,6 +285,20 @@ func validirajMaticniBroj(broj string) string {
return "" return ""
} }
// validirajProfilFirme proverava da li je kombinacija pravnog/poreskog statusa
// pravno moguća — vidi docs/Greške.md §1. „Samo evidencija” gasi ceo zakonski
// sloj, pa dok je firma u tom režimu ne postoji osnov da bude PDV obveznik
// niti da izdaje fiskalne račune.
func validirajProfilFirme(rezim, pdvObveznik, fiskalizacija string) string {
if rezim != "samo_evidencija" {
return ""
}
if pdvObveznik == "da" || fiskalizacija == "da" {
return "Režim „Samo evidencija” ne dozvoljava PDV obveznika ni izdavanje fiskalnih računa — prebacite firmu na režim „Pun” ako stvarno posluje."
}
return ""
}
// SacuvajPodesavanja prima POST i čuva podešavanja u bazu // SacuvajPodesavanja prima POST i čuva podešavanja u bazu
func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok {
@@ -320,6 +334,19 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
return return
} }
if _, jeFirmaSekcija := r.Form["_sekcija_firma"]; jeFirmaSekcija {
greska := validirajProfilFirme(r.FormValue("firma_rezim"), r.FormValue("firma_pdv_obveznik"), r.FormValue("firma_fiskalizacija"))
if greska != "" {
middleware.SetFlash(w, r, h.DB, "greska", greska)
sledeci := "/podesavanja"
if r.FormValue("_next") == "/admin/podesavanja/opste" {
sledeci = "/admin/podesavanja/opste"
}
http.Redirect(w, r, sledeci, http.StatusSeeOther)
return
}
}
polja := map[string]string{ polja := map[string]string{
"naziv_firme": r.FormValue("naziv_firme"), "naziv_firme": r.FormValue("naziv_firme"),
"podnazlov": r.FormValue("podnazlov"), "podnazlov": r.FormValue("podnazlov"),
+107 -32
View File
@@ -54,7 +54,9 @@ type PodaciDetaljiProdaje struct {
KlijentNaziv string KlijentNaziv string
FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano
FiskalGreska bool // true: fiskalizacija aktivna, nalog nije storniran, ali nema fiskalnog računa FiskalGreska bool // true: fiskalizacija aktivna, nalog nije storniran, ali nema fiskalnog računa
StornoBezRefunda bool // true: nalog je storniran, ali fiskalni refund ka PFR-u nije uspeo
PotrebnaIdentKupca bool // true: refundacija zahteva ručni unos PIB/JMBG kupca (nema ga iz klijenta) PotrebnaIdentKupca bool // true: refundacija zahteva ručni unos PIB/JMBG kupca (nema ga iz klijenta)
PotrebnaIdentKupcaRetry bool // isto, ali za retry dugme na već storniranom nalogu (StornoBezRefunda)
Sacuvano bool Sacuvano bool
} }
@@ -243,7 +245,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
return return
} }
nalog, stavke, greska := parseFormuProdaje(r) nalog, stavke, greska := parseFormuProdaje(r, h.validnePdvStope(r.Context()))
renderujGresku := func(poruka string) { renderujGresku := func(poruka string) {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
@@ -410,7 +412,9 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
KlijentNaziv: klijentNaziv, KlijentNaziv: klijentNaziv,
FiskalniRacun: fr, FiskalniRacun: fr,
FiskalGreska: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr == nil, FiskalGreska: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr == nil,
StornoBezRefunda: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && nalog.Stornirano && fr != nil && fr.TipTransakcije == "Sale" && !fr.Storniran,
PotrebnaIdentKupca: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr != nil && !fr.Storniran && !imaIdentKupca, PotrebnaIdentKupca: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr != nil && !fr.Storniran && !imaIdentKupca,
PotrebnaIdentKupcaRetry: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && nalog.Stornirano && fr != nil && fr.TipTransakcije == "Sale" && !fr.Storniran && !imaIdentKupca,
Sacuvano: r.URL.Query().Get("sacuvano") == "1", Sacuvano: r.URL.Query().Get("sacuvano") == "1",
} }
@@ -532,7 +536,9 @@ pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;dis
} }
// parseFormuProdaje čita zaglavlje i stavke iz HTTP forme i vraća model i eventualnu grešku // parseFormuProdaje čita zaglavlje i stavke iz HTTP forme i vraća model i eventualnu grešku
func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProdaje, string) { // validneStope su dozvoljene PDV stope na stavci (v. Handler.validnePdvStope) —
// izvedene iz šifarnika, ne hardkodovane.
func parseFormuProdaje(r *http.Request, validneStope map[float64]bool) (model.ProdajniNalog, []model.StavkaProdaje, string) {
var nalog model.ProdajniNalog var nalog model.ProdajniNalog
if klijentIDStr := r.FormValue("klijent_id"); klijentIDStr != "" { if klijentIDStr := r.FormValue("klijent_id"); klijentIDStr != "" {
@@ -585,8 +591,8 @@ func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProd
return nalog, nil, "Neispravna PDV stopa u stavci." return nalog, nil, "Neispravna PDV stopa u stavci."
} }
} }
if pdvStopa != 0 && pdvStopa != 10 && pdvStopa != 20 { if !validneStope[pdvStopa] {
return nalog, nil, "PDV stopa mora biti 0, 10 ili 20." return nalog, nil, "PDV stopa u stavci nije u šifarniku PDV stopa."
} }
var popust float64 var popust float64
@@ -687,17 +693,68 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
return err return err
} }
// stornirana prodaja ne ulazi u PDV — ukloni vezani auto-KIR zapis // stornirana prodaja se ne briše iz KIR (zakon o računovodstvu ne dozvoljava
if err := h.PdvKirRepo.ObrisiPoIzvoru(ctx, "prodaja", id); err != nil { // brisanje poslovnih knjiga) — dograđuje se storno stavka koja poništava original
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err) if zapisi, err := h.PdvKirRepo.DohvatiPoIzvoru(ctx, "prodaja", id); err != nil {
slog.Error("čitanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
} else {
for _, z := range zapisi {
storno := model.KirStorno(z, razlog, time.Now())
if _, e := h.PdvKirRepo.Kreiraj(ctx, &storno); e != nil {
slog.Error("upis storno KIR zapisa nije uspeo", "prodaja_id", id, "error", e)
}
}
} }
// fiskalni refund — best-effort // fiskalni refund — best-effort
if h.modulUkljucen(ctx, config.ModulFiskalizacija) { if h.modulUkljucen(ctx, config.ModulFiskalizacija) {
if fr, _ := h.FiskalRepo.DohvatiPoProdaji(ctx, id); fr != nil && !fr.Storniran { if err := h.posaljiFiskalniRefundProdaje(ctx, id, poreskiBrojKupca); err != nil {
if fk := h.fiskalKlijent(); fk != nil { slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", err)
if nalogFisk, e := h.ProdajaRepo.DohvatiID(ctx, id); e == nil { }
if stavkeFisk, e2 := h.ProdajaRepo.DohvatiStavke(ctx, id); e2 == nil { }
// stornirana prodaja se ne briše iz KPO (redni broj mora ostati bez prekida) —
// dograđuje se storno stavka koja poništava original
if h.modulUkljucen(ctx, "kpo") {
if zapisi, e := h.KpoRepo.DohvatiPoIzvoru(ctx, "prodaja", id); e != nil {
slog.Error("čitanje vezanog KPO zapisa nije uspelo", "prodaja_id", id, "error", e)
} else {
for _, z := range zapisi {
storno := model.KpoStorno(z, razlog, time.Now())
if _, e := h.KpoRepo.Kreiraj(ctx, &storno); e != nil {
slog.Error("upis storno KPO zapisa nije uspeo", "prodaja_id", id, "error", e)
}
}
}
}
return nil
}
// posaljiFiskalniRefundProdaje šalje fiskalni refund za već storniranu prodaju čiji
// originalni fiskalni (Sale) račun još nije označen kao storniran — koristi ga i
// stornirajProdaju (odmah posle storna) i ručni retry (PokusajRefundProdaje) kad prvi
// pokušaj padne. poreskiBrojKupca se koristi samo ako klijent nema PIB/JMBG na kartici.
func (h *Handler) posaljiFiskalniRefundProdaje(ctx context.Context, id int64, poreskiBrojKupca string) error {
fr, err := h.FiskalRepo.DohvatiPoProdaji(ctx, id)
if err != nil {
return err
}
if fr == nil || fr.Storniran || fr.TipTransakcije != "Sale" {
return nil
}
fk := h.fiskalKlijent()
if fk == nil {
return errors.New("fiskalizacija nije podešena")
}
nalogFisk, err := h.ProdajaRepo.DohvatiID(ctx, id)
if err != nil {
return err
}
stavkeFisk, err := h.ProdajaRepo.DohvatiStavke(ctx, id)
if err != nil {
return err
}
kasirFisk := h.imeKasira(ctx) kasirFisk := h.imeKasira(ctx)
var pibFisk, jmbgFisk string var pibFisk, jmbgFisk string
if nalogFisk.KlijentID != nil { if nalogFisk.KlijentID != nil {
@@ -707,11 +764,15 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
} }
if pibFisk == "" && jmbgFisk == "" { if pibFisk == "" && jmbgFisk == "" {
pibFisk, jmbgFisk = klasifikujPoreskiBroj(poreskiBrojKupca) pibFisk, jmbgFisk = klasifikujPoreskiBroj(poreskiBrojKupca)
if pibFisk == "" && jmbgFisk == "" {
return errPotrebnaIdentKupca
}
} }
zahtevFisk := fiskal.NapraviRefundZahtev(*nalogFisk, stavkeFisk, pibFisk, jmbgFisk, kasirFisk, fr.PfrBroj) zahtevFisk := fiskal.NapraviRefundZahtev(*nalogFisk, stavkeFisk, pibFisk, jmbgFisk, kasirFisk, fr.PfrBroj)
if odgFisk, errFisk := fk.IzdajRacun(ctx, zahtevFisk); errFisk != nil { odgFisk, err := fk.IzdajRacun(ctx, zahtevFisk)
slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", errFisk) if err != nil {
} else { return err
}
poreskeJSON, _ := json.Marshal(odgFisk.TaxItems) poreskeJSON, _ := json.Marshal(odgFisk.TaxItems)
siroviJSON, _ := json.Marshal(odgFisk) siroviJSON, _ := json.Marshal(odgFisk)
refundFr := &model.FiskalniRacun{ refundFr := &model.FiskalniRacun{
@@ -732,26 +793,40 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
Zatrazio: odgFisk.RequestedBy, Zatrazio: odgFisk.RequestedBy,
Poruka: odgFisk.Messages, Poruka: odgFisk.Messages,
} }
if _, errFisk = h.FiskalRepo.Kreiraj(ctx, refundFr); errFisk != nil { if _, err := h.FiskalRepo.Kreiraj(ctx, refundFr); err != nil {
slog.Error("čuvanje fiskalnog refunda nije uspelo", "prodaja_id", id, "error", errFisk) return err
}
return h.FiskalRepo.OznačiKaoStorniran(ctx, fr.ID)
}
// PokusajRefundProdaje je ručni retry za "storno bez fiskalnog refunda" — kad je
// prodaja već stornirana u NTech-u, ali fiskalni refund ka PFR-u nije uspeo pri
// samom stornu (best-effort poziv je pao). Prikazuje se kao dugme na stranici detalja.
func (h *Handler) PokusajRefundProdaje(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "prodaja.storno"); !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
poreskiBrojKupca := strings.TrimSpace(r.FormValue("poreski_broj_kupca"))
if err := h.posaljiFiskalniRefundProdaje(r.Context(), id, poreskiBrojKupca); err != nil {
poruka := "Fiskalni refund nije uspeo — fiskalni server nije dostupan."
if errors.Is(err, errPotrebnaIdentKupca) {
poruka = errPotrebnaIdentKupca.Error()
}
middleware.SetFlash(w, r, h.DB, "greska", poruka)
} else { } else {
_ = h.FiskalRepo.OznačiKaoStorniran(ctx, fr.ID) middleware.SetFlash(w, r, h.DB, "uspeh", "Fiskalni refund je uspešno poslat.")
} }
} http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
}
}
}
}
// brisanje KPO zapisa na storno
if h.modulUkljucen(ctx, "kpo") {
if e := h.KpoRepo.ObrisiPoIzvoru(ctx, "prodaja", id); e != nil {
slog.Error("brisanje vezanog KPO zapisa nije uspelo", "prodaja_id", id, "error", e)
}
}
return nil
} }
// fiskalizujProdaju šalje fiskalni zahtev za prodajni nalog. Best-effort: greške // fiskalizujProdaju šalje fiskalni zahtev za prodajni nalog. Best-effort: greške
+658 -115
View File
File diff suppressed because it is too large Load Diff
+127 -24
View File
@@ -59,10 +59,18 @@ func sumnjiviDuplikati(svi []kandidatUskladjivanja) map[int64]bool {
// KIR upis) sa mapom postojećih upisa i kandidatima za detekciju duplikata. Koristi se // KIR upis) sa mapom postojećih upisa i kandidatima za detekciju duplikata. Koristi se
// i za backfill (KirBackfillProdaje) i za brojanje praznina na dashboard-u. // i za backfill (KirBackfillProdaje) i za brojanje praznina na dashboard-u.
func (h *Handler) kirKandidatiProdaje(ctx context.Context) ([]model.ProdajniNalogSaDetaljem, map[int64]bool, []kandidatUskladjivanja, error) { func (h *Handler) kirKandidatiProdaje(ctx context.Context) ([]model.ProdajniNalogSaDetaljem, map[int64]bool, []kandidatUskladjivanja, error) {
nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{}) svi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
// stornirani nalozi se nikad ne razmatraju za KIR upis — ni za backfill ni za
// brojanje praznina — jer stornirana prodaja ne predstavlja oporeziv promet
var nalozi []model.ProdajniNalogSaDetaljem
for _, nd := range svi {
if !nd.Stornirano {
nalozi = append(nalozi, nd)
}
}
postojiMap := make(map[int64]bool, len(nalozi)) postojiMap := make(map[int64]bool, len(nalozi))
var kandidati []kandidatUskladjivanja var kandidati []kandidatUskladjivanja
for _, nd := range nalozi { for _, nd := range nalozi {
@@ -110,7 +118,7 @@ func (h *Handler) kpoKandidatiServisa(ctx context.Context) (naplaceni []model.Se
} }
naplaceni = make([]model.ServisniNalogSaKlijentom, 0, len(servisNalozi)) naplaceni = make([]model.ServisniNalogSaKlijentom, 0, len(servisNalozi))
for _, sn := range servisNalozi { for _, sn := range servisNalozi {
if sn.Status == model.StatusPreuzeto && sn.Naplaceno != 0 { if sn.Status == model.StatusPreuzeto && sn.Naplaceno != 0 && !sn.Stornirano {
naplaceni = append(naplaceni, sn) naplaceni = append(naplaceni, sn)
} else { } else {
nenaplaceniBroj++ nenaplaceniBroj++
@@ -131,6 +139,36 @@ func (h *Handler) kpoKandidatiServisa(ctx context.Context) (naplaceni []model.Se
return naplaceni, postojiMap, kandidati, nenaplaceniBroj, nil return naplaceni, postojiMap, kandidati, nenaplaceniBroj, nil
} }
// kirKandidatiServisa vraća sve preuzete servisne naloge sa identifikovanim kupcem
// (KlijentID != nil, isti kriterijum kao u Prodaji — B2B/identifikovan kupac) sa
// mapom postojećih KIR upisa i kandidatima za detekciju duplikata.
func (h *Handler) kirKandidatiServisa(ctx context.Context) ([]model.ServisniNalogSaKlijentom, map[int64]bool, []kandidatUskladjivanja, error) {
svi, err := h.ServisRepo.Lista(ctx, "", "")
if err != nil {
return nil, nil, nil, err
}
var kandidatNalozi []model.ServisniNalogSaKlijentom
for _, sn := range svi {
if sn.Status == model.StatusPreuzeto && !sn.Stornirano && !sn.PopravkaOdbijena && sn.KlijentID != nil {
kandidatNalozi = append(kandidatNalozi, sn)
}
}
postojiMap := make(map[int64]bool, len(kandidatNalozi))
var kandidati []kandidatUskladjivanja
for _, sn := range kandidatNalozi {
postoji, _ := h.PdvKirRepo.PostojiZaIzvor(ctx, "servis", sn.ID)
postojiMap[sn.ID] = postoji
datum := sn.DatumPrijema
if sn.DatumZavrsetka != nil {
datum = *sn.DatumZavrsetka
}
kandidati = append(kandidati, kandidatUskladjivanja{
ID: sn.ID, KlijentID: sn.KlijentID, Iznos: sn.Naplaceno, Datum: datum, ImaUpis: postoji,
})
}
return kandidatNalozi, postojiMap, kandidati, nil
}
// PraznineKnjigovodstva broji naloge (prodaja + servis) koji čekaju KIR/KPO/fiskalni // PraznineKnjigovodstva broji naloge (prodaja + servis) koji čekaju KIR/KPO/fiskalni
// upis prema TRENUTNO aktivnim modulima firme, i naloge koji su sumnjivi duplikati // upis prema TRENUTNO aktivnim modulima firme, i naloge koji su sumnjivi duplikati
// (double-submit) pa ih automatski backfill preskače. // (double-submit) pa ih automatski backfill preskače.
@@ -138,21 +176,38 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
var p model.PrazninaKnjigovodstva var p model.PrazninaKnjigovodstva
if h.modulUkljucen(ctx, "pdv") { if h.modulUkljucen(ctx, "pdv") {
_, postojiMap, kandidati, err := h.kirKandidatiProdaje(ctx) _, postojiMapP, kandidatiP, err := h.kirKandidatiProdaje(ctx)
if err != nil { if err != nil {
return p, err return p, err
} }
duplikati := sumnjiviDuplikati(kandidati) _, postojiMapS, kandidatiS, err := h.kirKandidatiServisa(ctx)
for id, postoji := range postojiMap { if err != nil {
return p, err
}
duplikatiP := sumnjiviDuplikati(kandidatiP)
duplikatiS := sumnjiviDuplikati(kandidatiS)
for id, postoji := range postojiMapP {
if postoji { if postoji {
continue continue
} }
if duplikati[id] { if duplikatiP[id] {
p.SumnjiviDupli++ p.SumnjiviDupli++
continue continue
} }
p.BezKir++ p.BezKirProdajaID = append(p.BezKirProdajaID, id)
} }
for id, postoji := range postojiMapS {
if postoji {
continue
}
if duplikatiS[id] {
p.SumnjiviDupli++
continue
}
p.BezKirServisID = append(p.BezKirServisID, id)
}
p.BezKirProdaja = len(p.BezKirProdajaID)
p.BezKirServis = len(p.BezKirServisID)
} }
if h.modulUkljucen(ctx, "kpo") { if h.modulUkljucen(ctx, "kpo") {
@@ -174,7 +229,7 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
p.SumnjiviDupli++ p.SumnjiviDupli++
continue continue
} }
p.BezKpo++ p.BezKpoProdajaID = append(p.BezKpoProdajaID, id)
} }
for id, postoji := range postojiMapS { for id, postoji := range postojiMapS {
if postoji { if postoji {
@@ -184,38 +239,86 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
p.SumnjiviDupli++ p.SumnjiviDupli++
continue continue
} }
p.BezKpo++ p.BezKpoServisID = append(p.BezKpoServisID, id)
} }
p.BezKpoProdaja = len(p.BezKpoProdajaID)
p.BezKpoServis = len(p.BezKpoServisID)
} }
if h.modulUkljucen(ctx, "fiskalizacija") { if h.modulUkljucen(ctx, "fiskalizacija") {
if m, err := h.FiskalRepo.ProdajeBezFiskalnog(ctx); err == nil { if m, err := h.FiskalRepo.ProdajeBezFiskalnog(ctx); err == nil {
p.BezFiskalnog += len(m) for id := range m {
p.BezFiskalnogProdajaID = append(p.BezFiskalnogProdajaID, id)
}
p.BezFiskalnogProdaja = len(p.BezFiskalnogProdajaID)
} }
if m, err := h.FiskalRepo.ServisiBezFiskalnog(ctx); err == nil { if m, err := h.FiskalRepo.ServisiBezFiskalnog(ctx); err == nil {
p.BezFiskalnog += len(m) for id := range m {
p.BezFiskalnogServisID = append(p.BezFiskalnogServisID, id)
} }
if n, err := h.stornoBezRefunda(ctx); err == nil { p.BezFiskalnogServis = len(p.BezFiskalnogServisID)
p.BezRefunda = n }
if idsP, idsS, err := h.stornoBezRefunda(ctx); err == nil {
p.BezRefundaProdajaID = idsP
p.BezRefundaServisID = idsS
p.BezRefundaProdaja = len(idsP)
p.BezRefundaServis = len(idsS)
} }
} }
return p, nil return p, nil
} }
// stornoBezRefunda broji stornirane prodajne naloge čiji je originalni fiskalni // stornoBezRefunda vraća ID-jeve storniranih prodajnih i servisnih naloga čiji je
// račun i dalje neoznačen kao storniran — znači da je storno u bazi prošao, ali // originalni fiskalni račun i dalje neoznačen kao storniran — znači da je storno u
// fiskalni refund ka ESIR/L-PFR uređaju NIJE uspeo (best-effort poziv je pao). // bazi prošao, ali fiskalni refund ka ESIR/L-PFR uređaju NIJE uspeo (best-effort
// Servis nema storno funkciju — obuhvaćena je samo prodaja. // poziv je pao).
func (h *Handler) stornoBezRefunda(ctx context.Context) (int, error) { func (h *Handler) stornoBezRefunda(ctx context.Context) (prodajaIDs, servisIDs []int64, err error) {
var n int redoviP, err := h.DB.QueryContext(ctx, `
err := h.DB.QueryRowContext(ctx, ` SELECT DISTINCT pn.id FROM prodajni_nalozi pn
SELECT COUNT(*) FROM prodajni_nalozi pn
JOIN fiskalni_racuni fr ON fr.prodaja_id = pn.id JOIN fiskalni_racuni fr ON fr.prodaja_id = pn.id
AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0 AND fr.tip_racuna = 'Normal' AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0
WHERE pn.stornirano = 1 WHERE pn.stornirano = 1
`).Scan(&n) `)
return n, err if err != nil {
return nil, nil, err
}
defer redoviP.Close()
for redoviP.Next() {
var id int64
if err := redoviP.Scan(&id); err != nil {
return nil, nil, err
}
prodajaIDs = append(prodajaIDs, id)
}
if err := redoviP.Err(); err != nil {
return nil, nil, err
}
// tip_racuna='Normal' je bitno: servis sa avansom ima i Advance/Sale zapis koji
// se NIKAD ne stornira pri storno servisa (samo konačan Normal/Sale) — bez ovog
// filtera bi JOIN dao dva reda za isti servis (duplikat u listi na dashboard-u)
redoviS, err := h.DB.QueryContext(ctx, `
SELECT DISTINCT sn.id FROM servisni_nalozi sn
JOIN fiskalni_racuni fr ON fr.servis_id = sn.id
AND fr.tip_racuna = 'Normal' AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0
WHERE sn.stornirano = 1
`)
if err != nil {
return nil, nil, err
}
defer redoviS.Close()
for redoviS.Next() {
var id int64
if err := redoviS.Scan(&id); err != nil {
return nil, nil, err
}
servisIDs = append(servisIDs, id)
}
if err := redoviS.Err(); err != nil {
return nil, nil, err
}
return prodajaIDs, servisIDs, nil
} }
func istKlijent(a, b *int64) bool { func istKlijent(a, b *int64) bool {
+7 -5
View File
@@ -63,7 +63,7 @@ func (h *Handler) NovaUsluga(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
sifra = "USL-001" sifra = "USL-001"
} }
h.renderujFormuUsluge(w, r, model.Usluga{Sifra: sifra, PdvStopa: 20, JedinicaMere: "usluga"}, false, "") h.renderujFormuUsluge(w, r, model.Usluga{Sifra: sifra, PdvStopa: h.podrazumevanaPdvStopa(r.Context()), JedinicaMere: "usluga"}, false, "")
} }
// IzmeniUslugu prikazuje formu sa postojećom uslugom // IzmeniUslugu prikazuje formu sa postojećom uslugom
@@ -86,7 +86,7 @@ func (h *Handler) SacuvajUslugu(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "artikal.dodaj"); !ok { if _, ok := h.zahtevajDozvolu(w, r, "artikal.dodaj"); !ok {
return return
} }
usluga, greska := parseFormuUsluge(r) usluga, greska := parseFormuUsluge(r, h.podrazumevanaPdvStopa(r.Context()))
if greska != "" { if greska != "" {
h.renderujFormuUsluge(w, r, usluga, false, greska) h.renderujFormuUsluge(w, r, usluga, false, greska)
return return
@@ -108,7 +108,7 @@ func (h *Handler) SacuvajIzmenuUsluge(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Neispravan ID usluge", http.StatusBadRequest) http.Error(w, "Neispravan ID usluge", http.StatusBadRequest)
return return
} }
usluga, greska := parseFormuUsluge(r) usluga, greska := parseFormuUsluge(r, h.podrazumevanaPdvStopa(r.Context()))
usluga.ID = id usluga.ID = id
if greska != "" { if greska != "" {
h.renderujFormuUsluge(w, r, usluga, true, greska) h.renderujFormuUsluge(w, r, usluga, true, greska)
@@ -139,7 +139,9 @@ func (h *Handler) ObrisiUslugu(w http.ResponseWriter, r *http.Request) {
} }
// parseFormuUsluge čita i validira polja forme usluge. Vraća model i poruku o grešci. // parseFormuUsluge čita i validira polja forme usluge. Vraća model i poruku o grešci.
func parseFormuUsluge(r *http.Request) (model.Usluga, string) { // podrazumevanaStopa je opšta PDV stopa iz šifarnika (v. Handler.podrazumevanaPdvStopa) —
// koristi se samo kad korisnik nije uneo stopu, nikad hardkodovana vrednost.
func parseFormuUsluge(r *http.Request, podrazumevanaStopa float64) (model.Usluga, string) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
return model.Usluga{}, "Greška pri čitanju forme." return model.Usluga{}, "Greška pri čitanju forme."
} }
@@ -154,7 +156,7 @@ func parseFormuUsluge(r *http.Request) (model.Usluga, string) {
} }
pdv, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("pdv_stopa")), 64) pdv, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("pdv_stopa")), 64)
if err != nil || pdv < 0 { if err != nil || pdv < 0 {
pdv = 20 pdv = podrazumevanaStopa
} }
jm := strings.TrimSpace(r.FormValue("jedinica_mere")) jm := strings.TrimSpace(r.FormValue("jedinica_mere"))
+10 -1
View File
@@ -24,6 +24,7 @@ var sveAkcije = []string{
"servis.dodaj", "servis.dodaj",
"servis.izmeni", "servis.izmeni",
"servis.obrisi", "servis.obrisi",
"servis.storno",
"prodaja.pregled", "prodaja.pregled",
"prodaja.dodaj", "prodaja.dodaj",
"prodaja.storno", "prodaja.storno",
@@ -45,6 +46,9 @@ var sveAkcije = []string{
"kpo.pregled", "kpo.pregled",
"kpo.dodaj", "kpo.dodaj",
"kpo.obrisi", "kpo.obrisi",
"fiskal.pazar",
"fiskal.zakljucenje",
"bezbednost.odblokiraj",
} }
// SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu // SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu
@@ -77,7 +81,7 @@ func ImaDozvolu(uloga, akcija string) bool {
return true return true
// servis // servis
case "servis.pregled", "servis.dodaj", case "servis.pregled", "servis.dodaj",
"servis.izmeni", "servis.obrisi": "servis.izmeni", "servis.obrisi", "servis.storno":
return true return true
// prodaja // prodaja
case "prodaja.pregled", "prodaja.dodaj", "prodaja.storno": case "prodaja.pregled", "prodaja.dodaj", "prodaja.storno":
@@ -108,6 +112,11 @@ func ImaDozvolu(uloga, akcija string) bool {
// KPO (knjiga o ostvarenom prometu) — administrativno, radnik nema // KPO (knjiga o ostvarenom prometu) — administrativno, radnik nema
case "kpo.pregled", "kpo.dodaj", "kpo.obrisi": case "kpo.pregled", "kpo.dodaj", "kpo.obrisi":
return true return true
// dnevni fiskalni pazar — administrativno, radnik nema (zaključenje dana je nepovratno)
case "fiskal.pazar", "fiskal.zakljucenje":
return true
// "bezbednost.odblokiraj" NAMERNO izostavljeno iz admin uloge — odblokiranje IP
// adrese zaobilazi bruteforce zaštitu, pa je rezervisano isključivo za superadmin.
} }
return false return false
+21
View File
@@ -22,3 +22,24 @@ type KpoSume struct {
Prihod float64 Prihod float64
Broj int Broj int
} }
// KpoStorno gradi storno zapis za dati KPO red: negiran prihod, upisan pod istim
// izvorom/izvor_id kao original. Knjiga o ostvarenom prometu paušalaca vodi redni
// broj bez prekida (Pravilnik) — original se nikad ne briše, storno se dodaje kao
// poništavajuća stavka koja upućuje na original.
func KpoStorno(original KpoZapis, razlog string, datumStorna time.Time) KpoZapis {
napomena := "Storno naloga " + original.BrojDokumenta
if razlog != "" {
napomena += ": " + razlog
}
return KpoZapis{
DatumPrometa: datumStorna,
BrojDokumenta: "STORNO-" + original.BrojDokumenta,
Opis: original.Opis,
Prihod: -original.Prihod,
NacinPlacanja: original.NacinPlacanja,
Napomena: napomena,
Izvor: original.Izvor,
IzvorID: original.IzvorID,
}
}
+7
View File
@@ -12,3 +12,10 @@ type LoginPokusaj struct {
Razlog string Razlog string
Vreme time.Time Vreme time.Time
} }
// BlokiranaIP predstavlja IP adresu trenutno zaključanu zbog previše neuspelih pokušaja prijave
type BlokiranaIP struct {
IP string
BrojNeuspeha int
PoslednjiPokusaj time.Time
}
+89
View File
@@ -129,6 +129,68 @@ func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupac
return k return k
} }
// KirIzServisa gradi KIR zapis iz preuzetog servisnog naloga: radovi i ugrađeni
// delovi (bez predloženih koji čekaju odobrenje klijenta) se sabiraju kao NETO
// stavke po svojoj PDV stopi. datumPrometa je trenutak prometa (za sveže naloge
// „sada", za backfill starih naloga datum preuzimanja); datumKnjizenja je kad se
// zapis stvarno upisuje u knjigu (za backfill kasni za periodom).
func KirIzServisa(nalog ServisniNalog, radovi []ServisniRad, delovi []ServisniDeoSaArtiklom, kupacNaziv, kupacPib, kupacMesto string, datumPrometa, datumKnjizenja time.Time) PdvKir {
id := nalog.ID
k := PdvKir{
DatumPrometa: datumPrometa,
DatumKnjizenja: datumKnjizenja,
BrojDokumenta: nalog.BrojNaloga,
KupacNaziv: kupacNaziv,
KupacPib: kupacPib,
KupacMesto: kupacMesto,
Izvor: "servis",
IzvorID: &id,
}
for _, r := range radovi {
if r.Predlozeno {
continue
}
k.DodajNeto(r.Ukupno(), r.PdvStopa)
}
for _, d := range delovi {
if d.Predlozeno {
continue
}
k.DodajNeto(d.Ukupno(), d.PdvStopa)
}
return k
}
// KirStorno gradi storno (knjižno odobrenje) zapis za dati KIR red: iznosi su
// negirani original, upisan pod istim izvorom/izvor_id tako da PostojiZaIzvor
// ostane tačan i backfill ne pokuša ponovni upis. Zakon o računovodstvu ne
// dozvoljava brisanje knjigovodstvenih evidencija — storno se knjiži kao
// poništavajuća stavka koja upućuje na original, a ne kao brisanje reda.
func KirStorno(original PdvKir, razlog string, datumStorna time.Time) PdvKir {
napomena := "Storno računa " + original.BrojDokumenta
if razlog != "" {
napomena += ": " + razlog
}
return PdvKir{
DatumPrometa: datumStorna,
DatumKnjizenja: datumStorna,
BrojDokumenta: "STORNO-" + original.BrojDokumenta,
KupacNaziv: original.KupacNaziv,
KupacPib: original.KupacPib,
KupacMesto: original.KupacMesto,
OsnovicaOpsta: -original.OsnovicaOpsta,
PdvOpsta: -original.PdvOpsta,
OsnovicaPosebna: -original.OsnovicaPosebna,
PdvPosebna: -original.PdvPosebna,
OslobodenSaPravom: -original.OslobodenSaPravom,
OslobodenBezPrava: -original.OslobodenBezPrava,
Ukupno: -original.Ukupno,
Napomena: napomena,
Izvor: original.Izvor,
IzvorID: original.IzvorID,
}
}
// PdvKpr je jedan zapis u knjizi primljenih računa (ulazni PDV). // PdvKpr je jedan zapis u knjizi primljenih računa (ulazni PDV).
type PdvKpr struct { type PdvKpr struct {
ID int64 ID int64
@@ -153,6 +215,33 @@ type PdvKpr struct {
DatumUnosa time.Time DatumUnosa time.Time
} }
// KprStorno gradi storno zapis za dati KPR red — vidi KirStorno.
func KprStorno(original PdvKpr, razlog string, datumStorna time.Time) PdvKpr {
napomena := "Storno dokumenta " + original.BrojDokumenta
if razlog != "" {
napomena += ": " + razlog
}
return PdvKpr{
DatumPrometa: datumStorna,
DatumKnjizenja: datumStorna,
BrojDokumenta: "STORNO-" + original.BrojDokumenta,
DobavljacNaziv: original.DobavljacNaziv,
DobavljacPib: original.DobavljacPib,
DobavljacMesto: original.DobavljacMesto,
OsnovicaOpsta: -original.OsnovicaOpsta,
PdvOpsta: -original.PdvOpsta,
OsnovicaPosebna: -original.OsnovicaPosebna,
PdvPosebna: -original.PdvPosebna,
PdvBezOdbitka: -original.PdvBezOdbitka,
OslobodenNabavka: -original.OslobodenNabavka,
Ukupno: -original.Ukupno,
Napomena: napomena,
Izvor: original.Izvor,
IzvorID: original.IzvorID,
Uvoz: original.Uvoz,
}
}
// OznakaPoreskogBroja vraća „JMBG" za 13-cifreni broj, inače „PIB" (dobavljači su obično firme). // OznakaPoreskogBroja vraća „JMBG" za 13-cifreni broj, inače „PIB" (dobavljači su obično firme).
func (k PdvKpr) OznakaPoreskogBroja() string { func (k PdvKpr) OznakaPoreskogBroja() string {
cifre := 0 cifre := 0
+64
View File
@@ -70,6 +70,70 @@ func TestPdvKirDodajNeto(t *testing.T) {
} }
} }
// TestKirIzServisa: radovi i ugrađeni delovi se sabiraju kao NETO stavke po
// PDV stopi; predloženi (neprihvaćeni) redovi se ne naplaćuju.
func TestKirIzServisa(t *testing.T) {
nalog := ServisniNalog{ID: 7, BrojNaloga: "SN-2607-001"}
radovi := []ServisniRad{
{Naziv: "Zamena ekrana", Kolicina: 1, CenaKomada: 1000, PdvStopa: 20}, // osnovica 1000, PDV 200
{Naziv: "Predlog", Kolicina: 1, CenaKomada: 5000, PdvStopa: 20, Predlozeno: true},
}
delovi := []ServisniDeoSaArtiklom{
{ServisniDeo: ServisniDeo{Kolicina: 2, CenaKomada: 300}, PdvStopa: 10}, // osnovica 600, PDV 60
}
k := KirIzServisa(nalog, radovi, delovi, "Kupac doo", "123456789", "Niš",
time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC), time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC))
if k.Izvor != "servis" || k.IzvorID == nil || *k.IzvorID != 7 {
t.Errorf("izvor=%q izvor_id=%v, očekivano servis/7", k.Izvor, k.IzvorID)
}
if k.BrojDokumenta != "SN-2607-001" {
t.Errorf("broj_dokumenta=%q, očekivano SN-2607-001", k.BrojDokumenta)
}
if !blizu(k.OsnovicaOpsta, 1000) || !blizu(k.PdvOpsta, 200) {
t.Errorf("opšta: osnovica=%v pdv=%v, očekivano 1000/200 (predloženi rad se ne računa)", k.OsnovicaOpsta, k.PdvOpsta)
}
if !blizu(k.OsnovicaPosebna, 600) || !blizu(k.PdvPosebna, 60) {
t.Errorf("posebna: osnovica=%v pdv=%v, očekivano 600/60", k.OsnovicaPosebna, k.PdvPosebna)
}
if !blizu(k.Ukupno, 1860) {
t.Errorf("ukupno=%v, očekivano 1860 (1200+660)", k.Ukupno)
}
}
// TestKirStorno: storno stavka negira sve kolone originala, čuva izvor/izvor_id
// (da PostojiZaIzvor ostane tačan i backfill ne pokuša ponovni upis) i original
// se ne dira — storno se samo dograđuje kao novi red.
func TestKirStorno(t *testing.T) {
izvorID := int64(18)
original := PdvKir{
BrojDokumenta: "PR-2607-0013", KupacNaziv: "Kupac doo", KupacPib: "123456789",
OsnovicaOpsta: 1000, PdvOpsta: 200, OsnovicaPosebna: 100, PdvPosebna: 10,
OslobodenSaPravom: 50, OslobodenBezPrava: 20, Ukupno: 1380,
Izvor: "prodaja", IzvorID: &izvorID,
}
datumStorna := time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
s := KirStorno(original, "kupac odustao", datumStorna)
if s.BrojDokumenta != "STORNO-PR-2607-0013" {
t.Errorf("broj_dokumenta=%q, očekivano STORNO-PR-2607-0013", s.BrojDokumenta)
}
if s.Izvor != "prodaja" || s.IzvorID == nil || *s.IzvorID != 18 {
t.Errorf("izvor=%q izvor_id=%v, očekivano prodaja/18 (isto kao original)", s.Izvor, s.IzvorID)
}
if !blizu(s.OsnovicaOpsta, -1000) || !blizu(s.PdvOpsta, -200) {
t.Errorf("opšta: osnovica=%v pdv=%v, očekivano -1000/-200", s.OsnovicaOpsta, s.PdvOpsta)
}
if !blizu(s.Ukupno, -1380) {
t.Errorf("ukupno=%v, očekivano -1380", s.Ukupno)
}
if original.Ukupno != 1380 {
t.Errorf("original izmenjen: ukupno=%v, očekivano da ostane 1380", original.Ukupno)
}
}
func TestKprIzNabavke(t *testing.T) { func TestKprIzNabavke(t *testing.T) {
nabavka := Nabavka{ID: 3, Napomena: "test", Datum: time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)} nabavka := Nabavka{ID: 3, Napomena: "test", Datum: time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)}
stavke := []NabavkaStavkaPdv{ stavke := []NabavkaStavkaPdv{
+2
View File
@@ -60,6 +60,8 @@ type ServisniNalog struct {
JavniToken string JavniToken string
NacinPlacanja string // način plaćanja pri preuzimanju (Gotovina, Kartica, Virman...) NacinPlacanja string // način plaćanja pri preuzimanju (Gotovina, Kartica, Virman...)
Naplaceno float64 // iznos koji je naplaćen pri preuzimanju Naplaceno float64 // iznos koji je naplaćen pri preuzimanju
Stornirano bool
RazlogStorniranja string
} }
// ServisniLog je jedan zapis u istoriji događaja servisnog naloga. // ServisniLog je jedan zapis u istoriji događaja servisnog naloga.
+39 -5
View File
@@ -78,16 +78,50 @@ type PodaciDashboarda struct {
// sumnjivih duplikata (naloga koje automatski backfill namerno preskače) — prikazuje // sumnjivih duplikata (naloga koje automatski backfill namerno preskače) — prikazuje
// se kao kartica na dashboard-u. // se kao kartica na dashboard-u.
type PrazninaKnjigovodstva struct { type PrazninaKnjigovodstva struct {
BezKir int BezKirProdaja int // B2B prodaje bez KIR upisa
BezKpo int BezKirServis int // B2B servisi bez KIR upisa
BezFiskalnog int BezKirProdajaID []int64 // ID-jevi — za direktan link sa dashboard-a
BezRefunda int // stornirani nalozi čiji fiskalni refund nije uspeo (best-effort poziv pao) BezKirServisID []int64
BezKpoProdaja int // prodaje bez KPO upisa
BezKpoServis int // servisi bez KPO upisa
BezKpoProdajaID []int64
BezKpoServisID []int64
BezFiskalnogProdaja int // prodajni nalozi bez izdatog fiskalnog računa
BezFiskalnogServis int // preuzeti servisni nalozi bez izdatog fiskalnog računa
BezFiskalnogProdajaID []int64
BezFiskalnogServisID []int64
BezRefundaProdaja int // stornirane prodaje čiji fiskalni refund nije uspeo (best-effort poziv pao)
BezRefundaServis int // stornirani servisi čiji fiskalni refund nije uspeo (best-effort poziv pao)
BezRefundaProdajaID []int64 // ID-jevi stornirane prodaje bez refunda — za direktan link sa dashboard-a
BezRefundaServisID []int64 // ID-jevi storniranih servisa bez refunda — za direktan link sa dashboard-a
SumnjiviDupli int SumnjiviDupli int
} }
// BezKir vraća ukupan broj naloga (prodaja + servis) bez KIR upisa.
func (p PrazninaKnjigovodstva) BezKir() int {
return p.BezKirProdaja + p.BezKirServis
}
// BezKpo vraća ukupan broj naloga (prodaja + servis) bez KPO upisa.
func (p PrazninaKnjigovodstva) BezKpo() int {
return p.BezKpoProdaja + p.BezKpoServis
}
// BezFiskalnog vraća ukupan broj naloga (prodaja + servis) bez izdatog fiskalnog
// računa — koristi se za prikaz zbirnog broja na dashboard-u.
func (p PrazninaKnjigovodstva) BezFiskalnog() int {
return p.BezFiskalnogProdaja + p.BezFiskalnogServis
}
// BezRefunda vraća ukupan broj storniranih naloga (prodaja + servis) čiji fiskalni
// refund nije uspeo — koristi se za prikaz zbirnog broja na dashboard-u.
func (p PrazninaKnjigovodstva) BezRefunda() int {
return p.BezRefundaProdaja + p.BezRefundaServis
}
// Ukupno vraća zbir svih praznina UKLJUČUJUĆI sumnjive duplikate — koristi se da se // Ukupno vraća zbir svih praznina UKLJUČUJUĆI sumnjive duplikate — koristi se da se
// odluči da li se kartica uopšte prikazuje na dashboard-u (i kad ostanu samo // odluči da li se kartica uopšte prikazuje na dashboard-u (i kad ostanu samo
// duplikati za ručnu proveru, kartica mora da ostane vidljiva). // duplikati za ručnu proveru, kartica mora da ostane vidljiva).
func (p PrazninaKnjigovodstva) Ukupno() int { func (p PrazninaKnjigovodstva) Ukupno() int {
return p.BezKir + p.BezKpo + p.BezFiskalnog + p.BezRefunda + p.SumnjiviDupli return p.BezKir() + p.BezKpo() + p.BezFiskalnog() + p.BezRefunda() + p.SumnjiviDupli
} }
+5
View File
@@ -0,0 +1,5 @@
-- Servisni nalog dobija storno (kao Prodaja i Nabavka) — do sada je jedini način
-- da se poništi naplaćen/preuzet nalog bio fizičko brisanje, koje ostavlja mrtve
-- KIR/KPO/fiskalne zapise iza sebe.
ALTER TABLE servisni_nalozi ADD COLUMN stornirano INTEGER NOT NULL DEFAULT 0;
ALTER TABLE servisni_nalozi ADD COLUMN razlog_storniranja TEXT;
+11
View File
@@ -0,0 +1,11 @@
-- Zamrzava nabavnu cenu na stavci prodaje i ugrađenom servisnom delu u trenutku
-- unosa — potrebno za obračun prave zarade (cena nabavna cena) u Izveštajima
-- bez retroaktivnog preračunavanja ako se artikli.nabavna_cena kasnije promeni.
ALTER TABLE stavke_prodaje ADD COLUMN nabavna_cena REAL NOT NULL DEFAULT 0;
ALTER TABLE servisni_delovi ADD COLUMN nabavna_cena REAL NOT NULL DEFAULT 0;
UPDATE stavke_prodaje
SET nabavna_cena = COALESCE((SELECT a.nabavna_cena FROM artikli a WHERE a.id = stavke_prodaje.artikal_id), 0);
UPDATE servisni_delovi
SET nabavna_cena = COALESCE((SELECT a.nabavna_cena FROM artikli a WHERE a.id = servisni_delovi.artikal_id), 0);
+4
View File
@@ -226,6 +226,10 @@ document.addEventListener('alpine:init', () => {
this.artikliOpcije = window._ntechArtikli || [] this.artikliOpcije = window._ntechArtikli || []
this.klijenti = window._ntechKlijenti || [] this.klijenti = window._ntechKlijenti || []
this.pdvObveznik = window._ntechPdvObveznik === true this.pdvObveznik = window._ntechPdvObveznik === true
// ako firma ne fiskalizuje, ne otvaramo ni prazan tab u pripremiFiskalniTab() —
// stranica detalja nikad neće imati FiskalniRacun da ga popuni, pa bi ostao
// trajno na about:blank (v. docs/Greške.md)
this.prikaziRacun = window._ntechFiskalizacija === true
this.isMobile = window.matchMedia('(max-width: 768px)').matches this.isMobile = window.matchMedia('(max-width: 768px)').matches
window.matchMedia('(max-width: 768px)').addEventListener('change', e => { window.matchMedia('(max-width: 768px)').addEventListener('change', e => {
this.isMobile = e.matches this.isMobile = e.matches
+20 -5
View File
@@ -100,9 +100,10 @@
{{/* Knjigovodstvo — grupa: KIR/KPR/Obračun (modul „pdv") + Nivelacije (dozvola izmene cena) */}} {{/* Knjigovodstvo — grupa: KIR/KPR/Obračun (modul „pdv") + Nivelacije (dozvola izmene cena) */}}
{{$pdvVid := and (index .Moduli "pdv") (index .Dozvole "pdv.pregled")}} {{$pdvVid := and (index .Moduli "pdv") (index .Dozvole "pdv.pregled")}}
{{$kpoVid := and (index .Moduli "kpo") (index .Dozvole "kpo.pregled")}} {{$kpoVid := and (index .Moduli "kpo") (index .Dozvole "kpo.pregled")}}
{{$knjVid := or $pdvVid $kpoVid (index .Dozvole "artikal.izmeni")}} {{$pazarVid := and (index .Moduli "fiskalizacija") (index .Dozvole "fiskal.pazar")}}
{{$knjVid := or $pdvVid $kpoVid $pazarVid (index .Dozvole "artikal.izmeni")}}
{{if $knjVid}} {{if $knjVid}}
{{$knjAktivan := or (eq .Stranica "nivelacije") (eq .Stranica "kpo") (eq .Stranica "pdv-kir") (eq .Stranica "pdv-kpr") (eq .Stranica "pdv-obracun")}} {{$knjAktivan := or (eq .Stranica "nivelacije") (eq .Stranica "kpo") (eq .Stranica "pdv-kir") (eq .Stranica "pdv-kpr") (eq .Stranica "pdv-obracun") (eq .Stranica "fiskal-pazar")}}
<div> <div>
<button type="button" data-podmeni-dugme <button type="button" data-podmeni-dugme
class="nav-stavka {{if $knjAktivan}}aktivan{{end}}" class="nav-stavka {{if $knjAktivan}}aktivan{{end}}"
@@ -149,6 +150,13 @@
<span class="nav-tooltip">PDV obračun za period</span> <span class="nav-tooltip">PDV obračun za period</span>
</a> </a>
{{end}} {{end}}
{{if $pazarVid}}
<a href="/fiskal/pazar" class="nav-stavka nav-podstavka {{if eq .Stranica "fiskal-pazar"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
<span>Dnevni pazar</span>
<span class="nav-tooltip">Dnevni pazar</span>
</a>
{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
@@ -207,19 +215,19 @@
{{if index .Dozvole "podesavanja.pregled"}} {{if index .Dozvole "podesavanja.pregled"}}
<div> <div>
<button type="button" data-podmeni-dugme <button type="button" data-podmeni-dugme
class="nav-stavka {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole")}}aktivan{{end}}" class="nav-stavka {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole") (eq .Stranica "blokirane-ip")}}aktivan{{end}}"
style="width:100%;background:none;border:none;cursor:pointer;"> style="width:100%;background:none;border:none;cursor:pointer;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>Podešavanja</span> <span>Podešavanja</span>
<span class="nav-strelica"> <span class="nav-strelica">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="transition:transform 0.2s;transform:{{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole")}}rotate(180deg){{else}}rotate(0deg){{end}}"> style="transition:transform 0.2s;transform:{{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole") (eq .Stranica "blokirane-ip")}}rotate(180deg){{else}}rotate(0deg){{end}}">
<polyline points="6 9 12 15 18 9"/> <polyline points="6 9 12 15 18 9"/>
</svg> </svg>
</span> </span>
<span class="nav-tooltip">Podešavanja</span> <span class="nav-tooltip">Podešavanja</span>
</button> </button>
<div class="nav-podmeni {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "podesavanja-fiskalizacija") (eq .Stranica "dozvole")}}otvoren{{end}}"> <div class="nav-podmeni {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "podesavanja-fiskalizacija") (eq .Stranica "dozvole") (eq .Stranica "blokirane-ip")}}otvoren{{end}}">
<a href="/admin/podesavanja/opste" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-opste"}}aktivan{{end}}"> <a href="/admin/podesavanja/opste" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-opste"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14"/><path d="M9 21v-5h6v5"/><path d="M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14"/><path d="M9 21v-5h6v5"/><path d="M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg>
<span>Opšte</span> <span>Opšte</span>
@@ -257,6 +265,13 @@
<span class="nav-tooltip">Dozvole</span> <span class="nav-tooltip">Dozvole</span>
</a> </a>
{{end}} {{end}}
{{if index .Dozvole "bezbednost.odblokiraj"}}
<a href="/admin/blokirane-ip" class="nav-stavka nav-podstavka {{if eq .Stranica "blokirane-ip"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<span>Blokirane IP adrese</span>
<span class="nav-tooltip">Blokirane IP adrese</span>
</a>
{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
+50
View File
@@ -0,0 +1,50 @@
{{template "base" .}} {{define "naslov"}}Blokirane IP adrese — NTech{{end}} {{define "sadrzaj"}}
<div class="stranica-stack" style="display: flex; flex-direction: column; gap: 16px">
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap">
<span class="naslov-traka" style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">Blokirane IP adrese</span>
</div>
<div class="kartica animiraj" style="padding: 0; overflow: hidden">
<div style="padding: 16px 20px; border-bottom: 0.5px solid var(--ivica); display: flex; align-items: center; justify-content: space-between">
<span style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave</span>
<a href="/admin/blokirane-ip" class="btn-sekundarno" style="font-size: 13px; padding: 8px 16px">Osveži</a>
</div>
{{if .Lista}}
<div style="overflow-x: auto">
<table class="tabela">
<thead>
<tr style="border-bottom: 0.5px solid var(--ivica)">
<th style="padding: 10px 20px; text-align: left; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni)">IP adresa</th>
<th style="padding: 10px 20px; text-align: left; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni)">Neuspelih pokušaja</th>
<th style="padding: 10px 20px; text-align: left; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni); white-space: nowrap">Poslednji pokušaj</th>
<th style="padding: 10px 20px; text-align: left; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni)">Preostalo</th>
<th style="padding: 10px 20px; text-align: right; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni)"></th>
</tr>
</thead>
<tbody>
{{range .Lista}}
<tr class="animiraj" style="border-bottom: 0.5px solid var(--ivica)">
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-glavni); font-family: monospace">{{.IP}}</td>
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-glavni)">{{.BrojNeuspeha}}</td>
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-sporedni); white-space: nowrap">{{.PoslednjiPokusaj.Format "02.01.2006. 15:04:05"}}</td>
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-sporedni)">{{.Preostalo}}</td>
<td style="padding: 10px 20px; text-align: right">
<form method="POST" action="/admin/blokirane-ip/odblokiraj" style="display:inline;" data-full-reload
onsubmit="return confirm('Odblokirati IP adresu {{.IP}}? Ovo odmah briše sve neuspele pokušaje prijave za nju.')">
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
<input type="hidden" name="ip" value="{{.IP}}">
<button type="submit" class="btn-sekundarno" style="font-size: 13px; padding: 6px 14px">Odblokiraj</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div style="padding: 40px 20px; text-align: center; color: var(--tekst-sporedni); font-size: 14px">Trenutno nema blokiranih IP adresa.</div>
{{end}}
</div>
</div>
{{end}}
+25 -8
View File
@@ -22,18 +22,35 @@
{{ if gt .PrazninaKnjige.Ukupno 0 }} {{ if gt .PrazninaKnjige.Ukupno 0 }}
<div class="kartica animiraj" style="margin-bottom:16px;background:#fef3c7;border:1px solid #fde68a;"> <div class="kartica animiraj" style="margin-bottom:16px;background:#fef3c7;border:1px solid #fde68a;">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;"> <div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
<div style="display:flex;align-items:center;gap:12px;">
<div style="font-size:46px;line-height:1;color:#fdba74;flex-shrink:0;"></div>
<div> <div>
<div style="font-weight:600;color:#92400e;">Knjigovodstvo — praznine u evidenciji</div> <div style="font-weight:600;color:#fdba74;">Knjigovodstvo — praznine u evidenciji</div>
<div style="font-size:13px;color:#92400e;margin-top:2px;"> <div style="font-size:13px;color:#fdba74;margin-top:2px;">
{{ if gt .PrazninaKnjige.BezKir 0 }}{{ .PrazninaKnjige.BezKir }} bez KIR upisa{{ if or (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnog 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezKpo 0 }}{{ .PrazninaKnjige.BezKpo }} bez KPO upisa{{ if or (gt .PrazninaKnjige.BezFiskalnog 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezFiskalnog 0 }}{{ .PrazninaKnjige.BezFiskalnog }} bez fiskalnog računa{{ if gt .PrazninaKnjige.BezRefunda 0 }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezRefunda 0 }}{{ .PrazninaKnjige.BezRefunda }} storno bez fiskalnog refunda{{ end }} {{ if gt .PrazninaKnjige.BezKir 0 }}{{ .PrazninaKnjige.BezKir }} bez KIR upisa{{ if or (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezKpo 0 }}{{ .PrazninaKnjige.BezKpo }} bez KPO upisa{{ if or (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezFiskalnogProdaja 0 }}{{ .PrazninaKnjige.BezFiskalnogProdaja }} prodaja bez fiskalnog računa{{ if or (gt .PrazninaKnjige.BezFiskalnogServis 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezFiskalnogServis 0 }}{{ .PrazninaKnjige.BezFiskalnogServis }} servis bez fiskalnog računa{{ if gt .PrazninaKnjige.BezRefunda 0 }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezRefunda 0 }}{{ .PrazninaKnjige.BezRefunda }} storno bez fiskalnog refunda{{ end }}
{{ if gt .PrazninaKnjige.SumnjiviDupli 0 }}{{ if or (gt .PrazninaKnjige.BezKir 0) (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnog 0) (gt .PrazninaKnjige.BezRefunda 0) }} · {{ end }}{{ .PrazninaKnjige.SumnjiviDupli }} sumnjivih duplikata (proveri ručno){{ end }} {{ if gt .PrazninaKnjige.SumnjiviDupli 0 }}{{ if or (gt .PrazninaKnjige.BezKir 0) (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) (gt .PrazninaKnjige.BezRefunda 0) }} · {{ end }}{{ .PrazninaKnjige.SumnjiviDupli }} sumnjivih duplikata (proveri ručno){{ end }}
{{ if or (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) }}
<div style="margin-top:4px;">
Bez fiskalnog računa:
{{ range $i, $id := .PrazninaKnjige.BezFiskalnogProdajaID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja/{{ $id }}" style="color:#2563eb;text-decoration:underline;">Prodaja #{{ $id }}</a>{{ end }}
{{ if and .PrazninaKnjige.BezFiskalnogProdajaID .PrazninaKnjige.BezFiskalnogServisID }}, {{ end }}
{{ range $i, $id := .PrazninaKnjige.BezFiskalnogServisID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/{{ $id }}" style="color:#2563eb;text-decoration:underline;">Servis #{{ $id }}</a>{{ end }}
</div>
{{ end }}
{{ if gt .PrazninaKnjige.BezRefunda 0 }}
<div style="margin-top:4px;">
Storno bez refunda:
{{ range $i, $id := .PrazninaKnjige.BezRefundaProdajaID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja/{{ $id }}" style="color:#2563eb;text-decoration:underline;">Prodaja #{{ $id }}</a>{{ end }}
{{ if and .PrazninaKnjige.BezRefundaProdajaID .PrazninaKnjige.BezRefundaServisID }}, {{ end }}
{{ range $i, $id := .PrazninaKnjige.BezRefundaServisID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/{{ $id }}" style="color:#2563eb;text-decoration:underline;">Servis #{{ $id }}</a>{{ end }}
</div>
{{ end }}
</div>
</div> </div>
</div> </div>
<div style="display:flex;gap:8px;flex-wrap:wrap;"> <div style="display:flex;gap:8px;flex-wrap:wrap;">
{{ if gt .PrazninaKnjige.BezKir 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir" class="btn-sekundarno" style="font-size:13px;">KIR</a>{{ end }} {{ if gt .PrazninaKnjige.BezKir 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir" class="btn-sekundarno" style="font-size:13px;">KIR — pokreni usklađivanje</a>{{ end }}
{{ if gt .PrazninaKnjige.BezKpo 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/kpo" class="btn-sekundarno" style="font-size:13px;">KPO</a>{{ end }} {{ if gt .PrazninaKnjige.BezKpo 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/kpo" class="btn-sekundarno" style="font-size:13px;">KPO — pokreni usklađivanje</a>{{ end }}
{{ if gt .PrazninaKnjige.BezFiskalnog 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja?prikazi=sve" class="btn-sekundarno" style="font-size:13px;">Fiskalizacija</a>{{ end }}
{{ if gt .PrazninaKnjige.BezRefunda 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja?stornirano=1&prikazi=sve" class="btn-sekundarno" style="font-size:13px;">Storno bez refunda</a>{{ end }}
</div> </div>
</div> </div>
</div> </div>
+108
View File
@@ -0,0 +1,108 @@
{{template "base" .}}
{{define "naslov"}}Dnevni fiskalni presek — NTech{{end}}
{{define "sadrzaj"}}
<div class="stranica-stack" style="width:100%;max-width:720px;">
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Dnevni fiskalni presek</span>
</div>
{{if .Greska}}
<div class="fisk-status greska" style="margin-bottom:16px;">&#10007; {{.Greska}}</div>
{{end}}
{{if .Presek}}
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:16px;">
Kumulativni promet od poslednjeg (opcionog) reseta brojača: <strong>{{.Presek.StartOfPeriod}}</strong> do sada.
Za tačan izveštaj za određeni dan/period koristi „Preuzmi izveštaj za period" ispod.
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;">
<div style="padding:14px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:10px;">
<div style="font-size:12px;color:var(--tekst-sporedni);">Broj računa</div>
<div style="font-size:22px;font-weight:700;color:var(--tekst-glavni);">{{.Presek.InvoiceCount}}</div>
</div>
<div style="padding:14px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:10px;">
<div style="font-size:12px;color:var(--tekst-sporedni);">Ukupan promet</div>
<div style="font-size:22px;font-weight:700;color:var(--tekst-glavni);">{{dinari .Presek.Total}} din</div>
</div>
<div style="padding:14px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:10px;">
<div style="font-size:12px;color:var(--tekst-sporedni);">Gotovina</div>
<div style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">{{dinari .Presek.TotalCash}} din</div>
</div>
</div>
{{if .Presek.TotalByPaymentType}}
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);margin-bottom:8px;">Po vrsti plaćanja</div>
{{range .Presek.TotalByPaymentType}}
<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:0.5px solid var(--ivica);font-size:13px;">
<span style="color:var(--tekst-sporedni);">{{.PaymentType}}</span>
<span style="color:var(--tekst-glavni);">{{dinari .Amount}} din</span>
</div>
{{end}}
</div>
{{end}}
{{if .Presek.TotalByTax}}
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);margin-bottom:8px;">Po poreskim stopama</div>
{{range .Presek.TotalByTax}}
<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:0.5px solid var(--ivica);font-size:13px;">
<span style="color:var(--tekst-sporedni);">{{.Label}} ({{.Rate}}%)</span>
<span style="color:var(--tekst-glavni);">{{dinari .Amount}} din</span>
</div>
{{end}}
</div>
{{end}}
{{if .Presek.TotalByCashier}}
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);margin-bottom:8px;">Po kasiru</div>
{{range .Presek.TotalByCashier}}
<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:0.5px solid var(--ivica);font-size:13px;">
<span style="color:var(--tekst-sporedni);">{{.Name}}</span>
<span style="color:var(--tekst-glavni);">{{dinari .Amount}} din</span>
</div>
{{end}}
</div>
{{end}}
{{end}}
{{if .FiskalPodesen}}
<div style="padding-top:12px;border-top:0.5px solid var(--ivica);">
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);margin-bottom:8px;">Preuzmi izveštaj za period</div>
<form method="POST" action="/fiskal/pazar/izvestaj" data-full-reload style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;margin-bottom:16px;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div>
<label style="display:block;font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Od datuma</label>
<input type="date" name="od_datuma" value="{{.Danas}}" class="polje-forme" style="font-size:13px;padding:7px 10px;">
</div>
<div>
<label style="display:block;font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Do datuma</label>
<input type="date" name="do_datuma" value="{{.Danas}}" class="polje-forme" style="font-size:13px;padding:7px 10px;">
</div>
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Preuzmi izveštaj</button>
</form>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<a href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a>
{{if index .Dozvole "fiskal.zakljucenje"}}
<form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;" data-full-reload
onsubmit="return confirm('Resetovati brojače na ekranu? Ovo NIJE zakonska obaveza — izveštaj po periodu radi nezavisno od ovoga. Samo svodi prikaz Promet od poslednjeg preseka na nulu.')">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;color:var(--tekst-sporedni);">Resetuj brojače (opciono)</button>
</form>
{{end}}
</div>
</div>
{{end}}
</div>
</div>
{{end}}
+55 -14
View File
@@ -51,23 +51,41 @@
<thead> <thead>
<tr style="border-bottom:0.5px solid var(--ivica);"> <tr style="border-bottom:0.5px solid var(--ivica);">
<th style="padding:8px 12px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Mesec</th> <th style="padding:8px 12px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Mesec</th>
<th style="padding:8px 12px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Prodaja</th> <th style="padding:8px 12px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Prodaja</th>
<th style="padding:8px 12px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Servis</th> <th style="padding:8px 12px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Servis</th>
<th style="padding:8px 12px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Ukupno</th> <th style="padding:8px 12px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Ukupno</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range .MesecniPrihodi}} {{range .MesecniPrihodi}}
<tr style="border-bottom:0.5px solid var(--ivica);"> <tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-glavni);">{{.MesecPrikaz}}</td> <td style="padding:8px 12px;font-size:13px;color:var(--tekst-glavni);">{{.MesecPrikaz}}</td>
<td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);"> <td style="padding:8px 12px;font-size:13px;color:var(--tekst-sporedni);">
{{if gt .Prodaja 0.0}}{{dinariCeli .Prodaja}} din{{else}}—{{end}} {{if gt .Prodaja 0.0}}
<div style="display:grid;grid-template-columns:1fr auto 1fr;gap:6px;">
<span style="text-align:right;">{{dinariCeli .Prodaja}} din</span>
<span>-</span>
<span style="text-align:left;">{{dinariCeli .ZaradaProdaja}} din</span>
</div>
{{else}}<div style="text-align:center;"></div>{{end}}
</td> </td>
<td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);"> <td style="padding:8px 12px;font-size:13px;color:var(--tekst-sporedni);">
{{if gt .Servis 0.0}}{{dinariCeli .Servis}} din{{else}}—{{end}} {{if gt .Servis 0.0}}
<div style="display:grid;grid-template-columns:1fr auto 1fr;gap:6px;">
<span style="text-align:right;">{{dinariCeli .Servis}} din</span>
<span>-</span>
<span style="text-align:left;">{{dinariCeli .ZaradaServis}} din</span>
</div>
{{else}}<div style="text-align:center;"></div>{{end}}
</td> </td>
<td style="padding:8px 12px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);"> <td style="padding:8px 12px;font-size:13px;font-weight:500;color:var(--tekst-glavni);">
{{if gt .Ukupno 0.0}}{{dinariCeli .Ukupno}} din{{else}}—{{end}} {{if gt .Ukupno 0.0}}
<div style="display:grid;grid-template-columns:1fr auto 1fr;gap:6px;">
<span style="text-align:right;">{{dinariCeli .Ukupno}} din</span>
<span>-</span>
<span style="text-align:left;">{{dinariCeli .Zarada}} din</span>
</div>
{{else}}<div style="text-align:center;"></div>{{end}}
</td> </td>
</tr> </tr>
{{end}} {{end}}
@@ -207,18 +225,40 @@
labels: podaci.labele, labels: podaci.labele,
datasets: [ datasets: [
{ {
label: 'Prodaja', label: 'Prodaja — ukupno',
data: podaci.prodaja, data: podaci.prodaja,
backgroundColor: 'rgba(79, 126, 248, 0.75)', backgroundColor: 'rgba(79, 126, 248, 0.35)',
borderRadius: 4, borderRadius: 4,
borderSkipped: false, borderSkipped: false,
stack: 'prodaja',
order: 2,
}, },
{ {
label: 'Servis', label: 'Servis — ukupno',
data: podaci.servis, data: podaci.servis,
backgroundColor: 'rgba(22, 163, 74, 0.75)', backgroundColor: 'rgba(22, 163, 74, 0.35)',
borderRadius: 4, borderRadius: 4,
borderSkipped: false, borderSkipped: false,
stack: 'servis',
order: 2,
},
{
label: 'Zarada — prodaja',
data: podaci.zaradaProdaja,
backgroundColor: 'rgba(29, 66, 168, 0.9)',
borderRadius: 4,
borderSkipped: false,
stack: 'prodaja',
order: 1,
},
{
label: 'Zarada — servis',
data: podaci.zaradaServis,
backgroundColor: 'rgba(21, 94, 51, 0.9)',
borderRadius: 4,
borderSkipped: false,
stack: 'servis',
order: 1,
} }
] ]
}, },
@@ -239,11 +279,12 @@
}, },
scales: { scales: {
x: { x: {
stacked: false, stacked: true,
ticks: { color: tekstBoja, font: { size: 11, family: 'DejaVu Sans, Ubuntu, system-ui, sans-serif' } }, ticks: { color: tekstBoja, font: { size: 11, family: 'DejaVu Sans, Ubuntu, system-ui, sans-serif' } },
grid: { color: ivicaBoja } grid: { color: ivicaBoja }
}, },
y: { y: {
stacked: false,
ticks: { ticks: {
color: tekstBoja, color: tekstBoja,
font: { size: 11, family: 'DejaVu Sans, Ubuntu, system-ui, sans-serif' }, font: { size: 11, family: 'DejaVu Sans, Ubuntu, system-ui, sans-serif' },
+51
View File
@@ -33,6 +33,13 @@
Uvezi iz prodaje Uvezi iz prodaje
</button> </button>
</form> </form>
<form method="POST" action="/pdv/kir/backfill-servisa" data-full-reload>
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;white-space:nowrap;"
data-potvrda="Uvesti sve preuzete servisne naloge koji još nisu u KIR-u?">
Uvezi iz servisa
</button>
</form>
{{end}} {{end}}
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir/nova" class="btn-primarno" style="font-size:13px;padding:8px 16px;white-space:nowrap;">+ Nov izlazni račun</a> <a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir/nova" class="btn-primarno" style="font-size:13px;padding:8px 16px;white-space:nowrap;">+ Nov izlazni račun</a>
</div> </div>
@@ -101,5 +108,49 @@
</div> </div>
</div> </div>
<!-- sumnjivi duplikati (double-submit) — prodaje preskočene pri automatskom uvozu -->
{{if .SumnjiviDuplikati}}
<div class="kartica animiraj" style="margin-top:24px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
<span style="font-size:14px;font-weight:600;">⚠ Sumnjivi duplikati</span>
<span style="font-size:12px;color:var(--tekst-sporedni);">({{len .SumnjiviDuplikati}} preskočeno pri uvozu — mogući double-submit, proveri ručno)</span>
</div>
<div style="overflow-x:auto;">
<table class="tabela" style="width:100%;border-collapse:collapse;font-size:13px;min-width:600px;">
<thead>
<tr style="text-align:left;color:var(--tekst-sporedni);border-bottom:0.5px solid var(--ivica);">
<th style="padding:8px 12px;">Broj</th>
<th style="padding:8px 12px;">Datum</th>
<th style="padding:8px 12px;">Kupac</th>
<th style="padding:8px 12px;text-align:right;">Iznos</th>
<th style="padding:8px 12px;"></th>
</tr>
</thead>
<tbody>
{{range .SumnjiviDuplikati}}
<tr style="border-bottom:0.5px solid var(--ivica);{{if .Stornirano}}opacity:.5;{{end}}">
<td style="padding:8px 12px;font-family:monospace;">{{.BrojNaloga}}</td>
<td style="padding:8px 12px;white-space:nowrap;">{{.Datum.Format "02.01.2006. 15:04"}}</td>
<td style="padding:8px 12px;">{{.KlijentNaziv}}</td>
<td style="padding:8px 12px;text-align:right;font-weight:500;">{{dinari .Ukupno}}</td>
<td style="padding:8px 12px;text-align:right;white-space:nowrap;">
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML"
href="/prodaja/{{.ID}}" class="btn-sekundarno" style="font-size:12px;padding:4px 10px;">Pregled</a>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div style="margin-top:12px;font-size:12px;color:var(--tekst-sporedni);line-height:1.6;">
Ovo su prodaje koje izgledaju kao dupli unos (isti kupac, isti iznos, unutar 60 sekundi).
Automatski uvoz ih preskače da ne bi duplirao prihod u KIR-u.
<strong>Ako su to stvarno dve odvojene prodaje</strong> — unesi ručno preko
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir/nova">+ Nov izlazni račun</a>.
<strong>Ako je greška (double-submit)</strong> — storniraj/obriši duplikat u Prodaji.
</div>
</div>
{{end}}
</div> </div>
{{end}} {{end}}
+4 -3
View File
@@ -55,9 +55,10 @@
.potpis-linija { border-top: 1px solid #888; margin-top: 28px; padding-top: 3px; font-size: 10px; color: #777; } .potpis-linija { border-top: 1px solid #888; margin-top: 28px; padding-top: 3px; font-size: 10px; color: #777; }
@media print { @media print {
body { font-size: 11px; } body { font-size: 10px; min-width: 0; }
.strana { padding: 20px 28px; max-width: 780px; } .strana { padding: 0; max-width: 100%; }
@page { size: A4 landscape; margin: 8mm 10mm; } @page { size: A4 portrait; margin: 8mm 10mm; }
.tabela th, .tabela td { padding: 3px 4px; }
.tabela tr { break-inside: avoid; } .tabela tr { break-inside: avoid; }
.tabela thead { display: table-header-group; } .tabela thead { display: table-header-group; }
.dugme-stampa { display: none !important; } .dugme-stampa { display: none !important; }
+89 -35
View File
@@ -52,29 +52,6 @@
</span> </span>
{{end}} {{end}}
</div> </div>
<a
href="/prodaja/{{.Nalog.ID}}/stampa"
target="_blank"
class="btn-sekundarno"
style="gap: 6px">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true">
<polyline points="6 9 6 2 18 2 18 9" />
<path
d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
Štampaj
</a>
</div> </div>
<div <div
style=" style="
@@ -252,23 +229,66 @@
Storno vraća sve artikle na stanje u magacinu. Nalog ostaje u evidenciji sa oznakom storniran. Storno vraća sve artikle na stanje u magacinu. Nalog ostaje u evidenciji sa oznakom storniran.
</div> </div>
</div> </div>
<form method="POST" action="/prodaja/storno/{{.Nalog.ID}}" style="display:flex;flex-direction:column;gap:8px;align-items:flex-end;"> <button type="button" class="btn-upozorenje" onclick="ntechPotvrdiPaOtvoriStorno(this, 'Stornirati nalog {{.Nalog.BrojNaloga}}?')">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="text" name="razlog" placeholder="Razlog storniranja (opciono)" style="width:240px;">
{{if .PotrebnaIdentKupca}}
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="width:240px;">
{{end}}
<button type="submit" class="btn-upozorenje"
data-potvrda="Stornirati nalog {{.Nalog.BrojNaloga}}?">
Storniraj nalog Storniraj nalog
</button> </button>
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
<form method="dialog" class="premesti-zaglavlje">
<h3>Storniranje naloga {{.Nalog.BrojNaloga}}</h3>
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">&times;</button>
</form>
<div style="display:flex;flex-direction:column;gap:10px;padding:16px;">
<form method="POST" action="/prodaja/storno/{{.Nalog.ID}}" data-refresh="#sadrzaj" style="display:flex;flex-direction:column;gap:10px;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div>
<label class="polje-labela">Razlog storniranja (opciono)</label>
<input type="text" name="razlog" placeholder="Razlog storniranja" style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
{{if .PotrebnaIdentKupca}}
<div>
<label class="polje-labela">PIB ili JMBG kupca</label>
<input type="text" id="storno-pib" name="poreski_broj_kupca" placeholder="Obavezno za fiskalni refund" required
style="width:100%;padding:8px 12px;border:1px solid #dc2626;border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
{{end}}
<button type="submit" id="btn-storno-potvrdi" class="btn-upozorenje" style="width:100%;{{if .PotrebnaIdentKupca}}opacity:0.4;cursor:not-allowed;{{end}}" {{if .PotrebnaIdentKupca}}disabled{{end}}>
Potvrdi storno
</button>
</form> </form>
</div> </div>
</dialog>
</div>
<script>
(function(){
// isti obrazac kao u Servisu (servis_detalji.html) — v. komentar tamo zašto
// se NE koristi ntechOtvoriModal (auto-submit na zatvaranje dialoga).
window.ntechPotvrdiPaOtvoriStorno = function (btn, poruka) {
window._ntechPotvrdi(poruka).then(function (ok) {
if (!ok) return;
var d = btn.nextElementSibling;
if (d && d.tagName === 'DIALOG') d.showModal();
});
};
var pib = document.getElementById('storno-pib');
var btnPotvrdi = document.getElementById('btn-storno-potvrdi');
if (pib && btnPotvrdi) {
pib.addEventListener('input', function () {
var ok = pib.value.trim() !== '';
btnPotvrdi.disabled = !ok;
btnPotvrdi.style.opacity = ok ? '1' : '0.4';
btnPotvrdi.style.cursor = ok ? 'pointer' : 'not-allowed';
});
}
})();
</script>
{{end}} {{end}}
{{if .FiskalniRacun}} {{if .FiskalniRacun}}
<div class="fiskalni-racun" style="padding-top:14px;border-top:0.5px solid var(--ivica);"> <div class="fiskalni-racun" style="padding-top:14px;border-top:0.5px solid var(--ivica);">
<h3 style="margin:0 0 8px 0;font-size:16px;">Fiskalni račun</h3> <div style="margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:16px;">Fiskalni račun</h3>
<a href="/prodaja/{{.Nalog.ID}}/fiskalni-racun" target="_blank" class="btn-sekundarno" style="font-size:12px;padding:4px 12px;">Štampaj kopiju</a>
</div>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start;"> <div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start;">
<div style="flex:1;min-width:200px;"> <div style="flex:1;min-width:200px;">
<p style="margin:0 0 4px 0;"><strong>PFR broj:</strong> {{.FiskalniRacun.PfrBroj}}</p> <p style="margin:0 0 4px 0;"><strong>PFR broj:</strong> {{.FiskalniRacun.PfrBroj}}</p>
@@ -296,14 +316,27 @@
} }
})(); })();
</script> </script>
{{else}}
<script>
(function(){
// fiskalizacija je pokušana (racun=1 tražen pri naplati) ali računa nema —
// PFR nedostupan ili slično (v. FiskalGreska ispod). Prazan tab je već otvoren
// pre redirekta — zatvori ga umesto da ostane trajno na about:blank.
if (new URLSearchParams(location.search).get('racun') === '1') {
var t = window.open('', 'ntech-fiskalni-tab');
if (t) t.close();
}
})();
</script>
{{end}} {{end}}
{{if .FiskalGreska}} {{if .FiskalGreska}}
<div class="fiskalni-racun" style="padding:14px;border:1.5px solid #f59e0b;border-radius:8px;background:color-mix(in srgb,#fef3c7 60%,var(--pozadina));"> <div class="fiskalni-racun" style="padding:14px;border:1.5px solid #f59e0b;border-radius:8px;background:color-mix(in srgb,#fef3c7 60%,var(--pozadina));">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
<div style="font-size:46px;line-height:1;color:#fdba74;flex-shrink:0;"></div>
<div style="flex:1;"> <div style="flex:1;">
<div style="font-size:14px;font-weight:600;color:#92400e;margin-bottom:4px;">Fiskalni račun nije izdat</div> <div style="font-size:14px;font-weight:600;color:#fdba74;margin-bottom:4px;">Fiskalni račun nije izdat</div>
<div style="font-size:13px;color:#78350f;">Veza sa ESIR/PFR serverom nije bila dostupna pri kreiranju prodaje.</div> <div style="font-size:13px;color:#fdba74;">Veza sa ESIR/PFR serverom nije bila dostupna pri kreiranju prodaje.</div>
</div> </div>
{{if index $.Dozvole "prodaja.dodaj"}} {{if index $.Dozvole "prodaja.dodaj"}}
<form method="POST" action="/prodaja/{{.Nalog.ID}}/fiskalizuj"> <form method="POST" action="/prodaja/{{.Nalog.ID}}/fiskalizuj">
@@ -315,6 +348,27 @@
</div> </div>
{{end}} {{end}}
{{if .StornoBezRefunda}}
<div class="fiskalni-racun" style="padding:14px;border:1.5px solid #dc2626;border-radius:8px;background:color-mix(in srgb,#fee2e2 60%,var(--pozadina));">
<div style="display:flex;align-items:center;gap:14px;">
<div style="font-size:46px;line-height:1;color:#fca5a5;flex-shrink:0;"></div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;color:#fca5a5;margin-bottom:4px;">Storno bez fiskalnog refunda</div>
<div style="font-size:13px;color:#fca5a5;margin-bottom:10px;">Storno je upisan u NTech, ali fiskalni refund ka PFR uređaju nije uspeo (originalni fiskalni račun i dalje nije označen kao storniran).</div>
{{if index $.Dozvole "prodaja.storno"}}
<form method="POST" action="/prodaja/{{.Nalog.ID}}/refund-fiskalizuj" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
{{if .PotrebnaIdentKupcaRetry}}
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="padding:7px 10px;border:1px solid #dc2626;border-radius:6px;font-size:13px;">
{{end}}
<button type="submit" style="padding:8px 16px;background:#dc2626;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;">Pokušaj refund ponovo</button>
</form>
{{end}}
</div>
</div>
</div>
{{end}}
</div> </div>
</div> </div>
</div> </div>
@@ -21,6 +21,7 @@
var _ntechArtikli = {{.ArtikliJSON}}; var _ntechArtikli = {{.ArtikliJSON}};
var _ntechKlijenti = {{.KlijentiJSON}}; var _ntechKlijenti = {{.KlijentiJSON}};
var _ntechPdvObveznik = {{if index .Moduli "pdv"}}true{{else}}false{{end}}; var _ntechPdvObveznik = {{if index .Moduli "pdv"}}true{{else}}false{{end}};
var _ntechFiskalizacija = {{if index .Moduli "fiskalizacija"}}true{{else}}false{{end}};
</script> </script>
<div style="width: 100%" x-data="prodajaForma"> <div style="width: 100%" x-data="prodajaForma">
+193 -13
View File
@@ -45,6 +45,11 @@
<span style="font-size:20px;font-weight:600;color:var(--tekst-glavni);font-family:monospace;"> <span style="font-size:20px;font-weight:600;color:var(--tekst-glavni);font-family:monospace;">
{{.Nalog.BrojNaloga}} {{.Nalog.BrojNaloga}}
</span> </span>
{{if .Nalog.Stornirano}}
<span style="display:inline-block;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:500;background:rgba(220,38,38,0.12);color:#dc2626;">
Stornirano
</span>
{{end}}
{{if .Nalog.PopravkaOdbijena}} {{if .Nalog.PopravkaOdbijena}}
<div style="padding:8px 14px;background:color-mix(in srgb, var(--crvena, #ef4444) 14%, transparent);border:1px solid color-mix(in srgb, var(--crvena, #ef4444) 35%, transparent);border-radius:8px;"> <div style="padding:8px 14px;background:color-mix(in srgb, var(--crvena, #ef4444) 14%, transparent);border:1px solid color-mix(in srgb, var(--crvena, #ef4444) 35%, transparent);border-radius:8px;">
<span style="font-size:15px;font-weight:700;color:var(--crvena, #ef4444);">✕ Klijent je odbio popravku</span> <span style="font-size:15px;font-weight:700;color:var(--crvena, #ef4444);">✕ Klijent je odbio popravku</span>
@@ -125,19 +130,34 @@
<span style="font-size:16px;font-weight:600;color:var(--tekst-glavni);">Za naplatu</span> <span style="font-size:16px;font-weight:600;color:var(--tekst-glavni);">Za naplatu</span>
<span style="font-size:18px;font-weight:600;color:var(--tekst-glavni);" id="za-naplatu-iznos">{{if index .Moduli "pdv"}}{{dinari .PreostaloSveSaPdv}}{{else}}{{dinari .PreostaloSve}}{{end}} din</span> <span style="font-size:18px;font-weight:600;color:var(--tekst-glavni);" id="za-naplatu-iznos">{{if index .Moduli "pdv"}}{{dinari .PreostaloSveSaPdv}}{{else}}{{dinari .PreostaloSve}}{{end}} din</span>
</div> </div>
{{$visak := .VisakAvansa}}{{if index .Moduli "pdv"}}{{$visak = .VisakAvansaSaPdv}}{{end}}
{{if gt $visak 0.0}}
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:color-mix(in srgb,#f59e0b 12%,transparent);border:0.5px solid #f59e0b;border-radius:8px;">
<span style="color:#92400e;font-weight:500;">Avans premašuje cenu — vratiti klijentu</span>
<span style="font-size:16px;font-weight:600;color:#92400e;">{{dinari $visak}} din</span>
</div>
<div class="pomocni-tekst">Ovaj iznos se automatski vraća kroz avansni povraćaj na fiskalnom računu.</div>
{{end}}
{{$zaNaplatu := .PreostaloSve}}{{if index .Moduli "pdv"}}{{$zaNaplatu = .PreostaloSveSaPdv}}{{end}}
<form method="POST" action="/servis/{{.Nalog.ID}}/status" data-refresh="#sadrzaj" style="margin-top:8px;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="hidden" name="status" value="Preuzeto">
<input type="hidden" name="naplaceno" value="{{printf "%.2f" $zaNaplatu}}">
{{if gt $zaNaplatu 0.0}}
<div style="margin-top:10px;padding-top:10px;border-top:0.5px solid var(--ivica);"> <div style="margin-top:10px;padding-top:10px;border-top:0.5px solid var(--ivica);">
<label class="polje-labela">Primljeno (din)</label> <label class="polje-labela">Primljeno (din)</label>
<input type="number" id="primljeno" name="primljeno" min="0" step="0.01" placeholder="Unesite primljeni iznos" style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;box-sizing:border-box;" oninput="ntechPrimljenoUpdate(this)"> <input type="number" id="primljeno" name="primljeno" min="0" step="0.01" placeholder="Unesite primljeni iznos" style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;box-sizing:border-box;" oninput="ntechPrimljenoUpdate(this)">
</div> </div>
<div id="kusur-blok" style="display:none;justify-content:space-between;align-items:center;padding:8px 12px;background:color-mix(in srgb,var(--uspeh) 10%,transparent);border:0.5px solid var(--uspeh);border-radius:8px;"> <div id="kusur-blok" style="display:none;justify-content:space-between;align-items:center;padding:8px 12px;background:color-mix(in srgb,var(--uspeh) 10%,transparent);border:0.5px solid var(--uspeh);border-radius:8px;margin-top:10px;">
<span style="color:var(--uspeh);font-weight:500;">Kusur (vratiti)</span> <span style="color:var(--uspeh);font-weight:500;">Kusur (vratiti)</span>
<span id="kusur" style="font-size:16px;font-weight:600;color:var(--uspeh);">0,00 din</span> <span id="kusur" style="font-size:16px;font-weight:600;color:var(--uspeh);">0,00 din</span>
</div> </div>
<form method="POST" action="/servis/{{.Nalog.ID}}/status" data-refresh="#sadrzaj" style="margin-top:8px;"> {{else}}
<input type="hidden" name="_csrf" value="{{.CsrfToken}}"> <div style="margin-top:10px;padding-top:10px;border-top:0.5px solid var(--ivica);font-size:13px;color:var(--tekst-sporedni);">
<input type="hidden" name="status" value="Preuzeto"> Avans pokriva ceo iznos — ništa se dodatno ne naplaćuje.
<input type="hidden" name="naplaceno" value="{{if index .Moduli "pdv"}}{{printf "%.2f" .PreostaloSveSaPdv}}{{else}}{{printf "%.2f" .PreostaloSve}}{{end}}"> </div>
<div style="margin-bottom:10px;"> {{end}}
<div style="margin-bottom:10px;margin-top:10px;">
<label class="polje-labela">Način plaćanja</label> <label class="polje-labela">Način plaćanja</label>
<select name="nacin_placanja" id="nacin-placanja-sel" style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:13px;" onchange="ntechNacinPlacanjaBroj(this)"> <select name="nacin_placanja" id="nacin-placanja-sel" style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:13px;" onchange="ntechNacinPlacanjaBroj(this)">
<option value="Gotovina">Gotovina</option> <option value="Gotovina">Gotovina</option>
@@ -150,7 +170,11 @@
style="width:16px;height:16px;accent-color:var(--sb-akcent);cursor:pointer;flex-shrink:0;"> style="width:16px;height:16px;accent-color:var(--sb-akcent);cursor:pointer;flex-shrink:0;">
<span style="font-size:13px;color:var(--tekst-glavni);">Prikaži fiskalni račun</span> <span style="font-size:13px;color:var(--tekst-glavni);">Prikaži fiskalni račun</span>
</label> </label>
{{if gt $zaNaplatu 0.0}}
<button type="submit" id="btn-naplati" class="btn-primarno" style="width:100%;opacity:0.4;cursor:not-allowed;" disabled>Naplati i preuzmi</button> <button type="submit" id="btn-naplati" class="btn-primarno" style="width:100%;opacity:0.4;cursor:not-allowed;" disabled>Naplati i preuzmi</button>
{{else}}
<button type="submit" id="btn-naplati" class="btn-primarno" style="width:100%;">Preuzmi</button>
{{end}}
</form> </form>
<script> <script>
(function(){ (function(){
@@ -190,11 +214,27 @@
f.addEventListener('submit', function(){ f.addEventListener('submit', function(){
var cb = f.querySelector('#prikazi-racun'); var cb = f.querySelector('#prikazi-racun');
if (!cb || !cb.checked) return; if (!cb || !cb.checked) return;
// otvori prazan imenovani tab SINHRONO unutar submit-a (očuvan user
// gesture) — isti obrazac kao pripremiFiskalniTab u Prodaji (ntech.js).
// Naplata ovde ide preko AJAX-a (data-refresh), pa se popunjava tek
// kad MutationObserver javi da je swap gotov — a to je već van user
// gesture-a, pa direktan window.open() u tom trenutku brauzer tretira
// kao blokiran popup i otvara about:blank (v. docs/Greške.md).
var tab = window.open('', 'ntech-fiskalni-tab');
var sadrzaj = document.getElementById('sadrzaj'); var sadrzaj = document.getElementById('sadrzaj');
if (!sadrzaj) return; if (!sadrzaj) {
if (tab) tab.close();
return;
}
var observer = new MutationObserver(function(){ var observer = new MutationObserver(function(){
observer.disconnect(); observer.disconnect();
// jedan prozor — server u istu stranicu ubacuje i povraćaj viška
// avansa (ako postoji), da brauzer ne blokira drugi popup
if (tab && !tab.closed) {
tab.location.href = '/servis/{{.Nalog.ID}}/fiskalni-racun';
} else {
window.open('/servis/{{.Nalog.ID}}/fiskalni-racun', '_blank'); window.open('/servis/{{.Nalog.ID}}/fiskalni-racun', '_blank');
}
}); });
observer.observe(sadrzaj.parentElement, {childList: true}); observer.observe(sadrzaj.parentElement, {childList: true});
}); });
@@ -325,7 +365,7 @@
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Garancija</div> <div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Garancija</div>
<div style="font-size:14px;{{if not .BezGarancije}}color:#16a34a;font-weight:500;{{else}}color:var(--tekst-sporedni);{{end}}"> <div style="font-size:14px;{{if not .BezGarancije}}color:#16a34a;font-weight:500;{{else}}color:var(--tekst-sporedni);{{end}}">
{{garancijaTekst .Nalog.GarancijaDana}} {{garancijaTekst .Nalog.GarancijaDana}}
{{if index .Dozvole "servis.izmeni"}} {{if and (index .Dozvole "servis.izmeni") (ne .Nalog.Status "Preuzeto")}}
<button type="button" class="izmeni-polje" onclick="ntechOtvoriModal(this)">Promeni</button> <button type="button" class="izmeni-polje" onclick="ntechOtvoriModal(this)">Promeni</button>
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()"> <dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
<form method="dialog" class="premesti-zaglavlje"> <form method="dialog" class="premesti-zaglavlje">
@@ -366,7 +406,7 @@
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Serviser</div> <div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Serviser</div>
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);"> <div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">
{{if .TehnicarNaziv}}{{.TehnicarNaziv}}{{else}}<span style="color:var(--tekst-sporedni);font-weight:normal;">— nedodeljen —</span>{{end}} {{if .TehnicarNaziv}}{{.TehnicarNaziv}}{{else}}<span style="color:var(--tekst-sporedni);font-weight:normal;">— nedodeljen —</span>{{end}}
{{if index .Dozvole "servis.izmeni"}} {{if and (index .Dozvole "servis.izmeni") (ne .Nalog.Status "Preuzeto")}}
<button type="button" class="izmeni-polje" onclick="ntechOtvoriModal(this)">Promeni</button> <button type="button" class="izmeni-polje" onclick="ntechOtvoriModal(this)">Promeni</button>
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()"> <dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
<form method="dialog" class="premesti-zaglavlje"> <form method="dialog" class="premesti-zaglavlje">
@@ -977,9 +1017,14 @@
{{if .FiskalniRacun}} {{if .FiskalniRacun}}
<div class="kartica detalji-kartica animiraj"> <div class="kartica detalji-kartica animiraj">
<div style="margin-bottom:12px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;"> <div style="margin-bottom:12px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Fiskalni račun</span> <span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">
{{if eq .FiskalniRacun.TipRacuna "Advance"}}Avansni fiskalni račun{{else}}Fiskalni račun{{end}}
</span>
<a href="/servis/{{.Nalog.ID}}/fiskalni-racun" target="_blank" class="btn-sekundarno" style="font-size:12px;padding:4px 12px;">Štampaj kopiju</a> <a href="/servis/{{.Nalog.ID}}/fiskalni-racun" target="_blank" class="btn-sekundarno" style="font-size:12px;padding:4px 12px;">Štampaj kopiju</a>
</div> </div>
{{if eq .FiskalniRacun.TipRacuna "Advance"}}
<div style="font-size:12px;color:var(--tekst-sporedni);margin:-4px 0 10px 0;">Ovo je račun za primljeni avans — konačan račun se izdaje pri preuzimanju uređaja.</div>
{{end}}
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start;"> <div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start;">
<div style="flex:1;min-width:200px;"> <div style="flex:1;min-width:200px;">
<p style="margin:0 0 4px 0;"><strong>PFR broj:</strong> {{.FiskalniRacun.PfrBroj}}</p> <p style="margin:0 0 4px 0;"><strong>PFR broj:</strong> {{.FiskalniRacun.PfrBroj}}</p>
@@ -1000,12 +1045,57 @@
</div> </div>
{{end}} {{end}}
{{if .AvansRacun}}
<div class="kartica detalji-kartica animiraj">
<div style="margin-bottom:12px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Avansni fiskalni račun</span>
<a href="/servis/{{.Nalog.ID}}/fiskalni-racun?tip=avans" target="_blank" class="btn-sekundarno" style="font-size:12px;padding:4px 12px;">Štampaj kopiju</a>
</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin:-4px 0 10px 0;">Račun za primljeni avans, izdat pre preuzimanja uređaja.</div>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start;">
<div style="flex:1;min-width:200px;">
<p style="margin:0 0 4px 0;"><strong>PFR broj:</strong> {{.AvansRacun.PfrBroj}}</p>
<p style="margin:0 0 4px 0;"><strong>Vreme:</strong> {{.AvansRacun.PfrVreme}}</p>
<p style="margin:0 0 4px 0;color:var(--tekst-sporedno);"><strong>Brojač:</strong> {{.AvansRacun.Brojac}}</p>
</div>
{{if .AvansRacun.QRKod}}
<div>
<img src="data:image/png;base64,{{.AvansRacun.QRKod}}" alt="QR kod" style="width:25mm;height:25mm;">
</div>
{{end}}
</div>
</div>
{{end}}
{{if .PovracajAvansa}}
<div class="kartica detalji-kartica animiraj">
<div style="margin-bottom:12px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Povraćaj viška avansa</span>
<a href="/servis/{{.Nalog.ID}}/fiskalni-racun?tip=povracaj" target="_blank" class="btn-sekundarno" style="font-size:12px;padding:4px 12px;">Štampaj kopiju</a>
</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin:-4px 0 10px 0;">Avans je premašio konačnu cenu — razlika je vraćena klijentu posebnim fiskalnim dokumentom.</div>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start;">
<div style="flex:1;min-width:200px;">
<p style="margin:0 0 4px 0;"><strong>PFR broj:</strong> {{.PovracajAvansa.PfrBroj}}</p>
<p style="margin:0 0 4px 0;"><strong>Vreme:</strong> {{.PovracajAvansa.PfrVreme}}</p>
<p style="margin:0 0 4px 0;color:var(--tekst-sporedno);"><strong>Brojač:</strong> {{.PovracajAvansa.Brojac}}</p>
</div>
{{if .PovracajAvansa.QRKod}}
<div>
<img src="data:image/png;base64,{{.PovracajAvansa.QRKod}}" alt="QR kod" style="width:25mm;height:25mm;">
</div>
{{end}}
</div>
</div>
{{end}}
{{if .FiskalGreska}} {{if .FiskalGreska}}
<div class="kartica detalji-kartica animiraj" style="border:1.5px solid #f59e0b;background:color-mix(in srgb,#fef3c7 60%,var(--pozadina));"> <div class="kartica detalji-kartica animiraj" style="border:1.5px solid #f59e0b;background:color-mix(in srgb,#fef3c7 60%,var(--pozadina));">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
<div style="font-size:46px;line-height:1;color:#fdba74;flex-shrink:0;"></div>
<div style="flex:1;"> <div style="flex:1;">
<div style="font-size:14px;font-weight:600;color:#92400e;margin-bottom:4px;">Fiskalni račun nije izdat</div> <div style="font-size:14px;font-weight:600;color:#fdba74;margin-bottom:4px;">Fiskalni račun nije izdat</div>
<div style="font-size:13px;color:#78350f;">Veza sa ESIR/PFR serverom nije bila dostupna pri preuzimanju. Status naloga je promenjen, ali fiskalni račun nije poslat.</div> <div style="font-size:13px;color:#fdba74;">Veza sa ESIR/PFR serverom nije bila dostupna pri preuzimanju. Status naloga je promenjen, ali fiskalni račun nije poslat.</div>
</div> </div>
{{if index .Dozvole "servis.izmeni"}} {{if index .Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{.Nalog.ID}}/fiskalizuj"> <form method="POST" action="/servis/{{.Nalog.ID}}/fiskalizuj">
@@ -1017,6 +1107,96 @@
</div> </div>
{{end}} {{end}}
{{if .StornoBezRefunda}}
<div class="kartica detalji-kartica animiraj" style="border:1.5px solid #dc2626;background:color-mix(in srgb,#fee2e2 60%,var(--pozadina));">
<div style="display:flex;align-items:center;gap:14px;">
<div style="font-size:46px;line-height:1;color:#fca5a5;flex-shrink:0;"></div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;color:#fca5a5;margin-bottom:4px;">Storno bez fiskalnog refunda</div>
<div style="font-size:13px;color:#fca5a5;margin-bottom:10px;">Storno je upisan u NTech, ali fiskalni refund ka PFR uređaju nije uspeo (originalni fiskalni račun i dalje nije označen kao storniran).</div>
{{if index .Dozvole "servis.storno"}}
<form method="POST" action="/servis/{{.Nalog.ID}}/refund-fiskalizuj" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
{{if .PotrebnaIdentKupcaRetry}}
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="padding:7px 10px;border:1px solid #dc2626;border-radius:6px;font-size:13px;">
{{end}}
<button type="submit" style="padding:8px 16px;background:#dc2626;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;">Pokušaj refund ponovo</button>
</form>
{{end}}
</div>
</div>
</div>
{{end}}
<!-- storno -->
{{if and (index .Dozvole "servis.storno") (eq .Nalog.Status "Preuzeto") (not .Nalog.Stornirano)}}
<div class="kartica detalji-kartica animiraj" style="border-color:#dc262633;">
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;">
<div style="font-size:14px;font-weight:500;color:#f97316;margin-bottom:4px;">Storniranje naloga</div>
<div class="pomocni-tekst">
Storno vraća ugrađene delove na stanje u magacinu. Nalog ostaje u evidenciji sa oznakom storniran.
</div>
</div>
<button type="button" class="btn-upozorenje" onclick="ntechPotvrdiPaOtvoriStorno(this, 'Stornirati nalog {{.Nalog.BrojNaloga}}?')">
Storniraj nalog
</button>
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
<form method="dialog" class="premesti-zaglavlje">
<h3>Storniranje naloga {{.Nalog.BrojNaloga}}</h3>
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">&times;</button>
</form>
<div style="display:flex;flex-direction:column;gap:10px;padding:16px;">
<form method="POST" action="/servis/storno/{{.Nalog.ID}}" data-refresh="#sadrzaj" style="display:flex;flex-direction:column;gap:10px;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div>
<label class="polje-labela">Razlog storniranja (opciono)</label>
<input type="text" name="razlog" placeholder="Razlog storniranja" style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
{{if .PotrebnaIdentKupca}}
<div>
<label class="polje-labela">PIB ili JMBG kupca</label>
<input type="text" id="storno-pib" name="poreski_broj_kupca" placeholder="Obavezno za fiskalni refund" required
style="width:100%;padding:8px 12px;border:1px solid #dc2626;border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
{{end}}
<button type="submit" id="btn-storno-potvrdi" class="btn-upozorenje" style="width:100%;{{if .PotrebnaIdentKupca}}opacity:0.4;cursor:not-allowed;{{end}}" {{if .PotrebnaIdentKupca}}disabled{{end}}>
Potvrdi storno
</button>
</form>
</div>
</dialog>
</div>
</div>
<script>
(function(){
// storno: potvrda namere PRE otvaranja modala (dvostepeni tok), pa tek onda
// polje za PIB/JMBG (obavezno samo ako je nalog fiskalizovan — v. .PotrebnaIdentKupca).
// NE koristi ntechOtvoriModal ovde — ta funkcija snima početno stanje forme i
// auto-submituje na zatvaranje dialoga (za popup polja bez dugmeta Sačuvaj), što
// bi za storno značilo da se nalog stornira i pri slučajnom zatvaranju bez klika
// na „Potvrdi storno".
window.ntechPotvrdiPaOtvoriStorno = function (btn, poruka) {
window._ntechPotvrdi(poruka).then(function (ok) {
if (!ok) return;
var d = btn.nextElementSibling;
if (d && d.tagName === 'DIALOG') d.showModal();
});
};
var pib = document.getElementById('storno-pib');
var btnPotvrdi = document.getElementById('btn-storno-potvrdi');
if (pib && btnPotvrdi) {
pib.addEventListener('input', function () {
var ok = pib.value.trim() !== '';
btnPotvrdi.disabled = !ok;
btnPotvrdi.style.opacity = ok ? '1' : '0.4';
btnPotvrdi.style.cursor = ok ? 'pointer' : 'not-allowed';
});
}
})();
</script>
{{end}}
<!-- zona za brisanje --> <!-- zona za brisanje -->
{{/* Brisanje je moguće samo dok je „Primljeno" (greška pri unosu); čim uđe u proceduru naplaćuje se dijagnostika */}} {{/* Brisanje je moguće samo dok je „Primljeno" (greška pri unosu); čim uđe u proceduru naplaćuje se dijagnostika */}}
{{if eq .Nalog.Status "Primljeno"}} {{if eq .Nalog.Status "Primljeno"}}
@@ -152,11 +152,19 @@
<td></td> <td></td>
</tr> </tr>
{{end}} {{end}}
{{if .Nalog.DatumOdluke}}
<tr class="auto">
<td>Poziv klijenta (obaveštenje o dijagnozi)</td>
<td><strong>{{.Nalog.DatumOdluke.Format "02.01.2006."}}</strong> <span class="badge-auto">auto</span></td>
<td>Klijent odgovorio preko javne stranice (QR kod)</td>
</tr>
{{else}}
<tr> <tr>
<td>Poziv klijenta (obaveštenje o dijagnozi)</td> <td>Poziv klijenta (obaveštenje o dijagnozi)</td>
<td><span class="linija-unos"></span></td> <td><span class="linija-unos"></span></td>
<td></td> <td></td>
</tr> </tr>
{{end}}
{{if .Nalog.DatumOdluke}} {{if .Nalog.DatumOdluke}}
<tr class="auto"> <tr class="auto">
<td>Odobrenje klijenta</td> <td>Odobrenje klijenta</td>
@@ -152,9 +152,7 @@
<thead> <thead>
<tr> <tr>
<th>Opis rada</th> <th>Opis rada</th>
<th>Kol.</th> <th style="text-align:right;">Kol.</th>
<th>Cena{{if index .Moduli "pdv"}} sa PDV{{end}}</th>
<th>Ukupno{{if index .Moduli "pdv"}} sa PDV{{end}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -162,8 +160,6 @@
<tr> <tr>
<td>{{.Naziv}}</td> <td>{{.Naziv}}</td>
<td style="text-align:right;">{{printf "%.0f" .Kolicina}}</td> <td style="text-align:right;">{{printf "%.0f" .Kolicina}}</td>
<td style="text-align:right;">{{if index $.Moduli "pdv"}}{{dinari .CenaSaPdv}}{{else}}{{dinari .CenaKomada}}{{end}}</td>
<td>{{if index $.Moduli "pdv"}}{{dinari .UkupnoSaPdv}}{{else}}{{dinari .Ukupno}}{{end}}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
@@ -182,9 +178,7 @@
<tr> <tr>
<th>Naziv</th> <th>Naziv</th>
<th>Šifra</th> <th>Šifra</th>
<th>Kol.</th> <th style="text-align:right;">Kol.</th>
<th>Cena{{if index .Moduli "pdv"}} sa PDV{{end}}</th>
<th>Ukupno{{if index .Moduli "pdv"}} sa PDV{{end}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -194,8 +188,6 @@
<td>{{.ArtikalNaziv}}</td> <td>{{.ArtikalNaziv}}</td>
<td style="font-family:monospace;">{{.ArtikalSifra}}</td> <td style="font-family:monospace;">{{.ArtikalSifra}}</td>
<td style="text-align:right;">{{.Kolicina}}</td> <td style="text-align:right;">{{.Kolicina}}</td>
<td style="text-align:right;">{{if index $.Moduli "pdv"}}{{dinari .CenaSaPdv}}{{else}}{{dinari .CenaKomada}}{{end}}</td>
<td>{{if index $.Moduli "pdv"}}{{dinari .UkupnoSaPdv}}{{else}}{{dinari .Ukupno}}{{end}}</td>
</tr> </tr>
{{end}} {{end}}
{{end}} {{end}}