Files
solduri2roa/extract_balanta.py
Marius Mutu 92dc8c7b32 solduri2roa: balante SAGA PDF -> xlsx initializare ROACONT
Extragere pe coordonate (pdfplumber), generarea celor doua xlsx pentru
FUNDATIA si MASTER 12/2025, regulile de mapare si rapoartele de verificare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdA5a3ECKFD6Citf1ptPZu
2026-09-21 00:02:30 +03:00

244 lines
9.0 KiB
Python

"""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 "<fisier.pdf>" "<iesire.csv>"
"""
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()