Files
solduri2roa/citire_fdb.py
Marius Mutu 99d9cf0aff Cititorul FDB: sume totale ca SAGA, analitice pastrate, baza oprita acceptata
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
2026-09-25 16:14:14 +03:00

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()