Funciones y logia de exportacion, como mejoras CRUD en las partes de exportacion

This commit is contained in:
2026-04-16 11:30:40 -05:00
parent 5c502b0937
commit 5e6ee5d4d1
33 changed files with 1815 additions and 589 deletions

View File

@@ -114,7 +114,9 @@ def apply_calculations(
.first()
)
if class_desc:
line.description.description_spanish, line.description.description_english = class_desc
line.description.description_spanish = class_desc[0]
if not line.description.description_english:
line.description.description_english = class_desc[1]
def calculate_values(
@@ -213,7 +215,8 @@ def calculate_values(
line.quantity.package_quantity = import_line.quantity.package_quantity
line.quantity.package_id = import_line.quantity.package_id
if import_line.description:
if import_line.description and not line.description.description_english:
# Only fill from import if the user hasn't captured their own English description
line.description.description_english = import_line.description.description_english
# ==========================================

View File

@@ -135,6 +135,97 @@ def validate_create(
if not fa_data.search_line:
errors.add_required_error(field=f"line[{line_number}].fa_data.search_line")
# REVISA_FACTURA logic if both invoice and line are present
if fa_data.search_invoice and fa_data.search_line:
linked_inv = db.query(InvoiceHeader).filter(
InvoiceHeader.invoice_number == fa_data.search_invoice,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
InvoiceHeader.operation_type == "imp" # Assuming link is always to an import
).first()
if not linked_inv:
errors.add_error(
field=f"line[{line_number}].fa_data.search_invoice",
message="La Factura No Existe Capture o seleccione una que si exista",
code="LINKED_INVOICE_NOT_FOUND",
solution=["Verificar el número de factura de importación."]
)
elif linked_inv.status != "processed":
errors.add_error(
field=f"line[{line_number}].fa_data.search_invoice",
message="La Factura no esta Actualizada, Capture o seleccione una que si este Actualizada",
code="LINKED_INVOICE_NOT_PROCESSED",
solution=["Actualizar/Procesar la factura de importación antes de descargarla."]
)
else:
# Invoice is valid and processed, check the line (REVISA_FACTURA part 2)
linked_line = db.query(LineItem).filter(
LineItem.invoice_id == linked_inv.id,
LineItem.line_number == fa_data.search_line,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id
).first()
if not linked_line:
errors.add_error(
field=f"line[{line_number}].fa_data.search_line",
message="La Partida de la Factura No Existe Capture o seleccione una que si exista",
code="LINKED_LINE_NOT_FOUND",
solution=["Verificar el número de renglón en la factura de importación."]
)
else:
# Validation for search_type == "Clase" (Parity with Clarion Valida.Validaciones)
if fa_data.search_type == "Clase" and linked_line.class_id != line.class_id:
# Load class codes for a better error message if necessary
current_class = db.query(Class).filter(Class.id == line.class_id).first()
import_class = db.query(Class).filter(Class.id == linked_line.class_id).first()
errors.add_error(
field=f"line[{line_number}].class_id",
message=f"Es necesario que la clase: {current_class.class_code if current_class else line.class_id} sea igual a la clase: {import_class.class_code if import_class else linked_line.class_id} de la factura de importación seleccionada.",
code="CLASS_MISMATCH_FOR_SEARCH_TYPE_CLASE",
solution=["Asegurarse de que el activo que se exporta pertenezca a la misma familia/clase que el que se importó."]
)
# REVISA_CANTIDADES_A_DESC_TEM logic
# 1. Get current balance from ledger
from api.v1.modules.a24.balance_movements.models import BalanceMovement, NEGATIVE_MOVEMENTS
from sqlalchemy import case, select
sign_expr = case(
(BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), Decimal("-1")),
else_=Decimal("1"),
)
available_balance = db.execute(
select(func.sum(sign_expr * BalanceMovement.quantity)).where(
BalanceMovement.import_item_line_id == linked_line.id
)
).scalar() or Decimal("0")
# 2. Get pending discharges in this same invoice (sibling lines)
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
pending_sum = db.query(func.sum(LineQuantity.quantity)).join(
LineItem, LineItem.id == LineQuantity.id
).join(
FaLineItem, FaLineItem.id == LineItem.id
).filter(
LineItem.invoice_id == line.invoice_id,
FaLineItem.search_invoice == fa_data.search_invoice,
FaLineItem.search_line == fa_data.search_line,
FaLineItem.movement_type_import == fa_data.movement_type_import,
FaLineItem.discharge == True
).scalar() or Decimal("0")
current_qty = line.quantity.quantity or Decimal("0")
remaining = available_balance - pending_sum - current_qty
# Note: Hard validation removed here to allow 'Multiple Source Discharge'
# or 'Automatic Deficit Handling' logic to function during full invoice processing.
# Current balance for information: {available_balance}, short: {remaining if remaining < 0 else 0}
pass
if (
fa_data.is_subitem and fa_data.contains_subitems
) and not fa_data.subitem_number:

View File

@@ -1,4 +1,5 @@
from decimal import Decimal
from sqlalchemy import func, exists
from sqlalchemy.orm import Session
from core.exceptions import ErrorCollector
@@ -8,6 +9,7 @@ from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from api.v1.modules.a76.classes.models import Class
from .common import validate_common
@@ -189,7 +191,7 @@ def validate_update(
existing_line.description.description_spanish
)
if line.description.description_english is None:
if not line.description.description_english:
line.description.description_english = (
existing_line.description.description_english
)
@@ -233,6 +235,119 @@ def validate_update(
if fa_data.search_line is None:
fa_data.search_line = existing_fa_data.search_line
# REVISA_FACTURA logic if both invoice and line are present (even after partial update)
if fa_data.discharge is True and fa_data.search_invoice and fa_data.search_line:
from sqlalchemy import exists
linked_inv = db.query(InvoiceHeader).filter(
InvoiceHeader.invoice_number == fa_data.search_invoice,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
InvoiceHeader.operation_type == "imp"
).first()
if not linked_inv:
errors.add_error(
field=f"line[{line_number}].fa_data.search_invoice",
message="La Factura No Existe Capture o seleccione una que si exista",
code="LINKED_INVOICE_NOT_FOUND",
solution=["Verificar el número de factura de importación."]
)
elif linked_inv.status != "processed":
errors.add_error(
field=f"line[{line_number}].fa_data.search_invoice",
message="La Factura no esta Actualizada, Capture o seleccione una que si este Actualizada",
code="LINKED_INVOICE_NOT_PROCESSED",
solution=["Actualizar/Procesar la factura de importación antes de descargarla."]
)
else:
# Invoice is valid and processed, check the line
linked_line_exists = db.query(exists().where(
(LineItem.invoice_id == linked_inv.id) &
(LineItem.line_number == fa_data.search_line) &
(LineItem.tenant_id == tenant_id) &
(LineItem.company_id == company_id)
)).scalar()
if not linked_line_exists:
errors.add_error(
field=f"line[{line_number}].fa_data.search_line",
message="La Partida de la Factura No Existe Capture o seleccione una que si exista",
code="LINKED_LINE_NOT_FOUND",
solution=["Verificar el número de renglón en la factura de importación."]
)
else:
# Validation for search_type == "Clase" (Parity with Clarion Valida.Validaciones)
# We need to get the actual IDs to compare
linked_import_line = db.query(LineItem).filter(
LineItem.invoice_id == linked_inv.id,
LineItem.line_number == fa_data.search_line,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id
).first()
if linked_import_line:
current_class_id = line.class_id if line.class_id else existing_line.class_id
if fa_data.search_type == "Clase" and linked_import_line.class_id != current_class_id:
current_class = db.query(Class).filter(Class.id == current_class_id).first()
import_class = db.query(Class).filter(Class.id == linked_import_line.class_id).first()
errors.add_error(
field=f"line[{line_number}].class_id",
message=f"Es necesario que la clase: {current_class.class_code if current_class else current_class_id} sea igual a la clase: {import_class.class_code if import_class else linked_import_line.class_id} de la factura de importación seleccionada.",
code="CLASS_MISMATCH_FOR_SEARCH_TYPE_CLASE",
solution=["Asegurarse de que el activo que se exporta pertenezca a la misma familia/clase que el que se importó."]
)
# REVISA_CANTIDADES_A_DESC_TEM logic for Update
# 0. Get the internal ID of the linked import line
linked_import_line = db.query(LineItem).filter(
LineItem.invoice_id == linked_inv.id,
LineItem.line_number == fa_data.search_line,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id
).first()
if linked_import_line:
# 1. Get current balance from ledger
from api.v1.modules.a24.balance_movements.models import BalanceMovement, NEGATIVE_MOVEMENTS
from sqlalchemy import case, select
sign_expr = case(
(BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), Decimal("-1")),
else_=Decimal("1"),
)
available_balance = db.execute(
select(func.sum(sign_expr * BalanceMovement.quantity)).where(
BalanceMovement.import_item_line_id == linked_import_line.id
)
).scalar() or Decimal("0")
# 2. Get pending discharges in this same invoice (sibling lines)
# EXCLUDING the current line we are updating
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
pending_sum = db.query(func.sum(LineQuantity.quantity)).join(
LineItem, LineItem.id == LineQuantity.id
).join(
FaLineItem, FaLineItem.id == LineItem.id
).filter(
LineItem.invoice_id == line.invoice_id,
LineItem.id != existing_line.id, # IMPORTANT: Exclude self
FaLineItem.search_invoice == fa_data.search_invoice,
FaLineItem.search_line == fa_data.search_line,
FaLineItem.movement_type_import == fa_data.movement_type_import,
FaLineItem.discharge == True
).scalar() or Decimal("0")
current_qty = line.quantity.quantity if line.quantity.quantity is not None else existing_line.quantity.quantity
remaining = available_balance - pending_sum - current_qty
# Note: Hard validation removed here to allow 'Multiple Source Discharge'
# or 'Automatic Deficit Handling' logic to function during full invoice processing.
# Current balance for information: {available_balance}, short: {remaining if remaining < 0 else 0}
pass
# Subpartidas (EsSubPartida / SubPartida)
if fa_data.is_subitem is None:
fa_data.is_subitem = existing_fa_data.is_subitem

View File

@@ -183,12 +183,12 @@ def validate_update(
line.order = existing_line.order
# Descripciones
if line.description.description_spanish is None:
if not line.description.description_spanish:
line.description.description_spanish = (
existing_line.description.description_spanish
)
if line.description.description_english is None:
if not line.description.description_english:
line.description.description_english = (
existing_line.description.description_english
)

View File

@@ -223,6 +223,11 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
back_populates="line",
cascade="all, delete-orphan",
)
series: Mapped[List["Serie"]] = relationship(
"Serie",
cascade="all, delete-orphan",
)
part_info: Mapped[Optional["Part"]] = relationship(
"Part",
foreign_keys=[part_number_id],

View File

@@ -167,6 +167,25 @@ async def delete_item(
return None
@router.delete("/{item_id}/series", status_code=status.HTTP_200_OK)
async def delete_item_series(
item_id: int = Path(..., description="Item ID"),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Delete all serial numbers for a specific item.
Equivalent to Clarion BORRAR_SERIES_EXPO.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = ItemService()
count = service.delete_item_series(db, item_id, tenant_id, company_id)
return {"message": f"Successfully deleted {count} series", "count": count}
# ============================================================================
# ADDITIONAL ENDPOINTS FOR INVOICE
# ============================================================================
@@ -209,6 +228,10 @@ async def get_items_with_balance(
"before this date are subtracted (CALCULA_SALDO_FECHA_EXPO logic)."
),
),
current_export_invoice_id: Optional[int] = Query(
None,
description="ID of the current export invoice being edited to subtract its pending quantities from balance."
),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
@@ -222,12 +245,15 @@ async def get_items_with_balance(
- available_balance : net balance still available for export discharge
- has_balance : true when available_balance > 0
If current_export_invoice_id is provided, quantities already assigned to
this specified invoice will be subtracted from available_balance.
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)
return service.get_lines_with_balance(db, invoice_id, tenant_id, company_id, as_of_date, current_export_invoice_id)
# ============================================================================

View File

@@ -207,6 +207,7 @@ class ItemService:
if not serie_dict:
continue
serie_dict.update({
"line_item_id": line.id,
"tenant_id": tenant_id,
"company_id": company_id
})
@@ -234,6 +235,7 @@ class ItemService:
if not id_dict:
continue
id_dict.update({
"item_line_id": line.id,
"tenant_id": tenant_id,
"company_id": company_id
})
@@ -571,7 +573,7 @@ class ItemService:
logger.error(f"Error creating item: {e}")
raise HTTPException(
status_code=400,
detail="LineItem creation failed - integrity constraint violated",
detail=f"LineItem creation failed - integrity constraint violated: {e.orig}",
)
except Exception as e:
db.rollback()
@@ -800,6 +802,32 @@ class ItemService:
logger.error(f"Unexpected error updating item: {e}")
raise HTTPException(status_code=500, detail=f"Error updating item: {str(e)}")
@staticmethod
def delete_item_series(
db: Session,
item_id: int,
tenant_id: int,
company_id: int,
current_user_name: Optional[str] = None
) -> int:
"""
Deletes all serial numbers for a specific item.
Equivalent to Clarion BORRAR_SERIES_EXPO.
"""
# Ensure item exists and belongs to the company
item = ItemService.get_by_id(db, item_id, tenant_id, company_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
# Delete series
deleted_count = db.query(Serie).filter(Serie.line_item_id == item_id).delete(synchronize_session='fetch')
# Log to bitácora (if system supports it)
# GBitacora('BORRAR TODAS LAS SERIE EXPO ', item.invoice_number)
db.commit()
return deleted_count
@staticmethod
def delete(
db: Session,
@@ -824,6 +852,29 @@ class ItemService:
status_code=404, detail="Invoice not found or could not be locked"
)
# Manual cascade cleanup for Anexo 24 references
from api.v1.modules.a24.balance_movements.models import BalanceMovement
from api.v1.modules.a24.discharges.models import DischargeDetail
# Check if this item is used in any discharges
if db_item.invoice and db_item.invoice.operation_type == "imp":
# Import item: check if it has been consumed
consumptions = db.query(BalanceMovement).filter(
BalanceMovement.import_item_line_id == item_id,
BalanceMovement.movement_type != "entry"
).count()
if consumptions > 0:
raise HTTPException(
status_code=400,
detail="No se puede borrar la partida de importación porque ya ha sido descargada/consumida parcial o totalmente."
)
# It's safe to delete its ENTRY movements
db.query(BalanceMovement).filter(BalanceMovement.import_item_line_id == item_id).delete()
else:
# Export item: delete its derived consumptions and discharge details
db.query(DischargeDetail).filter(DischargeDetail.export_item_line_id == item_id).delete()
db.query(BalanceMovement).filter(BalanceMovement.source_item_line_id == item_id).delete()
db.delete(db_item)
db.flush()
ItemService._renumber_all_invoice_lines(db, invoice_id)
@@ -832,8 +883,8 @@ class ItemService:
except Exception as e:
db.rollback()
logger.error(f"Error deleting item: {e}")
raise HTTPException(status_code=500, detail="Error deleting item")
logger.error(f"Error deleting item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def get_lines_with_balance(
@@ -842,20 +893,20 @@ class ItemService:
tenant_id: int,
company_id: int,
as_of_date: Optional[datetime.date] = None,
current_export_invoice_id: Optional[int] = 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.
If current_export_invoice_id is provided, it also subtracts quantities
already allocated in that export invoice to provide a "real-time"
remaining balance for the user during capture.
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).
as_of_date : optional cut-off date.
current_export_invoice_id : current export invoice being edited.
"""
lines: List[LineItem] = (
db.query(LineItem)
@@ -877,14 +928,42 @@ class ItemService:
.all()
)
# 1. Get official balance from ledger
result = []
used_map = ItemService._used_quantities_by_procedure(
db=db,
import_line_ids=[line.id for line in lines],
as_of_date=as_of_date,
)
# 2. Get locally reserved quantities in the current export invoice (if any)
reserved_map = {}
if current_export_invoice_id and lines:
import_inv = lines[0].invoice
if import_inv:
reserved_rows = (
db.query(FaLineItem.search_line, func.sum(LineQuantity.quantity))
.join(LineItem, LineItem.id == FaLineItem.id)
.join(LineQuantity, LineQuantity.id == LineItem.id)
.filter(
LineItem.invoice_id == current_export_invoice_id,
FaLineItem.search_invoice == import_inv.invoice_number,
FaLineItem.movement_type_import == import_inv.invoice_type, # Match TEM/DEF
FaLineItem.discharge == True
)
.group_by(FaLineItem.search_line)
.all()
)
reserved_map = {int(row[0]): Decimal(str(row[1] or 0)) for row in reserved_rows}
for line in lines:
# Official ledger balance
available_balance = ItemService._compute_balance(db, line.id, as_of_date)
# Subtract what's already assigned in THIS invoice
reserved = reserved_map.get(line.line_number, Decimal(0))
active_balance = available_balance - reserved
qty = line.quantity
desc = line.description
fa = line.fa_data
@@ -925,8 +1004,11 @@ class ItemService:
"quantity_used_temp": float(qty_used_temp),
"quantity_used_def": float(qty_used_def),
# Balance
"available_balance": float(available_balance),
"has_balance": available_balance > Decimal(0),
"available_balance": float(active_balance),
"has_balance": active_balance > Decimal(0),
# Weights for proportional calculation
"net_weight": float(qty.net_weight) if qty and qty.net_weight is not None else 0.0,
"gross_weight": float(qty.gross_weight) if qty and qty.gross_weight is not None else 0.0,
# FA / subitem info
"is_subitem": fa.is_subitem if fa else None,
"contains_subitems": fa.contains_subitems if fa else None,
@@ -934,6 +1016,39 @@ class ItemService:
})
return result
@staticmethod
def get_pending_discharge_sum(
db: Session,
invoice_id: int,
search_invoice: str,
search_line: int,
movement_type_import: Optional[str] = None,
exclude_line_id: Optional[int] = None
) -> Decimal:
"""
CUENTA_CANTIDADES_A_DESC equivalent.
Sums quantity from other lines in the same invoice targeting the same import source.
"""
query = (
select(func.sum(LineQuantity.quantity))
.join(LineItem, LineItem.id == LineQuantity.id)
.join(FaLineItem, FaLineItem.id == LineItem.id)
.where(
LineItem.invoice_id == invoice_id,
FaLineItem.search_invoice == search_invoice,
FaLineItem.search_line == search_line,
FaLineItem.discharge == True
)
)
if movement_type_import:
query = query.where(FaLineItem.movement_type_import == movement_type_import)
if exclude_line_id:
query = query.where(LineItem.id != exclude_line_id)
result = db.execute(query).scalar()
return Decimal(str(result or 0))
@staticmethod
def _compute_balance(
db: Session,