diff --git a/citire_fdb.py b/citire_fdb.py new file mode 100644 index 0000000..6b29534 --- /dev/null +++ b/citire_fdb.py @@ -0,0 +1,316 @@ +"""Cititor pentru baza SAGA noua (Firebird): balanta, parteneri si facturi. + +Produce, din copia unei baze `CONT_BAZA.FDB`, contractul intern al convertorului: + balanta_.csv - pagina, cont, denumire, prec_d, prec_c, rulaj_d, rulaj_c, + total_d, total_c, sold_d, sold_c, este_total, tip + parteneri_.csv - cont_analitic, cod, denumire, cod_fiscal + facturi_.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 (preluare + an) se neteaza pe +latura, ca in exportul SAGA; rulajul lunii se ia brut, iar total = prec + rulaj. +Analiticul din REGISTRU (401.00002) se ruleaza in sinteticul lui (401); contul +colector `%` se exclude. + +LIMITA VERIFICARII (scrisa si in raport): cititorul este verificat DOAR pe cazul +sintetic al firmei de joaca din `D:\\SAGA250909\\0001` (10 conturi, toate sintetice, +fara analitice in iesire, 2 furnizori, 0 clienti, 1 factura, luna 09/2026). Poarta +(balanta FDB identica cu exportul .xlsx) dovedeste rollup-ul la sintetic si +periodizarea pe acest caz; NU dovedeste nimic despre conturile analitice. Nu se +foloseste la un client fara rerularea portii pe datele lui. + +Utilizare: + py citire_fdb.py "" "" [dir_iesire] +""" + +import csv +import os +import sys +from calendar import monthrange +from datetime import date + +FBCLIENT = r"C:\Program Files\Firebird\Firebird30_Saga\fbclient.dll" +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"] + +# Prefix pe partea din stanga punctului (401.00002 -> 401). TRIM inainte, coloanele +# SAGA sunt VARCHAR(20) cu spatii la dreapta. +_SINT = ("CASE WHEN POSITION('.' IN TRIM(%s)) > 0 " + "THEN SUBSTRING(TRIM(%s) FROM 1 FOR POSITION('.' IN TRIM(%s)) - 1) " + "ELSE TRIM(%s) END") + +_SQL_BALANTA = """ +SELECT SINT, 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 {sint_d} AS SINT, 'D' AS LATURA, SUMA, DATA + FROM REGISTRU + WHERE TRIM(CONT_D) NOT IN ('', '%') AND DATA < ? + UNION ALL + SELECT {sint_c} AS SINT, 'C' AS LATURA, SUMA, DATA + FROM REGISTRU + WHERE TRIM(CONT_C) NOT IN ('', '%') AND DATA < ? +) A +GROUP BY SINT, LATURA +ORDER BY SINT, LATURA +""".format(sint_d=_SINT % (("CONT_D",) * 4), + sint_c=_SINT % (("CONT_C",) * 4)) + +_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 sint, latura, init, an_prec, luna_v in brute: + s = sume.setdefault(_text(sint), {"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 la inceputul lunii (preluare + an), pe net + prec_d, prec_c = _net(s["i_d"] + s["a_d"], s["i_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, ("", "")) + 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 produce(cale_fdb, firma, an, luna, dir_iesire="."): + """Scrie cele trei fisiere ale contractului intern; intoarce randurile.""" + if not os.path.isdir(dir_iesire): + os.makedirs(dir_iesire) + balanta = citeste_balanta(cale_fdb, an, luna) + parteneri = citeste_parteneri(cale_fdb) + facturi = citeste_facturi(cale_fdb, 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() diff --git a/docs/procedura_copie_fdb_client.md b/docs/procedura_copie_fdb_client.md new file mode 100644 index 0000000..7c1a29c --- /dev/null +++ b/docs/procedura_copie_fdb_client.md @@ -0,0 +1,59 @@ +# Copia bazei SAGA pentru conversie (pentru client) + +Ca sa convertim soldurile avem nevoie de o **copie** a bazei SAGA, nu de baza pe +care lucrezi zilnic. Copia se face asa incat sa fie `consistenta` (sa se poata +deschide) si sa nu te deranjeze in lucru. + +## Ce este baza + +Baza este un fisier numit `CONT_BAZA.FDB`, intr-un subfolder al instalarii SAGA +(de exemplu `C:\SAGA\0001\CONT_BAZA.FDB`; `0001` este firma). Dimensiunea creste in +timp; la firmele mici ajunge la zeci de MB. + +## Varianta recomandata - backup din SAGA (baza poate rula) + +1. In SAGA, cauta functia de administrare / intretinere a bazei: ceva de forma + "Backup baza de date" sau "Salvare baza de date". +2. Salveaza fisierul rezultat (are de obicei extensia `.fbk`) intr-un folder obisnuit. +3. Rezultatul este o copie **consistenta** si **mai mica** decat fisierul brut, si se + face fara sa opresti SAGA. + +Daca in SAGA nu gasesti functia, foloseste varianta urmatoare. + +## Varianta cu gbak (daca ai acces la folderul SAGA) + +Deschide o fereastra de comanda (Start -> scrie `cmd` -> Enter) si ruleaza, pe un +singur rand: + + "C:\Program Files\Firebird\Firebird30_Saga\gbak.exe" -b -v "C:\SAGA\0001\CONT_BAZA.FDB" "C:\temp\firma.fbk" -user SYSDBA -password PAROLA_SAGA + +- inlocuieste `C:\SAGA\0001\CONT_BAZA.FDB` cu calea reala a bazei tale; +- inlocuieste `C:\temp\firma.fbk` cu unde vrei sa iasa copia; +- `PAROLA_SAGA` este parola pe care o foloseste SAGA pentru baza (daca nu o stii, + foloseste varianta cu backupul din SAGA). + +Fisierul `.fbk` rezultat este mai mic decat baza si se deschide la noi cu o comanda +asemanatoare (`gbak -c`), fara sa atingem instalarea ta. + +## Daca preferi sa trimiti fisierul brut `CONT_BAZA.FDB` + +Atunci trebuie neaparat sa **inchizi SAGA inainte**: + +1. Inchide SAGA complet (Fisier -> Iesire) si astepta sa se inchida. +2. Asteapta cateva secunde. +3. Copiaza `CONT_BAZA.FDB` intr-un folder obisnuit (Copy / Paste). + +Daca SAGA este pornit in timpul copierii, copia poate iesi **stricata** si nu se mai +poate deschide. + +## Cum trimiti + +- Pune fisierul (`.fbk` sau `.FDB`) intr-o arhiva ZIP, ca sa se trimita mai usor. +- Trimite arhiva pe canalul obisnuit. +- Nu modifica si nu redenumi baza. + +## Ce facem noi cu ea + +Deschidem doar copia, in mod **citire** (`read-only`), nu modificam si nu scriem +nimic in ea, si extragem balanta, partenerii si facturile pentru conversie. Baza ta +originala ramane neatinsa. diff --git a/docs/raport_lane_citire_fdb.md b/docs/raport_lane_citire_fdb.md new file mode 100644 index 0000000..ecbd2ba --- /dev/null +++ b/docs/raport_lane_citire_fdb.md @@ -0,0 +1,141 @@ +# Raport lane `citire-fdb` - cititorul Firebird (SAGA noua) + +Lane: `citire-fdb`, etapa 2. Data: 21.09.2026. Director: `D:\ROA\IMPORT2ROA\solduri2roa`. + +## 1. Facut (livrabile) + +| fisier | ce | +|---|---| +| `citire_fdb.py` | cititorul: balanta + parteneri + facturi din copia unei baze `CONT_BAZA.FDB` | +| `tests/test_citire_fdb.py` | 5 teste `unittest` (poarta + scadent + SUM(REGISTRU) + `%` + parteneri/facturi) | +| `docs/procedura_copie_fdb_client.md` | cum isi face clientul copia bazei (backup/gbak/fisier brut) | + +Puncte cheie in `citire_fdb.py`: +- `_SQL_BALANTA` (`:54`) - agregarea `REGISTRU` pe trei intervale si pe doua laturi, + cu rollup la sintetic (`SUBSTRING` pana la punct), excluderea randului `%` si filtru + `DATA < inceputul lunii urmatoare` (nu apar conturi cu activitate doar dupa luna ceruta). +- `_conecteaza` (`:92`) - embedded, `fbclient.dll`, SYSDBA/masterkey, charset `WIN1250`, + `no_gc=True`, tranzactie `access_mode=READ`; apelantul face `rollback` + `close`. +- `citeste_balanta` (`:154`) - neteaza precedentul (preluare + an) pe latura, da total = + prec + rulaj si sold pe net; denumire/tip din `CONTURI`. +- `citeste_parteneri` (`:200`) - `FURNIZORI` + `CLIENTI`; cod fiscal lipsa -> codul analitic. +- `citeste_facturi` (`:243`) - `INTRD` (+`INTRARI`, dedup pe `ID_INTRARE`) si `IESIRI`; + `SCADENT` NULL -> ultima zi a lunii cerute. +- `produce` (`:287`) - scrie cele trei CSV-uri; `main` (`:303`) - CLI + `py citire_fdb.py "" "" [dir]`. +- Docstring-ul (vizibil la `--help`) declara explicit ca cititorul e **verificat doar pe + cazul sintetic** si nu se foloseste la un client fara rerularea portii pe datele lui. + +Doar creari de fisiere noi. Nu s-a modificat `genereaza_xlsx.py`, `citire_xlsx.py`, +`mapare.py`, `extract_balanta.py`, `config/*`, `tests/golden/*`, `tests/test_regresie.py`, +`tests/anonimizeaza_etalon.py`, `init_facturi_balanta_note.xlsx`. Nu s-a scris nimic in +`D:\SAGA250909` (doar copie citita in temporar). + +## 2. Verificari + +### Poarta (criteriul de terminare) + +Comanda de test: + +``` +py -m unittest discover -s tests -v +``` + +Rezultat (exact): + +``` +Ran 23 tests in 1.271s + +OK +``` + +23 = cele 18 existente + 5 noi. `EXIT=0`. (Warnings: `openpyxl` fara stil implicit pe +exportul de joaca si `WARNING *** OLE2 inconsistency` de la `xlrd` pe `.xls` - preexistente, +nu esecuri.) + +Poarta propriu-zisa (`test_poarta_identica_cu_xlsx`): balanta produsa de `citire_fdb` din +copia `CONT_BAZA.FDB` este **identica** cu cea produsa de `citire_xlsx.py` din +`exemple\saga-sqlite-balanta-09-2026.xlsx`, pentru luna 09/2026. Verificat pe randuri +(`assertEqual`) **si la nivel de octet** pe CSV-ul scris de ambii (`878` octeti, identici). + +Verificare manuala suplimentara (aceeasi concluzie): + +``` +nr xlsx 10 nr fdb 10 +identice +``` + +### Inlocuitorul verificarii "Totaluri:" + +`test_sum_registru_egal_netul_balantei`: `SUM(REGISTRU)` pe debit si pe credit (fara +`%`/gol) la data ceruta; laturile sunt egale (`8774.15` = `8774.15`), iar +`(debit - credit)` = netul balantei generate (`0.00`). OK. + +### Restul garzilor + +- `SCADENT` NULL (singurul rand `INTRD`) -> `2026-09-30` (ultima zi a lunii cerute). OK. +- Contul colector `%` nu apare in iesire; niciun rand fara cont. OK. +- Parteneri: 2 randuri (`ROMFAST` fara cod fiscal -> provizoriu `401.00001`; `TRANSPORT` + -> `1879855`). Facturi: 1 rand (`401.00001`, `123`, `2026-07-02`, `2026-09-30`, `0.0`). +- Cod mod: fisier ASCII, LF (ca `citire_xlsx.py`), 0 octeti > 127. + +Nu s-a atins D:\SAGA250909: poarta si testele deschid o **copie** in +`C:\Users\mmari\AppData\Local\Temp\opencode` (facuta si stearsa de test); originalul nu a +fost deschis. + +## 3. Nefacut / blocat / intrebari deschise + +### 3.1 Analiticele din balanta - conflict plan vs poarta (de decis) + +Planul si sarcina spun: "Agregarea grupeaza pe partea din stanga punctului si emite **si** +randul analitic, **si** sinteticul". Poarta spune: balanta FDB trebuie sa fie **identica** +cu cea din `.xlsx`. + +Cele doua nu pot fi satisfacute simultan pe firma de joaca: `REGISTRU` are miscare pe +analiticul `401.00002`, dar exportul `.xlsx` are **doar** randul sintetic `401` (10 +randuri, niciunul cu punct). Daca as emite si analiticul, as obtine 11 randuri si poarta +pica. Am respectat poarta (criteriul explicit de terminare): `citire_balanta` **ruleaza +analiticele in sintetic** si emite un singur rand per sintetic. + +Consecinta, de stiut: pentru un client real, `balanta_.csv` produs de acest cititor +**nu contine conturile analitice**. In aval, `genereaza_xlsx.py` (pct. `:236-305`) asteapta +pentru conturile cu parteneri **si** randul sintetic (`by[p]`) **si** randurile analitice +(`children[p]`); cu iesirea doar-sintetic, `401` ar fi tratat ca partener fara analitice, +iar analiticele nepartenere ar disparea complet. Planul insusi scrie ca acest cititor e +"verificat partial ... nimic despre analitice - exact partea grea". + +Nu am ghicit: am ales poarta. Ramane de decis una din variantele: +(a) balanta FDB e prin definitie doar-sintetic, iar analiticele se rezolva in alt pas +(nu exista azi); sau +(b) cititorul primeste un mod/flag (ex. `--analitice`) si poarta se defineste pe proiectia +sintetica, nu pe "identic". + +### 3.2 `rulaj` brut vs net (asumare) + +`DEB_PREC == SOLD_IN_D` in export (ex. `421`: debit `1476`, credit `4050`, export +`0 / 2574`), deci **precedentul e netat per cont** - implementat asa. Pentru `RULAJ_D/_C` +nu exista nicio miscare in luna 09/2026, deci poarta nu poate decide daca SAGA da rulajul +brut sau netat. Am ales **brut** (pastreaza distinctia planului `total = prec + rulaj`, +altfel `total` ar deveni identic cu `soldul`). De reverificat pe primul client real. + +### 3.3 Surse de facturi + +`INTRARI` si `IES_DET` sunt goale pe firma de joaca, iar `IES_DET`/`INTRD_DET` sunt tabele +de **linii** (nu au `CONT_CLI`/`DATA`/`SCADENT`), deci nu pot da randul de factura. +Implementat: furnizori din `INTRD` (+ `INTRARI` daca are randuri, dedup pe `ID_INTRARE`), +clienti din `IESIRI`. Daca pe un client real facturile stau altfel, se ajusteaza dupa ce se +vede baza. + +### 3.4 Altele + +- `data`/`scadent` se scriu ISO (`YYYY-MM-DD`); contractul nu fixeaza formatul. +- `.gitignore`: planul (sectiunea Git) cere `parteneri_*.csv` / `facturi_*.csv` / `*.fdb`; + nu le-am adaugat (in afara livrabilului lane-ului). +- Ordinea balantei: crescator lexicografic pe `cont`; coincide cu ordinea din exportul de + joaca. Daca SAGA real ordoneaza altfel, poarta pe client va arata. + +## 4. Stare periculoasa + +Niciuna. Fara procese lasate pornite, fara `.vc2`/`.sc2` editate, fara date de test +consumate, fara commit (`git status`: doar cele 3 fisiere noi, netracked). Copiile +temporare ale bazei au fost sterse. diff --git a/tests/test_citire_fdb.py b/tests/test_citire_fdb.py new file mode 100644 index 0000000..3884a09 --- /dev/null +++ b/tests/test_citire_fdb.py @@ -0,0 +1,129 @@ +"""Teste pentru citire_fdb: balanta din Firebird identica cu exportul .xlsx. + +Poarta: `balanta` produsa din copia unei baze `CONT_BAZA.FDB` trebuie sa fie +identica (pana la octet) cu cea produsa de `citire_xlsx.py` din exportul de foaie +de calcul, pe firma de joaca, luna 09/2026. Baza originala NU se deschide: se +copiaza intr-un director temporar. + +Pe o masina fara SAGA (fara fbclient.dll, baza, driver sau export) testele se +sar, nu esueaza. + +Rulare: py -m unittest discover -s tests -v +""" +import os +import pathlib +import shutil +import sys +import tempfile +import unittest + +RADACINA = pathlib.Path(__file__).resolve().parent.parent +if str(RADACINA) not in sys.path: + sys.path.insert(0, str(RADACINA)) + +import citire_fdb +import citire_xlsx + +FBCLIENT = pathlib.Path(r"C:\Program Files\Firebird\Firebird30_Saga\fbclient.dll") +BAZA = pathlib.Path(r"D:\SAGA250909\0001\CONT_BAZA.FDB") +XLSX = RADACINA / "exemple" / "saga-sqlite-balanta-09-2026.xlsx" + +AN, LUNA = 2026, 9 +TOL = 0.01 + + +def _driver_disponibil(): + try: + import firebird.driver # noqa: F401 + return True + except Exception: + return False + + +class TestCitireFdb(unittest.TestCase): + + @classmethod + def setUpClass(cls): + if not FBCLIENT.exists(): + raise unittest.SkipTest("lipseste fbclient.dll: %s" % FBCLIENT) + if not BAZA.exists(): + raise unittest.SkipTest("lipseste baza Firebird: %s" % BAZA) + if not _driver_disponibil(): + raise unittest.SkipTest("lipseste pachetul firebird-driver") + cls._tmp = tempfile.TemporaryDirectory(prefix="citire_fdb_") + cls.baza = os.path.join(cls._tmp.name, "CONT_BAZA.FDB") + shutil.copy2(str(BAZA), cls.baza) + cls.dir = cls._tmp.name + + @classmethod + def tearDownClass(cls): + cls._tmp.cleanup() + + def test_poarta_identica_cu_xlsx(self): + if not XLSX.exists(): + self.skipTest("lipseste exportul %s" % XLSX) + rows_xlsx = citire_xlsx.citeste_balanta(str(XLSX)) + rows_fdb = citire_fdb.citeste_balanta(self.baza, AN, LUNA) + self.assertTrue(rows_xlsx, "exportul xlsx nu are randuri") + self.assertEqual(rows_xlsx, rows_fdb, + "balanta din FDB difera de exportul xlsx") + + # comparatie stricta: acelasi fisier CSV, la octet + cale_xlsx = os.path.join(self.dir, "din_xlsx.csv") + cale_fdb = os.path.join(self.dir, "din_fdb.csv") + citire_xlsx.scrie_csv(rows_xlsx, cale_xlsx) + citire_fdb.scrie_csv(rows_fdb, cale_fdb, citire_fdb.COLOANE_BALANTA) + with open(cale_xlsx, "rb") as f: + a = f.read() + with open(cale_fdb, "rb") as f: + b = f.read() + self.assertEqual(a, b, "CSV-urile difera la nivel de octet") + + def test_sum_registru_egal_netul_balantei(self): + d_an, d_luna, d_urm = citire_fdb._limite(AN, LUNA) + con = citire_fdb._conecteaza(self.baza) + try: + deb, cred = citire_fdb._select( + con, + "SELECT " + "SUM(CASE WHEN TRIM(CONT_D) NOT IN ('', '%') THEN SUMA ELSE 0 END), " + "SUM(CASE WHEN TRIM(CONT_C) NOT IN ('', '%') THEN SUMA ELSE 0 END) " + "FROM REGISTRU WHERE DATA < ?", (d_urm,))[0] + finally: + con.rollback() + con.close() + self.assertAlmostEqual(float(deb), float(cred), delta=TOL, + msg="SUM(REGISTRU) nu e echilibrat pe laturi") + + rows = citire_fdb.citeste_balanta(self.baza, AN, LUNA) + net_balanta = sum(r["total_d"] - r["total_c"] for r in rows) + self.assertAlmostEqual(float(deb) - float(cred), net_balanta, delta=TOL, + msg="netul din REGISTRU != netul balantei") + + def test_scadent_null_cade_pe_ultima_zi_a_lunii(self): + rows = citire_fdb.citeste_facturi(self.baza, AN, LUNA) + self.assertTrue(rows, "nu s-au citit facturi") + asteptat = citire_fdb._ultima_zi(AN, LUNA).isoformat() + # singura factura din firma de joaca are SCADENT NULL in INTRD + self.assertEqual(rows[0]["scadent"], asteptat) + + def test_cont_colector_exclus(self): + rows = citire_fdb.citeste_balanta(self.baza, AN, LUNA) + self.assertNotIn("%", [r["cont"] for r in rows]) + self.assertTrue(all(r["cont"] for r in rows), "rand fara cont") + + def test_parteneri_si_facturi(self): + parteneri = citire_fdb.citeste_parteneri(self.baza) + self.assertTrue(parteneri, "nu s-au citit parteneri") + for r in parteneri: + self.assertTrue(r["cont_analitic"]) + self.assertTrue(r["cod_fiscal"], + "cod fiscal gol pentru %s" % r["cont_analitic"]) + facturi = citire_fdb.citeste_facturi(self.baza, AN, LUNA) + for r in facturi: + self.assertTrue(r["cont_analitic"]) + self.assertTrue(r["scadent"]) + + +if __name__ == "__main__": + unittest.main()