Lane integrare: extrage.py si genereaza.py cu parametri, maparea legata

Firmele si lunile nu mai sunt hardcodate. extrage.py dispecerizeaza catre cele
trei cititoare (pdf / xlsx / fdb, dedus din extensie, extensie necunoscuta =
eroare) si scrie contractul intern; genereaza.py aplica maparea si scrie
init_<FIRMA>_<an>_<luna>.xlsx plus mapare_<FIRMA>.xlsx.

genereaza_xlsx.py devine biblioteca: an/luna parametri, data si scadenta
calculate din ele, sablonul cautat langa script. Cand exista parteneri_ si
facturi_ (drumul FDB) se emite cate un FACTURA per document, cu cod fiscal real,
si numai daca suma documentelor da netul contului; altfel ramane comportamentul
de azi, un FACTURA agregat.

Corectat in orchestrare: la redenumirea unui cont cu parteneri, generatorul lua
primul analitic ca sa afle contul sintetic. Daca analiticele erau mapate pe
conturi diferite, BALANTA agregat si randurile FACTURA cadeau pe conturi
diferite, tacut. Acum se opreste cu eroare; colapsarea pe acelasi cont ramane
legala. Doua teste acopera ambele cazuri.

29 de teste trec, inclusiv regresia pe etalon cu generatorul modificat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYeAtVxeS8m4oXekjX8Am2
This commit is contained in:
2026-09-21 16:23:45 +03:00
parent 6b37d370d7
commit 82511c8adb
5 changed files with 630 additions and 70 deletions

View File

@@ -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_<FIRMA>_<an>_<luna>.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_<FIRMA>.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 <temp>` -> `balanta_JOACA.csv` (10 randuri);
`py genereaza.py --firma JOACA --an 2026 --luna 9 --dir <temp>` -> `rows: 11
totdeb=7298.15 totcred=7298.15 net=0.0`, toate cele 4 verificari OK (exit 0).
- `py extrage.py --sursa fdb --fisier <copie CONT_BAZA.FDB> --firma JOACA --an 2026 --luna 9
--dir <temp>` -> `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_<FIRMA>.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_<FIRMA>.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`.

86
extrage.py Normal file
View File

@@ -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_<FIRMA>.csv si, pe drumul fdb, parteneri_<FIRMA>.csv si
facturi_<FIRMA>.csv. Daca --sursa lipseste, se deduce din extensie; o extensie
necunoscuta da eroare, nu ghicit.
Utilizare:
py extrage.py --sursa pdf --fisier "<balanta.pdf>" --firma FIRMA --an 2025 --luna 12
py extrage.py --sursa xlsx --fisier "<balanta.xlsx>" --firma FIRMA --an 2026 --luna 9
py extrage.py --sursa fdb --fisier "<copie CONT_BAZA.FDB>" --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()

123
genereaza.py Normal file
View File

@@ -0,0 +1,123 @@
"""Punct de intrare: de la contractul intern la xlsx-ul de import ROA.
Citeste balanta_<FIRMA>.csv, aplica maparea (mapare.py) si scrie:
init_<FIRMA>_<an>_<luna>.xlsx - xlsx-ul de import
mapare_<FIRMA>.xlsx - propunerea completa (doar unealta scrie)
corectii_<FIRMA>.xlsx - creat gol la prima rulare a firmei
docs/verificare_<FIRMA>.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_<FIRMA>.xlsx (docs/decizii_import.md pct. 16). Randurile de diferenta
raman calculate in cod.
Daca exista parteneri_<FIRMA>.csv / facturi_<FIRMA>.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_<FIRMA>.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()

View File

@@ -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_<FIRMA>_2025_12.xlsx - un sheet, randul 1 = capul de tabel,
datele de la randul 2 in jos
docs/verificare_<FIRMA>.md - verificarile obligatorii
Biblioteca folosita de `genereaza.py` (parametri de linie de comanda); produce:
init_<FIRMA>_<an>_<luna>.xlsx - un sheet, randul 1 = capul de tabel,
datele de la randul 2 in jos
verificare_<FIRMA>.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()

164
tests/test_integrare.py Normal file
View File

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