Files
clawd/tools/process_bon.py
Echo f184b2b179 feat(process_bon): script complet procesare bonuri fiscale
- OCR via roa2web API
- SQLite via API (payment_methods, tva_breakdown)
- Oracle: partener, TVA încasare (4426/4428), ID_FDOC=17
- ID_JTVA_COLOANA per cotă TVA
- TAXCODE (TVAI pentru firma TVA încasare)
- Suport multiple cote TVA în același bon
- Plată CARD: fără 401=5311 (se face la extras)
2026-02-03 16:50:48 +00:00

589 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Procesare bon fiscal: PDF → OCR API → SQLite API → Oracle
Usage:
python process_bon.py <pdf_path> [--save]
--save Salvează efectiv în Oracle (altfel dry run)
Fluxul:
1. OCR extract via API (http://10.0.20.171:8000/api/data-entry/ocr/extract)
2. Save receipt via API (http://10.0.20.171:8000/api/data-entry/receipts/) - TOATE datele
3. Save to Oracle:
- Verifică/creează partener
- Verifică TVA la încasare (CALENDAR.TVA_INCASARE)
- Generează note contabile corecte
"""
import sys
import json
import time
import argparse
from pathlib import Path
from datetime import datetime
from decimal import Decimal
import requests
import oracledb
# === CONFIG ===
API_BASE = "http://10.0.20.171:8000"
API_USER = "MARIUS M"
API_PASS = "123"
SERVER_ID = "central"
COMPANY_ID = 110 # MARIUSM AUTO
ORACLE_CONFIG = {
"user": "MARIUSM_AUTO",
"password": "ROMFASTSOFT",
"dsn": "10.0.20.121:1521/ROA"
}
# Mapare CUI → cont cheltuială
CUI_TO_CONT = {
"11201891": "6022", # MOL
"1590082": "6022", # OMV Petrom
"14991381": "6022", # Rompetrol
"10562600": "6021", # Dedeman / Five Holding (Brick)
"1879865": "6021", # Five Holding
}
# Mapare cotă TVA → (ID_JTVA baza, ID_JTVA tva, TAXCODE, TAXCODE_TVAI)
# Pentru achiziții interne neexigibile (TVA la încasare)
JTVA_NEEX = {
21: (210, 211, 301104, 301305), # ACH. INT. NEEX. 21%
19: (188, 189, 301101, 301301), # ACH. INT. NEEX. 19%
11: (214, 215, 301105, 301306), # ACH. INT. NEEX. 11%
9: (172, 173, 301102, 301302), # ACH. INT. NEEX. 9%
5: (174, 175, 301103, 301303), # ACH. INT. NEEX. 5%
}
# Pentru achiziții interne normale (fără TVA la încasare)
JTVA_NORMAL = {
21: (208, 209, 301104, 301305), # ACH. INT. 21%
19: (None, None, 301101, 301301),
9: (None, None, 301102, 301302),
}
def get_cont(cui: str) -> str:
"""Mapare CUI → cont cheltuială."""
cui_clean = (cui or "").upper().replace("RO", "").strip()
return CUI_TO_CONT.get(cui_clean, "6028") # 6028 = alte cheltuieli
class APIClient:
"""Client pentru roa2web API."""
def __init__(self, base_url: str):
self.base_url = base_url.rstrip("/")
self.token = None
self.session = requests.Session()
def login(self, username: str, password: str, server_id: str) -> bool:
"""Login și obține token."""
r = self.session.post(
f"{self.base_url}/api/auth/login",
json={"username": username, "password": password, "server_id": server_id}
)
if r.status_code == 200:
data = r.json()
self.token = data.get("access_token")
self.session.headers["Authorization"] = f"Bearer {self.token}"
return True
print(f"Login failed: {r.status_code} - {r.text}")
return False
def ocr_extract(self, file_path: Path) -> dict:
"""Submit OCR job și așteaptă rezultatul."""
# Determine mime type
suffix = file_path.suffix.lower()
if suffix == ".pdf":
mime_type = "application/pdf"
elif suffix in (".jpg", ".jpeg"):
mime_type = "image/jpeg"
elif suffix == ".png":
mime_type = "image/png"
else:
# Try to detect from content
with open(file_path, "rb") as f:
header = f.read(8)
if header[:4] == b'%PDF':
mime_type = "application/pdf"
suffix = ".pdf"
elif header[:3] == b'\xff\xd8\xff':
mime_type = "image/jpeg"
suffix = ".jpg"
elif header[:8] == b'\x89PNG\r\n\x1a\n':
mime_type = "image/png"
suffix = ".png"
else:
mime_type = "application/pdf" # default
suffix = ".pdf"
# Use proper filename with extension
filename = file_path.stem + suffix if not file_path.suffix else file_path.name
# Submit
with open(file_path, "rb") as f:
r = self.session.post(
f"{self.base_url}/api/data-entry/ocr/extract",
files={"file": (filename, f, mime_type)}
)
if r.status_code != 200:
return {"success": False, "error": f"OCR submit failed: {r.text}"}
job_id = r.json().get("job_id")
print(f" OCR job: {job_id}")
# Wait for result (max 60s per request, retry if pending)
for _ in range(4): # Max 4 retries = ~240s total
r = self.session.get(
f"{self.base_url}/api/data-entry/ocr/jobs/{job_id}/wait",
params={"timeout": 60, "wait_for_terminal": "true"},
timeout=70
)
if r.status_code != 200:
return {"success": False, "error": f"OCR wait failed: {r.text}"}
data = r.json()
status = data.get("status")
if status == "completed":
return {"success": True, "result": data.get("result"), "time_ms": data.get("processing_time_ms")}
elif status == "failed":
return {"success": False, "error": data.get("error") or "OCR failed"}
# Still pending/processing - will retry
return {"success": False, "error": "OCR timeout"}
def create_receipt(self, ocr_result: dict, company_id: int) -> dict:
"""Creează receipt în SQLite via API cu TOATE datele."""
# Parse date
date_str = ocr_result.get("receipt_date")
if date_str:
receipt_date = date_str[:10] # YYYY-MM-DD
else:
receipt_date = datetime.now().strftime("%Y-%m-%d")
# Build TVA breakdown from OCR
tva_breakdown = []
for tva_entry in (ocr_result.get("tva_entries") or []):
tva_breakdown.append({
"code": tva_entry.get("code"),
"percent": tva_entry.get("percent"),
"amount": float(tva_entry.get("amount") or 0)
})
# Build payment methods from OCR
payment_methods = []
for pm in (ocr_result.get("payment_methods") or []):
payment_methods.append({
"method": pm.get("method"),
"amount": float(pm.get("amount") or 0)
})
# Determine payment mode
payment_mode = ocr_result.get("suggested_payment_mode") or "casa"
# If has CARD payment, it's "banca"
if any(pm.get("method", "").upper() == "CARD" for pm in payment_methods):
payment_mode = "banca"
elif any(pm.get("method", "").upper() == "NUMERAR" for pm in payment_methods):
payment_mode = "casa"
payload = {
"receipt_type": "bon_fiscal",
"direction": "cheltuiala",
"receipt_number": ocr_result.get("receipt_number"),
"receipt_series": ocr_result.get("receipt_series"),
"receipt_date": receipt_date,
"amount": float(ocr_result.get("amount") or 0),
"partner_name": ocr_result.get("partner_name"),
"cui": ocr_result.get("cui"),
"tva_total": float(ocr_result.get("tva_total") or 0),
"tva_breakdown": tva_breakdown if tva_breakdown else None,
"payment_methods": payment_methods if payment_methods else None,
"payment_mode": payment_mode,
"company_id": company_id,
"vendor_address": ocr_result.get("address"),
"items_count": ocr_result.get("items_count"),
"ocr_raw_text": ocr_result.get("raw_text"),
}
# Remove None values
payload = {k: v for k, v in payload.items() if v is not None}
self.session.headers["X-Selected-Company"] = str(company_id)
r = self.session.post(
f"{self.base_url}/api/data-entry/receipts/",
json=payload
)
if r.status_code in (200, 201):
return {"success": True, "receipt": r.json()}
else:
return {"success": False, "error": f"Create receipt failed: {r.text}"}
def get_or_create_partner(cursor, cui: str, name: str, address: str = None) -> int:
"""Găsește sau creează partener în Oracle. Returnează ID_PART."""
cui_clean = (cui or "").upper().replace("RO", "").strip()
if not cui_clean:
return 0 # No CUI, no partner
# Try to find existing partner
cursor.execute("""
SELECT ID_PART FROM NOM_PARTENERI
WHERE COD_FISCAL = :cui OR COD_FISCAL = :cui2
""", cui=cui_clean, cui2="RO" + cui_clean)
row = cursor.fetchone()
if row:
return row[0] # Found existing partner
# Create new partner
cursor.execute("SELECT SEQ_NOM_PARTENERI.NEXTVAL FROM DUAL")
new_id = cursor.fetchone()[0]
# Clean name
partner_name = (name or f"PARTENER {cui_clean}")[:100]
partner_address = (address or "")[:200]
cursor.execute("""
INSERT INTO NOM_PARTENERI (ID_PART, NUME, COD_FISCAL, ADRESA, STERS, INACTIV)
VALUES (:id_part, :nume, :cui, :adresa, 0, 0)
""", id_part=new_id, nume=partner_name, cui=cui_clean, adresa=partner_address)
print(f" Partener nou creat: ID={new_id}, CUI={cui_clean}, Nume={partner_name}")
return new_id
def check_tva_incasare(cursor, an: int, luna: int) -> bool:
"""Verifică dacă firma e plătitoare de TVA la încasare în perioada dată."""
cursor.execute("""
SELECT NVL(TVA_INCASARE, 0) FROM CALENDAR
WHERE AN = :an AND LUNA = :luna
""", an=an, luna=luna)
row = cursor.fetchone()
return row[0] == 1 if row else False
def save_to_oracle(ocr_result: dict, do_commit: bool = False) -> dict:
"""Salvează nota contabilă în Oracle cu toate regulile."""
conn = oracledb.connect(**ORACLE_CONFIG)
cursor = conn.cursor()
try:
# Parse date
date_str = ocr_result.get("receipt_date")
if date_str:
receipt_date = datetime.strptime(date_str[:10], "%Y-%m-%d").date()
else:
receipt_date = datetime.now().date()
an, luna = receipt_date.year, receipt_date.month
# 1. Get or create partner
id_part = get_or_create_partner(
cursor,
ocr_result.get("cui"),
ocr_result.get("partner_name"),
ocr_result.get("address")
)
print(f" Partner ID: {id_part}")
# 2. Check TVA la încasare
tva_incasare = check_tva_incasare(cursor, an, luna)
cont_tva = "4428" if tva_incasare else "4426"
print(f" TVA la încasare: {'DA (4428)' if tva_incasare else 'NU (4426)'}")
# 3. Determine payment type
payment_methods = ocr_result.get("payment_methods") or []
has_cash = any(pm.get("method", "").upper() == "NUMERAR" for pm in payment_methods)
has_card = any(pm.get("method", "").upper() == "CARD" for pm in payment_methods)
# If no payment info, assume cash
if not payment_methods:
has_cash = True
print(f" Plată: {'NUMERAR' if has_cash else ''}{' + ' if has_cash and has_card else ''}{'CARD' if has_card else ''}")
# 4. Init PACK_CONTAFIN
cursor.callproc('PACK_CONTAFIN.INITIALIZEAZA_SCRIERE_ACT_RUL',
[0, datetime.now(), an, luna, 0, 0, 0, 0])
# 5. Get next COD
cursor.execute(
"SELECT NVL(MAX(COD), 0) + 1 FROM ACT WHERE AN = :an AND LUNA = :luna",
an=an, luna=luna
)
cod = cursor.fetchone()[0]
# 6. Calculate amounts
total = float(ocr_result.get("amount") or 0)
tva = float(ocr_result.get("tva_total") or 0)
fara_tva = total - tva
nract = ocr_result.get("receipt_number", "")
nract = int(nract) if str(nract).isdigit() else 0
cont_cheltuiala = get_cont(ocr_result.get("cui") or "")
expl = f"OCR: {ocr_result.get('partner_name') or 'N/A'}"[:100]
print(f" COD: {cod}, Cont: {cont_cheltuiala}")
print(f" Total: {total}, Bază: {fara_tva}, TVA: {tva}")
# 7. Process TVA entries from OCR (pot fi mai multe cote TVA)
tva_entries = ocr_result.get("tva_entries") or []
# 8. Build accounting lines
lines = []
# Calculate base for each TVA rate
if tva_entries:
# Process each TVA entry separately
for tva_entry in tva_entries:
tva_rate = tva_entry.get("percent") or 21
tva_amount = float(tva_entry.get("amount") or 0)
if tva_amount <= 0:
continue
# Calculate base for this TVA rate
base_amount = tva_amount / (tva_rate / 100)
# Get ID_JTVA_COLOANA and TAXCODE based on TVA rate and TVA la încasare
if tva_incasare:
jtva_data = JTVA_NEEX.get(tva_rate, (210, 211, 301104, 301305))
else:
jtva_data = JTVA_NORMAL.get(tva_rate, (208, 209, 301104, 301305))
jtva_baza, jtva_tva, taxcode_normal, taxcode_tvai = jtva_data
taxcode = taxcode_tvai if tva_incasare else taxcode_normal
print(f" TVA {tva_rate}%: baza={base_amount:.2f}, tva={tva_amount:.2f}, JTVA=({jtva_baza},{jtva_tva}), TAXCODE={taxcode}")
# Linia cheltuială pentru această cotă
lines.append({
"scd": cont_cheltuiala, "scc": "401",
"suma": base_amount, "expl": expl,
"id_partc": id_part, "id_partd": 0,
"id_jtva": jtva_baza,
"taxcode": taxcode
})
# Linia TVA pentru această cotă
proc_tva = 1 + tva_rate / 100 # 1.21, 1.19, etc.
lines.append({
"scd": cont_tva, "scc": "401",
"suma": tva_amount, "expl": f"TVA {tva_rate}% {expl}"[:100],
"id_partc": id_part, "id_partd": 0,
"proc_tva": proc_tva,
"id_jtva": jtva_tva,
"taxcode": taxcode
})
else:
# Fallback: use total amounts if no tva_entries
if fara_tva > 0:
tva_rate = round(tva / fara_tva * 100) if fara_tva > 0 else 21
else:
tva_rate = 21
if tva_incasare:
jtva_data = JTVA_NEEX.get(tva_rate, (210, 211, 301104, 301305))
else:
jtva_data = JTVA_NORMAL.get(tva_rate, (208, 209, 301104, 301305))
jtva_baza, jtva_tva, taxcode_normal, taxcode_tvai = jtva_data
taxcode = taxcode_tvai if tva_incasare else taxcode_normal
print(f" TVA {tva_rate}% (estimat): JTVA=({jtva_baza},{jtva_tva}), TAXCODE={taxcode}")
lines.append({
"scd": cont_cheltuiala, "scc": "401",
"suma": fara_tva, "expl": expl,
"id_partc": id_part, "id_partd": 0,
"id_jtva": jtva_baza,
"taxcode": taxcode
})
if tva > 0:
proc_tva = 1 + tva_rate / 100
lines.append({
"scd": cont_tva, "scc": "401",
"suma": tva, "expl": f"TVA {tva_rate}% {expl}"[:100],
"id_partc": id_part, "id_partd": 0,
"proc_tva": proc_tva,
"id_jtva": jtva_tva,
"taxcode": taxcode
})
# Linia plată din casă (DOAR dacă plată numerar)
if has_cash and not has_card:
lines.append({
"scd": "401", "scc": "5311",
"suma": total, "expl": f"Plata {expl}"[:100],
"id_partc": 0, "id_partd": id_part,
"id_jtva": None, # Nu are JTVA pentru plată
"taxcode": None
})
# Dacă plată CARD - nu se face nota 401=5311 (se face la extras bancar)
# ID_FDOC = 17 pentru BON FISCAL
id_fdoc = 17
# 9. Insert lines
for line in lines:
proc_tva = line.get("proc_tva") or 0 # Default 0 for non-TVA lines
id_jtva = line.get("id_jtva") # Poate fi None pentru plăți
taxcode = line.get("taxcode") # Poate fi None pentru plăți
cursor.execute("""
INSERT INTO ACT_TEMP (
LUNA, AN, COD, DATAIREG, DATAACT, NRACT,
EXPLICATIA, SCD, SCC, SUMA, PROC_TVA,
ID_PARTC, ID_PARTD, ID_FDOC, ID_JTVA_COLOANA, TAXCODE, ID_UTIL, DATAORA
) VALUES (
:luna, :an, :cod, TRUNC(SYSDATE), :dataact, :nract,
:expl, :scd, :scc, :suma, :proc_tva,
:id_partc, :id_partd, :id_fdoc, :id_jtva, :taxcode, 0, SYSDATE
)
""",
luna=luna, an=an, cod=cod, dataact=receipt_date, nract=nract,
expl=line["expl"], scd=line["scd"], scc=line["scc"],
suma=line["suma"], proc_tva=proc_tva,
id_partc=line["id_partc"], id_partd=line["id_partd"],
id_fdoc=id_fdoc, id_jtva=id_jtva, taxcode=taxcode
)
jtva_info = f" [JTVA={id_jtva}]" if id_jtva else ""
taxcode_info = f" [TAX={taxcode}]" if taxcode else ""
print(f" {line['scd']} = {line['scc']}: {line['suma']:.2f}{jtva_info}{taxcode_info}")
# 9. Finalize
mesaj = cursor.var(oracledb.STRING, 4000)
cursor.callproc('PACK_CONTAFIN.FINALIZEAZA_SCRIERE_ACT_RUL',
[0, cod, 0, 0, 0, mesaj])
if do_commit:
conn.commit()
return {"success": True, "cod": cod, "luna": luna, "an": an, "saved": True,
"id_part": id_part, "tva_incasare": tva_incasare}
else:
conn.rollback()
return {"success": True, "cod": cod, "luna": luna, "an": an, "saved": False,
"id_part": id_part, "tva_incasare": tva_incasare}
except Exception as e:
conn.rollback()
import traceback
return {"success": False, "error": str(e), "traceback": traceback.format_exc()}
finally:
cursor.close()
conn.close()
def process_bon(file_path: Path, do_save: bool = False, company_id: int = COMPANY_ID,
api_user: str = API_USER, api_pass: str = API_PASS):
"""Procesează un bon fiscal: OCR → SQLite → Oracle."""
print("=" * 60)
print(f"📄 Procesez: {file_path.name}")
print("=" * 60)
# 1. Login
print("\n🔑 Login API...")
client = APIClient(API_BASE)
if not client.login(api_user, api_pass, SERVER_ID):
print("❌ Login failed!")
return None
print(" ✅ OK")
# 2. OCR
print("\n🔍 OCR extract...")
ocr_result = client.ocr_extract(file_path)
if not ocr_result["success"]:
print(f"{ocr_result['error']}")
return None
ocr = ocr_result["result"]
print(f" ✅ OK ({ocr_result.get('time_ms', '?')}ms)")
print(f" CUI: {ocr.get('cui')}")
print(f" Partner: {ocr.get('partner_name')}")
print(f" Data: {ocr.get('receipt_date')}")
print(f" Total: {ocr.get('amount')} RON")
print(f" TVA: {ocr.get('tva_total')} RON")
# Show payment methods
payment_methods = ocr.get("payment_methods") or []
if payment_methods:
pm_str = ", ".join(f"{pm.get('method')}: {pm.get('amount')}" for pm in payment_methods)
print(f" Plăți: {pm_str}")
# Show TVA breakdown
tva_entries = ocr.get("tva_entries") or []
if tva_entries:
tva_str = ", ".join(f"{t.get('code')}({t.get('percent')}%): {t.get('amount')}" for t in tva_entries)
print(f" TVA detaliat: {tva_str}")
# 3. SQLite (via API) - cu TOATE datele
print("\n💾 Save SQLite (via API)...")
sqlite_result = client.create_receipt(ocr, company_id)
if not sqlite_result["success"]:
print(f"{sqlite_result['error']}")
return None
receipt = sqlite_result["receipt"]
print(f" ✅ Receipt ID: {receipt.get('id')}")
print(f" Payment mode: {receipt.get('payment_mode')}")
# 4. Oracle (direct)
mode = "SAVE" if do_save else "DRY RUN"
print(f"\n🗄️ Save Oracle ({mode})...")
oracle_result = save_to_oracle(ocr, do_commit=do_save)
if oracle_result["success"]:
if oracle_result["saved"]:
print(f" ✅ SALVAT: COD={oracle_result['cod']}, {oracle_result['luna']:02d}/{oracle_result['an']}")
else:
print(f" ⚠️ DRY RUN: ar fi COD={oracle_result['cod']}")
else:
print(f"{oracle_result.get('error')}")
if oracle_result.get("traceback"):
print(oracle_result["traceback"])
print("\n" + "=" * 60)
return {
"ocr": ocr,
"sqlite_receipt_id": receipt.get("id"),
"oracle": oracle_result
}
def main():
parser = argparse.ArgumentParser(description="Procesare bon fiscal: OCR → SQLite → Oracle")
parser.add_argument("file", help="Path către PDF sau imagine")
parser.add_argument("--save", action="store_true", help="Salvează efectiv în Oracle")
parser.add_argument("--company", type=int, default=COMPANY_ID, help="Company ID")
parser.add_argument("--user", default=API_USER, help="API username")
parser.add_argument("--password", default=API_PASS, help="API password")
args = parser.parse_args()
file_path = Path(args.file)
if not file_path.exists():
print(f"❌ File not found: {file_path}")
sys.exit(1)
result = process_bon(file_path, do_save=args.save, company_id=args.company,
api_user=args.user, api_pass=args.password)
if result:
print("\n✅ Done!")
else:
print("\n❌ Failed!")
sys.exit(1)
if __name__ == "__main__":
main()