Merge pull request 'feature/balance_by_item' (#220) from feature/balance_by_item into development
Reviewed-on: ADUANASOFT/anexo76#220
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,239 @@
|
||||
"""
|
||||
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 api.v1.modules.a76.invoices.models import InvoiceHeader as A76InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
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
|
||||
|
||||
|
||||
def _proportional_qty(consume: Decimal, base_qty: Optional[Decimal], base_total: Optional[Decimal]) -> Optional[Decimal]:
|
||||
"""
|
||||
Proratea un valor (peso/valor) en proporción a lo consumido.
|
||||
- consume: cantidad consumida del lote
|
||||
- base_qty: valor total del lote (ej. peso neto total del lote)
|
||||
- base_total: cantidad total del lote (ej. quantity del lote)
|
||||
"""
|
||||
if base_qty is None:
|
||||
return None
|
||||
if base_total is None or base_total <= 0:
|
||||
return None
|
||||
return (consume / base_total) * base_qty
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
|
||||
# Caches to avoid N+1 queries in loops
|
||||
export_line_cache: dict[int, LineItem] = {}
|
||||
import_line_cache: dict[int, LineItem] = {}
|
||||
import_invoice_number_cache: dict[int, str] = {}
|
||||
|
||||
# ── 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
|
||||
|
||||
export_line_obj: Optional[LineItem] = None
|
||||
if export_line_id:
|
||||
export_line_obj = export_line_cache.get(export_line_id)
|
||||
if export_line_obj is None:
|
||||
export_line_obj = db.get(LineItem, export_line_id)
|
||||
if export_line_obj is not None:
|
||||
export_line_cache[export_line_id] = export_line_obj
|
||||
|
||||
# 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
|
||||
|
||||
# Load import-line object for denormalized customs/weights fields
|
||||
import_line_obj = import_line_cache.get(lot.import_item_line_id)
|
||||
if import_line_obj is None:
|
||||
import_line_obj = db.get(LineItem, lot.import_item_line_id)
|
||||
if import_line_obj is not None:
|
||||
import_line_cache[lot.import_item_line_id] = import_line_obj
|
||||
|
||||
# Import invoice number (for origin_import_invoice in DischargeDetail)
|
||||
origin_import_invoice: Optional[str] = None
|
||||
if lot.import_invoice_id:
|
||||
origin_import_invoice = import_invoice_number_cache.get(lot.import_invoice_id)
|
||||
if origin_import_invoice is None:
|
||||
inv = db.get(A76InvoiceHeader, lot.import_invoice_id)
|
||||
origin_import_invoice = inv.invoice_number if inv else None
|
||||
if origin_import_invoice:
|
||||
import_invoice_number_cache[lot.import_invoice_id] = origin_import_invoice
|
||||
|
||||
# ── 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 ─────────────────────────────────────────
|
||||
# Denormalized fields expected by reports:
|
||||
imp_cust = import_line_obj.customs if import_line_obj else None
|
||||
imp_qty = import_line_obj.quantity if import_line_obj else None
|
||||
imp_total_qty = imp_qty.quantity if imp_qty else None
|
||||
|
||||
net_weight = _proportional_qty(consume, imp_qty.net_weight if imp_qty else None, imp_total_qty)
|
||||
gross_weight = _proportional_qty(consume, imp_qty.gross_weight if imp_qty else None, imp_total_qty)
|
||||
|
||||
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,
|
||||
net_weight=net_weight,
|
||||
gross_weight=gross_weight,
|
||||
tariff_fraction=imp_cust.fraction if imp_cust else None,
|
||||
fraction_type=imp_cust.fraction_type if imp_cust else None,
|
||||
ad_valorem=imp_cust.advalorem if imp_cust else None,
|
||||
country_of_origin=imp_cust.origin_country if imp_cust else None,
|
||||
sector=imp_cust.sector if imp_cust else None,
|
||||
procedence=entry.origin_procedure or None,
|
||||
part_number=entry.part_number or None,
|
||||
export_part_number=(
|
||||
export_line_obj.part_info.part_number
|
||||
if export_line_obj and export_line_obj.part_info and export_line_obj.part_info.part_number
|
||||
else None
|
||||
),
|
||||
origin_import_invoice=origin_import_invoice,
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -64,6 +64,7 @@ celery_app.conf.update(
|
||||
"api.v1.modules.core.help_center.tasks",
|
||||
"api.v1.modules.a76.invoices.imports.process.task",
|
||||
"api.v1.modules.a76.invoices.imports.revert.task",
|
||||
"api.v1.modules.a76.invoices.exports.process.task",
|
||||
] # Ruta al módulo donde están las tareas
|
||||
)
|
||||
|
||||
|
||||
@@ -299,5 +299,52 @@ export const itemsApi = {
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Lista las líneas de una factura de importación con su saldo disponible.
|
||||
* Solo las líneas con has_balance = true tienen mercancía disponible para descarga.
|
||||
*
|
||||
* @param invoiceId - ID de la factura de importación
|
||||
* @param companyId - ID de la empresa
|
||||
* @param asOfDate - Fecha corte opcional (ISO: "YYYY-MM-DD").
|
||||
* Pasa la fecha de la factura de exportación para que
|
||||
* los consumos futuros no se descuenten del saldo.
|
||||
*/
|
||||
listByInvoiceWithBalance: (
|
||||
invoiceId: number,
|
||||
companyId: number,
|
||||
asOfDate?: string
|
||||
) => {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
if (asOfDate) params.append('as_of_date', asOfDate);
|
||||
return api.get<ImportLineWithBalance[]>(
|
||||
`/v1/a76/items/invoice/${invoiceId}/items-with-balance?${params.toString()}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export interface ImportLineWithBalance {
|
||||
id: number;
|
||||
line_number: number;
|
||||
// Invoice info
|
||||
invoice_number?: string;
|
||||
invoice_date?: string;
|
||||
invoice_status?: string;
|
||||
// Part / class
|
||||
part_number?: string;
|
||||
class_code?: string;
|
||||
description_spanish?: string;
|
||||
unit_of_measure_code?: string;
|
||||
// Quantities
|
||||
quantity?: number;
|
||||
quantity_returned_temp?: number;
|
||||
quantity_returned?: number;
|
||||
// Balance
|
||||
available_balance: number;
|
||||
has_balance: boolean;
|
||||
// FA / subitem
|
||||
is_subitem?: boolean;
|
||||
contains_subitems?: boolean;
|
||||
subitem_count?: number;
|
||||
}
|
||||
|
||||
@@ -7,17 +7,19 @@
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
regimen?: string;
|
||||
operationType?: 'imp' | 'exp';
|
||||
onSelect: (invoice: Invoice) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
regimen = 'Temporal',
|
||||
operationType = 'imp' as 'imp' | 'exp',
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
regimen?: string;
|
||||
operationType?: 'imp' | 'exp';
|
||||
onSelect: (invoice: Invoice) => void;
|
||||
} = $props();
|
||||
}: Props = $props();
|
||||
|
||||
let invoices = $state<Invoice[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { Loader2, Package, Save, X, FileText, Folder } from 'lucide-svelte';
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { invoicesApi } from '$lib/api/dashboard/a76/invoices';
|
||||
import { itemsApi, type Item } from '$lib/api/dashboard/a76/items';
|
||||
import { itemsApi, type Item, type ImportLineWithBalance } from '$lib/api/dashboard/a76/items';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// Child components
|
||||
@@ -68,6 +68,9 @@
|
||||
if (editingItem.fa_data.omit_annex31 === undefined) {
|
||||
editingItem.fa_data.omit_annex31 = false;
|
||||
}
|
||||
if (editingItem.fa_data.discharge === undefined) {
|
||||
editingItem.fa_data.discharge = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -78,7 +81,7 @@
|
||||
let showImportLinePicker = $state(false);
|
||||
let selectedImportInvoiceId = $state<number | null>(null);
|
||||
let selectedExportInvoiceId = $state<number | null>(null);
|
||||
let importInvoiceLines = $state<Item[]>([]);
|
||||
let importInvoiceLines = $state<ImportLineWithBalance[]>([]);
|
||||
let exportInvoiceLines = $state<Item[]>([]);
|
||||
let loadingImportLines = $state(false);
|
||||
let loadingExportLines = $state(false);
|
||||
@@ -88,8 +91,13 @@
|
||||
if (!companyId) return;
|
||||
loadingImportLines = true;
|
||||
try {
|
||||
const res = await itemsApi.listByInvoice(invoiceId, companyId);
|
||||
importInvoiceLines = res.data?.items ?? [];
|
||||
// Pass the export invoice date so consumption movements after that
|
||||
// date are not subtracted from the available balance.
|
||||
const asOfDate = invoice?.invoice_date
|
||||
? invoice.invoice_date.split('T')[0]
|
||||
: undefined;
|
||||
const res = await itemsApi.listByInvoiceWithBalance(invoiceId, companyId, asOfDate);
|
||||
importInvoiceLines = res.data ?? [];
|
||||
} catch {
|
||||
importInvoiceLines = [];
|
||||
} finally {
|
||||
@@ -334,7 +342,7 @@
|
||||
value={editingItem.fa_data?.search_type || ''}
|
||||
onValueChange={(v) => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.search_type = v ?? undefined;
|
||||
editingItem.fa_data.search_type = v ?? 'Factura';
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="fa_rep_search_type" class="h-8 text-sm">
|
||||
@@ -345,6 +353,7 @@
|
||||
<Select.Content>
|
||||
<Select.Item value="Factura">Factura</Select.Item>
|
||||
<Select.Item value="NumParte">NumParte</Select.Item>
|
||||
<Select.Item value="Clase">Clase</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
@@ -630,25 +639,111 @@
|
||||
|
||||
<!-- Diálogo para elegir línea (Impo) -->
|
||||
<Dialog.Root bind:open={showImportLinePicker}>
|
||||
<Dialog.Content class="max-w-sm">
|
||||
<Dialog.Content class="max-w-4xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-sm">Seleccionar línea</Dialog.Title>
|
||||
<Dialog.Title class="text-sm">Seleccionar línea de importación</Dialog.Title>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
Solo se muestran líneas con saldo disponible
|
||||
</p>
|
||||
</Dialog.Header>
|
||||
<div class="max-h-[280px] overflow-y-auto py-2">
|
||||
{#each importInvoiceLines as lineItem}
|
||||
{@const num = lineItem.line_number ?? lineItem.id}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-muted rounded-md"
|
||||
onclick={() => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.search_line = typeof num === 'number' ? num : undefined;
|
||||
showImportLinePicker = false;
|
||||
}}
|
||||
>
|
||||
Línea {num}
|
||||
</button>
|
||||
{/each}
|
||||
<!-- overflow-x on a wrapper that does NOT also do overflow-y.
|
||||
The inner div handles vertical scroll so sticky columns work
|
||||
independently from the horizontal scrollbar. -->
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-y-auto max-h-[500px]">
|
||||
{#if importInvoiceLines.every(l => !l.has_balance)}
|
||||
<p class="px-3 py-8 text-xs text-muted-foreground text-center">
|
||||
No hay líneas con saldo disponible en esta factura.
|
||||
</p>
|
||||
{:else}
|
||||
<table class="text-xs border-collapse" style="min-width: max-content; width: 100%;">
|
||||
<thead class="sticky top-0 z-20">
|
||||
<tr class="border-b border-border bg-muted">
|
||||
<!-- sticky cols 1-3: left offsets match td widths below -->
|
||||
<th class="sticky left-0 z-20 bg-muted px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[48px]">Línea</th>
|
||||
<th class="sticky left-[48px] z-20 bg-muted px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[120px]">Factura</th>
|
||||
<th class="sticky left-[168px] z-20 bg-muted px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[88px]">Fecha</th>
|
||||
<th class="px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap">Num. Parte</th>
|
||||
<th class="px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap">Clase</th>
|
||||
<th class="px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[180px]">Descripción</th>
|
||||
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Cant. Imp.</th>
|
||||
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Ret. Temp.</th>
|
||||
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Ret. Def.</th>
|
||||
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Saldo Disp.</th>
|
||||
<th class="px-2 py-2 text-center font-semibold text-muted-foreground whitespace-nowrap">Estatus</th>
|
||||
<th class="px-2 py-2 text-center font-semibold text-muted-foreground whitespace-nowrap">Sub.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
{#each importInvoiceLines as lineItem}
|
||||
{#if lineItem.has_balance}
|
||||
<tr
|
||||
class="hover:bg-muted/50 cursor-pointer transition-colors group"
|
||||
onclick={() => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.search_line = lineItem.line_number;
|
||||
showImportLinePicker = false;
|
||||
}}
|
||||
>
|
||||
<td class="sticky left-0 z-10 bg-background group-hover:bg-muted/50 px-2 py-1.5 font-semibold text-primary whitespace-nowrap w-[48px]">{lineItem.line_number}</td>
|
||||
<td class="sticky left-[48px] z-10 bg-background group-hover:bg-muted/50 px-2 py-1.5 font-mono whitespace-nowrap w-[120px]">{lineItem.invoice_number ?? '-'}</td>
|
||||
<td class="sticky left-[168px] z-10 bg-background group-hover:bg-muted/50 px-2 py-1.5 text-muted-foreground whitespace-nowrap w-[88px]">
|
||||
{lineItem.invoice_date ? lineItem.invoice_date.slice(0, 10) : '-'}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 font-mono whitespace-nowrap">{lineItem.part_number ?? '-'}</td>
|
||||
<td class="px-2 py-1.5 whitespace-nowrap">{lineItem.class_code ?? '-'}</td>
|
||||
<td class="px-2 py-1.5 w-[180px] max-w-[180px] truncate text-muted-foreground" title={lineItem.description_spanish ?? ''}>
|
||||
{lineItem.description_spanish ?? '-'}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||
{lineItem.quantity != null ? lineItem.quantity.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'}
|
||||
<span class="text-muted-foreground">{lineItem.unit_of_measure_code ?? ''}</span>
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-amber-600 dark:text-amber-400">
|
||||
{lineItem.quantity_returned_temp != null ? lineItem.quantity_returned_temp.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-blue-600 dark:text-blue-400">
|
||||
{lineItem.quantity_returned != null ? lineItem.quantity_returned.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap text-emerald-600 dark:text-emerald-400">
|
||||
{lineItem.available_balance.toLocaleString('es-MX', { maximumFractionDigits: 4 })}
|
||||
<span class="font-normal text-muted-foreground">{lineItem.unit_of_measure_code ?? ''}</span>
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-center whitespace-nowrap">
|
||||
{#if lineItem.invoice_status === 'processed'}
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
Procesada
|
||||
</span>
|
||||
{:else if lineItem.invoice_status === 'reversed'}
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 dark:bg-red-900/40 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:text-red-300">
|
||||
Revertida
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center rounded-full bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium text-zinc-600 dark:text-zinc-400">
|
||||
{lineItem.invoice_status ?? 'Pendiente'}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-center whitespace-nowrap">
|
||||
{#if lineItem.is_subitem}
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/40 px-1.5 py-0.5 text-[10px] font-medium text-purple-700 dark:text-purple-300">
|
||||
Sub
|
||||
</span>
|
||||
{:else if lineItem.contains_subitems}
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/40 px-1.5 py-0.5 text-[10px] font-medium text-indigo-700 dark:text-indigo-300" title="{lineItem.subitem_count} subpartida(s)">
|
||||
{lineItem.subitem_count ?? 0} sub
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -5,11 +5,21 @@
|
||||
import { selectSearchContextKey, type SelectSearchContext } from './select-search-context';
|
||||
import { type WithoutChild } from '$lib/utils.js';
|
||||
|
||||
// SelectPrimitive.RootProps is a discriminated union (single | multiple).
|
||||
// Spreading a discriminated union collapses conflicting members (e.g. onValueChange) to `never`.
|
||||
// We widen the props type so callers can pass either variant without hitting `never`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SelectRootProps = Omit<WithoutChild<SelectPrimitive.RootProps>, 'value' | 'onValueChange'> & {
|
||||
type?: 'single' | 'multiple';
|
||||
value?: string | string[];
|
||||
onValueChange?: (value: any) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
children,
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.RootProps> = $props();
|
||||
}: SelectRootProps = $props();
|
||||
|
||||
let open = $state(false);
|
||||
const query = writable('');
|
||||
|
||||
Reference in New Issue
Block a user