Add municipal receipt module and convert navbar to sidebar
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:
Carlos Escalante
2026-04-02 16:11:51 -06:00
parent 45166f9d20
commit 739a32efd4
8 changed files with 1492 additions and 84 deletions

View 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
],
)

View File

@@ -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)