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
244 lines
9.0 KiB
Python
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()
|