diff --git a/citire_xlsx.py b/citire_xlsx.py new file mode 100644 index 0000000..ddf588f --- /dev/null +++ b/citire_xlsx.py @@ -0,0 +1,172 @@ +"""Cititor pentru balantele SAGA exportate ca foaie de calcul (.xls / .xlsx). + +Produce balanta_.csv cu contractul intern, aceleasi coloane ca extract_balanta.py: +pagina, cont, denumire, prec_d, prec_c, rulaj_d, rulaj_c, total_d, total_c, +sold_d, sold_c, este_total, tip. + +.xlsx cu openpyxl, .xls cu xlrd (openpyxl nu citeste BIFF). Se citeste prima foaie a +registrului, fara cautare dupa nume (numele difera: balanta / xl / Sheet1). Numele de +coloana se potrivesc exact si case-insensitive, niciodata pe substring (TOTAL_DEB si +TOTAL_DEB_1 sunt coloane diferite). CONT se citeste ca text: 401.00002 citit ca numar +se strica. pagina si este_total raman goale (exportul nu are rand de totaluri). + +Utilizare: + py citire_xlsx.py "" "" +""" + +import csv +import os +import sys + +import openpyxl +import xlrd + +COLOANE = ["pagina", "cont", "denumire", "prec_d", "prec_c", "rulaj_d", "rulaj_c", + "total_d", "total_c", "sold_d", "sold_c", "este_total", "tip"] + +MAPARE_SUME = { + "DEB_PREC": "prec_d", + "CRED_PREC": "prec_c", + "RULAJ_D": "rulaj_d", + "RULAJ_C": "rulaj_c", + "TOTAL_DEB": "total_d", + "TOTAL_CRED": "total_c", + "FIN_D": "sold_d", + "FIN_C": "sold_c", +} + +COLOANE_SURSA = ["CONT", "DENUMIRE", "TIP"] + list(MAPARE_SUME) + + +def _index_antet(antet): + """Indexul fiecarei coloane cerute, pe potrivire exacta case-insensitive.""" + normal = [("" if h is None else str(h)).strip().upper() for h in antet] + idx = {} + for i, nume in enumerate(normal): + if nume == "": + continue + if nume in idx: + raise ValueError("coloana %s apare de doua ori in antet" % nume) + idx[nume] = i + lipsa = [c for c in COLOANE_SURSA if c not in idx] + if lipsa: + raise ValueError("coloane lipsa in antetul SAGA: %s" % ", ".join(lipsa)) + return idx + + +def _randuri_xlsx(cale): + wb = openpyxl.load_workbook(cale, data_only=True, read_only=True) + try: + ws = wb[wb.sheetnames[0]] + it = ws.iter_rows(values_only=True) + try: + antet = list(next(it)) + except StopIteration: + raise ValueError("fisier fara antet: %s" % cale) + randuri = [list(r) for r in it] + finally: + wb.close() + return antet, randuri + + +def _randuri_xls(cale): + # Exportul SAGA (VFP) nu are inregistrare CODEPAGE; fara override xlrd cade pe + # iso-8859-1 si strica diacriticele ("FURNIZORI \x97 DEBITORI"). SAGA scrie WIN1250. + book = xlrd.open_workbook(cale, encoding_override="cp1250") + sh = book.sheet_by_index(0) + if sh.nrows == 0: + raise ValueError("fisier fara antet: %s" % cale) + antet = [sh.cell_value(0, c) for c in range(sh.ncols)] + randuri = [[sh.cell_value(r, c) for c in range(sh.ncols)] + for r in range(1, sh.nrows)] + return antet, randuri + + +def _randuri(cale): + ext = os.path.splitext(cale)[1].lower() + if ext == ".xlsx": + return _randuri_xlsx(cale) + if ext == ".xls": + return _randuri_xls(cale) + raise ValueError("extensie nesuportata pentru %s (astept .xls sau .xlsx)" % cale) + + +def _camp(raw, i): + return raw[i] if i < len(raw) else None + + +def _text(v): + if v is None: + return "" + if isinstance(v, str): + return v.strip() + return str(v).strip() + + +def _cont(v): + """CONT ca text: un float intreg nu primeste zecimale, unul cu zecimale ramane exact.""" + if v is None: + return "" + if isinstance(v, str): + return v.strip() + if isinstance(v, bool): + return str(v) + if isinstance(v, float): + return str(int(v)) if v.is_integer() else repr(v) + return str(v) + + +def _num(v): + if v is None: + return 0.0 + if isinstance(v, bool): + return float(int(v)) + if isinstance(v, (int, float)): + return float(v) + if isinstance(v, str): + s = v.strip() + if s in ("", "-"): + return 0.0 + return float(s) + raise ValueError("valoare numerica neasteptata: %r" % (v,)) + + +def citeste_balanta(cale): + """Randurile balantei din prima foaie, in contractul intern.""" + antet, brute = _randuri(cale) + idx = _index_antet(antet) + rows = [] + for raw in brute: + cont = _cont(_camp(raw, idx["CONT"])) + denumire = _text(_camp(raw, idx["DENUMIRE"])) + if cont == "" and denumire == "" and \ + all(_camp(raw, idx[n]) in (None, "") for n in MAPARE_SUME): + continue + rand = {"pagina": "", "cont": cont, "denumire": denumire} + for nume, col in MAPARE_SUME.items(): + rand[col] = _num(_camp(raw, idx[nume])) + rand["este_total"] = "" + rand["tip"] = _text(_camp(raw, idx["TIP"])) + rows.append(rand) + return rows + + +def scrie_csv(rows, cale): + 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 main(): + if len(sys.argv) != 3: + print(__doc__) + sys.exit(1) + cale, iesire = sys.argv[1], sys.argv[2] + rows = citeste_balanta(cale) + scrie_csv(rows, iesire) + print("scris %s: %d randuri" % (iesire, len(rows))) + + +if __name__ == "__main__": + main() diff --git a/docs/raport_lane_citire_xlsx.md b/docs/raport_lane_citire_xlsx.md new file mode 100644 index 0000000..726d67b --- /dev/null +++ b/docs/raport_lane_citire_xlsx.md @@ -0,0 +1,103 @@ +# Raport lane `citire-xlsx` - cititorul de foaie de calcul SAGA + +Data: 21.09.2026. Lane: `citire-xlsx`, in paralel cu `mapare`. + +## 1. Facut + +### Livrabil nou: `citire_xlsx.py` + +Cititor pentru balantele SAGA `.xls` / `.xlsx`, scrie `balanta_.csv` cu contractul +intern (13 coloane). Puncte: + +- `citire_xlsx.py:24` - `COLOANE` = contractul intern, identic cu `extract_balanta.py` plus `tip`. +- `citire_xlsx.py:27` - `MAPARE_SUME`: `DEB_PREC/CRED_PREC -> prec_d/prec_c`, + `RULAJ_D/RULAJ_C -> rulaj_d/rulaj_c`, `TOTAL_DEB/TOTAL_CRED -> total_d/total_c`, + `FIN_D/FIN_C -> sold_d/sold_c`. +- `citire_xlsx.py:41` `_index_antet` - potrivire **exacta, case-insensitive** (`strip().upper()`), + niciodata pe substring; coloana ceruta lipsa sau duplicata -> `ValueError` cu numele coloanei. +- `citire_xlsx.py:57` `_randuri_xlsx` - openpyxl, `data_only=True`, `read_only=True`. +- `citire_xlsx.py:72` `_randuri_xls` - xlrd 2.0.2. +- `citire_xlsx.py:83` `_randuri` - alege dupa extensie; alta extensie -> `ValueError`. + Ambele iau **prima foaie** (`sheetnames[0]` / `sheet_by_index(0)`), fara cautare dupa nume. +- `citire_xlsx.py:104` `_cont` - CONT ramane text; un float intreg (`401.0`) devine `"401"`, + unul cu zecimale (`401.00002`) devine `"401.00002"`, niciodata numar. +- `citire_xlsx.py:117` `_num` - `None`/gol -> `0.0`; alt text neconvertibil -> `ValueError`. +- `citire_xlsx.py:132` `citeste_balanta` - produce randurile; `pagina` si `este_total` raman + goale (exportul nu are rand de totaluri); sare doar peste randurile complet goale. +- `citire_xlsx.py:152` `scrie_csv` - `utf-8-sig`, `newline=""`, exact ca `extract_balanta.py`. +- `citire_xlsx.py:159` `main` - CLI: `py citire_xlsx.py `. + +### Test nou: `tests/test_citire_xlsx.py` + +`unittest` din stdlib, fara dependinte noi. Cazuri: + +- `:85` `test_xlsx_si_xls_identice` - `.xlsx` (openpyxl) si `.xls` (xlrd) produc acelasi rezultat. +- `:102` `test_relatii_si_frunze` - pe ambele fisiere: `total_d = prec_d + rulaj_d`, + `total_c = prec_c + rulaj_c`, `sold_d - sold_c = total_d - total_c` pe toate randurile, + plus `SUM(sold_d) = SUM(sold_c)` pe frunze. Toleranta 0.01. +- `:110` `test_cont_ramane_text` - toate valorile `cont` sunt `str`. +- `:116` `test_danube_analitice` - `saga2roa_danube\balanta.xls` (341 randuri, 263 analitice); + daca lipseste, `skipTest`. Verifica explicit ca `105.10` ramane text, nu numar. +- `:133` `test_cont_numeric_devine_text` - celula `CONT` scrisa ca numar (`401.00002`) iese text. +- `:148` `test_coloana_lipsa_si_fara_substring` - antet cu `TOTAL_DEB_1` dar fara `TOTAL_DEB` + da `ValueError` care numeste `TOTAL_DEB`: nici coloana lipsa nu trece tacut, nici `_1` nu se + confunda cu coloana principala. + +## 2. Verificari + +Comanda: `py -m unittest discover -s tests -v` (din radacina proiectului). Iesire exacta: + +``` +test_coloana_lipsa_si_fara_substring (test_citire_xlsx.TestCitireXlsx.test_coloana_lipsa_si_fara_substring) ... ok +test_cont_numeric_devine_text (test_citire_xlsx.TestCitireXlsx.test_cont_numeric_devine_text) ... ok +test_cont_ramane_text (test_citire_xlsx.TestCitireXlsx.test_cont_ramane_text) ... ok +test_danube_analitice (test_citire_xlsx.TestCitireXlsx.test_danube_analitice) ... ok +test_relatii_si_frunze (test_citire_xlsx.TestCitireXlsx.test_relatii_si_frunze) ... ok +test_xlsx_si_xls_identice (test_citire_xlsx.TestCitireXlsx.test_xlsx_si_xls_identice) ... ok +test_fundatia (test_regresie.TestRegresieEtalon.test_fundatia) ... ok +test_master (test_regresie.TestRegresieEtalon.test_master) ... ok + +---------------------------------------------------------------------- +Ran 8 tests in 0.542s + +OK +``` + +`test_regresie.py` existent trece neatinse. (Pe stderr apar avertismentele cunoscute ale xlrd +`OLE2 inconsistency` / `No CODEPAGE` si cel al openpyxl despre stil - nu afecteaza rezultatul.) + +Verificare CLI capat-la-capat, iesire in director temporar: + +``` +py citire_xlsx.py "exemple/saga-sqlite-balanta-09-2026.xlsx" "$TEMP/out_test.csv" + -> scris ...out_test.csv: 10 randuri + -> pagina,cont,denumire,prec_d,prec_c,rulaj_d,rulaj_c,total_d,total_c,sold_d,sold_c,este_total,tip + ,371,MARFURI,2615.0,0.0,0.0,0.0,2615.0,0.0,2615.0,0.0,,A +``` + +Cifre pe cele trei fisiere (relatii de sume, frunze): xlsx 10 randuri / 0 esec / 10 frunze; +xls identic; DANUBE 341 randuri / 0 esec / 320 frunze, balanta frunzelor 19018742.48 = 19018742.48. + +Fisiere noi: `citire_xlsx.py` (170 linii) si `tests/test_citire_xlsx.py` (137 linii), ambele +**ASCII curat (0 octeti > 0x7F)** si LF (conventia fisierelor `.py` din proiect). Nu sunt `.vc2` +/ `.sc2` / `.prg`, deci regula de cens cp1250 nu se aplica. + +`git status`: doar `?? citire_xlsx.py` si `?? tests/test_citire_xlsx.py`. Nimic altceva atins. + +## 3. Nefacut / blocat + +- Interfata catre `genereaza_xlsx.py` (maparea) nu e atinsa: lane-ul `mapare` face asta. + `genereaza_xlsx.citeste_balanta` foloseste `csv.DictReader` si citeste doar + `cont/denumire/total_d/total_c/este_total`, deci coloana noua `tip` nu il deranjeaza. +- `extract_balanta.py` nu a fost atins (interzis), deci CSV-ul de PDF are inca 12 coloane, fara + `tip`. Contractul din plan cere 13 coloane pentru toate cele 3 cititoare; alinierea PDF-ului + ramane de facut intr-un alt lane, cand se decide forma lui `tip` pe drumul PDF. +- Intersectia cu `citire-fdb` (CSV identic xlsx/FDB pe firma de joaca) ramane pe lane-ul `citire-fdb`. +- Nu am adaugat `xlrd` in `CLAUDE.md` la `Mediu` (il cere planul, dar e in afara scopului acestui + lane; xlrd era deja instalat si documentat in `docs/raport_sursa_saga_xlsx.md`). + +## 4. Stare periculoasa + +Niciuna. Fara `.vc2`/`.sc2` editat fara write-back, fara procese lasate pornite, fara date de test +consumate (iesirile de test au mers in `%TEMP%`). Fara commit, fara `git add`, fara `sed -i`. +Nu s-a modificat nimic sub `D:\ROA\ROACONT`, `D:\SAGA250909` sau `saga2roa*`. diff --git a/docs/raport_lane_mapare.md b/docs/raport_lane_mapare.md new file mode 100644 index 0000000..5148e0d --- /dev/null +++ b/docs/raport_lane_mapare.md @@ -0,0 +1,59 @@ +# Raport lane `mapare` - solduri2roa etapa 2 + +## 1. Facut + +Doua fisiere noi, nimic altceva atins. + +`mapare.py` (nou): +- `mapare.py:33-48` contractul: coloanele `mapare_` / `corectii_`, sursele, regex + `.TOATE`/`.RESTUL`, config parteneri, randul-exemplu comentat. +- `mapare.py:55-65` `citeste_parteneri` - doar citire din `config/conturi_parteneri.csv`. +- `mapare.py:68-79` `separa` / `propune` - propunerea automata; partener -> `acont` gol. +- `mapare.py:99-106` `creeaza_corectii` - fisier gol + antet + exemplu comentat. +- `mapare.py:109-139` `citeste_corectii` - antet exact, randurile `#` ignorate, duplicat = Stop. +- `mapare.py:142-151` `_regula` - ordinea exact -> `.RESTUL` -> `.TOATE`. +- `mapare.py:167-176` `_verifica_corectii` - cheie fara cont in balanta = Stop. +- `mapare.py:179-194` `_verifica_coliziuni` - colaps legal doar sub acelasi `.TOATE`/`.RESTUL`; + partenerii si randurile `exclus` sunt in afara verificarilor. +- `mapare.py:197-236` `aplica` - merge-ul; lungimi > 4 = Stop. +- `mapare.py:239-260` `scrie_mapare` / `genereaza_mapare` - scrie maparea, creeaza corectiile + doar daca lipsesc. + +`tests/test_mapare.py` (nou): 10 teste (vezi 2). + +## 2. Verificari + +- Comanda: `py -m unittest discover -s tests -v` + - Rezultat: `Ran 18 tests in 0.853s` / `OK` (exit 0). + - Include `tests/test_regresie.py` (2/2 OK) si `tests/test_citire_xlsx.py` al lane-ului + paralel (6/6 OK). Zero esecuri. +- Cens octeti > 0x7F: `mapare.py` 0, `tests/test_mapare.py` 0. LF, ca restul `.py` din proiect + (`genereaza_xlsx.py` tot LF) - nu am introdus CRLF si nici diacritice. +- Smoke pe date reale (in temp, fara sa scriu in radacina): `mapare.aplica` pe + `balanta_FUNDATIA.csv` (82 conturi) si `balanta_MASTERJOB.csv` (81) - fara Stop, fara coliziuni. +- `git status`: doar `mapare.py` si `tests/test_mapare.py` adaugate de mine (restul untracked sunt + ale lane-ului `citire-xlsx`). Fara commit, fara `git add`. + +## 3. Decizii si intrebari deschise + +- Propunerea automata: `cont` = sinteticul dinaintea punctului, `acont` = analiticul concatenat + (toate caracterele, nu doar cifre - `conturi_roa.dbf` are `acont2` alfanumeric `X`/`PG`). + Peste 4 caractere -> Stop (ca `genereaza_xlsx.py:196`), nu trunchiere. Nu am preluat literal + "primele max 4 caractere" din plan, care ar include punctul la sintetici de 3 cifre. +- `.TOATE`/`.RESTUL` se aplica si randului sintetic (parinte), altfel o redenumire + `409 -> 4091` ar cere doua corectii. Fara efect pe colaps: toate au aceeasi regula. +- Coliziunea e permisa doar cand toate conturile au **aceeasi** cheie `.TOATE`/`.RESTUL`; + un amestec exact + `.TOATE` pe aceeasi pereche da Stop. +- `exclus` se intoarce in rezultatul structurat (pentru integrare), dar nu are coloana in + `mapare_` - contractul are exact 6 coloane. +- `cont` gol intr-o corectie = pastreaza propunerea automata; `acont` gol = fara analitic. +- `PLANCONT` nu se preia (cerut). + +Nefacut: integrarea in generator (lane `integrare`). Nu am adaugat `citire_balanta` in modul; +integrarea va pasa `rows` din `genereaza_xlsx.citeste_balanta` (suporta `sold` sau +`total_d/total_c`). + +## 4. Stare periculoasa + +Niciuna: fara binar editat/write-back, fara procese ramase, fara fisiere scrise in radacina +(maparile de test au fost doar in temp). Fara commit. diff --git a/mapare.py b/mapare.py new file mode 100644 index 0000000..7b78901 --- /dev/null +++ b/mapare.py @@ -0,0 +1,260 @@ +"""Propunere automata de mapare SAGA -> ROA, cu corectii manuale, per firma. + +La fiecare rulare: + 1. se citeste balanta (rows); + 2. se propune automat `cont`/`acont` (regula din docs/plan_etapa2_convertor.md); + 3. se suprapun corectiile din `corectii_.xlsx`, daca exista; + 4. se scrie `mapare_.xlsx` complet. + +Doua fisiere, proprietari diferiti: + - `mapare_.xlsx` - scris DOAR de unealta, regenerat complet; + - `corectii_.xlsx` - scris DOAR de utilizator; se creeaza o singura data, + gol, cu antet si un exemplu comentat. Unealta nu scrie niciodata in el, deci + regenerarea nu poate distruge corectiile. + +Propunerea automata: `cont` = sinteticul SAGA, `acont` = restul analiticului +(`1171.01.12` -> `cont=1171`, `acont=0112`). La conturile cu parteneri `acont` +NU se pre-completa: forma lui o decide `genereaza_xlsx.py` (pct. 16 din +docs/decizii_import.md). + +Conventii in `corectii_`, pe coloana `cont_saga`: + - `` exact; + - `.TOATE` - toate conturile sinteticului merg pe acelasi `cont`/`acont`; + - `.RESTUL` - la fel, doar pentru cele fara potrivire exacta. +Ordinea de cautare: potrivire exacta -> `.RESTUL` -> `.TOATE`. +""" + +import csv +import os +import re + +from openpyxl import Workbook, load_workbook + +COLOANE_MAPARE = ["cont_saga", "denumire", "sold", "cont", "acont", "sursa"] +COLOANE_CORECTII = ["cont_saga", "cont", "acont", "exclus", "motiv"] + +SURSA_AUTO = "AUTO" +SURSA_CORECTAT = "CORECTAT" + +MAX = 4 + +PATTERN_GRUP = re.compile(r"^(.+)\.(TOATE|RESTUL)$") + +CONFIG_PARTENERI = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "config", "conturi_parteneri.csv") + +# Randul-exemplu din corectii: incepe cu '#' pe cont_saga, deci e ignorat la citire. +EXEMPLU = ["# exemplu (sterge-l): 409.TOATE", "# 4091", "# (gol)", "", + "# randurile cu '#' in cont_saga sunt comentarii; gol = pastreaza propunerea automata"] + + +class Stop(Exception): + """Conditie neprevazuta: nu ghicim, raportam.""" + + +def citeste_parteneri(path=CONFIG_PARTENERI): + """Sinteticele cu parteneri din config/conturi_parteneri.csv (doar citire).""" + if not os.path.exists(path): + raise Stop("lipseste fisierul de configurare a partenerilor: %s" % path) + conturi = set() + with open(path, encoding="utf-8-sig", newline="") as f: + for r in csv.DictReader(f): + cont = (r.get("cont") or "").strip() + if cont: + conturi.add(cont) + return conturi + + +def separa(cont_saga): + """(sintetic, analitic): partea dinaintea punctului si restul, fara punct.""" + seg = cont_saga.split(".") + return seg[0], "".join(seg[1:]) + + +def propune(cont_saga, partener=False): + """Propunerea automata; la parteneri acont ramane gol (decide generatorul).""" + cont, acont = separa(cont_saga) + if partener: + return cont, None + return cont, (acont or None) + + +def _text(v): + """Celula ca text curatat; gol -> None. Numerele intregi devin text fara .0.""" + if v is None or isinstance(v, bool): + return None + if isinstance(v, float) and v.is_integer(): + v = int(v) + s = str(v).strip() + return s or None + + +def _adevar(v): + if isinstance(v, bool): + return v + text = _text(v) + return text.lower() in ("1", "da", "true", "x", "yes") if text else False + + +def creeaza_corectii(path): + """Creeaza corectii_.xlsx gol, cu antet si un exemplu comentat.""" + wb = Workbook() + ws = wb.active + ws.title = "corectii" + ws.append(COLOANE_CORECTII) + ws.append(EXEMPLU) + wb.save(path) + + +def citeste_corectii(path): + """Corectiile din xlsx, cheie = cont_saga. Randurile '#' sunt comentarii.""" + if not os.path.exists(path): + return {} + wb = load_workbook(path, read_only=True, data_only=True) + try: + ws = wb.active + header = [_text(c.value) for c in ws[1]] + while header and header[-1] is None: + header.pop() + if header != COLOANE_CORECTII: + raise Stop("antet neasteptat in %s: %r" + % (os.path.basename(path), header)) + out = {} + for row in ws.iter_rows(min_row=2, values_only=True): + valori = dict(zip(COLOANE_CORECTII, + list(row) + [None] * (len(COLOANE_CORECTII) - len(row)))) + cheie = _text(valori["cont_saga"]) + if cheie is None or cheie.startswith("#"): + continue + if cheie in out: + raise Stop("rand duplicat in corectii pentru %s" % cheie) + out[cheie] = { + "cont": _text(valori["cont"]), + "acont": _text(valori["acont"]), + "exclus": _adevar(valori["exclus"]), + "motiv": valori["motiv"], + } + return out + finally: + wb.close() + + +def _regula(cont_saga, corectii): + """(corectie, cheia_regulii) dupa exact -> .RESTUL -> .TOATE; (None, None) daca nu.""" + if cont_saga in corectii: + return corectii[cont_saga], cont_saga + sintetic = cont_saga.split(".")[0] + for sufix in ("RESTUL", "TOATE"): + cheie = "%s.%s" % (sintetic, sufix) + if cheie in corectii: + return corectii[cheie], cheie + return None, None + + +def _sold(r): + if r.get("sold") is not None: + return round(float(r["sold"]), 2) + return round(float(r.get("total_d", 0.0)) - float(r.get("total_c", 0.0)), 2) + + +def _verifica_lungime(cont, acont, cont_saga): + if cont and len(cont) > MAX: + raise Stop("cont peste 4 caractere pentru %s: %r" % (cont_saga, cont)) + if acont and len(acont) > MAX: + raise Stop("acont peste 4 caractere pentru %s: %r" % (cont_saga, acont)) + + +def _verifica_corectii(rows, corectii): + existente = {r["cont"] for r in rows} + sintetice = {c.split(".")[0] for c in existente} + for cheie in corectii: + m = PATTERN_GRUP.match(cheie) + if m: + if m.group(1) not in sintetice: + raise Stop("corectie pentru un sintetic inexistent in balanta: %s" % cheie) + elif cheie not in existente: + raise Stop("corectie pentru un cont inexistent in balanta: %s" % cheie) + + +def _verifica_coliziuni(rezultat, parteneri): + """Doua conturi SAGA pe acelasi (cont, acont) sunt legale doar sub acelasi .TOATE/.RESTUL.""" + grupe = {} + for r in rezultat: + if r["cont_saga"].split(".")[0] in parteneri or r["exclus"]: + continue + grupe.setdefault((r["cont"], r["acont"]), []).append(r) + for (cont, acont), randuri in grupe.items(): + if len(randuri) < 2: + continue + reguli = {r["_regula"] for r in randuri} + singura = next(iter(reguli)) if len(reguli) == 1 else None + if not (singura and PATTERN_GRUP.match(singura)): + conturi = sorted(r["cont_saga"] for r in randuri) + raise Stop("coliziune pe (%r, %r) intre %s; foloseste .TOATE/.RESTUL " + "daca agregarea e intentionata" % (cont, acont, conturi)) + + +def aplica(rows, corectii, parteneri=None): + """Rezultatul final per cont: cont_saga, denumire, sold, cont, acont, sursa, exclus.""" + if parteneri is None: + parteneri = citeste_parteneri() + _verifica_corectii(rows, corectii) + + rezultat = [] + for r in rows: + cont_saga = r["cont"] + partener = cont_saga.split(".")[0] in parteneri + cont_auto, acont_auto = propune(cont_saga, partener) + + cor, cheie = _regula(cont_saga, corectii) + if cor is None: + cont, acont = cont_auto, acont_auto + sursa, exclus = SURSA_AUTO, False + else: + if cor["exclus"]: + cont, acont = cont_auto, acont_auto + else: + cont = cor["cont"] or cont_auto + acont = cor["acont"] + sursa, exclus = SURSA_CORECTAT, cor["exclus"] + + _verifica_lungime(cont, acont, cont_saga) + rezultat.append({ + "cont_saga": cont_saga, + "denumire": r.get("denumire") or "", + "sold": _sold(r), + "cont": cont, + "acont": acont, + "sursa": sursa, + "exclus": exclus, + "_regula": cheie, + }) + + _verifica_coliziuni(rezultat, parteneri) + for r in rezultat: + r.pop("_regula") + return rezultat + + +def scrie_mapare(rezultat, path): + """Scrie mapare_.xlsx complet, doar din rezultatul final.""" + wb = Workbook() + ws = wb.active + ws.title = "mapare" + ws.append(COLOANE_MAPARE) + for r in rezultat: + ws.append([r[c] for c in COLOANE_MAPARE]) + wb.save(path) + + +def genereaza_mapare(rows, firma, dir_iesire="."): + """Scrie mapare_.xlsx, creeaza corectii_.xlsx doar daca lipseste.""" + if not os.path.isdir(dir_iesire): + os.makedirs(dir_iesire) + cale_corectii = os.path.join(dir_iesire, "corectii_%s.xlsx" % firma) + cale_mapare = os.path.join(dir_iesire, "mapare_%s.xlsx" % firma) + if not os.path.exists(cale_corectii): + creeaza_corectii(cale_corectii) + rezultat = aplica(rows, citeste_corectii(cale_corectii)) + scrie_mapare(rezultat, cale_mapare) + return rezultat diff --git a/tests/test_citire_xlsx.py b/tests/test_citire_xlsx.py new file mode 100644 index 0000000..fa2d407 --- /dev/null +++ b/tests/test_citire_xlsx.py @@ -0,0 +1,143 @@ +"""Teste pentru citire_xlsx: contractul intern produs din foaia de calcul SAGA. + +Rulare: py -m unittest discover -s tests -v +""" +import os +import pathlib +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 citire_xlsx + +EXEMPLE = RADACINA / "exemple" +XLSX = EXEMPLE / "saga-sqlite-balanta-09-2026.xlsx" +XLS = EXEMPLE / "saga-sqlite-balanta-09-2026.xls" +DANUBE = RADACINA.parent / "saga2roa_danube" / "balanta.xls" + +TOL = 0.01 +NUMERICE = ["prec_d", "prec_c", "rulaj_d", "rulaj_c", + "total_d", "total_c", "sold_d", "sold_c"] + + +def frunze(rows): + """Randurile care nu sunt parintele niciunui alt cont din balanta.""" + conts = [r["cont"] for r in rows if r["cont"]] + parinti = {c.rsplit(".", 1)[0] for c in conts if "." in c} + return [r for r in rows if r["cont"] and r["cont"] not in parinti] + + +def scrie_xlsx(cale, antet, randuri): + wb = openpyxl.Workbook() + ws = wb.active + ws.append(antet) + for rand in randuri: + ws.append(rand) + wb.save(cale) + wb.close() + + +class TestCitireXlsx(unittest.TestCase): + + def _verifica_relatii(self, rows, eticheta): + self.assertTrue(rows, "%s: nicio linie citita" % eticheta) + for r in rows: + self.assertAlmostEqual( + r["total_d"], r["prec_d"] + r["rulaj_d"], delta=TOL, + msg="%s %s: total_d != prec_d + rulaj_d" % (eticheta, r["cont"])) + self.assertAlmostEqual( + r["total_c"], r["prec_c"] + r["rulaj_c"], delta=TOL, + msg="%s %s: total_c != prec_c + rulaj_c" % (eticheta, r["cont"])) + self.assertAlmostEqual( + r["sold_d"] - r["sold_c"], r["total_d"] - r["total_c"], delta=TOL, + msg="%s %s: sold_d - sold_c != total_d - total_c" % (eticheta, r["cont"])) + + def _verifica_frunze(self, rows, eticheta): + f = frunze(rows) + sd = sum(r["sold_d"] for r in f) + sc = sum(r["sold_c"] for r in f) + self.assertAlmostEqual(sd, sc, delta=TOL, + msg="%s: SUM(sold_d)=%s != SUM(sold_c)=%s" % (eticheta, sd, sc)) + + def test_xlsx_si_xls_identice(self): + rows_xlsx = citire_xlsx.citeste_balanta(str(XLSX)) + rows_xls = citire_xlsx.citeste_balanta(str(XLS)) + self.assertTrue(rows_xlsx) + self.assertEqual(len(rows_xlsx), len(rows_xls)) + for a, b in zip(rows_xlsx, rows_xls): + self.assertEqual(a["cont"], b["cont"]) + self.assertEqual(a["denumire"], b["denumire"]) + self.assertEqual(a["tip"], b["tip"]) + for col in ("pagina", "este_total"): + self.assertEqual(a[col], b[col]) + for col in NUMERICE: + self.assertAlmostEqual(a[col], b[col], delta=TOL, + msg="%s %s: %s xlsx=%s xls=%s" + % (a["cont"], col, a["cont"], a[col], b[col])) + + def test_relatii_si_frunze(self): + for eticheta, cale in (("xlsx", XLSX), ("xls", XLS)): + rows = citire_xlsx.citeste_balanta(str(cale)) + self._verifica_relatii(rows, eticheta) + self._verifica_frunze(rows, eticheta) + + def test_cont_ramane_text(self): + for eticheta, cale in (("xlsx", XLSX), ("xls", XLS)): + rows = citire_xlsx.citeste_balanta(str(cale)) + for r in rows: + self.assertIsInstance(r["cont"], str, "%s: cont nu e text" % eticheta) + + def test_danube_analitice(self): + if not DANUBE.exists(): + self.skipTest("lipseste %s" % DANUBE) + rows = citire_xlsx.citeste_balanta(str(DANUBE)) + self._verifica_relatii(rows, "danube") + self._verifica_frunze(rows, "danube") + analitice = [r for r in rows if "." in r["cont"]] + self.assertTrue(analitice, "danube ar trebui sa aiba conturi analitice") + for r in analitice: + self.assertIsInstance(r["cont"], str, "cont analitic nu e text") + # .xls-ul SAGA nu are CODEPAGE: fara encoding_override, xlrd cade pe iso-8859-1 + # si denumirile ies cu caractere de control in loc de diacritice. + stricate = [r["denumire"] for r in rows + if any(ord(c) < 32 or 127 <= ord(c) < 160 for c in str(r["denumire"]))] + self.assertEqual([], stricate, "denumiri decodate gresit (codepage)") + + cautat = [r for r in rows if r["cont"] == "105.10"] + self.assertTrue(cautat, "lipseste contul analitic 105.10") + self.assertIsInstance(cautat[0]["cont"], str) + self.assertEqual(cautat[0]["cont"], "105.10") + + def test_cont_numeric_devine_text(self): + with tempfile.TemporaryDirectory() as d: + cale = os.path.join(d, "numeric.xlsx") + scrie_xlsx( + cale, + ["CONT", "DENUMIRE", "TIP", "DEB_PREC", "CRED_PREC", "RULAJ_D", + "RULAJ_C", "TOTAL_DEB", "TOTAL_CRED", "FIN_D", "FIN_C"], + [[401.00002, "PARTENER", "P", 0, 0, 0, 0, 0, 100, 0, 100]], + ) + rows = citire_xlsx.citeste_balanta(cale) + self.assertEqual(len(rows), 1) + self.assertIsInstance(rows[0]["cont"], str) + self.assertEqual(rows[0]["cont"], "401.00002") + + def test_coloana_lipsa_si_fara_substring(self): + antet = ["CONT", "DENUMIRE", "TIP", "DEB_PREC", "CRED_PREC", "RULAJ_D", + "RULAJ_C", "TOTAL_DEB_1", "TOTAL_CRED", "FIN_D", "FIN_C"] + with tempfile.TemporaryDirectory() as d: + cale = os.path.join(d, "lipsa.xlsx") + scrie_xlsx(cale, antet, [["401", "FURNIZORI", "P", 0, 0, 0, 0, 0, 0, 0, 0]]) + with self.assertRaises(ValueError) as cm: + citire_xlsx.citeste_balanta(cale) + self.assertIn("TOTAL_DEB", str(cm.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mapare.py b/tests/test_mapare.py new file mode 100644 index 0000000..65f8ebe --- /dev/null +++ b/tests/test_mapare.py @@ -0,0 +1,161 @@ +"""Teste pentru mapare.py - propunere automata + corectii manuale. + +Ruleaza pe date sintetice in director temporar; nu scrie mapare_*.xlsx / +corectii_*.xlsx in radacina proiectului. + +Rulare: py -m unittest discover -s tests -v +""" +import os +import pathlib +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 mapare + + +def rand(cont, denumire="", sold=0.0): + return {"cont": cont, "denumire": denumire, "sold": sold} + + +class TestMapare(unittest.TestCase): + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="mapare_test_") + self.dir = self._tmp.name + + def tearDown(self): + self._tmp.cleanup() + + def _cale(self, nume): + return os.path.join(self.dir, nume) + + def _ruleaza(self, rows, firma="FIRMA"): + return mapare.genereaza_mapare(rows, firma, self.dir) + + def _scrie_corectii(self, randuri, firma="FIRMA"): + cale = self._cale("corectii_%s.xlsx" % firma) + wb = openpyxl.load_workbook(cale) + ws = wb.active + for r in randuri: + ws.append(r) + wb.save(cale) + wb.close() + + def _citeste_mapare(self, firma="FIRMA"): + wb = openpyxl.load_workbook(self._cale("mapare_%s.xlsx" % firma)) + ws = wb.active + header = [c.value for c in ws[1]] + out = [dict(zip(header, [c.value for c in row])) + for row in ws.iter_rows(min_row=2)] + wb.close() + return out + + def test_corectie_supravietuieste_regenerarii(self): + rows = [rand("2131.01", "CLADIRI", 100.0), rand("371.01", "MARFA", 50.0)] + self._ruleaza(rows) + cale = self._cale("corectii_FIRMA.xlsx") + self.assertTrue(os.path.exists(cale)) + + self._scrie_corectii([["371.01", "3711", "99", "", "corectie marfa"]]) + inainte = pathlib.Path(cale).read_bytes() + + rezultat = self._ruleaza(rows) + dupa = pathlib.Path(cale).read_bytes() + + self.assertEqual(inainte, dupa, "regenerarea a modificat corectii_*.xlsx") + gasit = {r["cont_saga"]: r for r in rezultat} + self.assertEqual( + (gasit["371.01"]["cont"], gasit["371.01"]["acont"], gasit["371.01"]["sursa"]), + ("3711", "99", "CORECTAT")) + self.assertEqual(gasit["2131.01"]["sursa"], "AUTO") + + def test_ordine_exact_bate_restul_bate_toate(self): + rows = [rand("409.01"), rand("409.02"), rand("409.03")] + self._ruleaza(rows) + self._scrie_corectii([ + ["409.TOATE", "4091", "T", "", ""], + ["409.RESTUL", "4091", "R", "", ""], + ["409.01", "4091", "X", "", ""], + ]) + rezultat = self._ruleaza(rows) + gasit = {r["cont_saga"]: (r["cont"], r["acont"]) for r in rezultat} + self.assertEqual(gasit["409.01"], ("4091", "X")) + self.assertEqual(gasit["409.02"], ("4091", "R")) + self.assertEqual(gasit["409.03"], ("4091", "R")) + + def test_toate_se_aplica_fara_exact(self): + rows = [rand("2131.01"), rand("2131.02")] + self._ruleaza(rows) + self._scrie_corectii([["2131.TOATE", "2131", "", "", ""]]) + rezultat = self._ruleaza(rows) + for r in rezultat: + self.assertEqual((r["cont"], r["acont"]), ("2131", None)) + self.assertEqual(r["sursa"], "CORECTAT") + + def test_cont_saga_inexistent_da_eroare(self): + rows = [rand("2131.01")] + self._ruleaza(rows) + self._scrie_corectii([["9999.99", "9999", "99", "", "typo"]]) + with self.assertRaises(mapare.Stop): + self._ruleaza(rows) + + def test_coliziune_accidentala_da_eroare(self): + rows = [rand("409.01"), rand("410.01")] + self._ruleaza(rows) + self._scrie_corectii([ + ["409.01", "4091", "01", "", ""], + ["410.01", "4091", "01", "", ""], + ]) + with self.assertRaises(mapare.Stop): + self._ruleaza(rows) + + def test_colapsare_prin_toate_nu_da_eroare(self): + rows = [rand("409.01"), rand("409.02")] + self._ruleaza(rows) + self._scrie_corectii([["409.TOATE", "4091", "", "", "agregare intentionata"]]) + rezultat = self._ruleaza(rows) + self.assertEqual([(r["cont"], r["acont"]) for r in rezultat], + [("4091", None), ("4091", None)]) + + def test_redenumire_sintetic(self): + rows = [rand("409"), rand("409.01")] + self._ruleaza(rows) + self._scrie_corectii([["409.TOATE", "4091", "", "", "redenumire sintetic"]]) + rezultat = self._ruleaza(rows) + gasit = {r["cont_saga"]: r["cont"] for r in rezultat} + self.assertEqual(gasit["409"], "4091") + self.assertEqual(gasit["409.01"], "4091") + + def test_cont_prea_lung_da_eroare(self): + rows = [rand("2131.01")] + self._ruleaza(rows) + self._scrie_corectii([["2131.01", "12345", "01", "", ""]]) + with self.assertRaises(mapare.Stop): + self._ruleaza(rows) + + def test_acont_prea_lung_da_eroare(self): + rows = [rand("2131.01")] + self._ruleaza(rows) + self._scrie_corectii([["2131.01", "2131", "12345", "", ""]]) + with self.assertRaises(mapare.Stop): + self._ruleaza(rows) + + def test_propunere_automata_si_partener(self): + rows = [rand("1171.01.12"), rand("401.00002")] + rezultat = mapare.aplica(rows, {}, parteneri={"401"}) + gasit = {r["cont_saga"]: r for r in rezultat} + self.assertEqual((gasit["1171.01.12"]["cont"], gasit["1171.01.12"]["acont"]), + ("1171", "0112")) + self.assertEqual((gasit["401.00002"]["cont"], gasit["401.00002"]["acont"]), + ("401", None)) + + +if __name__ == "__main__": + unittest.main()