feat(pdv): prodaja → KIR automatski (Faza 2b-1)
Kad se sačuva prodaja na klijenta (PDV obveznik), zapis se sam zavede u KIR (model.KirIzProdaje grupiše stavke po stopi). Storno/brisanje prodaje uklanja vezani KIR zapis (ObrisiPoIzvoru). Maloprodaja građanima (bez klijenta) se preskače — ide preko fiskalizacije (Faza 3). Helper modulUkljucen; auto-zapisi u UI nemaju ručno brisanje. Test.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -130,6 +131,16 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
|
|||||||
h.PdvKprRepo = sqlite.NoviPdvKprRepo(novaDB)
|
h.PdvKprRepo = sqlite.NoviPdvKprRepo(novaDB)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modulUkljucen vraća da li je zakonski modul (npr. „pdv") uključen za firmu prema profilu.
|
||||||
|
// Koristi se pri automatskom punjenju KIR/KPR iz prodaje/nabavke.
|
||||||
|
func (h *Handler) modulUkljucen(ctx context.Context, modul string) bool {
|
||||||
|
podesavanja, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return config.ModulUkljucen(podesavanja, modul)
|
||||||
|
}
|
||||||
|
|
||||||
// zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju.
|
// zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju.
|
||||||
// U suprotnom šalje 403 sa srpskom porukom i vraća ok=false (handler tada return-uje).
|
// U suprotnom šalje 403 sa srpskom porukom i vraća ok=false (handler tada return-uje).
|
||||||
func (h *Handler) zahtevajDozvolu(w http.ResponseWriter, r *http.Request, akcija string) (*model.Korisnik, bool) {
|
func (h *Handler) zahtevajDozvolu(w http.ResponseWriter, r *http.Request, akcija string) (*model.Korisnik, bool) {
|
||||||
|
|||||||
@@ -197,6 +197,22 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// automatski zavedi u KIR ako je firma PDV obveznik i prodaja je na klijenta (B2B faktura).
|
||||||
|
// Maloprodaja građanima (bez klijenta) ide zbirno preko fiskalizacije (Faza 3) — preskače se.
|
||||||
|
if nalog.KlijentID != nil && h.modulUkljucen(r.Context(), "pdv") {
|
||||||
|
if klijent, e := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID); e == nil {
|
||||||
|
nalog.ID = id
|
||||||
|
pib := klijent.PIB
|
||||||
|
if klijent.Tip != "pravno" {
|
||||||
|
pib = klijent.JMBG
|
||||||
|
}
|
||||||
|
kir := model.KirIzProdaje(nalog, stavke, klijent.PunoIme(), pib, klijent.Mesto)
|
||||||
|
if _, e := h.PdvKirRepo.Kreiraj(r.Context(), &kir); e != nil {
|
||||||
|
slog.Error("auto-upis u KIR nije uspeo", "prodaja_id", id, "error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +335,10 @@ func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError)
|
http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// ukloni vezani auto-KIR zapis (ako ga je ova prodaja kreirala)
|
||||||
|
if err := h.PdvKirRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); err != nil {
|
||||||
|
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/prodaja?obrisan=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/prodaja?obrisan=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
@@ -408,6 +428,10 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// stornirana prodaja ne ulazi u PDV — ukloni vezani auto-KIR zapis
|
||||||
|
if err := h.PdvKirRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); err != nil {
|
||||||
|
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
|
||||||
|
}
|
||||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.")
|
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.")
|
||||||
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,44 @@ func SumirajKir(zapisi []PdvKir) PdvKirSume {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KirIzProdaje gradi KIR zapis iz prodaje: stavke se grupišu po PDV stopi
|
||||||
|
// (20→opšta, 10→posebna, ostalo→oslobođeno). CenaPoKomadu je prodajna cena SA PDV,
|
||||||
|
// pa se osnovica izvodi deljenjem sa (1 + stopa/100).
|
||||||
|
func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupacPib, kupacMesto string) PdvKir {
|
||||||
|
id := nalog.ID
|
||||||
|
k := PdvKir{
|
||||||
|
DatumPrometa: nalog.Datum,
|
||||||
|
DatumKnjizenja: nalog.Datum,
|
||||||
|
BrojDokumenta: nalog.BrojNaloga,
|
||||||
|
KupacNaziv: kupacNaziv,
|
||||||
|
KupacPib: kupacPib,
|
||||||
|
KupacMesto: kupacMesto,
|
||||||
|
Izvor: "prodaja",
|
||||||
|
IzvorID: &id,
|
||||||
|
}
|
||||||
|
for _, s := range stavke {
|
||||||
|
ukupnoLinija := float64(s.Kolicina) * s.CenaPoKomadu
|
||||||
|
osnovica := ukupnoLinija
|
||||||
|
if s.PdvStopa > 0 {
|
||||||
|
osnovica = ukupnoLinija / (1 + s.PdvStopa/100)
|
||||||
|
}
|
||||||
|
pdv := ukupnoLinija - osnovica
|
||||||
|
switch s.PdvStopa {
|
||||||
|
case 20:
|
||||||
|
k.OsnovicaOpsta += osnovica
|
||||||
|
k.PdvOpsta += pdv
|
||||||
|
case 10:
|
||||||
|
k.OsnovicaPosebna += osnovica
|
||||||
|
k.PdvPosebna += pdv
|
||||||
|
default:
|
||||||
|
// 0% / oslobođeno — osnovica bez PDV-a u oslobođen promet sa pravom na odbitak
|
||||||
|
k.OslobodenSaPravom += osnovica
|
||||||
|
}
|
||||||
|
k.Ukupno += ukupnoLinija
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
// PdvKpr je jedan zapis u knjizi primljenih računa (ulazni PDV).
|
// PdvKpr je jedan zapis u knjizi primljenih računa (ulazni PDV).
|
||||||
type PdvKpr struct {
|
type PdvKpr struct {
|
||||||
ID int64
|
ID int64
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func blizu(a, b float64) bool { return math.Abs(a-b) < 0.01 }
|
||||||
|
|
||||||
|
func TestKirIzProdaje(t *testing.T) {
|
||||||
|
nalog := ProdajniNalog{ID: 5, BrojNaloga: "P-1", Datum: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)}
|
||||||
|
stavke := []StavkaProdaje{
|
||||||
|
// 20%: 2 × 120 = 240 (osnovica 200, PDV 40)
|
||||||
|
{Kolicina: 2, CenaPoKomadu: 120, PdvStopa: 20},
|
||||||
|
// 10%: 1 × 110 = 110 (osnovica 100, PDV 10)
|
||||||
|
{Kolicina: 1, CenaPoKomadu: 110, PdvStopa: 10},
|
||||||
|
// 0%: 1 × 50 = 50 (oslobođeno, bez PDV)
|
||||||
|
{Kolicina: 1, CenaPoKomadu: 50, PdvStopa: 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
k := KirIzProdaje(nalog, stavke, "Kupac doo", "123456789", "Niš")
|
||||||
|
|
||||||
|
if k.Izvor != "prodaja" || k.IzvorID == nil || *k.IzvorID != 5 {
|
||||||
|
t.Errorf("izvor=%q izvor_id=%v, očekivano prodaja/5", k.Izvor, k.IzvorID)
|
||||||
|
}
|
||||||
|
if k.BrojDokumenta != "P-1" || k.KupacNaziv != "Kupac doo" || k.KupacPib != "123456789" {
|
||||||
|
t.Errorf("zaglavlje ne odgovara: %+v", k)
|
||||||
|
}
|
||||||
|
if !blizu(k.OsnovicaOpsta, 200) || !blizu(k.PdvOpsta, 40) {
|
||||||
|
t.Errorf("opšta: osnovica=%v pdv=%v, očekivano 200/40", k.OsnovicaOpsta, k.PdvOpsta)
|
||||||
|
}
|
||||||
|
if !blizu(k.OsnovicaPosebna, 100) || !blizu(k.PdvPosebna, 10) {
|
||||||
|
t.Errorf("posebna: osnovica=%v pdv=%v, očekivano 100/10", k.OsnovicaPosebna, k.PdvPosebna)
|
||||||
|
}
|
||||||
|
if !blizu(k.OslobodenSaPravom, 50) {
|
||||||
|
t.Errorf("oslobođeno=%v, očekivano 50", k.OslobodenSaPravom)
|
||||||
|
}
|
||||||
|
if !blizu(k.Ukupno, 400) {
|
||||||
|
t.Errorf("ukupno=%v, očekivano 400 (240+110+50)", k.Ukupno)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,9 +55,13 @@
|
|||||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OslobodenUkupno}}</td>
|
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OslobodenUkupno}}</td>
|
||||||
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{printf "%.2f" .Ukupno}}</td>
|
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{printf "%.2f" .Ukupno}}</td>
|
||||||
<td style="padding:10px 12px;text-align:right;white-space:nowrap;">
|
<td style="padding:10px 12px;text-align:right;white-space:nowrap;">
|
||||||
|
{{if eq .Izvor "rucno"}}
|
||||||
<form method="POST" action="/pdv/kir/obrisi/{{.ID}}" style="display:inline;">
|
<form method="POST" action="/pdv/kir/obrisi/{{.ID}}" style="display:inline;">
|
||||||
<button type="submit" class="btn-obrisi-malo" data-potvrda="Obrisati ovaj zapis iz KIR?">Obriši</button>
|
<button type="submit" class="btn-obrisi-malo" data-potvrda="Obrisati ovaj zapis iz KIR?">Obriši</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<span style="font-size:11px;color:var(--tekst-sporedni);" title="Zavedeno automatski — upravlja se preko prodaje (storniraj/obriši prodaju)">iz prodaje</span>
|
||||||
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
Reference in New Issue
Block a user