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 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"
_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"):
"""Učitava lokalizacioni fajl u dict."""
filename = f"locale_{lang}.properties"
@@ -646,3 +661,30 @@ def generate_report(report_data, lang="latin"):
lines.append(separator("=", W))
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
pillow
reportlab
+155 -1
View File
@@ -21,7 +21,7 @@ import socket
import urllib.parse
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 ──────────────────────────────────────────
PORT = 4566 # Teron standard port
@@ -38,6 +38,7 @@ QR_DIR = DATA_DIR / "qr"
RECEIPTS_DIR = DATA_DIR / "receipts"
LOG_FILE = DATA_DIR / "server.log"
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]:
d.mkdir(parents=True, exist_ok=True)
@@ -525,6 +526,151 @@ def resp_invoice_search(request_body=None):
]
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 ────────────────────────────────────────────────────
ROUTES = [
@@ -540,6 +686,9 @@ ROUTES = [
("GET", "api/invoices/:invoiceNumber", "invoice_by_number"),
("POST", "api/invoices/search", "invoice_search"),
("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 = {
@@ -555,6 +704,9 @@ HANDLERS = {
"invoice_by_request": resp_invoice_by_request,
"invoice_by_number": resp_invoice_by_number,
"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 ─────────────────────────────────────────────
@@ -619,6 +771,8 @@ class FiscalHandler(http.server.BaseHTTPRequestHandler):
result = handler(params.get("invoiceNumber", ""))
elif r_name == "invoice_search":
result = handler(self._read_body())
elif r_name == "financial_report_summary":
result = handler(self._read_body())
elif r_name in ("settings_post",):
self._read_body()
result = handler()
+9
View File
@@ -291,6 +291,9 @@ func main() {
r.Get("/podesavanja/fiskalizacija/test", h.TestFiskalizacije)
r.Get("/podesavanja/fiskalizacija/be-status", h.BeStatus)
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.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu)
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.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-servisa", h.KirBackfillServisa)
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")).Get("/pdv/kpr", h.PdvKpr)
@@ -380,6 +384,7 @@ func main() {
r.Get("/servis/izmeni/{id}", h.IzmeniNalog)
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.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}/radni-nalog", h.StampaRadnogNaloga)
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}/eskalacioni-list", h.StampaEskalacionogLista)
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}/garancija", h.AzurirajGaranciju)
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.storno")).Post("/prodaja/storno/{id}", h.StornoProdaje)
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}/fiskalni-racun", h.StampaFiskalnogProdaje)
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.Use(ntechmw.RequireSuperAdmin)
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.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)
Izmeni(ctx context.Context, s *model.PdvStopa) 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)
@@ -66,8 +69,9 @@ type PdvKirRepository interface {
DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error)
Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error)
Obrisi(ctx context.Context, id int64) error
// ObrisiPoIzvoru briše zapise vezane za dati izvor (npr. pri stornu prodaje)
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.PdvKir, error)
// PostojiZaIzvor vraća true ako postoji bar jedan zapis za dati izvor i izvorID
PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error)
// 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)
Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error)
Obrisi(ctx context.Context, id int64) error
// ObrisiPoIzvoru briše zapise vezane za dati izvor (npr. pri brisanju nabavke)
ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error
// DohvatiPoIzvoru vraća zapise vezane za dati izvor (npr. za storno nabavke
// 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(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
ObrisiOdlukuKlijenta(ctx context.Context, id 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)
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)
VremePoslednjeg(ctx context.Context, ip string, od time.Time) (time.Time, bool, 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
@@ -371,6 +383,8 @@ type IzvestajRepository interface {
// izveštaji
MesecniPrihodProdaja(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)
TopArtikli(ctx context.Context, limit int) ([]model.TopArtikalRed, 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)
DohvatiPoProdaji(ctx context.Context, prodajaID 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
ServisiBezFiskalnog(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)
Obrisi(ctx context.Context, id int64) 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
}
// 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"
// a nemaju fiskalni račun (za oznaku u listi naloga).
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")
}
// 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) {
rows, err := r.db.QueryContext(ctx, `
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()
}
// 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) {
rez, err := r.db.ExecContext(ctx, `
INSERT INTO kpo_unosi
(datum_prometa, broj_dokumenta, opis, prihod, nacin_placanja, napomena, izvor, izvor_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
(datum_prometa, redni_broj, broj_dokumenta, opis, prihod, nacin_placanja, napomena, izvor, izvor_id)
VALUES (?, (SELECT COALESCE(MAX(redni_broj), 0) + 1 FROM kpo_unosi), ?, ?, ?, ?, ?, ?, ?)`,
z.DatumPrometa.Format("2006-01-02"),
z.BrojDokumenta,
nullString(z.Opis),
@@ -115,10 +117,41 @@ func (r *KpoRepo) PostojiZaIzvor(ctx context.Context, izvor string, izvorID int6
return n > 0, nil
}
// ObrisiPoIzvoru briše KPO zapise vezane za dati izvor (npr. pri stornu prodaje).
func (r *KpoRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM kpo_unosi WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil {
return fmt.Errorf("ntech: KpoRepo.ObrisiPoIzvoru: %w", err)
// DohvatiPoIzvoru vraća sve KPO zapise vezane za dati izvor (npr. za storno prodaje).
func (r *KpoRepo) DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.KpoZapis, error) {
redovi, err := r.db.QueryContext(ctx, `
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
}
// ObrisiPoIzvoru briše KIR zapise vezane za dati izvor (npr. pri stornu/brisanju prodaje)
func (r *PdvKirRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kir WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil {
return fmt.Errorf("ntech: PdvKirRepo.ObrisiPoIzvoru: %w", err)
// DohvatiPoIzvoru vraća sve KIR zapise vezane za dati izvor (npr. za storno prodaje)
func (r *PdvKirRepo) DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKir, error) {
redovi, err := r.db.QueryContext(ctx, `
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
@@ -318,12 +338,32 @@ func (r *PdvKprRepo) Obrisi(ctx context.Context, id int64) error {
return nil
}
// ObrisiPoIzvoru briše KPR zapise vezane za dati izvor (npr. pri brisanju nabavke)
func (r *PdvKprRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kpr WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil {
return fmt.Errorf("ntech: PdvKprRepo.ObrisiPoIzvoru: %w", err)
// DohvatiPoIzvoru vraća sve KPR zapise vezane za dati izvor (npr. za storno nabavke)
func (r *PdvKprRepo) DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKpr, error) {
redovi, err := r.db.QueryContext(ctx, `
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
+6 -6
View File
@@ -162,12 +162,12 @@ func TestPdvKirIzvor(t *testing.T) {
}
}
// ObrisiPoIzvoru briše samo vezani auto zapis, ručni ostaje
if err := repo.ObrisiPoIzvoru(ctx, "prodaja", 7); err != nil {
t.Fatalf("ObrisiPoIzvoru: %v", err)
// DohvatiPoIzvoru vraća samo vezani auto zapis
poIzvoru, err := repo.DohvatiPoIzvoru(ctx, "prodaja", 7)
if err != nil {
t.Fatalf("DohvatiPoIzvoru: %v", err)
}
preostali, _ := repo.Lista(ctx, time.Time{}, time.Time{})
if len(preostali) != 1 || preostali[0].BrojDokumenta != "R-1" {
t.Errorf("posle ObrisiPoIzvoru očekivan samo ručni zapis, dobijeno %d", len(preostali))
if len(poIzvoru) != 1 || poIzvoru[0].BrojDokumenta != "P-1" {
t.Errorf("očekivan samo auto zapis P-1, dobijeno %d", len(poIzvoru))
}
}
+22
View File
@@ -78,6 +78,28 @@ func (r *PdvStopaRepo) Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, e
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)
func (r *PdvStopaRepo) Izmeni(ctx context.Context, s *model.PdvStopa) error {
_, err := r.db.ExecContext(ctx, `
+50
View File
@@ -5,6 +5,8 @@ import (
"database/sql"
"fmt"
"time"
"ntech/internal/model"
)
// PokusajiPrijaveRepo implementira db.PokusajiPrijaveRepository nad SQLite
@@ -77,3 +79,51 @@ func (r *PokusajiPrijaveRepo) ObrisiStare(ctx context.Context, pre time.Time) er
}
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]
var naziv, tip string
var stanjePre int
var nabavnaCena float64
err := tx.QueryRowContext(ctx,
"SELECT naziv, kolicina, tip FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&naziv, &stanjePre, &tip)
"SELECT naziv, kolicina, tip, nabavna_cena FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&naziv, &stanjePre, &tip, &nabavnaCena)
if err != nil {
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
_, err = tx.ExecContext(ctx, `
INSERT INTO stavke_prodaje
(nalog_id, artikal_id, kolicina, cena_po_komadu, popust_procenat, ukupno, pdv_stopa, pdv_iznos, cena_bez_pdv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(nalog_id, artikal_id, kolicina, cena_po_komadu, popust_procenat, ukupno, pdv_stopa, pdv_iznos, cena_bez_pdv, nabavna_cena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nalogID, s.ArtikalID, s.Kolicina, s.CenaPoKomadu, s.PopustProcenat, ukupnoStavke,
s.PdvStopa, pdvIznos, cenaBezPdv,
s.PdvStopa, pdvIznos, cenaBezPdv, nabavnaCena,
)
if err != nil {
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.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.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,
(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)
@@ -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,
opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna,
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)
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,
opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna,
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)
var n model.ServisniNalog
@@ -461,6 +461,86 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er
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.
func (r *ServisRepo) SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error {
_, 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 naplaceno sql.NullFloat64
var stornirano sql.NullInt64
var razlogStorniranja sql.NullString
args := []any{
&n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
&n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
&avans, &napomena, &garancijaDo, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum,
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta, &odlukaKlijenta, &datumOdluke,
&nacinPlacanja, &naplaceno,
&nacinPlacanja, &naplaceno, &stornirano, &razlogStorniranja,
}
if klijentNaziv != nil {
@@ -565,6 +647,8 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
if naplaceno.Valid {
n.Naplaceno = naplaceno.Float64
}
n.Stornirano = stornirano.Int64 != 0
n.RazlogStorniranja = razlogStorniranja.String
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
var stanjePre int
var nabavnaCena float64
if err = tx.QueryRowContext(ctx,
"SELECT kolicina FROM artikli WHERE id = ?", artikalID,
).Scan(&stanjePre); err != nil {
"SELECT kolicina, nabavna_cena FROM artikli WHERE id = ?", artikalID,
).Scan(&stanjePre, &nabavnaCena); err != nil {
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)
if errRed == nil {
if _, err = tx.ExecContext(ctx,
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
postojeciKol+ugradjeno, cenaKomada, postojeciID,
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ?, nabavna_cena = ? WHERE id = ?",
postojeciKol+ugradjeno, cenaKomada, nabavnaCena, postojeciID,
); err != nil {
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: merge: %w", err)
}
} else if errors.Is(errRed, sql.ErrNoRows) {
if _, err = tx.ExecContext(ctx,
"INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 0)",
nalogID, artikalID, ugradjeno, cenaKomada,
"INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno, nabavna_cena) VALUES (?, ?, ?, ?, 0, ?)",
nalogID, artikalID, ugradjeno, cenaKomada, nabavnaCena,
); err != nil {
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
// 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 {
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 postojeciKol int
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)
if cenaKomada > 0 {
_, err = tx.ExecContext(ctx,
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
postojeciKol+kolicina, cenaKomada, postojeciID,
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ?, nabavna_cena = ? WHERE id = ?",
postojeciKol+kolicina, cenaKomada, nabavnaCena, postojeciID,
)
} else {
_, err = tx.ExecContext(ctx,
@@ -223,9 +230,9 @@ func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sq
}
if errors.Is(err, sql.ErrNoRows) {
_, err = tx.ExecContext(ctx, `
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada)
VALUES (?, ?, ?, ?)`,
nalogID, artikalID, kolicina, cenaKomada,
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, nabavna_cena)
VALUES (?, ?, ?, ?, ?)`,
nalogID, artikalID, kolicina, cenaKomada, nabavnaCena,
)
if err != nil {
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
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, `
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
LEFT JOIN usluge u ON u.id = sr.usluga_id
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 {
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.
@@ -147,6 +153,151 @@ func (k *Klijent) IzdajRacun(ctx context.Context, zahtev InvoiceRequest) (*Invoi
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.
func (k *Klijent) dodajAuth(req *http.Request) {
if k.APIKey != "" {
+49 -4
View File
@@ -64,10 +64,11 @@ func NapraviZahtev(
ukupanIznos := 0.0
for _, s := range stavke {
stopa := 20.0 // podrazumevano 20% ako stopa nije eksplicitno postavljena
if s.PdvStopa > 0 {
stopa = s.PdvStopa
}
// stopa je uvek ono što je snimljeno na stavci — 0 je legitimna vrednost
// (van sistema PDV/neobveznik), ne „nije postavljeno". Nikad je ne prepisivati
// 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);
// popust se ponovo primenjuje samo u fallback grani kad CenaBezPdv nije popunjena.
netoCena := s.CenaBezPdv
@@ -142,3 +143,47 @@ func NapraviRefundZahtev(
zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj
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)
}
// 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.
// 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) {
+36
View File
@@ -57,6 +57,9 @@ type MesecniPrihod struct {
Prodaja float64
Servis float64
Ukupno float64
Zarada float64
ZaradaProdaja float64
ZaradaServis float64
}
// 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)
var mesecniPrihodi []MesecniPrihod
var grafikonLabele []string
var grafikonProdaja []float64
var grafikonServis []float64
var grafikonZaradaProdaja []float64
var grafikonZaradaServis []float64
for _, kljuc := range kljuceviMeseci(time.Now(), 12) {
prod := prodajaPoMesecu[kljuc]
serv := servisPoMesecu[kljuc]
zaradaProd := zaradaProdajaPoMesecu[kljuc]
zaradaServ := zaradaServisPoMesecu[kljuc]
mesecniPrihodi = append(mesecniPrihodi, MesecniPrihod{
MesecPrikaz: formatujMesec(kljuc),
Prodaja: prod,
Servis: serv,
Ukupno: prod + serv,
Zarada: zaradaProd + zaradaServ,
ZaradaProdaja: zaradaProd,
ZaradaServis: zaradaServ,
})
grafikonLabele = append(grafikonLabele, formatujMesec(kljuc))
grafikonProdaja = append(grafikonProdaja, prod)
grafikonServis = append(grafikonServis, serv)
grafikonZaradaProdaja = append(grafikonZaradaProdaja, zaradaProd)
grafikonZaradaServis = append(grafikonZaradaServis, zaradaServ)
}
type grafikonPodaci struct {
Labele []string `json:"labele"`
Prodaja []float64 `json:"prodaja"`
Servis []float64 `json:"servis"`
ZaradaProdaja []float64 `json:"zaradaProdaja"`
ZaradaServis []float64 `json:"zaradaServis"`
}
jsonBytes, _ := json.Marshal(grafikonPodaci{
Labele: grafikonLabele,
Prodaja: grafikonProdaja,
Servis: grafikonServis,
ZaradaProdaja: grafikonZaradaProdaja,
ZaradaServis: grafikonZaradaServis,
})
// --- 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)
var saSidebar = []string{
"admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole",
"admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole", "blokirane_ip",
"dashboard",
"dobavljaci", "dobavljac_forma",
"izvestaji", "prometni_list", "stanje_zaliha", "popis",
@@ -28,6 +28,7 @@ var saSidebar = []string{
"magacin", "magacin_forma", "magacin_kartica",
"nabavke", "nabavka_forma", "nabavka_detalji",
"podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", "podesavanja_servis", "podesavanja_fiskalizacija",
"fiskal_pazar",
"pdv_stope",
"pdv_kir", "pdv_kir_forma", "pdv_kir_dnevni_pazar",
"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,
Kategorije: kategorije,
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,
})
}
@@ -79,7 +79,7 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
return
}
artikal, greska := parseFormuArtikla(r)
artikal, greska := parseFormuArtikla(r, h.podrazumevanaPdvStopa(r.Context()))
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
@@ -210,7 +210,7 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
return
}
artikal, greska := parseFormuArtikla(r)
artikal, greska := parseFormuArtikla(r, h.podrazumevanaPdvStopa(r.Context()))
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
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
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")
if naziv == "" {
return model.Artikal{}, "Naziv artikla je obavezan."
@@ -389,8 +391,8 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
artikal.NabavnaCena = v
}
// PDV stopa — podrazumevano 20%
pdvStopa := 20.0
// PDV stopa — podrazumevano opšta stopa iz šifarnika
pdvStopa := podrazumevanaStopa
if p := r.FormValue("pdv_stopa"); p != "" {
if v, err := strconv.ParseFloat(p, 64); err == nil && v >= 0 {
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)
return
}
// stornirana nabavka ne ulazi u PDV — ukloni vezani auto-KPR zapis
if err := h.PdvKprRepo.ObrisiPoIzvoru(r.Context(), "nabavka", id); err != nil {
slog.Error("brisanje vezanog KPR zapisa nije uspelo", "nabavka_id", id, "error", err)
// stornirana nabavka se ne briše iz KPR (zakon o računovodstvu ne dozvoljava
// brisanje poslovnih knjiga) — dograđuje se storno stavka koja poništava original
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)
+9 -9
View File
@@ -22,7 +22,7 @@ func TestParseFormuArtikla(t *testing.T) {
t.Run("validan", func(t *testing.T) {
a, greska := parseFormuArtikla(formZahtev(url.Values{
"naziv": {"Pumpa"}, "kolicina": {"10"}, "prodajna_cena": {"1500.50"},
}))
}), 20.0)
if 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) {
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.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 == "" {
t.Fatal("negativna količina mora dati grešku")
}
})
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 == "" {
t.Fatal("neispravna cena mora dati grešku")
}
@@ -91,7 +91,7 @@ func TestParseFormuKlijenta(t *testing.T) {
func TestParseFormuProdaje(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 == "" {
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) {
_, _, greska := parseFormuProdaje(formZahtev(url.Values{
"artikal_id[]": {"1"}, "kolicina[]": {"0"}, "cena_po_komadu[]": {"100"},
}))
}), map[float64]bool{0: true, 10: true, 20: true})
if greska == "" {
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) {
_, _, greska := parseFormuProdaje(formZahtev(url.Values{
"artikal_id[]": {"0"}, "kolicina[]": {"1"}, "cena_po_komadu[]": {"100"},
}))
}), map[float64]bool{0: true, 10: true, 20: true})
if greska == "" {
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) {
nalog, stavke, greska := parseFormuProdaje(formZahtev(url.Values{
"artikal_id[]": {"3"}, "kolicina[]": {"2"}, "cena_po_komadu[]": {"250"}, "pdv_stopa[]": {"20"},
}))
}), map[float64]bool{0: true, 10: true, 20: true})
if 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) {
_, _, greska := parseFormuProdaje(formZahtev(url.Values{
"artikal_id[]": {"1", "2"}, "kolicina[]": {"1"}, "cena_po_komadu[]": {"100"},
}))
}), map[float64]bool{0: true, 10: true, 20: true})
if greska == "" {
t.Fatal("nesklad broja stavki mora dati grešku")
}
+89 -2
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
appdb "ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
@@ -22,9 +23,10 @@ type PodaciPdvKir struct {
model.PodaciStranice
Zapisi []model.PdvKir
Sume model.PdvKirSume
Od string // filter perioda (YYYY-MM-DD), prazno = bez granice
Od 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
@@ -105,6 +107,21 @@ func (h *Handler) PdvKir(w http.ResponseWriter, r *http.Request) {
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "pdv-kir"
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{
PodaciStranice: ps,
Zapisi: zapisi,
@@ -112,6 +129,7 @@ func (h *Handler) PdvKir(w http.ResponseWriter, r *http.Request) {
Od: odStr,
Do: doStr,
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)
}
// 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)
type PodaciDnevniPazar struct {
model.PodaciStranice
+27
View File
@@ -285,6 +285,20 @@ func validirajMaticniBroj(broj string) string {
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
func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok {
@@ -320,6 +334,19 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
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{
"naziv_firme": r.FormValue("naziv_firme"),
"podnazlov": r.FormValue("podnazlov"),
+107 -32
View File
@@ -54,7 +54,9 @@ type PodaciDetaljiProdaje struct {
KlijentNaziv string
FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano
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)
PotrebnaIdentKupcaRetry bool // isto, ali za retry dugme na već storniranom nalogu (StornoBezRefunda)
Sacuvano bool
}
@@ -243,7 +245,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
return
}
nalog, stavke, greska := parseFormuProdaje(r)
nalog, stavke, greska := parseFormuProdaje(r, h.validnePdvStope(r.Context()))
renderujGresku := func(poruka string) {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
@@ -410,7 +412,9 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
KlijentNaziv: klijentNaziv,
FiskalniRacun: fr,
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,
PotrebnaIdentKupcaRetry: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && nalog.Stornirano && fr != nil && fr.TipTransakcije == "Sale" && !fr.Storniran && !imaIdentKupca,
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
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
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."
}
}
if pdvStopa != 0 && pdvStopa != 10 && pdvStopa != 20 {
return nalog, nil, "PDV stopa mora biti 0, 10 ili 20."
if !validneStope[pdvStopa] {
return nalog, nil, "PDV stopa u stavci nije u šifarniku PDV stopa."
}
var popust float64
@@ -687,17 +693,68 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
return err
}
// stornirana prodaja ne ulazi u PDV — ukloni vezani auto-KIR zapis
if err := h.PdvKirRepo.ObrisiPoIzvoru(ctx, "prodaja", id); err != nil {
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
// stornirana prodaja se ne briše iz KIR (zakon o računovodstvu ne dozvoljava
// brisanje poslovnih knjiga) — dograđuje se storno stavka koja poništava original
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
if h.modulUkljucen(ctx, config.ModulFiskalizacija) {
if fr, _ := h.FiskalRepo.DohvatiPoProdaji(ctx, id); fr != nil && !fr.Storniran {
if fk := h.fiskalKlijent(); fk != nil {
if nalogFisk, e := h.ProdajaRepo.DohvatiID(ctx, id); e == nil {
if stavkeFisk, e2 := h.ProdajaRepo.DohvatiStavke(ctx, id); e2 == nil {
if err := h.posaljiFiskalniRefundProdaje(ctx, id, poreskiBrojKupca); err != nil {
slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", err)
}
}
// 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)
var pibFisk, jmbgFisk string
if nalogFisk.KlijentID != nil {
@@ -707,11 +764,15 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
}
if pibFisk == "" && jmbgFisk == "" {
pibFisk, jmbgFisk = klasifikujPoreskiBroj(poreskiBrojKupca)
if pibFisk == "" && jmbgFisk == "" {
return errPotrebnaIdentKupca
}
}
zahtevFisk := fiskal.NapraviRefundZahtev(*nalogFisk, stavkeFisk, pibFisk, jmbgFisk, kasirFisk, fr.PfrBroj)
if odgFisk, errFisk := fk.IzdajRacun(ctx, zahtevFisk); errFisk != nil {
slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", errFisk)
} else {
odgFisk, err := fk.IzdajRacun(ctx, zahtevFisk)
if err != nil {
return err
}
poreskeJSON, _ := json.Marshal(odgFisk.TaxItems)
siroviJSON, _ := json.Marshal(odgFisk)
refundFr := &model.FiskalniRacun{
@@ -732,26 +793,40 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
Zatrazio: odgFisk.RequestedBy,
Poruka: odgFisk.Messages,
}
if _, errFisk = h.FiskalRepo.Kreiraj(ctx, refundFr); errFisk != nil {
slog.Error("čuvanje fiskalnog refunda nije uspelo", "prodaja_id", id, "error", errFisk)
if _, err := h.FiskalRepo.Kreiraj(ctx, refundFr); err != nil {
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 {
_ = h.FiskalRepo.OznačiKaoStorniran(ctx, fr.ID)
middleware.SetFlash(w, r, h.DB, "uspeh", "Fiskalni refund je uspešno poslat.")
}
}
}
}
}
}
}
// 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
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// 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
// 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) {
nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
svi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
if err != nil {
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))
var kandidati []kandidatUskladjivanja
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))
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)
} else {
nenaplaceniBroj++
@@ -131,6 +139,36 @@ func (h *Handler) kpoKandidatiServisa(ctx context.Context) (naplaceni []model.Se
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
// upis prema TRENUTNO aktivnim modulima firme, i naloge koji su sumnjivi duplikati
// (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
if h.modulUkljucen(ctx, "pdv") {
_, postojiMap, kandidati, err := h.kirKandidatiProdaje(ctx)
_, postojiMapP, kandidatiP, err := h.kirKandidatiProdaje(ctx)
if err != nil {
return p, err
}
duplikati := sumnjiviDuplikati(kandidati)
for id, postoji := range postojiMap {
_, postojiMapS, kandidatiS, err := h.kirKandidatiServisa(ctx)
if err != nil {
return p, err
}
duplikatiP := sumnjiviDuplikati(kandidatiP)
duplikatiS := sumnjiviDuplikati(kandidatiS)
for id, postoji := range postojiMapP {
if postoji {
continue
}
if duplikati[id] {
if duplikatiP[id] {
p.SumnjiviDupli++
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") {
@@ -174,7 +229,7 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
p.SumnjiviDupli++
continue
}
p.BezKpo++
p.BezKpoProdajaID = append(p.BezKpoProdajaID, id)
}
for id, postoji := range postojiMapS {
if postoji {
@@ -184,38 +239,86 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
p.SumnjiviDupli++
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 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 {
p.BezFiskalnog += len(m)
for id := range m {
p.BezFiskalnogServisID = append(p.BezFiskalnogServisID, id)
}
if n, err := h.stornoBezRefunda(ctx); err == nil {
p.BezRefunda = n
p.BezFiskalnogServis = len(p.BezFiskalnogServisID)
}
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
}
// stornoBezRefunda broji stornirane prodajne naloge čiji je originalni fiskalni
// račun i dalje neoznačen kao storniran — znači da je storno u bazi prošao, ali
// fiskalni refund ka ESIR/L-PFR uređaju NIJE uspeo (best-effort poziv je pao).
// Servis nema storno funkciju — obuhvaćena je samo prodaja.
func (h *Handler) stornoBezRefunda(ctx context.Context) (int, error) {
var n int
err := h.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM prodajni_nalozi pn
// stornoBezRefunda vraća ID-jeve storniranih prodajnih i servisnih naloga čiji je
// originalni fiskalni račun i dalje neoznačen kao storniran — znači da je storno u
// bazi prošao, ali fiskalni refund ka ESIR/L-PFR uređaju NIJE uspeo (best-effort
// poziv je pao).
func (h *Handler) stornoBezRefunda(ctx context.Context) (prodajaIDs, servisIDs []int64, err error) {
redoviP, err := h.DB.QueryContext(ctx, `
SELECT DISTINCT pn.id FROM prodajni_nalozi pn
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
`).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 {
+7 -5
View File
@@ -63,7 +63,7 @@ func (h *Handler) NovaUsluga(w http.ResponseWriter, r *http.Request) {
if err != nil {
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
@@ -86,7 +86,7 @@ func (h *Handler) SacuvajUslugu(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "artikal.dodaj"); !ok {
return
}
usluga, greska := parseFormuUsluge(r)
usluga, greska := parseFormuUsluge(r, h.podrazumevanaPdvStopa(r.Context()))
if greska != "" {
h.renderujFormuUsluge(w, r, usluga, false, greska)
return
@@ -108,7 +108,7 @@ func (h *Handler) SacuvajIzmenuUsluge(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Neispravan ID usluge", http.StatusBadRequest)
return
}
usluga, greska := parseFormuUsluge(r)
usluga, greska := parseFormuUsluge(r, h.podrazumevanaPdvStopa(r.Context()))
usluga.ID = id
if 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.
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 {
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)
if err != nil || pdv < 0 {
pdv = 20
pdv = podrazumevanaStopa
}
jm := strings.TrimSpace(r.FormValue("jedinica_mere"))
+10 -1
View File
@@ -24,6 +24,7 @@ var sveAkcije = []string{
"servis.dodaj",
"servis.izmeni",
"servis.obrisi",
"servis.storno",
"prodaja.pregled",
"prodaja.dodaj",
"prodaja.storno",
@@ -45,6 +46,9 @@ var sveAkcije = []string{
"kpo.pregled",
"kpo.dodaj",
"kpo.obrisi",
"fiskal.pazar",
"fiskal.zakljucenje",
"bezbednost.odblokiraj",
}
// 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
// servis
case "servis.pregled", "servis.dodaj",
"servis.izmeni", "servis.obrisi":
"servis.izmeni", "servis.obrisi", "servis.storno":
return true
// prodaja
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
case "kpo.pregled", "kpo.dodaj", "kpo.obrisi":
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
+21
View File
@@ -22,3 +22,24 @@ type KpoSume struct {
Prihod float64
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
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
}
// 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).
type PdvKpr struct {
ID int64
@@ -153,6 +215,33 @@ type PdvKpr struct {
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).
func (k PdvKpr) OznakaPoreskogBroja() string {
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) {
nabavka := Nabavka{ID: 3, Napomena: "test", Datum: time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)}
stavke := []NabavkaStavkaPdv{
+2
View File
@@ -60,6 +60,8 @@ type ServisniNalog struct {
JavniToken string
NacinPlacanja string // način plaćanja pri preuzimanju (Gotovina, Kartica, Virman...)
Naplaceno float64 // iznos koji je naplaćen pri preuzimanju
Stornirano bool
RazlogStorniranja string
}
// 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
// se kao kartica na dashboard-u.
type PrazninaKnjigovodstva struct {
BezKir int
BezKpo int
BezFiskalnog int
BezRefunda int // stornirani nalozi čiji fiskalni refund nije uspeo (best-effort poziv pao)
BezKirProdaja int // B2B prodaje bez KIR upisa
BezKirServis int // B2B servisi bez KIR upisa
BezKirProdajaID []int64 // ID-jevi — za direktan link sa dashboard-a
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
}
// 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
// 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).
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.klijenti = window._ntechKlijenti || []
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
window.matchMedia('(max-width: 768px)').addEventListener('change', e => {
this.isMobile = e.matches
+20 -5
View File
@@ -100,9 +100,10 @@
{{/* Knjigovodstvo — grupa: KIR/KPR/Obračun (modul „pdv") + Nivelacije (dozvola izmene cena) */}}
{{$pdvVid := and (index .Moduli "pdv") (index .Dozvole "pdv.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}}
{{$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>
<button type="button" data-podmeni-dugme
class="nav-stavka {{if $knjAktivan}}aktivan{{end}}"
@@ -149,6 +150,13 @@
<span class="nav-tooltip">PDV obračun za period</span>
</a>
{{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>
{{end}}
@@ -207,19 +215,19 @@
{{if index .Dozvole "podesavanja.pregled"}}
<div>
<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;">
<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 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"
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"/>
</svg>
</span>
<span class="nav-tooltip">Podešavanja</span>
</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}}">
<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>
@@ -257,6 +265,13 @@
<span class="nav-tooltip">Dozvole</span>
</a>
{{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>
{{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 }}
<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;gap:12px;">
<div style="font-size:46px;line-height:1;color:#fdba74;flex-shrink:0;"></div>
<div>
<div style="font-weight:600;color:#92400e;">Knjigovodstvo — praznine u evidenciji</div>
<div style="font-size:13px;color:#92400e;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.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 }}
<div style="font-weight:600;color:#fdba74;">Knjigovodstvo — praznine u evidenciji</div>
<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.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.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 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.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.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 }}
{{ 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 — pokreni usklađivanje</a>{{ end }}
</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>
<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:right;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: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);">Prodaja</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:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Ukupno</th>
</tr>
</thead>
<tbody>
{{range .MesecniPrihodi}}
<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;text-align:right;font-size:13px;color:var(--tekst-sporedni);">
{{if gt .Prodaja 0.0}}{{dinariCeli .Prodaja}} din{{else}}—{{end}}
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-sporedni);">
{{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 style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">
{{if gt .Servis 0.0}}{{dinariCeli .Servis}} din{{else}}—{{end}}
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-sporedni);">
{{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 style="padding:8px 12px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">
{{if gt .Ukupno 0.0}}{{dinariCeli .Ukupno}} din{{else}}—{{end}}
<td style="padding:8px 12px;font-size:13px;font-weight:500;color:var(--tekst-glavni);">
{{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>
</tr>
{{end}}
@@ -207,18 +225,40 @@
labels: podaci.labele,
datasets: [
{
label: 'Prodaja',
label: 'Prodaja — ukupno',
data: podaci.prodaja,
backgroundColor: 'rgba(79, 126, 248, 0.75)',
backgroundColor: 'rgba(79, 126, 248, 0.35)',
borderRadius: 4,
borderSkipped: false,
stack: 'prodaja',
order: 2,
},
{
label: 'Servis',
label: 'Servis — ukupno',
data: podaci.servis,
backgroundColor: 'rgba(22, 163, 74, 0.75)',
backgroundColor: 'rgba(22, 163, 74, 0.35)',
borderRadius: 4,
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: {
x: {
stacked: false,
stacked: true,
ticks: { color: tekstBoja, font: { size: 11, family: 'DejaVu Sans, Ubuntu, system-ui, sans-serif' } },
grid: { color: ivicaBoja }
},
y: {
stacked: false,
ticks: {
color: tekstBoja,
font: { size: 11, family: 'DejaVu Sans, Ubuntu, system-ui, sans-serif' },
+51
View File
@@ -33,6 +33,13 @@
Uvezi iz prodaje
</button>
</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}}
<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>
@@ -101,5 +108,49 @@
</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>
{{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; }
@media print {
body { font-size: 11px; }
.strana { padding: 20px 28px; max-width: 780px; }
@page { size: A4 landscape; margin: 8mm 10mm; }
body { font-size: 10px; min-width: 0; }
.strana { padding: 0; max-width: 100%; }
@page { size: A4 portrait; margin: 8mm 10mm; }
.tabela th, .tabela td { padding: 3px 4px; }
.tabela tr { break-inside: avoid; }
.tabela thead { display: table-header-group; }
.dugme-stampa { display: none !important; }
+89 -35
View File
@@ -52,29 +52,6 @@
</span>
{{end}}
</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
style="
@@ -252,23 +229,66 @@
Storno vraća sve artikle na stanje u magacinu. Nalog ostaje u evidenciji sa oznakom storniran.
</div>
</div>
<form method="POST" action="/prodaja/storno/{{.Nalog.ID}}" style="display:flex;flex-direction:column;gap:8px;align-items:flex-end;">
<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}}?">
<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="/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>
</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}}
{{if .FiskalniRacun}}
<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="flex:1;min-width:200px;">
<p style="margin:0 0 4px 0;"><strong>PFR broj:</strong> {{.FiskalniRacun.PfrBroj}}</p>
@@ -296,14 +316,27 @@
}
})();
</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}}
{{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 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="font-size:14px;font-weight:600;color:#92400e;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:14px;font-weight:600;color:#fdba74;margin-bottom:4px;">Fiskalni račun nije izdat</div>
<div style="font-size:13px;color:#fdba74;">Veza sa ESIR/PFR serverom nije bila dostupna pri kreiranju prodaje.</div>
</div>
{{if index $.Dozvole "prodaja.dodaj"}}
<form method="POST" action="/prodaja/{{.Nalog.ID}}/fiskalizuj">
@@ -315,6 +348,27 @@
</div>
{{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>
@@ -21,6 +21,7 @@
var _ntechArtikli = {{.ArtikliJSON}};
var _ntechKlijenti = {{.KlijentiJSON}};
var _ntechPdvObveznik = {{if index .Moduli "pdv"}}true{{else}}false{{end}};
var _ntechFiskalizacija = {{if index .Moduli "fiskalizacija"}}true{{else}}false{{end}};
</script>
<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;">
{{.Nalog.BrojNaloga}}
</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}}
<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>
@@ -125,19 +130,34 @@
<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>
</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);">
<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)">
</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 id="kusur" style="font-size:16px;font-weight:600;color:var(--uspeh);">0,00 din</span>
</div>
<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="{{if index .Moduli "pdv"}}{{printf "%.2f" .PreostaloSveSaPdv}}{{else}}{{printf "%.2f" .PreostaloSve}}{{end}}">
<div style="margin-bottom:10px;">
{{else}}
<div style="margin-top:10px;padding-top:10px;border-top:0.5px solid var(--ivica);font-size:13px;color:var(--tekst-sporedni);">
Avans pokriva ceo iznos — ništa se dodatno ne naplaćuje.
</div>
{{end}}
<div style="margin-bottom:10px;margin-top:10px;">
<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)">
<option value="Gotovina">Gotovina</option>
@@ -150,7 +170,11 @@
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>
</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>
{{else}}
<button type="submit" id="btn-naplati" class="btn-primarno" style="width:100%;">Preuzmi</button>
{{end}}
</form>
<script>
(function(){
@@ -190,11 +214,27 @@
f.addEventListener('submit', function(){
var cb = f.querySelector('#prikazi-racun');
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');
if (!sadrzaj) return;
if (!sadrzaj) {
if (tab) tab.close();
return;
}
var observer = new MutationObserver(function(){
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');
}
});
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:14px;{{if not .BezGarancije}}color:#16a34a;font-weight:500;{{else}}color:var(--tekst-sporedni);{{end}}">
{{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>
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
<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: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 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>
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
<form method="dialog" class="premesti-zaglavlje">
@@ -977,9 +1017,14 @@
{{if .FiskalniRacun}}
<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);">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>
</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="flex:1;min-width:200px;">
<p style="margin:0 0 4px 0;"><strong>PFR broj:</strong> {{.FiskalniRacun.PfrBroj}}</p>
@@ -1000,12 +1045,57 @@
</div>
{{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}}
<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="font-size:14px;font-weight:600;color:#92400e;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:14px;font-weight:600;color:#fdba74;margin-bottom:4px;">Fiskalni račun nije izdat</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>
{{if index .Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{.Nalog.ID}}/fiskalizuj">
@@ -1017,6 +1107,96 @@
</div>
{{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 -->
{{/* 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"}}
@@ -152,11 +152,19 @@
<td></td>
</tr>
{{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>
<td>Poziv klijenta (obaveštenje o dijagnozi)</td>
<td><span class="linija-unos"></span></td>
<td></td>
</tr>
{{end}}
{{if .Nalog.DatumOdluke}}
<tr class="auto">
<td>Odobrenje klijenta</td>
@@ -152,9 +152,7 @@
<thead>
<tr>
<th>Opis rada</th>
<th>Kol.</th>
<th>Cena{{if index .Moduli "pdv"}} sa PDV{{end}}</th>
<th>Ukupno{{if index .Moduli "pdv"}} sa PDV{{end}}</th>
<th style="text-align:right;">Kol.</th>
</tr>
</thead>
<tbody>
@@ -162,8 +160,6 @@
<tr>
<td>{{.Naziv}}</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>
{{end}}
</tbody>
@@ -182,9 +178,7 @@
<tr>
<th>Naziv</th>
<th>Šifra</th>
<th>Kol.</th>
<th>Cena{{if index .Moduli "pdv"}} sa PDV{{end}}</th>
<th>Ukupno{{if index .Moduli "pdv"}} sa PDV{{end}}</th>
<th style="text-align:right;">Kol.</th>
</tr>
</thead>
<tbody>
@@ -194,8 +188,6 @@
<td>{{.ArtikalNaziv}}</td>
<td style="font-family:monospace;">{{.ArtikalSifra}}</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>
{{end}}
{{end}}