Proba pe LACERTA (06/2026, firma reala) a gasit doua defecte in citire_fdb: - precedentul netea si rulajele din an, deci conturile de cheltuieli/venituri pierdeau rulajul (CPP gresit). Acum: preluare netata + rulaj an brut; potriveste 44/44 sintetice din importul facut din exportul SAGA (inainte 19/44). - analiticele se rulau in sintetic, deci facturile per document nu se foloseau si analiticele nepartener disparea. Acum balanta are toate nivelurile. Baza primita se copiaza in temporar si copia se repune online (clientul o trimite si in full shutdown). tests/test_lacerta.py: 48/53 frunze identice cu REF, restul explicate; se sare fara fisierele clientului, care sunt ignorate de git. README nou; analiza in docs/analiza_lacerta_fdb.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYeAtVxeS8m4oXekjX8Am2
341 lines
12 KiB
Python
341 lines
12 KiB
Python
"""Cititor pentru baza SAGA noua (Firebird): balanta, parteneri si facturi.
|
|
|
|
Produce, din copia unei baze `CONT_BAZA.FDB`, contractul intern al convertorului:
|
|
balanta_<FIRMA>.csv - pagina, cont, denumire, prec_d, prec_c, rulaj_d, rulaj_c,
|
|
total_d, total_c, sold_d, sold_c, este_total, tip
|
|
parteneri_<FIRMA>.csv - cont_analitic, cod, denumire, cod_fiscal
|
|
facturi_<FIRMA>.csv - cont_analitic, numar, data, scadent, sold
|
|
|
|
Baza se deschide NUMAI embedded (pe TCP 3060 parola implicita nu merge), in
|
|
tranzactie read-only, doar SELECT, rollback la final. Parametrul este calea catre o
|
|
COPIE a bazei (gbak sau fisier oprit), niciodata baza vie a clientului.
|
|
|
|
Sumele nu vin din soldurile stocate in CONTURI (sunt 0); se agrega REGISTRU pe trei
|
|
intervale: inainte de inceputul anului (sold de preluare), de la inceputul anului pana
|
|
la inceputul lunii cerute si luna ceruta. Precedentul = soldul de preluare netat pe
|
|
latura + rulajele din an, BRUTE (ca SAGA: netarea lor ar anula rulajul conturilor de
|
|
cheltuieli si venituri); rulajul lunii se ia brut, iar total = prec + rulaj.
|
|
Fiecare cont din REGISTRU intra in toate nivelurile lui: 401.00002 -> 401 si 401.00002,
|
|
deci balanta are sinteticele si analiticele, ca exportul SAGA cu analitice. Contul
|
|
colector `%` se exclude.
|
|
|
|
Baza primita se copiaza in temporar si copia se repune online (clientul o poate opri
|
|
cu `full shutdown` inainte de copiere); fisierul primit nu se atinge.
|
|
|
|
VERIFICARI: firma de joaca din `D:\SAGA250909\0001` (sinteticele = exportul .xlsx,
|
|
la octet) si LACERTA 06/2026 (tests/test_lacerta.py: 48 din 53 de frunze identice cu
|
|
importul facut din exportul SAGA, restul explicate in docs/analiza_lacerta_fdb.md;
|
|
facturile per document insumeaza netul fiecarui partener). La un client nou se
|
|
reruleaza o poarta pe datele lui.
|
|
|
|
Utilizare:
|
|
py citire_fdb.py "<copie CONT_BAZA.FDB>" "<FIRMA>" <an> <luna> [dir_iesire]
|
|
"""
|
|
|
|
import csv
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from calendar import monthrange
|
|
from datetime import date
|
|
|
|
FBCLIENT = r"C:\Program Files\Firebird\Firebird30_Saga\fbclient.dll"
|
|
GFIX = os.path.join(os.path.dirname(FBCLIENT), "gfix.exe")
|
|
UTILIZATOR = "SYSDBA"
|
|
PAROLA = "masterkey"
|
|
CHARSET = "WIN1250"
|
|
|
|
COLOANE_BALANTA = ["pagina", "cont", "denumire", "prec_d", "prec_c", "rulaj_d",
|
|
"rulaj_c", "total_d", "total_c", "sold_d", "sold_c",
|
|
"este_total", "tip"]
|
|
COLOANE_PARTENERI = ["cont_analitic", "cod", "denumire", "cod_fiscal"]
|
|
COLOANE_FACTURI = ["cont_analitic", "numar", "data", "scadent", "sold"]
|
|
|
|
_SQL_BALANTA = """
|
|
SELECT CONT, LATURA,
|
|
SUM(CASE WHEN DATA < ? THEN SUMA ELSE 0 END) AS INIT,
|
|
SUM(CASE WHEN DATA >= ? AND DATA < ? THEN SUMA ELSE 0 END) AS AN_PREC,
|
|
SUM(CASE WHEN DATA >= ? AND DATA < ? THEN SUMA ELSE 0 END) AS LUNA
|
|
FROM (
|
|
SELECT TRIM(CONT_D) AS CONT, 'D' AS LATURA, SUMA, DATA
|
|
FROM REGISTRU
|
|
WHERE TRIM(CONT_D) NOT IN ('', '%') AND DATA < ?
|
|
UNION ALL
|
|
SELECT TRIM(CONT_C) AS CONT, 'C' AS LATURA, SUMA, DATA
|
|
FROM REGISTRU
|
|
WHERE TRIM(CONT_C) NOT IN ('', '%') AND DATA < ?
|
|
) A
|
|
GROUP BY CONT, LATURA
|
|
ORDER BY CONT, LATURA
|
|
"""
|
|
|
|
_API_INCARCAT = False
|
|
|
|
|
|
def _driver():
|
|
import firebird.driver as fdb
|
|
return fdb
|
|
|
|
|
|
def _incarca_clientul():
|
|
global _API_INCARCAT
|
|
fdb = _driver()
|
|
if not os.path.exists(FBCLIENT):
|
|
raise FileNotFoundError("lipseste fbclient.dll: %s" % FBCLIENT)
|
|
if not _API_INCARCAT:
|
|
fdb.load_api(FBCLIENT)
|
|
_API_INCARCAT = True
|
|
return fdb
|
|
|
|
|
|
def _conecteaza(cale):
|
|
"""Conexiune embedded read-only; se inchide cu rollback de catre apelant."""
|
|
fdb = _incarca_clientul()
|
|
if not os.path.exists(cale):
|
|
raise FileNotFoundError("lipseste baza Firebird: %s" % cale)
|
|
con = fdb.connect(database=cale, user=UTILIZATOR, password=PAROLA,
|
|
charset=CHARSET, no_gc=True)
|
|
con.begin(fdb.tpb(fdb.Isolation.READ_COMMITTED,
|
|
access_mode=fdb.TraAccessMode.READ))
|
|
return con
|
|
|
|
|
|
def _select(con, sql, params=()):
|
|
cur = con.cursor()
|
|
try:
|
|
cur.execute(sql, params)
|
|
return cur.fetchall()
|
|
finally:
|
|
cur.close()
|
|
|
|
|
|
def _text(v):
|
|
if v is None:
|
|
return ""
|
|
return str(v).strip()
|
|
|
|
|
|
def _num(v):
|
|
if v is None:
|
|
return 0.0
|
|
return float(v)
|
|
|
|
|
|
def _net(d, c):
|
|
"""(debitor, creditor) pe netul celor doua sume."""
|
|
if d >= c:
|
|
return d - c, 0
|
|
return 0, c - d
|
|
|
|
|
|
def _limite(an, luna):
|
|
an, luna = int(an), int(luna)
|
|
if not 1 <= luna <= 12:
|
|
raise ValueError("luna trebuie intre 1 si 12: %r" % luna)
|
|
urmator = date(an + 1, 1, 1) if luna == 12 else date(an, luna + 1, 1)
|
|
return date(an, 1, 1), date(an, luna, 1), urmator
|
|
|
|
|
|
def _ultima_zi(an, luna):
|
|
an, luna = int(an), int(luna)
|
|
return date(an, luna, monthrange(an, luna)[1])
|
|
|
|
|
|
def _conturi(con):
|
|
"""{cont: (denumire, tip)} din planul de conturi."""
|
|
info = {}
|
|
for cont, denumire, tip in _select(
|
|
con, "SELECT TRIM(CONT), TRIM(DENUMIRE), TRIM(TIP) FROM CONTURI"):
|
|
info[_text(cont)] = (_text(denumire), _text(tip))
|
|
return info
|
|
|
|
|
|
def citeste_balanta(cale, an, luna):
|
|
"""Randurile balantei in contractul intern, pentru luna ceruta."""
|
|
d_an, d_luna, d_urm = _limite(an, luna)
|
|
con = _conecteaza(cale)
|
|
try:
|
|
brute = _select(con, _SQL_BALANTA,
|
|
(d_an, d_an, d_luna, d_luna, d_urm, d_urm, d_urm))
|
|
conturi = _conturi(con)
|
|
finally:
|
|
con.rollback()
|
|
con.close()
|
|
|
|
sume = {}
|
|
for cont, latura, init, an_prec, luna_v in brute:
|
|
seg = _text(cont).split(".")
|
|
# contul intra in fiecare nivel al lui: 215.1 -> 215 si 215.1
|
|
for k in range(1, len(seg) + 1):
|
|
s = sume.setdefault(".".join(seg[:k]), {"i_d": 0, "i_c": 0, "a_d": 0,
|
|
"a_c": 0, "l_d": 0, "l_c": 0})
|
|
if latura == "D":
|
|
s["i_d"] += init or 0
|
|
s["a_d"] += an_prec or 0
|
|
s["l_d"] += luna_v or 0
|
|
else:
|
|
s["i_c"] += init or 0
|
|
s["a_c"] += an_prec or 0
|
|
s["l_c"] += luna_v or 0
|
|
|
|
rows = []
|
|
for cont in sorted(sume):
|
|
s = sume[cont]
|
|
# precedent = soldul de preluare pe net + rulajele din an, brute (ca SAGA:
|
|
# netarea rulajelor ar sterge rulajul conturilor de cheltuieli/venituri)
|
|
prec_d, prec_c = _net(s["i_d"], s["i_c"])
|
|
prec_d, prec_c = prec_d + s["a_d"], prec_c + s["a_c"]
|
|
rulaj_d, rulaj_c = s["l_d"], s["l_c"]
|
|
total_d = prec_d + rulaj_d
|
|
total_c = prec_c + rulaj_c
|
|
sold_d, sold_c = _net(total_d, total_c)
|
|
denumire, tip = conturi.get(cont) or ("", conturi.get(cont.split(".")[0], ("", ""))[1])
|
|
rows.append({
|
|
"pagina": "", "cont": cont, "denumire": denumire,
|
|
"prec_d": _num(prec_d), "prec_c": _num(prec_c),
|
|
"rulaj_d": _num(rulaj_d), "rulaj_c": _num(rulaj_c),
|
|
"total_d": _num(total_d), "total_c": _num(total_c),
|
|
"sold_d": _num(sold_d), "sold_c": _num(sold_c),
|
|
"este_total": "", "tip": tip,
|
|
})
|
|
return rows
|
|
|
|
|
|
def citeste_parteneri(cale):
|
|
"""Furnizori + clienti; codul fiscal lipsa cade pe codul contului analitic."""
|
|
con = _conecteaza(cale)
|
|
try:
|
|
brute = _select(
|
|
con,
|
|
"SELECT TRIM(ANALITIC), TRIM(COD), TRIM(DENUMIRE), TRIM(COD_FISCAL) "
|
|
"FROM FURNIZORI "
|
|
"UNION ALL "
|
|
"SELECT TRIM(ANALITIC), TRIM(COD), TRIM(DENUMIRE), TRIM(COD_FISCAL) "
|
|
"FROM CLIENTI")
|
|
finally:
|
|
con.rollback()
|
|
con.close()
|
|
|
|
rows = []
|
|
for analitic, cod, denumire, cod_fiscal in brute:
|
|
cont_analitic = _text(analitic)
|
|
if cont_analitic == "":
|
|
continue
|
|
rows.append({
|
|
"cont_analitic": cont_analitic,
|
|
"cod": _text(cod),
|
|
"denumire": _text(denumire),
|
|
"cod_fiscal": _text(cod_fiscal) or cont_analitic,
|
|
})
|
|
rows.sort(key=lambda r: (r["cont_analitic"], r["cod"]))
|
|
return rows
|
|
|
|
|
|
def _rand_factura(cont_analitic, numar, data, scadent, sold, scadent_implicit):
|
|
cont_analitic = _text(cont_analitic)
|
|
if cont_analitic == "":
|
|
return None
|
|
return {
|
|
"cont_analitic": cont_analitic,
|
|
"numar": _text(numar),
|
|
"data": data.isoformat() if data is not None else "",
|
|
"scadent": (scadent or scadent_implicit).isoformat(),
|
|
"sold": _num(sold),
|
|
}
|
|
|
|
|
|
def citeste_facturi(cale, an, luna):
|
|
"""Facturi furnizori (INTRD/INTRARI) + clienti (IESIRI), intr-un singur fisier."""
|
|
scadent_implicit = _ultima_zi(an, luna)
|
|
con = _conecteaza(cale)
|
|
try:
|
|
furnizori = _select(
|
|
con,
|
|
"SELECT ID_INTRARE, CONT_FUR, NR_INTRARE, DATA, SCADENT, NEACHITAT "
|
|
"FROM INTRD")
|
|
vazute = {row[0] for row in furnizori}
|
|
for row in _select(
|
|
con,
|
|
"SELECT ID_INTRARE, CONT_FUR, NR_INTRARE, DATA, SCADENT, NEACHITAT "
|
|
"FROM INTRARI"):
|
|
if row[0] not in vazute:
|
|
furnizori.append(row)
|
|
vazute.add(row[0])
|
|
clienti = _select(
|
|
con,
|
|
"SELECT CONT_CLI, NR_IESIRE, DATA, SCADENT, NEACHITAT FROM IESIRI")
|
|
finally:
|
|
con.rollback()
|
|
con.close()
|
|
|
|
rows = []
|
|
for _, cont, numar, data, scadent, sold in furnizori:
|
|
r = _rand_factura(cont, numar, data, scadent, sold, scadent_implicit)
|
|
if r:
|
|
rows.append(r)
|
|
for cont, numar, data, scadent, sold in clienti:
|
|
r = _rand_factura(cont, numar, data, scadent, sold, scadent_implicit)
|
|
if r:
|
|
rows.append(r)
|
|
rows.sort(key=lambda r: (r["cont_analitic"], r["data"], r["numar"], r["scadent"]))
|
|
return rows
|
|
|
|
|
|
def scrie_csv(rows, cale, coloane):
|
|
with open(cale, "w", newline="", encoding="utf-8-sig") as f:
|
|
wr = csv.DictWriter(f, fieldnames=coloane, extrasaction="ignore")
|
|
wr.writeheader()
|
|
wr.writerows(rows)
|
|
|
|
|
|
def copie_online(cale_fdb, dir_tmp):
|
|
"""Copiaza baza in dir_tmp si o repune online, daca clientul a oprit-o inainte de
|
|
copiere (`full shutdown`). Fisierul primit nu se atinge. Pe o baza deja online
|
|
gfix raspunde cu eroare, inofensiva: daca repunerea chiar esueaza, conectarea
|
|
urmatoare pica cu mesajul real (`database ... shutdown`)."""
|
|
if not os.path.exists(cale_fdb):
|
|
raise FileNotFoundError("lipseste baza Firebird: %s" % cale_fdb)
|
|
copie = os.path.join(dir_tmp, "CONT_BAZA.FDB")
|
|
shutil.copyfile(cale_fdb, copie)
|
|
if os.path.exists(GFIX):
|
|
subprocess.run([GFIX, "-online", "normal", copie, "-user", UTILIZATOR,
|
|
"-password", PAROLA], capture_output=True)
|
|
return copie
|
|
|
|
|
|
def produce(cale_fdb, firma, an, luna, dir_iesire="."):
|
|
"""Scrie cele trei fisiere ale contractului intern; intoarce randurile.
|
|
Citeste dintr-o copie temporara, repusa online (vezi copie_online)."""
|
|
if not os.path.isdir(dir_iesire):
|
|
os.makedirs(dir_iesire)
|
|
with tempfile.TemporaryDirectory(prefix="citire_fdb_") as tmp:
|
|
copie = copie_online(cale_fdb, tmp)
|
|
balanta = citeste_balanta(copie, an, luna)
|
|
parteneri = citeste_parteneri(copie)
|
|
facturi = citeste_facturi(copie, an, luna)
|
|
scrie_csv(balanta, os.path.join(dir_iesire, "balanta_%s.csv" % firma),
|
|
COLOANE_BALANTA)
|
|
scrie_csv(parteneri, os.path.join(dir_iesire, "parteneri_%s.csv" % firma),
|
|
COLOANE_PARTENERI)
|
|
scrie_csv(facturi, os.path.join(dir_iesire, "facturi_%s.csv" % firma),
|
|
COLOANE_FACTURI)
|
|
return {"balanta": balanta, "parteneri": parteneri, "facturi": facturi}
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) not in (5, 6) or sys.argv[1] in ("-h", "--help"):
|
|
print(__doc__)
|
|
sys.exit(1)
|
|
cale, firma, an, luna = sys.argv[1:5]
|
|
dir_iesire = sys.argv[5] if len(sys.argv) == 6 else "."
|
|
rez = produce(cale, firma, an, luna, dir_iesire)
|
|
print("balanta_%s.csv: %d randuri" % (firma, len(rez["balanta"])))
|
|
print("parteneri_%s.csv: %d randuri" % (firma, len(rez["parteneri"])))
|
|
print("facturi_%s.csv: %d randuri" % (firma, len(rez["facturi"])))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|