Enhance invoice and item processing with balance tracking features

- Added `company_id` to `ClientProviderAddress` and `ClientProviderPrograms` for better association.
- Updated `_process_with_discharge` to simplify invoice review by removing redundant parameters.
- Improved `pre_validators` to ensure address validation checks for existence before accessing country.
- Enhanced balance comparison logic in `compare_balances` by tracking consumed quantities.
- Modified `fill_available_balances` to reflect invoice status changes from 'UNPROCESSED' to 'PENDING'.
- Introduced `get_lines_with_balance` method in `ItemService` to fetch invoice lines with available balance.
- Added new API endpoint to retrieve import invoice lines with balance information.
- Updated frontend components to display available balances and improve user experience in invoice selection.
This commit is contained in:
2026-03-18 00:55:17 -05:00
parent 5265ec6849
commit 2a861e480c
17 changed files with 554 additions and 46 deletions

View File

@@ -24,7 +24,10 @@ if TYPE_CHECKING:
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
# Imported at runtime so SQLAlchemy's mapper registry can resolve the class name
# used in the relationship string below.
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
# ============================================================================
# CORE ENTITIES

View File

@@ -3,7 +3,8 @@ API Endpoints for Items management
Handles CRUD operations for Item with one-to-many relationships to LineItems
"""
from typing import Dict, Any, Optional
import datetime
from typing import Dict, Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from sqlalchemy.orm import Session
@@ -191,6 +192,39 @@ async def get_items_by_invoice(
skip=skip,
limit=limit
)
@router.get("/invoice/{invoice_id}/items-with-balance", response_model=List[dict])
async def get_items_with_balance(
invoice_id: int = Path(..., description="Import Invoice ID"),
company_id: int = Query(..., description="Company ID"),
as_of_date: Optional[datetime.date] = Query(
None,
description=(
"Cut-off date for balance calculation. Only consumptions on or "
"before this date are subtracted (CALCULA_SALDO_FECHA_EXPO logic)."
),
),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Returns every line of the given import invoice with its available balance
from the a24.balance_movement ledger.
Each item in the response includes:
- id, line_number, part_number, class_code, unit_of_measure_code
- quantity : original imported quantity
- available_balance : net balance still available for export discharge
- has_balance : true when available_balance > 0
Use ``as_of_date`` to restrict consumption movements to a specific date
(pass the export invoice date so that future discharges are not counted).
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = ItemService()
return service.get_lines_with_balance(db, invoice_id, tenant_id, company_id, as_of_date)
# ============================================================================
# STATISTICS & UTILITIES
# ============================================================================

View File

@@ -12,10 +12,12 @@ After refactoring: LineItem is the main entity, representing a single line item
There is no intermediate Item entity anymore. Each LineItem belongs directly to an InvoiceHeader.
"""
import datetime
import logging
from decimal import Decimal
from typing import Optional, List, Tuple
from fastapi import HTTPException
from sqlalchemy import and_, or_
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
@@ -41,6 +43,7 @@ from .series.models import Serie
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
from api.v1.modules.a24.balance_movements.models import BalanceMovement, NEGATIVE_MOVEMENTS
logger = logging.getLogger(__name__)
@@ -372,10 +375,11 @@ class ItemService:
errors.raise_if_errors("Error al crear el item - invoice_id es requerido")
invoice = invoice_exists_by_id(
db, item_data.invoice_id, tenant_id, company_id, errors
db, item_data.invoice_id, tenant_id, company_id, None
)
if not invoice:
errors.add_error("invoice_id", "La factura no existe", code="NOT_FOUND", value=str(item_data.invoice_id))
errors.raise_if_errors("Error al encontra la factura para el item")
if not invoice_processed(db, item_data.invoice_id, tenant_id, company_id, errors):
errors.raise_if_errors("Error al crear el item - la factura ya fue actualizada, no se pueden agregar items")
@@ -511,9 +515,10 @@ class ItemService:
errors = ErrorCollector()
invoice = invoice_exists_by_id(
db, item_data.invoice_id, tenant_id, company_id, errors
db, item_data.invoice_id, tenant_id, company_id, None
)
if not invoice:
errors.add_error("invoice_id", "La factura no existe", code="NOT_FOUND", value=str(item_data.invoice_id))
errors.raise_if_errors("Error al encontra la factura para el item")
# Lock invoice
@@ -684,3 +689,125 @@ class ItemService:
db.rollback()
logger.error(f"Error deleting item: {e}")
raise HTTPException(status_code=500, detail="Error deleting item")
@staticmethod
def get_lines_with_balance(
db: Session,
invoice_id: int,
tenant_id: int,
company_id: int,
as_of_date: Optional[datetime.date] = None,
) -> List[dict]:
"""
Returns every line of an import invoice together with its current
available balance calculated from the a24.balance_movement ledger.
Lines with balance <= 0 are included but marked as unavailable so
the frontend can grey them out / disable them.
Parameters
----------
as_of_date : optional cut-off date. Only negative movements
(consumptions, etc.) on or before this date are counted,
mirroring the CALCULA_SALDO_FECHA_EXPO Clarion logic.
If None, all movements are counted (no date restriction).
"""
lines: List[LineItem] = (
db.query(LineItem)
.filter(
LineItem.invoice_id == invoice_id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.options(
joinedload(LineItem.quantity),
joinedload(LineItem.description),
joinedload(LineItem.part_info),
joinedload(LineItem.class_info),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.fa_data),
joinedload(LineItem.invoice),
)
.order_by(LineItem.line_number)
.all()
)
result = []
for line in lines:
available_balance = ItemService._compute_balance(db, line.id, as_of_date)
qty = line.quantity
desc = line.description
fa = line.fa_data
inv = line.invoice
# Count subitems (lines that reference this line as parent via subitem_number)
subitem_count = 0
if fa and fa.contains_subitems:
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem as FaModel
subitem_count = (
db.query(func.count(LineItem.id))
.join(FaModel, FaModel.id == LineItem.id)
.filter(
LineItem.invoice_id == invoice_id,
LineItem.tenant_id == tenant_id,
FaModel.is_subitem == True,
FaModel.subitem_number == line.line_number,
)
.scalar() or 0
)
result.append({
"id": line.id,
"line_number": line.line_number,
# Invoice info
"invoice_number": inv.invoice_number if inv else None,
"invoice_date": inv.invoice_date.isoformat() if inv and inv.invoice_date else None,
"invoice_status": inv.status if inv and inv.status else None,
# Part / class
"part_number": line.part_info.part_number if line.part_info else None,
"class_code": line.class_info.class_code if line.class_info else None,
"description_spanish": desc.description_spanish if desc else None,
"unit_of_measure_code": line.unit_of_measure_info.code if line.unit_of_measure_info else None,
# Quantities
"quantity": float(qty.quantity) if qty and qty.quantity is not None else None,
"quantity_returned_temp": float(qty.quantity_returned_temp) if qty and qty.quantity_returned_temp is not None else None,
"quantity_returned": float(qty.quantity_returned) if qty and qty.quantity_returned is not None else None,
# Balance
"available_balance": float(available_balance),
"has_balance": available_balance > Decimal(0),
# FA / subitem info
"is_subitem": fa.is_subitem if fa else None,
"contains_subitems": fa.contains_subitems if fa else None,
"subitem_count": subitem_count,
})
return result
@staticmethod
def _compute_balance(
db: Session,
item_line_id: int,
as_of_date: Optional[datetime.date],
) -> Decimal:
"""Net available balance for one import line from the ledger."""
sign_expr = case(
(BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), Decimal(-1)),
else_=Decimal(1),
)
if as_of_date is not None:
date_filter = case(
(
BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS),
BalanceMovement.operation_date <= as_of_date,
),
else_=True,
)
else:
date_filter = True # type: ignore[assignment]
result = db.execute(
select(func.sum(sign_expr * BalanceMovement.quantity)).where(
BalanceMovement.import_item_line_id == item_line_id,
date_filter,
)
).scalar()
return Decimal(str(result or 0))