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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user