"""Extractor fidel pentru balantele de verificare SAGA (PDF cu text). Foloseste pdfplumber cu coordonate (NU pdftotext -layout, care decaleaza coloanele). Fiecare pagina are un antet cu 8 coloane numerice: Sume precedente (D,C) | Rulaje perioada (D,C) | Sume totale (D,C) | Solduri finale (D,C) Utilizare: py extract_balanta.py "" "" """ import csv import re import sys import pdfplumber CONT_RE = re.compile(r"^\d+(?:\.\d+)*$") NUM_RE = re.compile(r"^-?\d[\d.]*$") CONT_MAX_X = 80.0 DENUM_MAX_X = 240.0 ROW_TOL = 3.0 TOL = 0.01 HEADER_WORDS = {"Cont", "Denumirea", "contului", "Debitoare", "Creditoare", "Sume", "precedente", "Rulaje", "perioada", "totale", "Solduri", "finale"} TOTAL_WORDS = ("total", "totaluri") def coloane_numerice(page0): """Centrele celor 8 coloane, din etichetele Debitoare/Creditoare de pe pagina 1.""" labels = [w for w in page0.extract_words() if w["text"] in ("Debitoare", "Creditoare") and 75 <= w["top"] <= 100] labels.sort(key=lambda w: w["x0"]) assert len(labels) == 8, f"astept 8 etichete numerice, gasit {len(labels)}" centre = [(w["x0"] + w["x1"]) / 2.0 for w in labels] granite = [(centre[i] + centre[i + 1]) / 2.0 for i in range(len(centre) - 1)] return centre, granite, max(w["top"] for w in labels) def col_index(center, granite): idx = 0 for g in granite: if center >= g: idx += 1 else: break return idx def randuri(pagina): """Grupeaza cuvintele in randuri dupa coordonata top (toleranta ROW_TOL).""" words = sorted(pagina.extract_words(), key=lambda w: (w["top"], w["x0"])) rows = [] for w in words: if rows and abs(w["top"] - rows[-1][0]) <= ROW_TOL: rows[-1][1].append(w) else: rows.append((w["top"], [w])) return rows def numar(txt): txt = txt.strip() if txt in ("", "-", "."): return 0.0 return float(txt) def parse_pdf(path): conturi = [] with pdfplumber.open(path) as pdf: _, granite, header_top = coloane_numerice(pdf.pages[0]) for pno, page in enumerate(pdf.pages, start=1): for top, ws in randuri(page): if top < header_top: continue cont = None denum = [] valori = [None] * 8 for w in ws: t = w["text"] if t in HEADER_WORDS: continue if w["x0"] < CONT_MAX_X: if CONT_RE.match(t): cont = t continue if w["x0"] < DENUM_MAX_X: denum.append(w) continue if NUM_RE.match(t): ci = col_index((w["x0"] + w["x1"]) / 2.0, granite) if valori[ci] is None: valori[ci] = [] valori[ci].append(w) denum = " ".join(w["text"] for w in sorted(denum, key=lambda x: x["x0"])) vals = [] for i in range(8): if valori[i] is None: vals.append(0.0) else: txt = "".join(x["text"] for x in sorted(valori[i], key=lambda x: x["x0"])) vals.append(numar(txt)) este_total = any(k in denum.lower() for k in TOTAL_WORDS) if cont: conturi.append({ "pagina": pno, "cont": cont, "denumire": denum, **dict(zip(("prec_d", "prec_c", "rulaj_d", "rulaj_c", "total_d", "total_c", "sold_d", "sold_c"), vals)), "este_total": 0, }) elif este_total: conturi.append({ "pagina": pno, "cont": "", "denumire": denum, **dict(zip(("prec_d", "prec_c", "rulaj_d", "rulaj_c", "total_d", "total_c", "sold_d", "sold_c"), vals)), "este_total": 1, }) elif denum and conturi and conturi[-1]["este_total"] == 0: # rand de continuare a denumirii contului precedent conturi[-1]["denumire"] = (conturi[-1]["denumire"] + " " + denum).strip() return conturi NUMS = ["prec_d", "prec_c", "rulaj_d", "rulaj_c", "total_d", "total_c", "sold_d", "sold_c"] def verifica(rows): reale = [r for r in rows if not r["este_total"]] totaluri = [r for r in rows if r["este_total"]] a = b = 0 for r in reale: if abs(r["total_d"] - (r["prec_d"] + r["rulaj_d"])) > TOL or \ abs(r["total_c"] - (r["prec_c"] + r["rulaj_c"])) > TOL: a += 1 if abs((r["sold_d"] - r["sold_c"]) - (r["total_d"] - r["total_c"])) > TOL: b += 1 coduri = {r["cont"] for r in reale if r["cont"]} copii = {} for c in coduri: parts = c.split(".") if len(parts) > 1: copii.setdefault(".".join(parts[:-1]), []).append(c) # frunza = nu apare ca parinte; radacina = parintele ei nu exista in balanta frunze = [r for r in reale if r["cont"] not in copii] radacini = [r for r in reale if ".".join(r["cont"].split(".")[:-1]) not in coduri] fd = round(sum(r["sold_d"] for r in frunze), 2) fc = round(sum(r["sold_c"] for r in frunze), 2) rd = round(sum(r["sold_d"] for r in radacini), 2) rc = round(sum(r["sold_c"] for r in radacini), 2) # (d) parinte vs suma analiticelor: pe fiecare latura si pe net (SAGA neteaza D/C) byk = {r["cont"]: r for r in reale} d_laturi = [] d_net = [] for r in reale: kids = copii.get(r["cont"]) if not kids: continue for col in NUMS: s = round(sum(byk[k][col] for k in kids if k in byk), 2) if abs(s - r[col]) > TOL: d_laturi.append((r["cont"], col, r[col], s, round(r[col] - s, 2))) pnet = round(r["sold_d"] - r["sold_c"], 2) cnet = round(sum(byk[k]["sold_d"] - byk[k]["sold_c"] for k in kids if k in byk), 2) if abs(pnet - cnet) > TOL: d_net.append((r["cont"], pnet, cnet, round(pnet - cnet, 2))) # cross-check parser: fiecare "Total sume clasa N" = suma radacinilor din clasa clase_ok = clase_rau = 0 clase_det = [] for t in totaluri: if "clasa" not in t["denumire"].lower(): continue cl = t["denumire"].split()[-1] top = [r for r in radacini if r["cont"].startswith(cl)] sd = round(sum(r["sold_d"] for r in top), 2) sc = round(sum(r["sold_c"] for r in top), 2) ok = abs(sd - t["sold_d"]) <= TOL and abs(sc - t["sold_c"]) <= TOL if ok: clase_ok += 1 else: clase_rau += 1 clase_det.append((cl, t["sold_d"], sd, t["sold_c"], sc)) tot_row = [t for t in totaluri if "totaluri" in t["denumire"].lower()] print("=== VERIFICARE ===") print(f"randuri conturi reale : {len(reale)}") print(f"conturi frunza : {len(frunze)} conturi radacina: {len(radacini)}") print(f"(a) total=prec+rulaj : {len(reale) - a}/{len(reale)} OK, {a} esec") print(f"(b) sold=total : {len(reale) - b}/{len(reale)} OK, {b} esec") print(f"(c) balanta frunze : sold_d={fd} sold_c={fc} " f"diferenta={round(fd - fc, 2)} {'OK' if abs(fd - fc) <= TOL else 'ESEC'}") print(f"(c2) balanta radacini : sold_d={rd} sold_c={rc} " f"diferenta={round(rd - rc, 2)} {'OK' if abs(rd - rc) <= TOL else 'ESEC'}") if tot_row: tr = tot_row[0] print(f" Totaluri (PDF) : sold_d={tr['sold_d']} sold_c={tr['sold_c']}") print(f" cross-check clase: {clase_ok} OK, {clase_rau} esec") print(f"(d) parinti vs analitice: {len(d_laturi)} diferente pe laturi, " f"{len(d_net)} diferente pe net") for p in d_laturi: print(" d-latura", p) for p in d_net: print(" d-NET ", p) for p in clase_det: print(" clasa ", p) return {"n": len(reale), "frunze": len(frunze), "radacini": len(radacini), "a": a, "b": b, "fd": fd, "fc": fc, "rd": rd, "rc": rc, "d_laturi": d_laturi, "d_net": d_net, "clase_det": clase_det, "totaluri": [(t["sold_d"], t["sold_c"]) for t in tot_row]} def main(): if len(sys.argv) != 3: print(__doc__) sys.exit(1) pdf_path, csv_path = sys.argv[1], sys.argv[2] rows = parse_pdf(pdf_path) cols = ["pagina", "cont", "denumire"] + NUMS + ["este_total"] with open(csv_path, "w", newline="", encoding="utf-8-sig") as f: wr = csv.DictWriter(f, fieldnames=cols) wr.writeheader() for r in rows: wr.writerow(r) print(f"scris {csv_path}: {len(rows)} randuri") verifica(rows) if __name__ == "__main__": main()