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:
@@ -185,6 +185,7 @@ class ClientProviderService:
|
||||
db_address = ClientProviderAddress(
|
||||
client_id=client.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
**client_data.address.model_dump(exclude_unset=True),
|
||||
)
|
||||
db.add(db_address)
|
||||
@@ -199,6 +200,7 @@ class ClientProviderService:
|
||||
db_programs = ClientProviderPrograms(
|
||||
client_id=client.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
**client_data.programs.model_dump(exclude_unset=True),
|
||||
)
|
||||
db.add(db_programs)
|
||||
|
||||
@@ -46,7 +46,7 @@ def _process_with_discharge(
|
||||
AFIJO, DONAC, SCRAP, REEXP, VEMEX.
|
||||
"""
|
||||
assign_no_discharges_series(db, lines, errors)
|
||||
review_class(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors)
|
||||
review_class(db, lines, errors)
|
||||
review_exchange_rate(db, invoice, errors)
|
||||
assign_values(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors)
|
||||
|
||||
@@ -201,4 +201,4 @@ def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id
|
||||
# invoice.status and totals are set inside finalize_invoice_no_discharge / termina_ac_o_lp_normal
|
||||
db.flush()
|
||||
|
||||
return {"status": "ok", "invoice_id": str(invoice.id)}
|
||||
return {"status": "success", "invoice_id": str(invoice.id)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from api.v1.modules.a76.general_catalogs.fractions.warning_fractions.models import WarningFraction
|
||||
@@ -47,7 +47,7 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.shipped_to_id,
|
||||
)
|
||||
if not shipped_to_exists.address.country:
|
||||
if not shipped_to_exists.address or not shipped_to_exists.address.country:
|
||||
errors.add_error(
|
||||
field="compliance_mx.shipped_to_id",
|
||||
message="El Destinatario no tiene capturado el pais.",
|
||||
@@ -100,11 +100,16 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_
|
||||
)
|
||||
|
||||
# Advertencias para las fracciones y su horario
|
||||
lines = db.query(LineItem).filter(
|
||||
LineItem.invoice_id == invoice.id,
|
||||
LineItem.tenant_id == tenant_id,
|
||||
LineItem.company_id == company_id,
|
||||
).all()
|
||||
lines = (
|
||||
db.query(LineItem)
|
||||
.options(joinedload(LineItem.fa_data))
|
||||
.filter(
|
||||
LineItem.invoice_id == invoice.id,
|
||||
LineItem.tenant_id == tenant_id,
|
||||
LineItem.company_id == company_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ def compare_balances(
|
||||
|
||||
entry.quantity_used += consume
|
||||
lot.available_qty -= consume
|
||||
lot.consumed_qty += consume
|
||||
|
||||
# ── Check if the entry was fully satisfied ────────────────────────────
|
||||
if entry.quantity_used < entry.quantity:
|
||||
|
||||
@@ -21,7 +21,11 @@ class AvailableLot:
|
||||
import_item_line_id : a76.item_lines.id of the import line (the lot)
|
||||
import_invoice_id : a76.invoice_header.id of the import invoice
|
||||
part_number_id : denormalized from the import line
|
||||
available_qty : net balance available (QSaldo:Cantidad)
|
||||
available_qty : net balance available (QSaldo:Cantidad); mutated by
|
||||
compare_balances() as quantity is distributed
|
||||
consumed_qty : how much was actually taken from this lot by
|
||||
compare_balances(); used by register_discharge_ledger
|
||||
to create the exact BalanceMovement amount
|
||||
value_me : USD value of the full lot (for proportional calc)
|
||||
value_mn : MXN value of the full lot (for proportional calc)
|
||||
order_peps : PEPS ordering key — lower = older = consumed first
|
||||
@@ -33,6 +37,7 @@ class AvailableLot:
|
||||
value_me: Optional[Decimal]
|
||||
value_mn: Optional[Decimal]
|
||||
order_peps: int
|
||||
consumed_qty: Decimal = field(default_factory=Decimal)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -66,7 +66,7 @@ def collect_lines_to_discharge(
|
||||
"""
|
||||
to_discharge: List[DownloadEntry] = []
|
||||
|
||||
discharge_lines = [line for line in lines if line.discharge]
|
||||
discharge_lines = [line for line in lines if line.fa_data and line.fa_data.discharge]
|
||||
|
||||
if not discharge_lines:
|
||||
return to_discharge
|
||||
|
||||
@@ -200,7 +200,7 @@ def fill_available_balances(
|
||||
continue
|
||||
|
||||
# Status 'NA' == not processed (Clarion: Estatus = 'NA')
|
||||
if import_invoice.status == InvoiceStatus.UNPROCESSED:
|
||||
if import_invoice.status == InvoiceStatus.PENDING:
|
||||
errors.add_error(
|
||||
field=f"line[{entry.export_line}].import_invoice",
|
||||
message=f"La Factura de Importación: '{entry.import_invoice}' está Desactualizada.",
|
||||
|
||||
@@ -23,6 +23,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .register_import_discharge import register_import_discharge
|
||||
from .register_discharge_series import register_discharge_series
|
||||
from .register_discharge_ledger import register_discharge_ledger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .discharge_types import DownloadEntry
|
||||
@@ -274,6 +275,9 @@ def finalize_invoice_with_discharge(
|
||||
generate_definitive_import(db, invoice, errors)
|
||||
|
||||
if to_discharge:
|
||||
# Write BalanceMovement (CONSUMPTION) + DischargeHeader + DischargeDetail
|
||||
register_discharge_ledger(db, invoice, to_discharge)
|
||||
# Update quantity_returned / value_returned on the import lines
|
||||
register_import_discharge(db, invoice, to_discharge)
|
||||
register_discharge_series(db, invoice, to_discharge)
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
register_discharge_ledger
|
||||
=========================
|
||||
Creates the full Annex-24 discharge record for one export invoice:
|
||||
|
||||
1. ONE DischargeHeader (one per export event)
|
||||
2. N BalanceMovement rows (type=CONSUMPTION, one per lot consumed)
|
||||
3. N DischargeDetail rows (one per export-line × import-lot pair),
|
||||
each referencing its BalanceMovement (design rule 3)
|
||||
|
||||
Design rules from a24.balance_movement (preserved here):
|
||||
1. NEVER update existing balance_movement rows — only INSERT.
|
||||
2. Balance = SUM of movements.
|
||||
3. Every DischargeDetail.movement_id MUST reference a BalanceMovement row.
|
||||
4. order_peps = movement.id (set after flush, globally monotonic).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a24.balance_movements.models import BalanceMovement, MovementType
|
||||
from api.v1.modules.a24.discharges.models import (
|
||||
DischargeDetail,
|
||||
DischargeHeader,
|
||||
DischargeStatus,
|
||||
DischargeType,
|
||||
)
|
||||
from .discharge_types import DownloadEntry, AvailableLot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _discharge_type_for_invoice(invoice: InvoiceHeader) -> DischargeType:
|
||||
mapping = {
|
||||
"AFIJO": DischargeType.TEMPORARY,
|
||||
"DONAC": DischargeType.TEMPORARY,
|
||||
"SCRAP": DischargeType.WASTE_SCRAP,
|
||||
"REEXP": DischargeType.DEFINITIVE,
|
||||
"VEMEX": DischargeType.DEFINITIVE,
|
||||
}
|
||||
return mapping.get(invoice.invoice_type or "", DischargeType.TEMPORARY)
|
||||
|
||||
|
||||
def _export_date(invoice: InvoiceHeader) -> datetime.date:
|
||||
d = invoice.invoice_date
|
||||
return d.date() if hasattr(d, "date") else d
|
||||
|
||||
|
||||
def _proportional_value(
|
||||
consume: Decimal,
|
||||
lot_consumed_total: Decimal,
|
||||
lot_value: Optional[Decimal],
|
||||
) -> Optional[Decimal]:
|
||||
"""Returns the proportional value for *consume* units out of *lot_consumed_total*."""
|
||||
if not lot_value or lot_consumed_total <= 0:
|
||||
return None
|
||||
return (consume / lot_consumed_total) * lot_value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register_discharge_ledger(
|
||||
db: Session,
|
||||
export_invoice: InvoiceHeader,
|
||||
to_discharge: List[DownloadEntry],
|
||||
) -> Optional[DischargeHeader]:
|
||||
"""
|
||||
Persists the complete Annex-24 discharge record for *export_invoice*.
|
||||
|
||||
Expects that compare_balances() has already run and populated
|
||||
``lot.consumed_qty`` for every lot that was drawn from.
|
||||
|
||||
Returns the created DischargeHeader, or None if nothing was discharged.
|
||||
"""
|
||||
# Only process entries that actually consumed something
|
||||
active = [e for e in to_discharge if e.quantity_used > Decimal(0)]
|
||||
if not active:
|
||||
return None
|
||||
|
||||
op_date = _export_date(export_invoice)
|
||||
discharge_type = _discharge_type_for_invoice(export_invoice)
|
||||
|
||||
# ── 1. DischargeHeader ────────────────────────────────────────────────
|
||||
header = DischargeHeader(
|
||||
tenant_id=export_invoice.tenant_id,
|
||||
company_id=export_invoice.company_id,
|
||||
source_invoice_id=export_invoice.id,
|
||||
discharge_type=discharge_type,
|
||||
status=DischargeStatus.APPLIED,
|
||||
discharge_date=op_date,
|
||||
)
|
||||
db.add(header)
|
||||
db.flush() # get header.id
|
||||
|
||||
total_movements = 0
|
||||
|
||||
for entry in active:
|
||||
export_line_id: Optional[int] = entry.line_item_id
|
||||
|
||||
# Only iterate lots that were actually consumed
|
||||
consumed_lots: List[AvailableLot] = [
|
||||
lot for lot in entry.available_lots if lot.consumed_qty > Decimal(0)
|
||||
]
|
||||
|
||||
for lot in consumed_lots:
|
||||
consume = lot.consumed_qty
|
||||
|
||||
# ── 2. BalanceMovement (CONSUMPTION) ──────────────────────────
|
||||
# Proportional value: consume / lot_consumed_total × lot_value
|
||||
# lot_consumed_total == consume for single-lot entries (most cases)
|
||||
value_me = _proportional_value(consume, consume, lot.value_me)
|
||||
value_mn = _proportional_value(consume, consume, lot.value_mn)
|
||||
|
||||
movement = BalanceMovement(
|
||||
tenant_id=export_invoice.tenant_id,
|
||||
company_id=export_invoice.company_id,
|
||||
import_invoice_id=lot.import_invoice_id,
|
||||
import_item_line_id=lot.import_item_line_id,
|
||||
part_number_id=lot.part_number_id,
|
||||
movement_type=MovementType.CONSUMPTION,
|
||||
quantity=consume,
|
||||
value_me=value_me,
|
||||
value_mn=value_mn,
|
||||
source_invoice_id=export_invoice.id,
|
||||
source_item_line_id=export_line_id,
|
||||
order_peps=0, # placeholder — set after flush (rule 4)
|
||||
operation_date=op_date,
|
||||
notes=(
|
||||
f"Descarga por factura de exportación "
|
||||
f"{export_invoice.invoice_number}"
|
||||
),
|
||||
)
|
||||
db.add(movement)
|
||||
db.flush() # get movement.id
|
||||
movement.order_peps = movement.id # rule 4: monotonic
|
||||
|
||||
# ── 3. DischargeDetail ─────────────────────────────────────────
|
||||
detail = DischargeDetail(
|
||||
tenant_id=export_invoice.tenant_id,
|
||||
company_id=export_invoice.company_id,
|
||||
discharge_header_id=header.id,
|
||||
export_item_line_id=export_line_id,
|
||||
import_item_line_id=lot.import_item_line_id,
|
||||
movement_id=movement.id,
|
||||
quantity_discharged=consume,
|
||||
unit_of_measure=entry.unit_of_measure or None,
|
||||
value_me=value_me,
|
||||
value_mn=value_mn,
|
||||
procedence=entry.origin_procedure or None,
|
||||
part_number=entry.part_number or None,
|
||||
)
|
||||
db.add(detail)
|
||||
total_movements += 1
|
||||
|
||||
logger.info(
|
||||
"register_discharge_ledger: invoice=%s header_id=%s details=%d",
|
||||
export_invoice.invoice_number,
|
||||
header.id,
|
||||
total_movements,
|
||||
)
|
||||
return header
|
||||
@@ -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