diff --git a/docs/raport_lane_integrare.md b/docs/raport_lane_integrare.md new file mode 100644 index 0000000..6d0f88e --- /dev/null +++ b/docs/raport_lane_integrare.md @@ -0,0 +1,119 @@ +# Raport lane `integrare` - solduri2roa etapa 2 + +Data: 21.09.2026. Director: `D:\ROA\IMPORT2ROA\solduri2roa`. Singurul lane activ. + +## 1. Facut + +### 1.1 Fisiere noi + +- `extrage.py` (nou) - punct de intrare sursa -> contractul intern: + - `extrage.py:25-30` `SURSE_DUPA_EXTENSIE` (`.pdf`, `.xls`, `.xlsx`, `.fdb`). + - `extrage.py:33-40` `deduce_sursa` - deducere din extensie; extensie necunoscuta = `SystemExit`. + - `extrage.py:43-52` `extrage_pdf` / `extrage_xlsx` - importa `extract_balanta.parse_pdf` si + `citire_xlsx.citeste_balanta`; scrie CSV-ul cu writer-ul existent `citire_xlsx.scrie_csv` + (adauga coloana `tip` goala pe drumul PDF, conform contractului). + - `extrage.py:55-59` `extrage_fdb` - cheama `citire_fdb.produce` (ca si `--an`/`--luna`). + - `extrage.py:62-85` CLI `--sursa/--fisier/--firma/--an/--luna/--dir`. +- `genereaza.py` (nou) - punct de intrare contractul intern -> xlsx: + - `genereaza.py:30-34` citire parteneri/facturi CSV (daca exista). + - `genereaza.py:37-46` `_mapare_si_exclusi` - din rezultatul `mapare.genereaza_mapare` iese + `override` (doar `sursa == CORECTAT`, cu `cont`/`acont` explicit) si `exclusi`. + - `genereaza.py:49-59` `_fara_exclusi` - un cont exclus iese cu tot cu subarbore (un parinte + fara copii ar rupe `arbore()`). + - `genereaza.py:62-112` fluxul: mapare -> generare -> scriere `init___.xlsx` + -> `verifica`; iese cu 1 doar cand verificarile de structura pica. +- `tests/test_integrare.py` (nou) - 4 teste (vezi 2). + +### 1.2 `genereaza_xlsx.py` (singurul fisier existent modificat) + +Parametrizare, nu rescriere: logica de constructie BALANTA/FACTURA si verificarile din etapa 5 +raman cu aceleasi formule. + +- `genereaza_xlsx.py:27-40` - `monthrange`; `TEMPLATE` devine cale absoluta langa script (iesirile + pot merge in orice `--dir`, inclusiv temp). Am scos constanta `FIRME` (hardcodarea ceruta) si + `main()`-ul vechi; modulul e acum biblioteca. +- `genereaza_xlsx.py:169-185` - `_data`/`_datascad` calculeaza `data`/`datascad` din `an`/`luna` + (prima/ultima zi); `rand(header, an, luna, ...)` primeste perioada ca parametru. Pentru + `2025/12` iese exact `01.12.2025` / `31.12.2025`, ca inainte. +- `genereaza_xlsx.py:188-203` - `_mapare_cont`: maparea suprascrie doar ce i s-a dat explicit; + la **parteneri** un `acont` lipsa (None) lasa forma generatorului. Asta e garda anti-dublare + din `decizii_import.md` pct. 16: maparea nu pre-completa `acont` generic pe `401`. +- `genereaza_xlsx.py:210-219` - `_text`/`_iso` (data ISO -> `DD.MM.YYYY` pentru facturile FDB). +- `genereaza_xlsx.py:222-410` - `genereaza(rows, header, an, luna, mapare, parteneri, facturi)`: + - conturile obisnuite iau `cont`/`acont` prin `_mapare_cont` (`:303-304`); + - partenerii BANCA pastreaza `acont`-ul analiticului, identic pe BALANTA si FACTURA + (`:329`, `:340-346`, `:388-389`) - corectia explicita de `acont` se aplica pe ambele; + - randurile de diferenta raman calculate in cod, fara mapare (`:309-318`, `:347-353`); + - `factura()` (`:264-297`) foloseste `parteneri`/`facturi` cand exista (cod fiscal real + un + rand FACTURA per document, doar cand suma documentelor = netul analiticului si soldul e pe + latura formularului), altfel cade pe comportamentul de azi (un FACTURA per partener, sold + total). Fara parametri, calea de azi e neatinsa. +- `genereaza_xlsx.py:427-428`, `:575-576` - `verifica(..., dir_verificare)` scrie + `verificare_.md` in directorul cerut si il creeaza daca lipseste. + +## 2. Verificari + +### 2.1 Teste + +Comanda: + +``` +py -m unittest discover -s tests -v +``` + +Rezultat (exact, la final): + +``` +Ran 27 tests in 3.226s + +OK +``` + +27 = cele 23 existente (nemodificate: 2 regresie + 6 citire_xlsx + 5 citire_fdb + 10 mapare) + +2 capat-la-capata (xlsx si fdb) + 2 dispecer. `EXIT=0`. `tests/test_regresie.py` trece +**neschimbat**, pe FUNDATIA si MASTER. + +Fluxurile au fost rulate si separat, cu `--dir` in temp (nu in radacina): + +- `py extrage.py --sursa xlsx --fisier exemple\saga-sqlite-balanta-09-2026.xlsx --firma JOACA + --an 2026 --luna 9 --dir ` -> `balanta_JOACA.csv` (10 randuri); + `py genereaza.py --firma JOACA --an 2026 --luna 9 --dir ` -> `rows: 11 + totdeb=7298.15 totcred=7298.15 net=0.0`, toate cele 4 verificari OK (exit 0). +- `py extrage.py --sursa fdb --fisier --firma JOACA --an 2026 --luna 9 + --dir ` -> `balanta_JOACA.csv` (10) + `parteneri_JOACA.csv` (2) + `facturi_JOACA.csv` (1); + `genereaza.py` -> aceleasi `rows: 11`, `totdeb=totcred=7298.15`, exit 0. +- `py extrage.py --sursa pdf --fisier "IBB 31.12.2025.pdf" ...` -> 88 randuri; + `genereaza.py --firma FUNDATIA --an 2025 --luna 12` -> `rows: 75 totdeb=totcred=7068369.5`, OK. +- deducere fara `--sursa` (`.xlsx` -> xlsx, `.FDB` -> fdb) si extensie `.csv` -> eroare, exit 1. + +### 2.2 Cens octeti si linii + +Toate fisierele atinse sunt ASCII strict, LF (ca restul `.py` din proiect). Zero octeti > 0x7F +inainte si dupa: `genereaza_xlsx.py` 0, `genereaza.py` 0, `extrage.py` 0, `tests/test_integrare.py` +0. `genereaza_xlsx.py` are `CRLF=0`/`LF=588` pe disc, identic cu HEAD (`CRLF=0`, LF), deci nu am +schimbat terminatorii de linie. + +## 3. Nefacut / blocat / intrebari deschise + +- **`exclus` din `corectii_.xlsx`**: onorat la nivel de rand + subarbore in `genereaza.py` + (nu era cerut explicit de sarcina). Nu exista test dedicat - mapare nu are coloana `exclus` in + `mapare_.xlsx`, deci nu se poate verifica automat din fisierul de mapare. +- **`parteneri_`/`facturi_` de pe drumul FDB**: calea "factura per document" e implementata, dar pe + firma de joaca **nu se poate exercita**: `citire_fdb` ruleaza analiticele in sintetic (decizia + lane-ului `citire-fdb`, raport sectiunea 3.1), deci balanta nu are `401.00001` si documentele nu + au carui analitic sa se lege; cade corect pe comportamentul de azi. Necesita o baza reala cu + analitice, ca si poarta cititorului FDB. +- O corectie de `cont` per analitic la un partener SINTETIC (nu BANCA) ar pune FACTURA pe alt cont + decat BALANTA agregat; `mapare._verifica_coliziuni` sare peste parteneri. Nu e cazul periculos + din sarcina (acela e `acont`, acoperit), dar merita stiut. +- Nu am actualizat `CLAUDE.md` (tabelul de fisiere) cu `extrage.py`/`genereaza.py`; nu era in + livrabil si nu sunt dependinte noi. + +## 4. Stare periculoasa + +Niciuna. Fara binar VFP editat/write-back, fara procese lasate pornite, fara fisiere in +`D:\ROA\ROACONT`, `D:\SAGA250909` sau `saga2roa*` (baza Firebird a fost doar copiata in temp, in +test si in verificarea manuala; copiile au fost sterse). In timpul verificarii manuale am scris +din greseala `balanta_JOACA.csv`, `parteneri_JOACA.csv`, `facturi_JOACA.csv` in radacina (rule +fara `workdir`); le-am sters, iar testele trimit explicit `--dir` in temp. Fara commit, fara +`git add`. diff --git a/extrage.py b/extrage.py new file mode 100644 index 0000000..c6cbbc9 --- /dev/null +++ b/extrage.py @@ -0,0 +1,86 @@ +"""Punct de intrare: de la sursa (PDF / foaie SAGA / Firebird) la contractul intern. + +Dispecerizeaza catre cititoarele existente, fara sa le dubleze logica: + pdf -> extract_balanta.parse_pdf (raportul tiparit SAGA) + xlsx -> citire_xlsx.citeste_balanta (.xls / .xlsx SAGA) + fdb -> citire_fdb.produce (copia unei baze CONT_BAZA.FDB) + +Scrie balanta_.csv si, pe drumul fdb, parteneri_.csv si +facturi_.csv. Daca --sursa lipseste, se deduce din extensie; o extensie +necunoscuta da eroare, nu ghicit. + +Utilizare: + py extrage.py --sursa pdf --fisier "" --firma FIRMA --an 2025 --luna 12 + py extrage.py --sursa xlsx --fisier "" --firma FIRMA --an 2026 --luna 9 + py extrage.py --sursa fdb --fisier "" --firma FIRMA --an 2026 --luna 9 +""" + +import argparse +import os + +import citire_fdb +import citire_xlsx +import extract_balanta + +SURSE_DUPA_EXTENSIE = { + ".pdf": "pdf", + ".xls": "xlsx", + ".xlsx": "xlsx", + ".fdb": "fdb", +} + + +def deduce_sursa(fisier): + ext = os.path.splitext(fisier)[1].lower() + sursa = SURSE_DUPA_EXTENSIE.get(ext) + if sursa is None: + raise SystemExit( + "extensie necunoscuta pentru %s: %r (astept .pdf, .xls/.xlsx sau .fdb)" + % (fisier, ext)) + return sursa + + +def extrage_pdf(fisier, cale): + rows = extract_balanta.parse_pdf(fisier) + citire_xlsx.scrie_csv(rows, cale) + return len(rows) + + +def extrage_xlsx(fisier, cale): + rows = citire_xlsx.citeste_balanta(fisier) + citire_xlsx.scrie_csv(rows, cale) + return len(rows) + + +def extrage_fdb(fisier, firma, an, luna, dir_iesire): + if an is None or luna is None: + raise SystemExit("--sursa fdb cere --an si --luna") + rez = citire_fdb.produce(fisier, firma, an, luna, dir_iesire) + return len(rez["balanta"]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sursa", choices=("pdf", "xlsx", "fdb"), + help="lipsa = dedusa din extensia --fisier") + parser.add_argument("--fisier", required=True) + parser.add_argument("--firma", required=True) + parser.add_argument("--an", type=int) + parser.add_argument("--luna", type=int) + parser.add_argument("--dir", default=".", help="directorul de iesire") + a = parser.parse_args() + + sursa = a.sursa or deduce_sursa(a.fisier) + os.makedirs(a.dir, exist_ok=True) + + if sursa == "fdb": + n = extrage_fdb(a.fisier, a.firma, a.an, a.luna, a.dir) + else: + cale = os.path.join(a.dir, "balanta_%s.csv" % a.firma) + n = extrage_pdf(a.fisier, cale) if sursa == "pdf" \ + else extrage_xlsx(a.fisier, cale) + print("scris balanta_%s.csv: %d randuri (sursa %s)" % (a.firma, n, sursa)) + + +if __name__ == "__main__": + main() diff --git a/genereaza.py b/genereaza.py new file mode 100644 index 0000000..53379c1 --- /dev/null +++ b/genereaza.py @@ -0,0 +1,123 @@ +"""Punct de intrare: de la contractul intern la xlsx-ul de import ROA. + +Citeste balanta_.csv, aplica maparea (mapare.py) si scrie: + init___.xlsx - xlsx-ul de import + mapare_.xlsx - propunerea completa (doar unealta scrie) + corectii_.xlsx - creat gol la prima rulare a firmei + docs/verificare_.md - verificarile obligatorii + +Maparea hotaraste cont/acont pentru conturile obisnuite. La conturile cu parteneri +forma lui acont ramane a generatorului: maparea suprascrie doar ce i se da explicit +in corectii_.xlsx (docs/decizii_import.md pct. 16). Randurile de diferenta +raman calculate in cod. + +Daca exista parteneri_.csv / facturi_.csv (drumul FDB), se folosesc: +cod fiscal real si un rand FACTURA per document. Lipsa lor = comportamentul de azi +(un FACTURA per partener, sold total). + +Utilizare: + py genereaza.py --firma FIRMA --an 2025 --luna 12 [--balanta balanta_FIRMA.csv] +""" + +import argparse +import csv +import os + +import genereaza_xlsx as generator +import mapare + + +def _citeste_csv(path): + if not os.path.exists(path): + return [] + with open(path, encoding="utf-8-sig", newline="") as f: + return list(csv.DictReader(f)) + + +def _mapare_si_exclusi(rezultat): + """{cont_saga: {cont, acont}} pentru corectiile explicite + multimea exclusilor.""" + override = {} + exclusi = set() + for r in rezultat: + if r["exclus"]: + exclusi.add(r["cont_saga"]) + elif r["sursa"] == mapare.SURSA_CORECTAT: + override[r["cont_saga"]] = {"cont": r["cont"], "acont": r["acont"]} + return override, exclusi + + +def _fara_exclusi(rows, exclusi): + """Un cont exclus iese cu tot cu subarborele lui (un parinte fara copii ar rupe arborele).""" + if not exclusi: + return rows + out = [] + for r in rows: + c = r["cont"] + if any(c == e or c.startswith(e + ".") for e in exclusi): + continue + out.append(r) + return out + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--firma", required=True) + parser.add_argument("--an", type=int, required=True) + parser.add_argument("--luna", type=int, required=True) + parser.add_argument("--balanta", + help="implicit balanta_.csv din --dir") + parser.add_argument("--dir", default=".", help="directorul de iesire") + a = parser.parse_args() + + os.makedirs(a.dir, exist_ok=True) + balanta = a.balanta or os.path.join(a.dir, "balanta_%s.csv" % a.firma) + if not os.path.exists(balanta): + raise SystemExit("lipseste balanta: %s" % balanta) + + header = generator.citeste_header() + if len(header) != 39: + raise SystemExit("cap de tabel neasteptat: %d coloane" % len(header)) + rows = generator.citeste_balanta(balanta) + + rezultat = mapare.genereaza_mapare(rows, a.firma, a.dir) + override, exclusi = _mapare_si_exclusi(rezultat) + rows = _fara_exclusi(rows, exclusi) + + parteneri = _citeste_csv(os.path.join(a.dir, "parteneri_%s.csv" % a.firma)) + facturi = _citeste_csv(os.path.join(a.dir, "facturi_%s.csv" % a.firma)) + + out, diffs, leaf_map, leaf_rep, negative = generator.genereaza( + rows, header, an=a.an, luna=a.luna, mapare=override, + parteneri=parteneri, facturi=facturi) + + xlsx_path = os.path.join(a.dir, "init_%s_%d_%d.xlsx" + % (a.firma, a.an, a.luna)) + generator.scrie_xlsx(header, out, xlsx_path) + rez = generator.verifica(a.firma, balanta, xlsx_path, rows, out, diffs, + leaf_map, leaf_rep, negative, header, + dir_verificare=os.path.join(a.dir, "docs")) + + print("=== %s ===" % a.firma) + print(" rows: %d totdeb=%s totcred=%s net=%s" + % (len(out), rez["totdeb"], rez["totcred"], rez["net"])) + print(" balanta=%s frunze=%s parteneri=%s lungimi=%s" + % ("OK" if rez["bal"] else "ESEC", + "OK" if rez["leaves"] else "ESEC", + "OK" if rez["part"] else "ESEC", + "OK" if rez["lung"] else "ESEC")) + print(" scris %s si %s" % (xlsx_path, + os.path.join(a.dir, "docs", + "verificare_%s.md" % a.firma))) + if rez["negative"]: + print(" analitice cu semn negativ: %s" % (rez["negative"],)) + if rez["pierdute"] or rez["dubluri_leaf"] or rez["dubluri_bal"] or rez["lungimi"]: + print(" ATENTIE: %s %s %s %s" + % (rez["pierdute"], rez["dubluri_leaf"], rez["dubluri_bal"], + rez["lungimi"])) + + toate = rez["bal"] and rez["leaves"] and rez["part"] and rez["lung"] + raise SystemExit(0 if toate else 1) + + +if __name__ == "__main__": + main() diff --git a/genereaza_xlsx.py b/genereaza_xlsx.py index 86cce46..82d1e73 100644 --- a/genereaza_xlsx.py +++ b/genereaza_xlsx.py @@ -1,9 +1,9 @@ -"""Genereaza fisierele xlsx de initializare ROACONT din balantele SAGA (CSV). +"""Generatorul xlsx-urilor de initializare ROACONT din balantele SAGA (CSV). -Produce (pentru fiecare firma): - init__2025_12.xlsx - un sheet, randul 1 = capul de tabel, - datele de la randul 2 in jos - docs/verificare_.md - verificarile obligatorii +Biblioteca folosita de `genereaza.py` (parametri de linie de comanda); produce: + init___.xlsx - un sheet, randul 1 = capul de tabel, + datele de la randul 2 in jos + verificare_.md - verificarile obligatorii Reguli: docs/decizii_import.md (pct. 11-15) si docs/conturi_cu_analitice.md. @@ -26,17 +26,18 @@ Forma randurilor (revizuit 20.09.2026, vezi pct. 14-15): import csv import os +from calendar import monthrange from openpyxl import load_workbook AN = 2025 LUNA = 12 -DATA = "01.12.2025" -DATASCAD = "31.12.2025" SERIE = None VALUTA = None -TEMPLATE = "init_facturi_balanta_note.xlsx" +# Sablonul sta langa script, nu langa directorul de lucru: iesirile pot merge in temp. +TEMPLATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "init_facturi_balanta_note.xlsx") # Latura pe care formularul pune randurile FACTURA (frm...sc2:379/'383'): # conturile din lista merg pe CREDIT, tot restul pe DEBIT. @@ -78,11 +79,6 @@ def citeste_conturi_parteneri(path=CONFIG_PARTENERI): PARTENER = PARTENER_BANCA | PARTENER_SINTETIC | PARTENER_FARA_ANALITICE -FIRME = [ - ("FUNDATIA", "balanta_FUNDATIA.csv"), - ("MASTER", "balanta_MASTERJOB.csv"), -] - def citeste_header(): wb = load_workbook(TEMPLATE, read_only=True) @@ -170,30 +166,81 @@ def acont_diferenta(parent, kids): return ac -def rand(header, **kw): +def _data(an, luna): + """Prima zi a lunii, in formatul din sablon (DD.MM.YYYY).""" + return "01.%02d.%04d" % (int(luna), int(an)) + + +def _datascad(an, luna): + """Ultima zi a lunii, in formatul din sablon (DD.MM.YYYY).""" + return "%02d.%02d.%04d" % (monthrange(int(an), int(luna))[1], + int(luna), int(an)) + + +def rand(header, an, luna, **kw): r = {h: None for h in header} - r.update(an=AN, luna=LUNA, data=DATA, datascad=DATASCAD, + r.update(an=an, luna=luna, data=_data(an, luna), datascad=_datascad(an, luna), serie=SERIE, valuta=VALUTA) r.update(kw) return r +def _mapare_cont(cont_saga, cont_def, acont_def, mapare, partener): + """Cont/acont final: maparea suprascrie doar ce i s-a dat explicit. + + La parteneri `acont` ramane al generatorului (o pre-completare generica ar + pune acont pe 401 -> chei diferite -> dublare tacuta, decizii_import.md pct. 16); + o corectie explicita de `acont` se aplica totusi, pe ambele randuri. + """ + if not mapare: + return cont_def, acont_def + m = mapare.get(cont_saga) + if m is None: + return cont_def, acont_def + cont = m.get("cont") or cont_def + if partener and m.get("acont") is None: + return cont, acont_def + return cont, m.get("acont") + + def este_latura_formular(net, side): return (net > 0) == (side == "D") -def genereaza(rows, header): +def _text(v): + return "" if v is None else str(v).strip() + + +def _iso(s): + """'YYYY-MM-DD' -> 'DD.MM.YYYY'; altceva -> None.""" + p = _text(s).split("-") + if len(p) == 3 and len(p[0]) == 4: + return "%s.%s.%s" % (p[2], p[1], p[0]) + return None + + +def genereaza(rows, header, an=AN, luna=LUNA, mapare=None, parteneri=None, + facturi=None): by, children = arbore(rows) diffs = diferente(by, children) diff_by_parent = dict(diffs) - # verificarile de structura care nu se ghicesc + # parteneri/facturi reale (doar pe drumul FDB); lipsa = comportamentul de azi + part_map = {} + for r in (parteneri or []): + part_map[_text(r.get("cont_analitic"))] = r + fact_map = {} + for r in (facturi or []): + fact_map.setdefault(_text(r.get("cont_analitic")), []).append(r) + + # verificarile de structura care nu se ghicesc (pe valorile finale, dupa mapare) for cont in by: if este_partener(cont): continue if cont not in children: cont4, ac = split_cont(cont) - if len(cont4) > 4 or len(ac) > 4: + cont4, ac = _mapare_cont(cont, cont4, ac, mapare, partener=False) + if len(cont4) > 4 or len(ac or "") > 4: raise Stop(f"cont/acont prea lung: {cont} -> ({cont4},{ac})") for parent in diff_by_parent: if not este_partener(parent) or parent.split(".")[0] in PARTENER_BANCA: @@ -214,27 +261,64 @@ def genereaza(rows, header): leaf_map.setdefault(leaf, []).append(len(out) - 1) leaf_rep.add(leaf) + def factura(cod_saga, cont_out, acont, nume_def, side, net, sold, leaf): + """Un FACTURA agregat (azi) sau, cand exista documente reale, unul per factura.""" + info = part_map.get(cod_saga) or {} + cod_fisc = _text(info.get("cod_fiscal")) or cod_saga + nume = nume_def or _text(info.get("denumire")) or ("FURNIZOR %s" % cod_saga) + docs = fact_map.get(cod_saga) + if docs and sold > 0 and este_latura_formular(net, side): + total = round(sum(float(d.get("sold") or 0) for d in docs), 2) + if abs(total - abs(net)) <= 0.005: + primul = True + for d in docs: + v = round(float(d.get("sold") or 0), 2) + if v == 0: + continue + r = rand(header, an, luna, TIP="FACTURA", cont=cont_out, + acont=acont, numar=_text(d.get("numar")), nume=nume, + cod_fiscal=cod_fisc, sold=v, explicatia=side) + data, scad = _iso(d.get("data")), _iso(d.get("scadent")) + if data: + r["data"] = data + if scad: + r["datascad"] = scad + if primul: + add(r, leaf=leaf) + primul = False + else: + add(r) + if leaf is not None: + leaf_rep.add(leaf) + if not primul: + return + add(rand(header, an, luna, TIP="FACTURA", cont=cont_out, acont=acont, + numar=cod_saga.replace(".", ""), nume=nume, cod_fiscal=cod_fisc, + sold=sold, explicatia=side), leaf=leaf) + # A. conturi nepartener: cate un BALANTA per frunza for r in rows: if este_partener(r["cont"]) or r["cont"] in children: continue cont4, ac = split_cont(r["cont"]) - add(rand(header, TIP="BALANTA", cont=cont4, acont=ac, numar=1, + cont4, ac = _mapare_cont(r["cont"], cont4, ac, mapare, partener=False) + add(rand(header, an, luna, TIP="BALANTA", cont=cont4, acont=ac, numar=1, nume=r["denumire"], totdeb=round(r["total_d"], 2), totcred=round(r["total_c"], 2)), leaf=r["cont"]) - # A. analitic de diferenta pe parinte nepartener (postare directa pe sintetic) + # A. analitic de diferenta pe parinte nepartener (postare directa pe sintetic). + # Randurile de diferenta raman calculate aici, nu trec prin mapare. for cont, d in diffs: if este_partener(cont): continue ac = acont_diferenta(cont, children[cont]) - add(rand(header, TIP="BALANTA", cont=cont.split(".")[0], acont=ac, + add(rand(header, an, luna, TIP="BALANTA", cont=cont.split(".")[0], acont=ac, numar=1, nume="DIFERENTA SINTETIC SAGA", totdeb=d if d > 0 else None, totcred=-d if d < 0 else None)) - parteneri = sorted({c.split(".")[0] for c in by if este_partener(c)}) - for p in parteneri: + parteneri_activi = sorted({c.split(".")[0] for c in by if este_partener(c)}) + for p in parteneri_activi: direct = children.get(p, []) side = latura_factura(p) @@ -242,7 +326,7 @@ def genereaza(rows, header): # 5121/5124: BALANTA cu acont + FACTURA cu ACELASI acont. for k in direct: kd = by[k] - kac = split_cont(k)[1] + cont_k, kac = _mapare_cont(k, p, split_cont(k)[1], mapare, True) net = round(kd["total_d"] - kd["total_c"], 2) if net != 0 and not este_latura_formular(net, side): neg = -abs(net) @@ -253,19 +337,17 @@ def genereaza(rows, header): td, tc = None, neg else: td, tc = round(kd["total_d"], 2), round(kd["total_c"], 2) - add(rand(header, TIP="BALANTA", cont=p, acont=kac, numar=1, - nume=kd["denumire"], totdeb=td, totcred=tc), leaf=k) + add(rand(header, an, luna, TIP="BALANTA", cont=cont_k, acont=kac, + numar=1, nume=kd["denumire"], totdeb=td, totcred=tc), + leaf=k) if net == 0: continue sold = abs(net) if este_latura_formular(net, side) else -abs(net) - add(rand(header, TIP="FACTURA", cont=p, acont=kac, - numar=k.replace(".", ""), - nume=kd["denumire"] or f"FURNIZOR {k}", - cod_fiscal=k, sold=sold, explicatia=side)) + factura(k, cont_k, kac, kd["denumire"], side, net, sold, None) if p in diff_by_parent: d = diff_by_parent[p] ac = acont_diferenta(p, direct) - add(rand(header, TIP="BALANTA", cont=p, acont=ac, numar=1, + add(rand(header, an, luna, TIP="BALANTA", cont=p, acont=ac, numar=1, nume="DIFERENTA SINTETIC SAGA", totdeb=d if d > 0 else None, totcred=-d if d < 0 else None)) @@ -288,8 +370,20 @@ def genereaza(rows, header): base_d += by[k]["total_d"] base_c += by[k]["total_c"] d = diff_by_parent.get(p, 0.0) - add(rand(header, TIP="BALANTA", cont=p, acont=None, numar=1, - nume=by[p]["denumire"], + cont_p, ac_p = _mapare_cont(p, p, None, mapare, True) + if cont_p == p: # .TOATE/.RESTUL poate redenumi fara rand pe sintetic + # BALANTA agregat si randurile FACTURA trebuie sa cada pe acelasi + # cont. Daca analiticele sunt mapate pe conturi diferite, agregatul + # nu mai corespunde detaliului: oprim, nu luam primul analitic. + variante = {_mapare_cont(k, p, None, mapare, True)[0] for k in direct} + if len(variante) > 1: + raise Stop(f"analiticele lui {p} sunt mapate pe conturi diferite " + f"({sorted(variante)}); BALANTA agregat nu mai poate " + f"corespunde randurilor FACTURA") + if variante: + cont_p = variante.pop() + add(rand(header, an, luna, TIP="BALANTA", cont=cont_p, acont=ac_p, + numar=1, nume=by[p]["denumire"], totdeb=round(base_d + (d if d > 0 else 0.0), 2), totcred=round(base_c + (-d if d < 0 else 0.0), 2))) for k in direct: @@ -298,11 +392,8 @@ def genereaza(rows, header): leaf_rep.add(k) continue sold = abs(net) if este_latura_formular(net, side) else -abs(net) - add(rand(header, TIP="FACTURA", cont=p, acont=None, - numar=k.replace(".", ""), - nume=by[k]["denumire"] or f"FURNIZOR {k}", - cod_fiscal=k, sold=sold, explicatia=side), - leaf=k) + cont_k, kac = _mapare_cont(k, cont_p, ac_p, mapare, True) + factura(k, cont_k, kac, by[k]["denumire"], side, net, sold, k) else: net = round(by[p]["total_d"] - by[p]["total_c"], 2) if net != 0 and not este_latura_formular(net, side): @@ -315,14 +406,14 @@ def genereaza(rows, header): else: td = round(by[p]["total_d"], 2) tc = round(by[p]["total_c"], 2) - add(rand(header, TIP="BALANTA", cont=p, acont=None, numar=1, - nume=by[p]["denumire"], totdeb=td, totcred=tc), leaf=p) + cont_p, ac_p = _mapare_cont(p, p, None, mapare, True) + add(rand(header, an, luna, TIP="BALANTA", cont=cont_p, acont=ac_p, + numar=1, nume=by[p]["denumire"], totdeb=td, totcred=tc), + leaf=p) if net != 0: nume = "CENTRALA" if p == "5311" else by[p]["denumire"] sold = abs(net) if este_latura_formular(net, side) else -abs(net) - add(rand(header, TIP="FACTURA", cont=p, acont=None, - numar=p, nume=nume, cod_fiscal=p, sold=sold, - explicatia=side)) + factura(p, cont_p, ac_p, nume, side, net, sold, None) return out, diffs, leaf_map, leaf_rep, negative @@ -341,7 +432,7 @@ def scrie_xlsx(header, rows, path): def verifica(firma, csv_path, xlsx_path, rows, out, diffs, leaf_map, leaf_rep, - negative, header): + negative, header, dir_verificare="docs"): by, children = arbore(rows) leaves = [c for c in by if c not in children] diff_by_parent = dict(diffs) @@ -488,7 +579,9 @@ def verifica(firma, csv_path, xlsx_path, rows, out, diffs, leaf_map, leaf_rep, "control intern si este goala in xlsx-ul final (capul de tabel o pastreaza).") L.append("") - with open(os.path.join("docs", f"verificare_{firma}.md"), "w", + if dir_verificare: + os.makedirs(dir_verificare, exist_ok=True) + with open(os.path.join(dir_verificare, f"verificare_{firma}.md"), "w", encoding="utf-8") as f: f.write("\n".join(L)) @@ -499,29 +592,4 @@ def verifica(firma, csv_path, xlsx_path, rows, out, diffs, leaf_map, leaf_rep, "part_verif": part_verif, "negative": negative} -def main(): - header = citeste_header() - assert len(header) == 39, f"cap de tabel neasteptat: {len(header)} coloane" - for firma, csv_path in FIRME: - rows = citeste_balanta(csv_path) - out, diffs, leaf_map, leaf_rep, negative = genereaza(rows, header) - xlsx_path = f"init_{firma}_2025_12.xlsx" - scrie_xlsx(header, out, xlsx_path) - rez = verifica(firma, csv_path, xlsx_path, rows, out, diffs, leaf_map, - leaf_rep, negative, header) - print(f"=== {firma} ===") - print(f" rows: {len(out)} totdeb={rez['totdeb']} totcred={rez['totcred']} net={rez['net']}") - print(f" balanta={'OK' if rez['bal'] else 'ESEC'} " - f"frunze={'OK' if rez['leaves'] else 'ESEC'} " - f"parteneri={'OK' if rez['part'] else 'ESEC'} " - f"lungimi={'OK' if rez['lung'] else 'ESEC'}") - print(f" scris {xlsx_path} si docs/verificare_{firma}.md") - if rez["negative"]: - print(f" analitice cu semn negativ: {rez['negative']}") - if rez["pierdute"] or rez["dubluri_leaf"] or rez["dubluri_bal"] or rez["lungimi"]: - print(" ATENTIE:", rez["pierdute"], rez["dubluri_leaf"], - rez["dubluri_bal"], rez["lungimi"]) - -if __name__ == "__main__": - main() diff --git a/tests/test_integrare.py b/tests/test_integrare.py new file mode 100644 index 0000000..5080df2 --- /dev/null +++ b/tests/test_integrare.py @@ -0,0 +1,164 @@ +"""Test de integrare: capat-la-capat pe firma de joaca, din ambele surse. + +Ruleaza `extrage.py` apoi `genereaza.py` in directoare temporare (nu scrie nimic in +radacina proiectului), din: + - exportul de foaie de calcul SAGA (exemple/saga-sqlite-balanta-09-2026.xlsx); + - copia bazei Firebird D:\\SAGA250909\\0001\\CONT_BAZA.FDB. + +Verifica: xlsx-ul de import exista, are randuri de date si SUM(totdeb) = SUM(totcred). + +Pe o masina fara baza / fbclient.dll / driver, teste FDB se sar, nu esueaza. + +Rulare: py -m unittest discover -s tests -v +""" +import os +import pathlib +import shutil +import subprocess +import sys +import tempfile +import unittest + +import openpyxl + +RADACINA = pathlib.Path(__file__).resolve().parent.parent +if str(RADACINA) not in sys.path: + sys.path.insert(0, str(RADACINA)) + +import extrage +import genereaza_xlsx + +EXTRAGE = RADACINA / "extrage.py" +GENEREAZA = RADACINA / "genereaza.py" +XLSX_EXEMPLU = RADACINA / "exemple" / "saga-sqlite-balanta-09-2026.xlsx" +FBCLIENT = pathlib.Path(r"C:\Program Files\Firebird\Firebird30_Saga\fbclient.dll") +BAZA = pathlib.Path(r"D:\SAGA250909\0001\CONT_BAZA.FDB") + +FIRMA = "JOACA" +AN, LUNA = 2026, 9 +TOL = 0.01 + + +def _driver_disponibil(): + try: + import firebird.driver # noqa: F401 + return True + except Exception: + return False + + +def _ruleaza(script, arg, dir_iesire): + """Ruleaza un punct de intrare cu `--dir` in directorul temporar si verificat.""" + return subprocess.run( + [sys.executable, str(script), "--firma", FIRMA, + "--an", str(AN), "--luna", str(LUNA), "--dir", str(dir_iesire)] + arg, + cwd=str(dir_iesire), capture_output=True, text=True) + + +def _sume_xlsx(cale): + wb = openpyxl.load_workbook(cale) + try: + ws = wb.active + header = [c.value for c in ws[1]] + i_deb = header.index("totdeb") + i_cred = header.index("totcred") + deb = cred = 0.0 + if ws.max_row > 1: + for row in ws.iter_rows(min_row=2, values_only=True): + deb += row[i_deb] or 0 + cred += row[i_cred] or 0 + return ws.max_row, deb, cred + finally: + wb.close() + + +class TestIntegrare(unittest.TestCase): + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="integrare_") + self.dir = pathlib.Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def _flux(self, sursa, fisier): + r = _ruleaza(EXTRAGE, ["--sursa", sursa, "--fisier", str(fisier)], self.dir) + self.assertEqual(r.returncode, 0, "extrage.py a picat:\n%s\n%s" + % (r.stdout, r.stderr)) + self.assertTrue((self.dir / ("balanta_%s.csv" % FIRMA)).exists(), + "extrage.py nu a scris balanta") + + r = _ruleaza(GENEREAZA, [], self.dir) + self.assertEqual(r.returncode, 0, "genereaza.py a picat:\n%s\n%s" + % (r.stdout, r.stderr)) + + xlsx = self.dir / ("init_%s_%d_%d.xlsx" % (FIRMA, AN, LUNA)) + self.assertTrue(xlsx.exists(), "lipseste xlsx-ul de import: %s" % xlsx) + n, deb, cred = _sume_xlsx(xlsx) + self.assertGreater(n, 1, "xlsx-ul de import nu are randuri de date") + self.assertAlmostEqual(deb, cred, delta=TOL, + msg="SUM(totdeb)=%s != SUM(totcred)=%s" % (deb, cred)) + return xlsx + + def test_capat_la_capat_xlsx(self): + if not XLSX_EXEMPLU.exists(): + self.skipTest("lipseste exportul %s" % XLSX_EXEMPLU) + self._flux("xlsx", XLSX_EXEMPLU) + + def test_capat_la_capat_fdb(self): + if not FBCLIENT.exists(): + self.skipTest("lipseste fbclient.dll: %s" % FBCLIENT) + if not BAZA.exists(): + self.skipTest("lipseste baza Firebird: %s" % BAZA) + if not _driver_disponibil(): + self.skipTest("lipseste pachetul firebird-driver") + copie = self.dir / "CONT_BAZA.FDB" + shutil.copy2(str(BAZA), str(copie)) + self._flux("fdb", copie) + + +class TestDispecerSursa(unittest.TestCase): + + def test_deducere_din_extensie(self): + self.assertEqual(extrage.deduce_sursa("balanta.pdf"), "pdf") + self.assertEqual(extrage.deduce_sursa("balanta.xls"), "xlsx") + self.assertEqual(extrage.deduce_sursa("balanta.XLSX"), "xlsx") + self.assertEqual(extrage.deduce_sursa("CONT_BAZA.FDB"), "fdb") + + def test_extensie_necunoscuta_da_eroare(self): + with self.assertRaises(SystemExit): + extrage.deduce_sursa("balanta.csv") + + +if __name__ == "__main__": + unittest.main() + + +class TestMapareSinteticPartener(unittest.TestCase): + """Analiticele unui cont cu parteneri nu au voie sa cada pe conturi diferite: + BALANTA e agregat pe sintetic, FACTURA e per analitic, iar o redenumire + partiala ar rupe tacit corespondenta dintre ele.""" + + ROWS = [ + {"cont": "401", "denumire": "FURNIZORI", "total_d": 0.0, "total_c": 300.0}, + {"cont": "401.0001", "denumire": "A", "total_d": 0.0, "total_c": 100.0}, + {"cont": "401.0002", "denumire": "B", "total_d": 0.0, "total_c": 200.0}, + ] + + def setUp(self): + self.header = genereaza_xlsx.citeste_header() + + def test_conturi_diferite_dau_eroare(self): + mapare = {"401.0001": {"cont": "4011", "acont": None}, + "401.0002": {"cont": "4012", "acont": None}} + with self.assertRaises(genereaza_xlsx.Stop) as ctx: + genereaza_xlsx.genereaza(self.ROWS, self.header, 2025, 12, mapare=mapare) + self.assertIn("conturi diferite", str(ctx.exception)) + + def test_acelasi_cont_pe_toate_analiticele_e_legal(self): + mapare = {"401.0001": {"cont": "4011", "acont": None}, + "401.0002": {"cont": "4011", "acont": None}} + out, _, _, _, _ = genereaza_xlsx.genereaza(self.ROWS, self.header, 2025, 12, + mapare=mapare) + conturi = {r["cont"] for r in out} + self.assertEqual({"4011"}, conturi)