mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 09:28:47 +02:00
Add municipal receipt module and convert navbar to sidebar
All checks were successful
Deploy to VPS / deploy (push) Successful in 58s
All checks were successful
Deploy to VPS / deploy (push) Successful in 58s
- New module: Municipalidad de Belén receipt extraction via pdftotext+regex - Backend: MunicipalReceipt + WaterMeterReading models, upload/list/detail/water-consumption endpoints - Auto-creates budget Transaction on upload (duplicate-safe via reference) - Frontend: ServiciosMunicipales page with summary cards, water consumption bar chart, receipt history, PDF upload - Convert top navbar to left sidebar with section headers (General, Finanzas, Servicios) - Desktop: fixed 220px sidebar, mobile: sheet overlay - Grouped nav: Dashboard | Presupuesto, Salarios, Pensiones, Analytics | Municipalidad Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
285
backend/app/api/v1/endpoints/municipal_receipts.py
Normal file
285
backend/app/api/v1/endpoints/municipal_receipts.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from datetime import datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.models.models import (
|
||||
Category,
|
||||
Currency,
|
||||
MunicipalReceipt,
|
||||
MunicipalReceiptRead,
|
||||
Transaction,
|
||||
TransactionSource,
|
||||
TransactionType,
|
||||
WaterMeterReading,
|
||||
WaterMeterReadingRead,
|
||||
)
|
||||
from app.services.municipal_receipt_pdf import extract_municipal_receipt
|
||||
|
||||
router = APIRouter(prefix="/municipal-receipts", tags=["municipal-receipts"])
|
||||
|
||||
|
||||
# --- Response models ---
|
||||
|
||||
|
||||
class MunicipalReceiptDetailRead(MunicipalReceiptRead):
|
||||
water_readings: list[WaterMeterReadingRead] = []
|
||||
|
||||
|
||||
class MunicipalReceiptUploadResult(BaseModel):
|
||||
imported: int
|
||||
updated: int
|
||||
errors: list[str]
|
||||
receipt: Optional[MunicipalReceiptRead] = None
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
|
||||
def _auto_categorize(merchant: str, session: Session) -> Optional[int]:
|
||||
categories = session.exec(select(Category)).all()
|
||||
merchant_lower = merchant.lower()
|
||||
for cat in categories:
|
||||
if cat.auto_match_patterns:
|
||||
patterns = [p.strip().lower() for p in cat.auto_match_patterns.split(",")]
|
||||
if any(p in merchant_lower for p in patterns if p):
|
||||
return cat.id
|
||||
return None
|
||||
|
||||
|
||||
def _upsert_receipt(
|
||||
session: Session, data: dict, filename: str
|
||||
) -> tuple[MunicipalReceipt, bool]:
|
||||
"""Insert or update a municipal receipt. Returns (row, is_new)."""
|
||||
r = data["receipt"]
|
||||
totals = data["totals"]
|
||||
receipt_date_str = r["date"]
|
||||
# The receipt is issued in month N but covers month N-1
|
||||
receipt_dt = datetime.strptime(receipt_date_str, "%Y-%m-%d").date()
|
||||
billing_month = receipt_dt - relativedelta(months=1)
|
||||
period = billing_month.strftime("%Y-%m")
|
||||
|
||||
existing = session.exec(
|
||||
select(MunicipalReceipt).where(
|
||||
MunicipalReceipt.account == r["account"],
|
||||
MunicipalReceipt.period == period,
|
||||
)
|
||||
).first()
|
||||
|
||||
charges = [
|
||||
{"detail": c["detail"], "amount": c.get("amount", 0)}
|
||||
for c in data.get("charges", [])
|
||||
]
|
||||
|
||||
fields = dict(
|
||||
receipt_date=datetime.strptime(receipt_date_str, "%Y-%m-%d").date(),
|
||||
due_date=datetime.strptime(r["due_date"], "%Y-%m-%d").date(),
|
||||
period=period,
|
||||
account=r["account"],
|
||||
finca=r.get("finca", ""),
|
||||
holder_name=r.get("account_holder", {}).get("name", ""),
|
||||
holder_cedula=r.get("account_holder", {}).get("cedula", ""),
|
||||
holder_address=r.get("account_holder", {}).get("address", ""),
|
||||
subtotal=totals.get("subtotal", 0),
|
||||
interests=totals.get("interests", 0),
|
||||
iva=totals.get("iva", 0),
|
||||
total=totals.get("total", 0),
|
||||
raw_charges=charges,
|
||||
source_filename=filename,
|
||||
)
|
||||
|
||||
if existing:
|
||||
for k, v in fields.items():
|
||||
setattr(existing, k, v)
|
||||
session.add(existing)
|
||||
# Delete old water readings for this receipt
|
||||
old_readings = session.exec(
|
||||
select(WaterMeterReading).where(
|
||||
WaterMeterReading.receipt_id == existing.id
|
||||
)
|
||||
).all()
|
||||
for rd in old_readings:
|
||||
session.delete(rd)
|
||||
session.flush()
|
||||
return existing, False
|
||||
|
||||
row = MunicipalReceipt(**fields)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row, True
|
||||
|
||||
|
||||
def _insert_water_readings(
|
||||
session: Session, receipt: MunicipalReceipt, data: dict
|
||||
) -> None:
|
||||
"""Insert water meter readings (current + historical) for a receipt."""
|
||||
# Current period readings
|
||||
for wm in data.get("water_meters", []):
|
||||
reading = WaterMeterReading(
|
||||
receipt_id=receipt.id,
|
||||
meter_id=str(wm["meter_id"]),
|
||||
period=wm["period"],
|
||||
reading_previous=wm.get("reading_previous", 0),
|
||||
reading_current=wm.get("reading_current", 0),
|
||||
consumption_m3=wm.get("consumption_m3", 0),
|
||||
agua_potable=wm.get("agua_potable", 0),
|
||||
serv_ambientales=wm.get("serv_ambientales", 0),
|
||||
alcant_sanitario=wm.get("alcant_sanitario", 0),
|
||||
iva=wm.get("iva", 0),
|
||||
is_historical=False,
|
||||
)
|
||||
session.add(reading)
|
||||
|
||||
# Historical consumption entries
|
||||
for hc in data.get("historical_consumption", []):
|
||||
period = hc["period"]
|
||||
meter_id = str(hc["meter_id"])
|
||||
# Upsert: check if this historical entry already exists
|
||||
existing = session.exec(
|
||||
select(WaterMeterReading).where(
|
||||
WaterMeterReading.meter_id == meter_id,
|
||||
WaterMeterReading.period == period,
|
||||
WaterMeterReading.is_historical == True, # noqa: E712
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
existing.consumption_m3 = hc.get("consumption_m3", 0)
|
||||
session.add(existing)
|
||||
else:
|
||||
session.add(
|
||||
WaterMeterReading(
|
||||
receipt_id=receipt.id,
|
||||
meter_id=meter_id,
|
||||
period=period,
|
||||
consumption_m3=hc.get("consumption_m3", 0),
|
||||
is_historical=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _ensure_transaction(
|
||||
session: Session, receipt: MunicipalReceipt
|
||||
) -> None:
|
||||
"""Create a budget Transaction for this receipt if one doesn't exist."""
|
||||
reference = f"municipal-{receipt.account}-{receipt.period}"
|
||||
existing = session.exec(
|
||||
select(Transaction).where(Transaction.reference == reference)
|
||||
).first()
|
||||
if existing:
|
||||
# Update amount in case receipt was re-uploaded with corrections
|
||||
existing.amount = receipt.total
|
||||
session.add(existing)
|
||||
return
|
||||
|
||||
category_id = _auto_categorize("municipalidad", session)
|
||||
tx = Transaction(
|
||||
amount=receipt.total,
|
||||
currency=Currency.CRC,
|
||||
merchant="Municipalidad de Belén",
|
||||
date=datetime.combine(receipt.receipt_date, datetime.min.time()),
|
||||
transaction_type=TransactionType.COMPRA,
|
||||
source=TransactionSource.TRANSFER,
|
||||
reference=reference,
|
||||
category_id=category_id,
|
||||
notes=f"Recibo municipal {receipt.period}",
|
||||
)
|
||||
session.add(tx)
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
|
||||
@router.post("/upload", response_model=MunicipalReceiptUploadResult)
|
||||
async def upload_municipal_receipt(
|
||||
file: UploadFile,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
filename = file.filename or "unknown.pdf"
|
||||
errors: list[str] = []
|
||||
|
||||
try:
|
||||
pdf_bytes = await file.read()
|
||||
data = extract_municipal_receipt(pdf_bytes, filename)
|
||||
except ValueError as e:
|
||||
return MunicipalReceiptUploadResult(imported=0, updated=0, errors=[str(e)])
|
||||
except Exception as e:
|
||||
return MunicipalReceiptUploadResult(
|
||||
imported=0, updated=0, errors=[f"{filename}: {e}"]
|
||||
)
|
||||
|
||||
receipt, is_new = _upsert_receipt(session, data, filename)
|
||||
_insert_water_readings(session, receipt, data)
|
||||
_ensure_transaction(session, receipt)
|
||||
|
||||
session.commit()
|
||||
session.refresh(receipt)
|
||||
|
||||
return MunicipalReceiptUploadResult(
|
||||
imported=1 if is_new else 0,
|
||||
updated=0 if is_new else 1,
|
||||
errors=errors,
|
||||
receipt=MunicipalReceiptRead.model_validate(receipt),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[MunicipalReceiptRead])
|
||||
def list_receipts(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
rows = session.exec(
|
||||
select(MunicipalReceipt).order_by(
|
||||
MunicipalReceipt.receipt_date.desc() # type: ignore[union-attr]
|
||||
)
|
||||
).all()
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/water-consumption", response_model=list[WaterMeterReadingRead])
|
||||
def get_water_consumption(
|
||||
months: int = Query(default=24, ge=1, le=120),
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
rows = session.exec(
|
||||
select(WaterMeterReading)
|
||||
.where(WaterMeterReading.is_historical == False) # noqa: E712
|
||||
.order_by(
|
||||
WaterMeterReading.period.asc(), # type: ignore[union-attr]
|
||||
WaterMeterReading.meter_id.asc(), # type: ignore[union-attr]
|
||||
)
|
||||
.limit(months * 3) # up to 3 meters per month
|
||||
).all()
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/{receipt_id}", response_model=MunicipalReceiptDetailRead)
|
||||
def get_receipt_detail(
|
||||
receipt_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
receipt = session.get(MunicipalReceipt, receipt_id)
|
||||
if not receipt:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=404, detail="Receipt not found")
|
||||
|
||||
readings = session.exec(
|
||||
select(WaterMeterReading).where(
|
||||
WaterMeterReading.receipt_id == receipt_id
|
||||
)
|
||||
).all()
|
||||
|
||||
return MunicipalReceiptDetailRead(
|
||||
**MunicipalReceiptRead.model_validate(receipt).model_dump(),
|
||||
water_readings=[
|
||||
WaterMeterReadingRead.model_validate(r) for r in readings
|
||||
],
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from app.api.v1.endpoints import (
|
||||
categories,
|
||||
exchange_rate,
|
||||
import_transactions,
|
||||
municipal_receipts,
|
||||
notifications,
|
||||
pensions,
|
||||
salarios,
|
||||
@@ -30,3 +31,4 @@ api_router.include_router(budget.router)
|
||||
api_router.include_router(notifications.router)
|
||||
api_router.include_router(salarios.router)
|
||||
api_router.include_router(pensions.router)
|
||||
api_router.include_router(municipal_receipts.router)
|
||||
|
||||
@@ -358,3 +358,75 @@ class BalanceOverrideRead(SQLModel):
|
||||
month: int
|
||||
override_balance: float
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# --- Municipal Receipt ---
|
||||
|
||||
|
||||
class MunicipalReceiptBase(SQLModel):
|
||||
receipt_date: date
|
||||
due_date: date
|
||||
period: str # "YYYY-MM"
|
||||
account: str
|
||||
finca: str
|
||||
holder_name: str
|
||||
holder_cedula: str
|
||||
holder_address: str
|
||||
subtotal: float
|
||||
interests: float
|
||||
iva: float
|
||||
total: float
|
||||
raw_charges: list[dict] = Field(
|
||||
default_factory=list,
|
||||
sa_column=Column(JSON, nullable=False, server_default="[]"),
|
||||
)
|
||||
source_filename: str
|
||||
|
||||
|
||||
class MunicipalReceipt(MunicipalReceiptBase, table=True):
|
||||
__table_args__ = (UniqueConstraint("account", "period"),)
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
water_readings: list["WaterMeterReading"] = Relationship(
|
||||
back_populates="receipt",
|
||||
)
|
||||
|
||||
|
||||
class MunicipalReceiptCreate(MunicipalReceiptBase):
|
||||
pass
|
||||
|
||||
|
||||
class MunicipalReceiptRead(MunicipalReceiptBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# --- Water Meter Reading ---
|
||||
|
||||
|
||||
class WaterMeterReadingBase(SQLModel):
|
||||
meter_id: str
|
||||
period: str # "YYYY-MM"
|
||||
reading_previous: float = 0
|
||||
reading_current: float = 0
|
||||
consumption_m3: float
|
||||
agua_potable: float = 0
|
||||
serv_ambientales: float = 0
|
||||
alcant_sanitario: float = 0
|
||||
iva: float = 0
|
||||
is_historical: bool = False
|
||||
receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id")
|
||||
|
||||
|
||||
class WaterMeterReading(WaterMeterReadingBase, table=True):
|
||||
__table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),)
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
receipt: Optional[MunicipalReceipt] = Relationship(
|
||||
back_populates="water_readings",
|
||||
)
|
||||
|
||||
|
||||
class WaterMeterReadingRead(WaterMeterReadingBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
291
backend/app/services/municipal_receipt_pdf.py
Normal file
291
backend/app/services/municipal_receipt_pdf.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Extract structured data from Municipalidad de Belén receipts using pdftotext + regex.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
def _parse_amount(s: str) -> float:
|
||||
"""Parse a Costa Rican formatted number: '1,875.00' → 1875.00"""
|
||||
return float(s.replace(",", ""))
|
||||
|
||||
|
||||
def _parse_date(s: str) -> str:
|
||||
"""Convert dd/mm/yyyy → YYYY-MM-DD"""
|
||||
d, m, y = s.strip().split("/")
|
||||
return f"{y}-{m.zfill(2)}-{d.zfill(2)}"
|
||||
|
||||
|
||||
def _parse_period(s: str) -> str:
|
||||
"""Convert mm/yyyy → YYYY-MM"""
|
||||
m, y = s.strip().split("/")
|
||||
return f"{y}-{m.zfill(2)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Charge:
|
||||
detail: str
|
||||
interests: float
|
||||
iva: float
|
||||
amount: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class WaterMeter:
|
||||
period: str
|
||||
meter_id: str
|
||||
reading_previous: int
|
||||
reading_current: int
|
||||
consumption_m3: int
|
||||
agua_potable: float
|
||||
serv_ambientales: float
|
||||
alcant_sanitario: float
|
||||
iva: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class HistoricalConsumption:
|
||||
meter_id: str
|
||||
period: str
|
||||
consumption_m3: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class MunicipalReceiptData:
|
||||
receipt_date: str # YYYY-MM-DD
|
||||
due_date: str # YYYY-MM-DD
|
||||
holder_name: str
|
||||
holder_cedula: str
|
||||
holder_address: str
|
||||
account: str
|
||||
finca: str
|
||||
charges: list[Charge] = field(default_factory=list)
|
||||
subtotal: float = 0.0
|
||||
interests: float = 0.0
|
||||
iva: float = 0.0
|
||||
total: float = 0.0
|
||||
water_meters: list[WaterMeter] = field(default_factory=list)
|
||||
historical_consumption: list[HistoricalConsumption] = field(default_factory=list)
|
||||
|
||||
|
||||
def _pdf_to_text(pdf_bytes: bytes) -> str:
|
||||
"""Convert PDF bytes to text using pdftotext -layout."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp:
|
||||
tmp.write(pdf_bytes)
|
||||
tmp.flush()
|
||||
result = subprocess.run(
|
||||
["pdftotext", "-layout", tmp.name, "-"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"pdftotext failed: {result.stderr}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
# Regex patterns
|
||||
RE_FECHA = re.compile(r"Fecha:\s*(\d{2}/\d{2}/\d{4})")
|
||||
RE_VENCIMIENTO = re.compile(r"Fecha de vencimiento:\s*(\d{2}/\d{2}/\d{4})")
|
||||
RE_NOMBRE = re.compile(r"Nombre:\s*(.+)")
|
||||
RE_CEDULA = re.compile(r"Cédula:\s*(\d+)")
|
||||
RE_DIRECCION = re.compile(r"Dirección:\s*(.+)")
|
||||
|
||||
# Charge line: DETAIL_TEXT account finca interests iva periodo_actual periodo_anterior
|
||||
RE_CHARGE = re.compile(
|
||||
r"^([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s.]+?)\s+"
|
||||
r"(\d{4})\s+"
|
||||
r"(\d{6}---\d{3})\s+"
|
||||
r"([\d,]+\.\d{2})\s+"
|
||||
r"([\d,]+\.\d{2})\s+"
|
||||
r"([\d,]+\.\d{2})\s+"
|
||||
r"([\d,]+\.\d{2})\s*$"
|
||||
)
|
||||
|
||||
RE_SUBTOTAL = re.compile(r"Sub-Total:\s+([\d,]+\.\d{2})")
|
||||
RE_INTERESES = re.compile(r"Intereses:\s+([\d,]+\.\d{2})")
|
||||
RE_IVA = re.compile(r"IVA\s+([\d,]+\.\d{2})")
|
||||
RE_TOTAL = re.compile(r"Total:\s+([\d,]+\.\d{2})")
|
||||
|
||||
# Water meter line: period meter_id lec_ant lec_act consumo agua_potable serv_amb alcant iva
|
||||
RE_WATER_METER = re.compile(
|
||||
r"(\d{2}/\d{4})\s+"
|
||||
r"(\d{4})\s+"
|
||||
r"(\d{5})\s+"
|
||||
r"(\d{5})\s+"
|
||||
r"(\d+)\s+"
|
||||
r"([\d,]+\.\d{2})\s+"
|
||||
r"([\d,]+\.\d{2})\s+"
|
||||
r"([\d,]+\.\d{2})\s+"
|
||||
r"([\d,]+\.\d{2})"
|
||||
)
|
||||
|
||||
# Historical consumption: meter_id period consumption
|
||||
RE_HISTORICAL = re.compile(
|
||||
r"(\d{4})\s+(\d{2}/\d{4})\s+(\d{5})"
|
||||
)
|
||||
|
||||
|
||||
def extract_municipal_receipt(
|
||||
pdf_bytes: bytes, filename: str
|
||||
) -> dict:
|
||||
"""Extract structured data from a municipal receipt PDF.
|
||||
|
||||
Returns a dict matching the target JSON schema.
|
||||
"""
|
||||
text = _pdf_to_text(pdf_bytes)
|
||||
|
||||
if "RECIBO MUNICIPAL" not in text:
|
||||
raise ValueError(f"{filename}: Not a municipal receipt")
|
||||
|
||||
data = MunicipalReceiptData(
|
||||
receipt_date="",
|
||||
due_date="",
|
||||
holder_name="",
|
||||
holder_cedula="",
|
||||
holder_address="",
|
||||
account="",
|
||||
finca="",
|
||||
)
|
||||
|
||||
# --- Header fields ---
|
||||
m = RE_FECHA.search(text)
|
||||
if m:
|
||||
data.receipt_date = _parse_date(m.group(1))
|
||||
|
||||
m = RE_VENCIMIENTO.search(text)
|
||||
if m:
|
||||
data.due_date = _parse_date(m.group(1))
|
||||
|
||||
m = RE_NOMBRE.search(text)
|
||||
if m:
|
||||
data.holder_name = m.group(1).strip()
|
||||
|
||||
m = RE_CEDULA.search(text)
|
||||
if m:
|
||||
data.holder_cedula = m.group(1).strip()
|
||||
|
||||
m = RE_DIRECCION.search(text)
|
||||
if m:
|
||||
data.holder_address = m.group(1).strip().rstrip(".")
|
||||
|
||||
# --- Charges ---
|
||||
for line in text.splitlines():
|
||||
m = RE_CHARGE.match(line.strip())
|
||||
if m:
|
||||
detail = m.group(1).strip()
|
||||
data.account = m.group(2)
|
||||
data.finca = m.group(3)
|
||||
interests = _parse_amount(m.group(4))
|
||||
iva = _parse_amount(m.group(5))
|
||||
amount = _parse_amount(m.group(6))
|
||||
data.charges.append(Charge(detail=detail, interests=interests, iva=iva, amount=amount))
|
||||
|
||||
# --- Totals ---
|
||||
m = RE_SUBTOTAL.search(text)
|
||||
if m:
|
||||
data.subtotal = _parse_amount(m.group(1))
|
||||
|
||||
m = RE_INTERESES.search(text)
|
||||
if m:
|
||||
data.interests = _parse_amount(m.group(1))
|
||||
|
||||
m = RE_IVA.search(text)
|
||||
if m:
|
||||
data.iva = _parse_amount(m.group(1))
|
||||
|
||||
m = RE_TOTAL.search(text)
|
||||
if m:
|
||||
data.total = _parse_amount(m.group(1))
|
||||
|
||||
# --- Water meters ---
|
||||
for m in RE_WATER_METER.finditer(text):
|
||||
data.water_meters.append(
|
||||
WaterMeter(
|
||||
period=_parse_period(m.group(1)),
|
||||
meter_id=m.group(2),
|
||||
reading_previous=int(m.group(3)),
|
||||
reading_current=int(m.group(4)),
|
||||
consumption_m3=int(m.group(5)),
|
||||
agua_potable=_parse_amount(m.group(6)),
|
||||
serv_ambientales=_parse_amount(m.group(7)),
|
||||
alcant_sanitario=_parse_amount(m.group(8)),
|
||||
iva=_parse_amount(m.group(9)),
|
||||
)
|
||||
)
|
||||
|
||||
# --- Historical consumption ---
|
||||
# Only parse lines AFTER "DETALLE DE CONSUMO MESES ANTERIORES"
|
||||
hist_section = text.split("DETALLE DE CONSUMO MESES ANTERIORES")
|
||||
if len(hist_section) > 1:
|
||||
for m in RE_HISTORICAL.finditer(hist_section[1]):
|
||||
data.historical_consumption.append(
|
||||
HistoricalConsumption(
|
||||
meter_id=m.group(1),
|
||||
period=_parse_period(m.group(2)),
|
||||
consumption_m3=int(m.group(3)),
|
||||
)
|
||||
)
|
||||
|
||||
# --- Validation ---
|
||||
if not data.receipt_date:
|
||||
raise ValueError(f"{filename}: Could not parse receipt date")
|
||||
if not data.charges:
|
||||
raise ValueError(f"{filename}: No charges found")
|
||||
|
||||
# --- Build output dict ---
|
||||
return {
|
||||
"receipt": {
|
||||
"type": "RECIBO MUNICIPAL",
|
||||
"issuer": {
|
||||
"name": "MUNICIPALIDAD DE BELÉN",
|
||||
"phone": "(506) 2587-0000",
|
||||
"fax": "(506) 2293-3667",
|
||||
"website": "www.belen.go.cr",
|
||||
},
|
||||
"date": data.receipt_date,
|
||||
"due_date": data.due_date,
|
||||
"account_holder": {
|
||||
"name": data.holder_name,
|
||||
"cedula": data.holder_cedula,
|
||||
"address": data.holder_address,
|
||||
},
|
||||
"account": data.account,
|
||||
"finca": data.finca,
|
||||
},
|
||||
"charges": [
|
||||
{"detail": c.detail, "interests": c.interests, "iva": c.iva, "amount": c.amount}
|
||||
for c in data.charges
|
||||
],
|
||||
"totals": {
|
||||
"subtotal": data.subtotal,
|
||||
"interests": data.interests,
|
||||
"iva": data.iva,
|
||||
"total": data.total,
|
||||
},
|
||||
"water_meters": [
|
||||
{
|
||||
"period": wm.period,
|
||||
"meter_id": wm.meter_id,
|
||||
"reading_previous": wm.reading_previous,
|
||||
"reading_current": wm.reading_current,
|
||||
"consumption_m3": wm.consumption_m3,
|
||||
"agua_potable": wm.agua_potable,
|
||||
"serv_ambientales": wm.serv_ambientales,
|
||||
"alcant_sanitario": wm.alcant_sanitario,
|
||||
"iva": wm.iva,
|
||||
}
|
||||
for wm in data.water_meters
|
||||
],
|
||||
"historical_consumption": [
|
||||
{
|
||||
"meter_id": hc.meter_id,
|
||||
"period": hc.period,
|
||||
"consumption_m3": hc.consumption_m3,
|
||||
}
|
||||
for hc in data.historical_consumption
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user