Enhance invoice processing to support export and import differentiation
- Updated the invoice processing route to automatically detect and handle invoice types based on the operation_type. - Introduced error handling for non-existent invoices, returning a 404 status when an invoice is not found. - Added a new field 'serie_row' in the Serie model to accommodate additional data for export scenarios.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.orm import Session
|
||||
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
|
||||
|
||||
def _get_unit_equivalence(
|
||||
db: Session,
|
||||
from_unit: str,
|
||||
to_unit: str,
|
||||
tenant_id: str,
|
||||
company_id: str,
|
||||
) -> tuple[str, Decimal]:
|
||||
"""
|
||||
Busca una conversión entre dos unidades de medida.
|
||||
Paridad: REVEQUIVALENCIA (Clarion SCAII).
|
||||
|
||||
Retorna (multi_divide, factor_conv):
|
||||
- ('M', factor) → multiplicar cantidad por factor
|
||||
- ('D', factor) → dividir cantidad por factor
|
||||
- ('', 0) → no existe equivalencia
|
||||
"""
|
||||
conv = (
|
||||
db.query(UnitConversion)
|
||||
.filter(
|
||||
UnitConversion.tenant_id == tenant_id,
|
||||
UnitConversion.company_id == company_id,
|
||||
UnitConversion.from_unit_code == from_unit,
|
||||
UnitConversion.to_unit_code == to_unit,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if conv and conv.conversion_factor:
|
||||
return "M", conv.conversion_factor
|
||||
|
||||
conv_inv = (
|
||||
db.query(UnitConversion)
|
||||
.filter(
|
||||
UnitConversion.tenant_id == tenant_id,
|
||||
UnitConversion.company_id == company_id,
|
||||
UnitConversion.from_unit_code == to_unit,
|
||||
UnitConversion.to_unit_code == from_unit,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if conv_inv and conv_inv.conversion_factor:
|
||||
return "D", conv_inv.conversion_factor
|
||||
|
||||
return "", Decimal(0)
|
||||
128
backend/api/v1/modules/a76/invoices/common/process/review_uma.py
Normal file
128
backend/api/v1/modules/a76/invoices/common/process/review_uma.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasureCustoms
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
from .review_equivalence import _get_unit_equivalence
|
||||
|
||||
def revisa_uma(
|
||||
db: Session,
|
||||
line: LineItem,
|
||||
tenant_id: str,
|
||||
company_id: str,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
Revisa la unidad de medida americana (UMA) de aduana para una partida.
|
||||
Paridad: REVISA_UMA (Clarion SCAII).
|
||||
|
||||
Flujo:
|
||||
1. Verifica que la UM de la partida exista en el catálogo GUnimedida.
|
||||
2. Verifica que esa UM tenga asignada una Clave_AMex (UM aduana mexicana).
|
||||
3. Verifica que la Clave_AMex exista en el catálogo GUMAduana.
|
||||
4. Verifica que el registro de aduana tenga una UnidadSCAII (a76_unit_code).
|
||||
5. Calcula la cantidad UMA:
|
||||
- Si la UM de la partida coincide con UnidadSCAII → cantidad directa.
|
||||
- Si no → busca conversión en el catálogo de equivalencias.
|
||||
6. Escribe CantImpoUMA y ClaveUMA de regreso en la partida.
|
||||
"""
|
||||
uom = line.unit_of_measure_info
|
||||
line_um_code = uom.code if uom else ""
|
||||
|
||||
# ── 1. Verificar existencia de la UM en el catálogo ──────────────────────
|
||||
if uom is None:
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].unit_of_measure",
|
||||
message=(
|
||||
f"No existe la unidad de medida {line_um_code}"
|
||||
" en el catálogo de Unidades de Medida."
|
||||
),
|
||||
solution=["Capturarla en el catálogo de Unidades de Medida."],
|
||||
code="UNIMEDIDA",
|
||||
)
|
||||
return
|
||||
|
||||
# ── 2. Verificar que tenga Clave_AMex asignada ───────────────────────────
|
||||
customs_code = uom.customs_code or ""
|
||||
if not customs_code:
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].unit_of_measure",
|
||||
message=(
|
||||
f"No existe la unidad de medida de la aduana para la unidad de medida "
|
||||
f"{line_um_code} en el catálogo de Unidades de Medida."
|
||||
),
|
||||
solution=["Asignar la U.M.A en el catálogo de Unidades de Medida."],
|
||||
code="UNIMEDIDA",
|
||||
)
|
||||
return
|
||||
|
||||
# ── 3. Buscar la UM en el catálogo de Aduana Mex (GUMAduana) ─────────────
|
||||
customs_uom = (
|
||||
db.query(UnitOfMeasureCustoms)
|
||||
.filter(UnitOfMeasureCustoms.code == customs_code)
|
||||
.first()
|
||||
)
|
||||
|
||||
if customs_uom is None:
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].unit_of_measure",
|
||||
message=(
|
||||
f"No existe la unidad de medida {customs_code}"
|
||||
" en el catálogo de Unidades de Medida de la Aduana Mex."
|
||||
),
|
||||
solution=["Actualizar sus Catálogos Fijos o llamar a su proveedor."],
|
||||
code="UNIMEDIDA",
|
||||
)
|
||||
return
|
||||
|
||||
# ── 4. Verificar que tenga UnidadSCAII asignada ──────────────────────────
|
||||
scaii_unit = customs_uom.a76_unit_code or ""
|
||||
if not scaii_unit:
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].unit_of_measure",
|
||||
message=(
|
||||
f"No tiene asignada la U.M. Equivalente SCAII la unidad de medida "
|
||||
f"{customs_code} en el catálogo de Unidades de Medida de la Aduana Mex."
|
||||
),
|
||||
solution=["Actualizar sus Catálogos Fijos o llamar a su proveedor."],
|
||||
code="UNIMEDIDA",
|
||||
)
|
||||
return
|
||||
|
||||
# ── 5. Calcular cantidad UMA ──────────────────────────────────────────────
|
||||
line_qty = Decimal(str(line.quantity.quantity or 0)) if line.quantity else Decimal(0)
|
||||
cant_uma = Decimal(0)
|
||||
|
||||
if line_um_code == scaii_unit:
|
||||
cant_uma = line_qty
|
||||
else:
|
||||
multi_divide, factor_conv = _get_unit_equivalence(
|
||||
db, line_um_code, scaii_unit, tenant_id, company_id
|
||||
)
|
||||
|
||||
if multi_divide == "M":
|
||||
cant_uma = line_qty * factor_conv
|
||||
elif multi_divide == "D":
|
||||
cant_uma = line_qty / factor_conv if factor_conv else Decimal(0)
|
||||
else:
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].unit_of_measure",
|
||||
message=(
|
||||
f"No hay equivalencia entre la U.M. Partida: {line_um_code}"
|
||||
f" y la U.M. Aduana {scaii_unit}."
|
||||
),
|
||||
solution=[
|
||||
"Capturar su equivalencia en el Catálogo de Conversiones "
|
||||
"o configurar la U.M.Aduana correcta en la U.M. Comercial."
|
||||
],
|
||||
code="EQUIVALENCIA",
|
||||
)
|
||||
return
|
||||
|
||||
# ── 6. Escribir resultados en la partida ──────────────────────────────────
|
||||
if line.quantity:
|
||||
line.quantity.quantity_uma = cant_uma
|
||||
line.uma_key = customs_code
|
||||
@@ -0,0 +1,204 @@
|
||||
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from core.exceptions import ErrorCollector
|
||||
from .pre_validators import pre_validators
|
||||
from .sub_process.assign_no_discharges import assign_no_discharges_items, assign_no_discharges_series
|
||||
from .sub_process.review_class import review_class
|
||||
from .sub_process.review_exchange_rate import review_exchange_rate
|
||||
from .sub_process.assign_values import assign_values
|
||||
from .sub_process.review_exchange_rate import review_exchange_rate
|
||||
from .sub_process.review_qty_vs_weight import review_qty_vs_weight
|
||||
from .sub_process.review_unit_cost import review_unit_cost
|
||||
from .sub_process.review_limits import limit_weight, limit_value
|
||||
from .sub_process.series.review_qty_series import review_qty_series
|
||||
from .sub_process.download_balance_collector import collect_lines_to_discharge
|
||||
from .sub_process.discharge_types import DownloadEntry
|
||||
from .sub_process.finalize_invoice import (
|
||||
finalize_invoice_no_discharge,
|
||||
finalize_invoice_with_discharge,
|
||||
)
|
||||
from .sub_process.review_origin_procedure import review_origin_procedure
|
||||
from .sub_process.fill_available_balances import fill_available_balances
|
||||
from .sub_process.compare_balances import compare_balances
|
||||
from .sub_process.verify_consolidated import verify_consolidated
|
||||
from .sub_process.generate_definitive_import import (
|
||||
generate_definitive_import,
|
||||
generate_definitive_import_all_lines,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bloque reutilizable: descarga normal (AFIJO / DONAC / SCRAP / REEXP / VEMEX)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _process_with_discharge(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
lines: List[LineItem],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
Secuencia común para los tipos de factura que realizan descarga de saldos:
|
||||
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_exchange_rate(db, invoice, errors)
|
||||
assign_values(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors)
|
||||
|
||||
review_qty_vs_weight(lines, invoice.logistics.weight_type.upper(), errors)
|
||||
|
||||
review_unit_cost(lines, errors)
|
||||
total_qty, total_net_weight = limit_weight(lines)
|
||||
total_value = limit_value(lines)
|
||||
review_qty_series(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors)
|
||||
|
||||
# QUIERE_DESCARGAR → LLENA_QUEUE_SALDOS → COMPARA_SALDOS
|
||||
to_discharge = collect_lines_to_discharge(db, invoice, lines, errors)
|
||||
|
||||
fill_available_balances(db, invoice, to_discharge, errors)
|
||||
compare_balances(db, invoice, to_discharge, errors)
|
||||
verify_consolidated(db, invoice, to_discharge, errors)
|
||||
|
||||
finalize_invoice_with_discharge(db, invoice, lines, errors, to_discharge)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proceso principal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str) -> dict:
|
||||
"""
|
||||
Proceso principal para actualizar facturas de exportación.
|
||||
|
||||
Flujo (porta la rutina principal del legacy SCAII – Facturas de Exportación):
|
||||
|
||||
1. Validaciones previas (pre_validators)
|
||||
2. TODO: Compartir parámetros generales (QSisGen / GEmpresa)
|
||||
3. TODO: Compartir parámetros de exportación (QSisExpo) según EsCambioRegimen
|
||||
4. TODO: Validar permisos de usuario (GUsuarios / GNivelesSeguridad)
|
||||
5. TODO: Iniciar transacción SQL (BEGIN TRAN)
|
||||
6. Verificar que existan partidas
|
||||
7. TODO: Obtener tipo de cambio según SisGen:CalValBaseTCPedExpo
|
||||
(TCPED desde la fecha de pago del pedimento, o TCFAC desde la factura)
|
||||
8. TODO: Validar que la factura no exista ya en Importaciones Definitivas (si GeneraID='S')
|
||||
9. CASE invoice_type → ejecutar sub-proceso específico por tipo:
|
||||
- NODES : sin descarga
|
||||
- AFIJO / DONAC / SCRAP : con descarga + lógica de CambioRegimen opcional
|
||||
- REEXP / VEMEX : con descarga + revisión de procedencia DEF
|
||||
10. Si hay errores: rollback implícito (raise)
|
||||
Si no hay errores: COMMIT y marcar factura como procesada
|
||||
"""
|
||||
errors = ErrorCollector()
|
||||
|
||||
# --- Paso 1: Validaciones previas ----------------------------------------
|
||||
lines = pre_validators(db, invoice, tenant_id, company_id, errors)
|
||||
errors.raise_if_errors()
|
||||
|
||||
# --- Paso 2-4: Parámetros generales, parámetros expo y permisos ----------
|
||||
# TODO: Compartir QSisGen / GEmpresa
|
||||
# TODO: Compartir QSisExpo (EsCambioRegimen = 'S' → SisExp:EsCambioRegimen = 'CR')
|
||||
# TODO: Validar permisos usuario (GUsuarios / GNivelesSeguridad)
|
||||
|
||||
# --- Paso 5: Iniciar transacción -----------------------------------------
|
||||
# TODO: BEGIN TRAN (en el legacy: GSQLFile{PROP:SQL} = 'BEGIN TRAN')
|
||||
|
||||
# --- Paso 6: Verificar que existan partidas ------------------------------
|
||||
if not lines:
|
||||
errors.add_error(
|
||||
field="items",
|
||||
message="Esta Factura no tiene partidas.",
|
||||
solution=["Capturar al menos una partida a la factura."],
|
||||
code="NO_ITEMS_FOUND",
|
||||
)
|
||||
errors.raise_if_errors()
|
||||
|
||||
# --- Paso 7: Tipo de cambio ----------------------------------------------
|
||||
# TODO: Si SisGen:CalValBaseTCPedExpo = 1:
|
||||
# invoice.which_exchange_rate = 'TCPED'
|
||||
# Buscar pedimento (EqiPed:Pedimento = EqiFex:PedimentoExpo)
|
||||
# Buscar GTipoCambio por EqiPed:Fecha_Pago
|
||||
# exchange_rate = GenTC:Valor
|
||||
# Else:
|
||||
# invoice.which_exchange_rate = 'TCFAC'
|
||||
# exchange_rate = invoice.financials.exchange_rate
|
||||
|
||||
# --- Paso 8: Validar que la factura no exista en ImportDef ---------------
|
||||
# TODO: Si invoice.generate_id = True:
|
||||
# Buscar en QFacImpDef por invoice.invoice_number
|
||||
# Si ya existe → agregar error
|
||||
|
||||
# --- Paso 9: Sub-proceso por tipo de factura -----------------------------
|
||||
invoice_type = invoice.invoice_type
|
||||
|
||||
if invoice_type == "NODES":
|
||||
# Sin descarga de saldos
|
||||
assign_no_discharges_items(lines, errors)
|
||||
assign_no_discharges_series(db, lines, errors)
|
||||
review_class(db, lines, errors)
|
||||
review_exchange_rate(db, invoice, errors)
|
||||
assign_values(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors)
|
||||
|
||||
review_qty_vs_weight(lines, invoice.logistics.weight_type.upper(), errors)
|
||||
|
||||
review_unit_cost(lines, errors)
|
||||
review_qty_series(db, invoice, lines, tenant_id, company_id, errors)
|
||||
total_qty, total_net_weight = limit_weight(lines)
|
||||
total_value = limit_value(lines)
|
||||
|
||||
finalize_invoice_no_discharge(db, invoice, lines, errors)
|
||||
|
||||
elif invoice_type == "AFIJO":
|
||||
if invoice.compliance_mx and invoice.compliance_mx.is_regime_change:
|
||||
review_origin_procedure(db, invoice, lines, "TEM", errors)
|
||||
if invoice.generate_id and invoice.generate_desc_parties == "Todas":
|
||||
def_inv = generate_definitive_import(db, invoice, errors)
|
||||
if def_inv:
|
||||
generate_definitive_import_all_lines(db, invoice, def_inv, errors)
|
||||
|
||||
_process_with_discharge(db, invoice, lines, errors)
|
||||
|
||||
elif invoice_type == "DONAC":
|
||||
_process_with_discharge(db, invoice, lines, errors)
|
||||
|
||||
elif invoice_type == "SCRAP":
|
||||
if invoice.compliance_mx and invoice.compliance_mx.is_regime_change:
|
||||
review_origin_procedure(db, invoice, lines, "TEM", errors)
|
||||
if invoice.generate_id and invoice.generate_desc_parties == "Todas":
|
||||
def_inv = generate_definitive_import(db, invoice, errors)
|
||||
if def_inv:
|
||||
generate_definitive_import_all_lines(db, invoice, def_inv, errors)
|
||||
|
||||
_process_with_discharge(db, invoice, lines, errors)
|
||||
|
||||
elif invoice_type == "REEXP":
|
||||
review_origin_procedure(db, invoice, lines, "DEF", errors)
|
||||
_process_with_discharge(db, invoice, lines, errors)
|
||||
|
||||
elif invoice_type == "VEMEX":
|
||||
review_origin_procedure(db, invoice, lines, "DEF", errors)
|
||||
_process_with_discharge(db, invoice, lines, errors)
|
||||
|
||||
else:
|
||||
errors.add_error(
|
||||
field="invoice_type",
|
||||
message=f"{invoice_type} no es un Tipo de Factura válido, llamar al proveedor del Sistema SCAII.",
|
||||
solution=["Verificar el tipo de factura de exportación."],
|
||||
code="INVALID_INVOICE_TYPE",
|
||||
value=invoice_type,
|
||||
)
|
||||
|
||||
# --- Paso 10: Commit / Rollback ------------------------------------------
|
||||
errors.raise_if_errors()
|
||||
|
||||
# TODO: COMMIT TRAN (en el legacy: gSQLFile{PROP:SQL} = 'COMMIT TRAN')
|
||||
# TODO: GBitacora('ACTUALIZAR FACTURA', invoice.invoice_number)
|
||||
|
||||
# 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)}
|
||||
@@ -0,0 +1,117 @@
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
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
|
||||
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector):
|
||||
if invoice.status == InvoiceStatus.PROCESSED:
|
||||
errors.add_error(
|
||||
"status",
|
||||
"La factura ya fue procesada y no puede volver a actualizarse. Desactualícela primero.",
|
||||
solution=["Use el botón 'Desactualizar' antes de volver a procesar la factura."],
|
||||
code="ALREADY_PROCESSED",
|
||||
value=invoice.status,
|
||||
)
|
||||
errors.raise_if_errors()
|
||||
return
|
||||
|
||||
if not invoice.invoice_date:
|
||||
errors.add_required_error("invoice_date")
|
||||
|
||||
if invoice.invoice_type != "VEMEX":
|
||||
if not invoice.document_type:
|
||||
errors.add_required_error("document_type")
|
||||
|
||||
if not invoice.compliance_mx.provider_id:
|
||||
errors.add_required_error("compliance_mx.provider_id")
|
||||
|
||||
if not invoice.compliance_mx.sold_to_id:
|
||||
errors.add_required_error("compliance_mx.sold_to_id")
|
||||
|
||||
if not invoice.compliance_mx.shipped_to_id:
|
||||
errors.add_required_error("compliance_mx.shipped_to_id")
|
||||
else:
|
||||
shipped_to_exists = db.query(ClientProvider).filter(
|
||||
ClientProvider.id == invoice.compliance_mx.shipped_to_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
).first()
|
||||
if not shipped_to_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.shipped_to_id",
|
||||
message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Destinatario", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.shipped_to_id,
|
||||
)
|
||||
if not shipped_to_exists.address.country:
|
||||
errors.add_error(
|
||||
field="compliance_mx.shipped_to_id",
|
||||
message="El Destinatario no tiene capturado el pais.",
|
||||
solution=["Captura el pais de envío del Destinatario", "Revisa el catálogo"],
|
||||
code="MISSING_COUNTRY",
|
||||
value=invoice.compliance_mx.shipped_to_id,
|
||||
)
|
||||
|
||||
if invoice.invoice_type != "VEMEX":
|
||||
if not invoice.compliance_mx.customs_broker_id:
|
||||
errors.add_required_error("compliance_mx.customs_broker_id")
|
||||
|
||||
|
||||
if not invoice.financials.exchange_rate or invoice.financials.exchange_rate <= 0:
|
||||
errors.add_range_error(
|
||||
"financials.exchange_rate",
|
||||
min_value=0.0001,
|
||||
)
|
||||
|
||||
if not invoice.financials.currency:
|
||||
errors.add_required_error("El Tipo de Moneda esta vacio no se puede actualizar")
|
||||
elif invoice.financials.currency == "manual" and not invoice.financials.currency_type:
|
||||
errors.add_required_error("financials.currency_type")
|
||||
|
||||
#TODO: SSISGEN: Seguridad Ejemplo en: BrowseQFacImp
|
||||
|
||||
# 2.- Existe tipo de cambio para la factura seleccionada
|
||||
#TODO: SSISGEN: VALIDACION DEL TIPO DE CAMBIO EN BASE A LA FECHA DE PAGO DEL PEDIMENTO.
|
||||
|
||||
# 3.- Validacion que deber de existir un pedimento cuando es requerido
|
||||
if not invoice.compliance_mx.is_pedimento_pending and not invoice.compliance_mx.pedimento_id:
|
||||
errors.add_required_error("compliance_mx.pedimento_number")
|
||||
|
||||
# 4.- Verificacion de que existan partidas para la factura, si no hay partidas no se puede procesar
|
||||
item_count = (
|
||||
db.query(func.count(LineItem.id))
|
||||
.filter(
|
||||
LineItem.invoice_id == invoice.id,
|
||||
LineItem.tenant_id == invoice.tenant_id,
|
||||
LineItem.company_id == invoice.company_id,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
if item_count == 0:
|
||||
errors.add_error(
|
||||
field="items",
|
||||
message="La factura no tiene partidas capturadas.",
|
||||
solution=["Capture al menos una partida antes de procesar la factura."],
|
||||
code="NO_ITEMS_FOUND",
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.celery_app import celery_app
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
|
||||
from .task import process_export_invoice_task
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/invoices/{invoice_id}/process")
|
||||
def trigger_invoice_process(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Inicia el procesamiento de una factura de exportación como tarea Celery.
|
||||
Retorna el task_id para hacer polling del progreso.
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
task = process_export_invoice_task.apply_async(
|
||||
args=[invoice_id, str(tenant_id), str(company_id)]
|
||||
)
|
||||
|
||||
return {"task_id": task.id}
|
||||
|
||||
|
||||
@router.get("/invoices/process/{task_id}/status")
|
||||
def get_invoice_process_status(task_id: str):
|
||||
"""
|
||||
Consulta el estado de progreso de una tarea de procesamiento de factura.
|
||||
|
||||
Retorna:
|
||||
- state: 'PROCESSING' | 'SUCCESS' | 'FAILURE'
|
||||
- info: { current: int, status: str } (cuando state == 'PROCESSING')
|
||||
- result: dict (cuando state == 'SUCCESS' o 'FAILURE')
|
||||
"""
|
||||
task_result = celery_app.AsyncResult(task_id)
|
||||
|
||||
if task_result.state in ("PENDING", "STARTED"):
|
||||
return {
|
||||
"state": "PROCESSING",
|
||||
"info": {"current": 0, "status": "Iniciando..."},
|
||||
}
|
||||
|
||||
if task_result.state == "PROGRESS":
|
||||
return {
|
||||
"state": "PROCESSING",
|
||||
"info": task_result.info or {"current": 0, "status": "Procesando..."},
|
||||
}
|
||||
|
||||
if task_result.state == "SUCCESS":
|
||||
return {
|
||||
"state": "SUCCESS",
|
||||
"result": task_result.result,
|
||||
}
|
||||
|
||||
error_info = task_result.result
|
||||
if isinstance(error_info, Exception):
|
||||
error_msg = str(error_info)
|
||||
else:
|
||||
error_msg = str(error_info) if error_info else "Error desconocido"
|
||||
|
||||
return {
|
||||
"state": "FAILURE",
|
||||
"result": error_msg,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from api.v1.modules.a76.items.series.models import Serie
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
|
||||
def assign_no_discharges_items(
|
||||
lines: List[LineItem],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
ASIGNA_NODESCARGA_PARTIDAS
|
||||
Sets ``discharge = False`` on every line item of the invoice.
|
||||
Used exclusively by invoice type NODES (no discharge).
|
||||
|
||||
Legacy equivalent
|
||||
-----------------
|
||||
UPDATE QEqeMaq SET Descarga = 0
|
||||
FROM QEqeMaq
|
||||
WHERE Consecutivo = <invoice.id>
|
||||
"""
|
||||
try:
|
||||
for line in lines:
|
||||
line.fa_data.discharge = False
|
||||
except Exception as exc:
|
||||
errors.add_error(
|
||||
field="items.discharge",
|
||||
message="Error al asignar No-Descarga en las partidas de exportación.",
|
||||
solution=["Verifique la integridad de las partidas de la factura."],
|
||||
code="ASSIGN_NO_DISCHARGE_ITEMS_ERROR",
|
||||
value=str(exc),
|
||||
)
|
||||
|
||||
|
||||
def assign_no_discharges_series(
|
||||
db: Session,
|
||||
lines: List[LineItem],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
ASIGNA_NODESCARGA_SERIES
|
||||
Sets ``marca = False`` on every ``Serie`` row whose parent ``LineItem``
|
||||
has ``discharge = False`` (or ``discharge`` is ``None``).
|
||||
|
||||
Legacy equivalent
|
||||
-----------------
|
||||
UPDATE QSeriesExpo
|
||||
SET Marca = 0
|
||||
FROM QSeriesExpo SerExpo
|
||||
LEFT JOIN QEqeMaq EqiPex
|
||||
ON EqiPex.Consecutivo = SerExpo.Consecutivo
|
||||
AND EqiPex.LineaExpo = SerExpo.LineaExpo
|
||||
WHERE SerExpo.Consecutivo = <invoice.id>
|
||||
AND EqiPex.Descarga = 0
|
||||
"""
|
||||
try:
|
||||
no_discharge_line_ids = {
|
||||
line.id
|
||||
for line in lines
|
||||
if not line.fa_data.discharge
|
||||
}
|
||||
|
||||
if not no_discharge_line_ids:
|
||||
return
|
||||
|
||||
(
|
||||
db.query(Serie)
|
||||
.filter(Serie.line_item_id.in_(no_discharge_line_ids))
|
||||
.update({"discharge": False}, synchronize_session="fetch")
|
||||
)
|
||||
except Exception as exc:
|
||||
errors.add_error(
|
||||
field="series.discharge",
|
||||
message="Error al asignar No-Descarga en las series de exportación.",
|
||||
solution=["Verifique la integridad de las series de la factura."],
|
||||
code="ASSIGN_NO_DISCHARGE_SERIES_ERROR",
|
||||
value=str(exc),
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
ASIGNA_VALORES_PARTIDAS_ASIGNA_PESOS
|
||||
Resets and recalculates unit costs, export values
|
||||
(KGS ↔ LBS) for every line item of an export invoice.
|
||||
|
||||
Two cost-assignment strategies (controlled by SisExp:ValFactTC — TODO):
|
||||
TCE → bulk SQL UPDATE using the invoice-level exchange rate (Loc:TipoCambio).
|
||||
else → per-line loop that resolves each line's exchange rate from its
|
||||
source import invoice (TEM → QFacImp, DEF → QFacImpDef).
|
||||
|
||||
After costs are assigned the routine always:
|
||||
1. Calls REVISA_UMA for each line.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import Currency, InvoiceHeader
|
||||
from api.v1.modules.a76.invoices.common.process.review_uma import revisa_uma
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
_KGS_TO_LBS = Decimal("2.204624")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _assign_costs_tce(
|
||||
lines: List[LineItem],
|
||||
currency: Currency,
|
||||
tc: Decimal,
|
||||
tc_mm: Decimal,
|
||||
) -> None:
|
||||
"""
|
||||
Bulk-style cost assignment when SisExp:ValFactTC = 'TCE'.
|
||||
Uses the single invoice-level exchange rate for all lines.
|
||||
|
||||
Legacy equivalent (branch 1 of the IF SisExp:ValFactTC):
|
||||
UPDATE QEqeMaq SET CostoUnitarioDlls = ..., CostoUnitarioPesos = ...,
|
||||
ValorExpoMN = ..., ValorExpoME = ..., ValorExpoMC = ...
|
||||
WHERE Consecutivo = <invoice.id>
|
||||
"""
|
||||
for line in lines:
|
||||
if line.financial is None or line.quantity is None:
|
||||
continue
|
||||
|
||||
capture = line.financial.unit_cost_capture or Decimal(0)
|
||||
qty = line.quantity.quantity or Decimal(0)
|
||||
|
||||
if currency == Currency.FOREIGN: # ME
|
||||
line.financial.unit_cost_usd = capture
|
||||
line.financial.unit_cost_mxn = capture * tc
|
||||
line.financial.value_mxn = qty * capture * tc
|
||||
line.financial.value_usd = qty * capture
|
||||
line.financial.value_mc = qty * capture
|
||||
|
||||
elif currency == Currency.LOCAL: # MN
|
||||
line.financial.unit_cost_mxn = capture
|
||||
line.financial.unit_cost_usd = (capture / tc) if tc else Decimal(0)
|
||||
line.financial.value_mxn = qty * capture
|
||||
line.financial.value_usd = (qty * capture / tc) if tc else Decimal(0)
|
||||
line.financial.value_mc = qty * capture
|
||||
|
||||
elif currency == Currency.MANUAL: # MC
|
||||
cost_usd = capture * tc_mm
|
||||
line.financial.unit_cost_usd = cost_usd
|
||||
line.financial.unit_cost_mxn = cost_usd * tc
|
||||
line.financial.value_mxn = qty * cost_usd * tc
|
||||
line.financial.value_usd = qty * cost_usd
|
||||
line.financial.value_mc = qty * capture
|
||||
|
||||
|
||||
def _assign_costs_per_line(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
lines: List[LineItem],
|
||||
currency: Currency,
|
||||
tc_mm: Decimal,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
Per-line cost assignment when SisExp:ValFactTC != 'TCE'.
|
||||
Each line resolves the exchange rate from its source import invoice
|
||||
(TEM → QFacImp header, DEF → QFacImpDef header).
|
||||
|
||||
Legacy equivalent (ELSE branch – LOOP QEqeMaq):
|
||||
If TipoMovImpo = 'TEM' → ACCESS:QFacImp.TryFetch(EqiFim:FKFacturaImpo)
|
||||
Else → ACCESS:QFacImpDef.TryFetch(EqiFID:FKFacImpoDef)
|
||||
then assign CostoUnitarioDlls / CostoUnitarioPesos / ValorExpoMN/ME/MC
|
||||
"""
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader as InvHeader
|
||||
|
||||
for line in lines:
|
||||
if line.financial is None or line.quantity is None:
|
||||
continue
|
||||
|
||||
capture = line.financial.unit_cost_capture or Decimal(0)
|
||||
qty = line.quantity.quantity or Decimal(0)
|
||||
|
||||
# Resolve the exchange rate from the source import invoice
|
||||
line_tc = _get_source_invoice_tc(db, invoice, line, errors)
|
||||
|
||||
if currency == Currency.FOREIGN: # ME
|
||||
line.financial.unit_cost_usd = capture
|
||||
line.financial.unit_cost_mxn = capture * line_tc
|
||||
|
||||
elif currency == Currency.LOCAL: # MN
|
||||
line.financial.unit_cost_usd = (capture / line_tc) if line_tc else Decimal(0)
|
||||
line.financial.unit_cost_mxn = capture
|
||||
|
||||
elif currency == Currency.MANUAL: # MC
|
||||
cost_usd = capture * tc_mm
|
||||
line.financial.unit_cost_usd = cost_usd
|
||||
line.financial.unit_cost_mxn = cost_usd * line_tc
|
||||
|
||||
# Values are always: cost × qty
|
||||
line.financial.value_mxn = (line.financial.unit_cost_mxn or Decimal(0)) * qty
|
||||
line.financial.value_usd = (line.financial.unit_cost_usd or Decimal(0)) * qty
|
||||
line.financial.value_mc = capture * qty
|
||||
|
||||
|
||||
def _get_source_invoice_tc(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
line: LineItem,
|
||||
errors: ErrorCollector,
|
||||
) -> Decimal:
|
||||
"""
|
||||
Returns the exchange rate of the import invoice linked to this export line.
|
||||
|
||||
Movement type 'TEM' → look up QFacImp (temporary import header).
|
||||
Any other type → look up QFacImpDef (definitive import header).
|
||||
|
||||
Falls back to the export invoice's own exchange rate if the source invoice
|
||||
is not found, and records a warning-level error.
|
||||
|
||||
Legacy fields:
|
||||
EqiPex:TipoMovImpo → line.customs.origin_procedure
|
||||
EqiPex:FacturaImpo → line.reference.import_invoice (TODO: confirm field)
|
||||
"""
|
||||
fallback_tc = Decimal(str(invoice.financials.exchange_rate or 0))
|
||||
|
||||
movement_type = (line.customs.origin_procedure or "").strip().upper() if line.customs else ""
|
||||
import_invoice_number = (line.reference.import_invoice if line.reference else None) or ""
|
||||
|
||||
if not import_invoice_number:
|
||||
return fallback_tc
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader as InvHeader
|
||||
|
||||
if movement_type == "TEM":
|
||||
source = (
|
||||
db.query(InvHeader)
|
||||
.filter(
|
||||
InvHeader.invoice_number == import_invoice_number,
|
||||
InvHeader.tenant_id == invoice.tenant_id,
|
||||
InvHeader.company_id == invoice.company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
# DEF: definitive import
|
||||
source = (
|
||||
db.query(InvHeader)
|
||||
.filter(
|
||||
InvHeader.invoice_number == import_invoice_number,
|
||||
InvHeader.tenant_id == invoice.tenant_id,
|
||||
InvHeader.company_id == invoice.company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if source is None or source.financials is None:
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].import_invoice",
|
||||
message=(
|
||||
f"No se encontró la factura de importación '{import_invoice_number}' "
|
||||
f"referenciada en la partida {line.line_number}."
|
||||
),
|
||||
solution=[
|
||||
"Verificar el número de factura de importación en la partida.",
|
||||
],
|
||||
code="SOURCE_INVOICE_NOT_FOUND",
|
||||
)
|
||||
return fallback_tc
|
||||
|
||||
return Decimal(str(source.financials.exchange_rate or 0)) or fallback_tc
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def assign_values(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
lines: List[LineItem],
|
||||
tenant_id: str,
|
||||
company_id: str,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
ASIGNA_VALORES_PARTIDAS_ASIGNA_PESOS
|
||||
|
||||
1. Assigns unit costs and export values to every line (ME / MN / MC).
|
||||
Strategy A (TCE): bulk assignment using the invoice exchange rate.
|
||||
Strategy B (per-line): resolves exchange rate per source import invoice.
|
||||
2. Calls REVISA_UMA for each line.
|
||||
"""
|
||||
currency = invoice.financials.currency
|
||||
tc = Decimal(str(invoice.financials.exchange_rate or 0))
|
||||
tc_mm = Decimal(str(invoice.financials.exchange_rate_mm or 0))
|
||||
|
||||
|
||||
# --- Step 1: Assign costs / values ---------------------------------------
|
||||
# TODO: Read SisExp:ValFactTC from the export system parameters model.
|
||||
# When ValFactTC = 'TCE' use _assign_costs_tce (single TC for all lines).
|
||||
# Otherwise use _assign_costs_per_line (TC from each source import invoice).
|
||||
# For now the per-line strategy is always used as the safe default.
|
||||
val_fact_tc = "PER_LINE" # TODO: replace with SisExp.val_fact_tc
|
||||
|
||||
if val_fact_tc == "TCE":
|
||||
_assign_costs_tce(lines, currency, tc, tc_mm)
|
||||
else:
|
||||
_assign_costs_per_line(db, invoice, lines, currency, tc_mm, errors)
|
||||
|
||||
# --- Step 2: REVISA_UMA --------------------------------------------------
|
||||
for line in lines:
|
||||
revisa_uma(db=db, line=line, tenant_id=tenant_id, company_id=company_id, errors=errors)
|
||||
|
||||
# --- Step 3: Assign weights ----------------------------------------------
|
||||
# In anexo76 will be calculated in realtime based in weight type by conversion factor
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
COMPARA_SALDOS_POR_FACTURA
|
||||
Compares the discharge queue (QueADescargar) against the available PEPS lots
|
||||
(QSaldoActual) and distributes the quantity to discharge across the available
|
||||
lots, updating ``entry.quantity_used`` and ``lot.available_qty`` accordingly.
|
||||
|
||||
Also validates that the unit of measure on the export line matches the one
|
||||
on the import lot.
|
||||
|
||||
If after consuming all available lots a discharge entry still has remaining
|
||||
quantity, no explicit error is raised here — the caller (compare_balances)
|
||||
detects this and reports an insufficient-balance error.
|
||||
|
||||
Legacy mapping
|
||||
--------------
|
||||
QADesc:Cantidad → entry.quantity
|
||||
QADesc:CantUsada → entry.quantity_used
|
||||
QSaldo:Cantidad → lot.available_qty (net balance from ledger)
|
||||
QSaldo:CantUsada → lot_used (tracked locally; lots are mutated in-place)
|
||||
QADesc:UniMed → entry.unit_of_measure
|
||||
QSaldo:UniMed → resolved from import line (stored on AvailableLot via uom)
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from core.exceptions import ErrorCollector
|
||||
from .discharge_types import AvailableLot, DownloadEntry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_import_uom(db: Session, import_item_line_id: int) -> Optional[str]:
|
||||
"""
|
||||
Returns the unit-of-measure code of the import line (QSaldo:UniMed).
|
||||
Equivalent to EqiPim:UnidadMedida resolved via the import LineItem.
|
||||
"""
|
||||
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
|
||||
|
||||
row = db.execute(
|
||||
select(LineItem.unit_of_measure).where(LineItem.id == import_item_line_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
uom = db.get(UnitOfMeasure, row)
|
||||
return uom.code if uom else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compare_balances(
|
||||
db: Session,
|
||||
export_invoice: InvoiceHeader,
|
||||
to_discharge: List[DownloadEntry],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
COMPARA_SALDOS_POR_FACTURA
|
||||
For each discharge entry distributes the quantity to discharge across the
|
||||
available PEPS lots attached to the entry by ``fill_available_balances``.
|
||||
|
||||
Mutates ``entry.quantity_used`` and ``lot.available_qty`` in-place.
|
||||
After this call, ``entry.quantity_used`` should equal ``entry.quantity``
|
||||
for every entry; if not, there is insufficient balance.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : active SQLAlchemy session
|
||||
export_invoice : the export invoice being processed
|
||||
to_discharge : list of DownloadEntry objects populated by
|
||||
fill_available_balances (entry.available_lots must be set)
|
||||
errors : shared error collector
|
||||
"""
|
||||
# Sort mirrors Clarion:
|
||||
# Sort(QueADescargar, -Procedencia, FacturaImpo, LineaImpo)
|
||||
# Sort(QSaldoActual, -Procedencia, FacturaImpo, LineaImpo)
|
||||
sorted_entries = sorted(
|
||||
to_discharge,
|
||||
key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
for entry in sorted_entries:
|
||||
if not entry.available_lots:
|
||||
# No lots were found for this entry — balance check will catch it
|
||||
continue
|
||||
|
||||
# ── Validate unit of measure matches the import lot ───────────────────
|
||||
first_lot = entry.available_lots[0]
|
||||
import_uom = _resolve_import_uom(db, first_lot.import_item_line_id)
|
||||
|
||||
if import_uom and entry.unit_of_measure and import_uom != entry.unit_of_measure:
|
||||
errors.add_error(
|
||||
field=f"line[{entry.export_line}].unit_of_measure",
|
||||
message=(
|
||||
f"La U.M.: '{entry.unit_of_measure}' de la partida: {entry.export_line} "
|
||||
f"es diferente a la U.M: '{import_uom}' registrada en importación."
|
||||
),
|
||||
solution=["Revisar la Partida de Exportación y cambiar la Unidad de Medida."],
|
||||
code="UOM_MISMATCH",
|
||||
value={
|
||||
"export_line": entry.export_line,
|
||||
"export_uom": entry.unit_of_measure,
|
||||
"import_uom": import_uom,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
# ── Distribute quantity across available lots (PEPS order) ────────────
|
||||
# Lots are already ordered by order_peps (oldest first) from
|
||||
# fill_available_balances; sort defensively here too.
|
||||
sorted_lots: List[AvailableLot] = sorted(
|
||||
entry.available_lots, key=lambda lot: lot.order_peps
|
||||
)
|
||||
|
||||
for lot in sorted_lots:
|
||||
remaining_entry = entry.quantity - entry.quantity_used
|
||||
remaining_lot = lot.available_qty
|
||||
|
||||
if remaining_entry <= 0:
|
||||
break # Entry fully satisfied
|
||||
|
||||
if remaining_lot <= 0:
|
||||
continue # Lot exhausted — try next
|
||||
|
||||
# Consume as much as possible from this lot
|
||||
consume = min(remaining_entry, remaining_lot)
|
||||
|
||||
entry.quantity_used += consume
|
||||
lot.available_qty -= consume
|
||||
|
||||
# ── Check if the entry was fully satisfied ────────────────────────────
|
||||
if entry.quantity_used < entry.quantity:
|
||||
shortage = entry.quantity - entry.quantity_used
|
||||
errors.add_error(
|
||||
field=f"line[{entry.export_line}].quantity",
|
||||
message=(
|
||||
f"Saldo insuficiente para la partida: {entry.export_line}. "
|
||||
f"Se requieren {entry.quantity} y solo hay {entry.quantity_used} disponibles "
|
||||
f"(faltan {shortage})."
|
||||
),
|
||||
solution=[
|
||||
"Verificar el saldo disponible de la Factura de Importación.",
|
||||
"Reducir la cantidad a descargar.",
|
||||
],
|
||||
code="INSUFFICIENT_BALANCE",
|
||||
value={
|
||||
"export_line": entry.export_line,
|
||||
"required": str(entry.quantity),
|
||||
"available": str(entry.quantity_used),
|
||||
"shortage": str(shortage),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Shared dataclasses for the export invoice discharge process.
|
||||
|
||||
Kept in a standalone module (no local imports) so that
|
||||
download_balance_collector, review_series_exist, review_series_other_lines
|
||||
and fill_available_balances can all import from here without circular deps.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class AvailableLot:
|
||||
"""
|
||||
One PEPS lot available for discharge — equivalent to one QSaldoActual record.
|
||||
|
||||
Fields
|
||||
------
|
||||
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)
|
||||
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
|
||||
"""
|
||||
import_item_line_id: int
|
||||
import_invoice_id: int
|
||||
part_number_id: Optional[int]
|
||||
available_qty: Decimal
|
||||
value_me: Optional[Decimal]
|
||||
value_mn: Optional[Decimal]
|
||||
order_peps: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadEntry:
|
||||
"""
|
||||
Represents one export line that will be discharged from inventory.
|
||||
Equivalent to the QADesc (QueADescargar) record in the legacy system.
|
||||
|
||||
Fields
|
||||
------
|
||||
origin_procedure : TipoMovImpo – 'TEM' (temporal) | 'DEF' (definitiva)
|
||||
export_line : LineaExpo – line number on the export invoice
|
||||
part_number : NumParte – part number code
|
||||
class_code : Clase – class code
|
||||
quantity : CantExpo – quantity to discharge
|
||||
quantity_used : CantUsada – amount already consumed (starts at 0)
|
||||
unit_of_measure : UniMed – unit of measure code
|
||||
import_invoice : FacturaImpo – source import invoice number
|
||||
import_line : LineaImpo – source import line number
|
||||
line_item_id : internal DB id of the LineItem (for series lookups)
|
||||
available_lots : PEPS lots attached by fill_available_balances
|
||||
"""
|
||||
origin_procedure: str
|
||||
export_line: int
|
||||
part_number: str
|
||||
class_code: str
|
||||
quantity: Decimal
|
||||
unit_of_measure: str
|
||||
import_invoice: str
|
||||
import_line: int
|
||||
line_item_id: int
|
||||
quantity_used: Decimal = field(default_factory=Decimal)
|
||||
available_lots: List[AvailableLot] = field(default_factory=list)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
QUIERE_DESCARGAR
|
||||
Collects every export line item that has discharge = True and builds the
|
||||
"QueADescargar" list used by the balance-verification steps that follow
|
||||
(LLENA_QUEUE_SALDOS_DISPONIBLES_FACTURA, COMPARA_SALDOS_POR_FACTURA,
|
||||
VERIFICAQCONSOLIDADO).
|
||||
|
||||
For each discharge line the routine also triggers two series sub-validations:
|
||||
· REVISA_SERIES_EXISTA → verifies the series to be discharged exist
|
||||
· REVISA_SERIES_OTRAS_PAR → verifies the series are not already discharged
|
||||
on another line (REVISA_SERIES_DESC was commented-out in the legacy)
|
||||
|
||||
Legacy equivalent
|
||||
-----------------
|
||||
SELECT EqiPex.TipoMovImpo, EqiPex.LineaExpo, EqiPex.NumParte,
|
||||
EqiPex.Clase, EqiPex.CantExpo, EqiPex.UnidadMedida,
|
||||
EqiPex.FacturaImpo, EqiPex.LineaImpo
|
||||
FROM QEqeMaq EqiPex
|
||||
WHERE Consecutivo = <invoice.id>
|
||||
AND EqiPex.Descarga = 1
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import List, Set, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from core.exceptions import ErrorCollector
|
||||
from .discharge_types import AvailableLot, DownloadEntry # re-exported for callers
|
||||
from .series.review_series_exist import review_series_exist
|
||||
from .series.review_series_other_lines import review_series_other_lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_lines_to_discharge(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
lines: List[LineItem],
|
||||
errors: ErrorCollector,
|
||||
) -> List[DownloadEntry]:
|
||||
"""
|
||||
QUIERE_DESCARGAR
|
||||
Builds and returns the list of ``DownloadEntry`` records for every line
|
||||
that has ``discharge = True``. If no lines have discharge enabled the
|
||||
list is empty and the subsequent balance steps are skipped.
|
||||
|
||||
For each collected line the function also runs:
|
||||
· _revisa_series_exista
|
||||
· _revisa_series_otras_par
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : active SQLAlchemy session
|
||||
invoice : the export invoice being processed
|
||||
lines : all line items of the invoice (already loaded by pre_validators)
|
||||
errors : shared error collector
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[DownloadEntry] — the "QueADescargar" equivalent
|
||||
"""
|
||||
to_discharge: List[DownloadEntry] = []
|
||||
|
||||
discharge_lines = [line for line in lines if line.discharge]
|
||||
|
||||
if not discharge_lines:
|
||||
return to_discharge
|
||||
|
||||
# Shared across all lines — accumulates series keys to detect duplicates
|
||||
# between lines (equivalent to QueueSeries in the Clarion)
|
||||
seen_series: Set[Tuple] = set()
|
||||
|
||||
for line in discharge_lines:
|
||||
origin_procedure = (
|
||||
line.customs.origin_procedure
|
||||
if line.customs and line.customs.origin_procedure
|
||||
else ""
|
||||
)
|
||||
part_number = ""
|
||||
if line.part_info:
|
||||
part_number = line.part_info.part_number or ""
|
||||
|
||||
class_code = ""
|
||||
if line.class_info:
|
||||
class_code = line.class_info.class_code or ""
|
||||
|
||||
quantity = (
|
||||
line.quantity.quantity or Decimal(0)
|
||||
if line.quantity
|
||||
else Decimal(0)
|
||||
)
|
||||
|
||||
uom_code = ""
|
||||
if line.unit_of_measure_info:
|
||||
uom_code = line.unit_of_measure_info.code or ""
|
||||
|
||||
import_invoice_number = ""
|
||||
import_line_number = 0
|
||||
if line.fa_data:
|
||||
import_invoice_number = line.fa_data.search_invoice or ""
|
||||
import_line_number = line.fa_data.search_line or 0
|
||||
|
||||
entry = DownloadEntry(
|
||||
origin_procedure=origin_procedure,
|
||||
export_line=line.line_number,
|
||||
part_number=part_number,
|
||||
class_code=class_code,
|
||||
quantity=quantity,
|
||||
quantity_used=Decimal(0),
|
||||
unit_of_measure=uom_code,
|
||||
import_invoice=import_invoice_number,
|
||||
import_line=import_line_number,
|
||||
line_item_id=line.id,
|
||||
)
|
||||
to_discharge.append(entry)
|
||||
|
||||
review_series_exist(db, invoice, line, entry, errors)
|
||||
review_series_other_lines(db, invoice, line, entry, seen_series, errors)
|
||||
|
||||
return to_discharge
|
||||
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
LLENA_QUEUE_SALDOS_DISPONIBLES_FACTURA
|
||||
For each entry in ``to_discharge`` (QueADescargar), validates the source
|
||||
import invoice/line and computes the net available balance from
|
||||
``a24.balance_movement`` using the PEPS ledger.
|
||||
|
||||
New logic vs. legacy
|
||||
--------------------
|
||||
The legacy Clarion routine read ``CantImpo - CantRetornadaTemp - CantRetornada``
|
||||
directly from the import line record. Since we migrated to an append-only
|
||||
ledger (a24.balance_movement), the available balance is now computed as:
|
||||
|
||||
SUM(signed_qty) per import_item_line_id
|
||||
|
||||
where sign = +1 for positive movement types and -1 for negative ones
|
||||
(see NEGATIVE_MOVEMENTS set in the BalanceMovement model).
|
||||
|
||||
Validations preserved from legacy
|
||||
----------------------------------
|
||||
1. Import invoice must exist.
|
||||
2. Import invoice must be processed (status != 'NA' / not 'unprocessed').
|
||||
3. Import invoice date must not be later than the export invoice date.
|
||||
4. Import line must exist.
|
||||
5. Net available balance must be > 0 (otherwise the lot is skipped).
|
||||
|
||||
Skipped items (equivalent to legacy CYCLE)
|
||||
------------------------------------------
|
||||
- Entries already seen in the same call (duplicate import_invoice + import_line
|
||||
combination) — handled naturally since each entry is unique in QueADescargar.
|
||||
- Lots with net balance <= 0.
|
||||
|
||||
Output
|
||||
------
|
||||
On success, ``entry.available_lots`` is populated with one ``AvailableLot``
|
||||
per lot that has available balance. Errors are added to ``errors``.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from api.v1.modules.a24.balance_movements.models import BalanceMovement, NEGATIVE_MOVEMENTS
|
||||
from core.exceptions import ErrorCollector
|
||||
from .discharge_types import AvailableLot, DownloadEntry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_import_invoice(
|
||||
db: Session,
|
||||
invoice_number: str,
|
||||
export_invoice: InvoiceHeader,
|
||||
) -> Optional[InvoiceHeader]:
|
||||
return (
|
||||
db.query(InvoiceHeader)
|
||||
.filter(
|
||||
InvoiceHeader.invoice_number == invoice_number,
|
||||
InvoiceHeader.tenant_id == export_invoice.tenant_id,
|
||||
InvoiceHeader.company_id == export_invoice.company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _fetch_import_line(
|
||||
db: Session,
|
||||
invoice_id: int,
|
||||
line_number: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
) -> Optional[LineItem]:
|
||||
return (
|
||||
db.query(LineItem)
|
||||
.filter(
|
||||
LineItem.invoice_id == invoice_id,
|
||||
LineItem.line_number == line_number,
|
||||
LineItem.tenant_id == tenant_id,
|
||||
LineItem.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _net_balance_for_lot(
|
||||
db: Session,
|
||||
import_item_line_id: int,
|
||||
as_of_date: datetime.date,
|
||||
) -> Decimal:
|
||||
"""
|
||||
Computes the net available balance for one import lot as of ``as_of_date``.
|
||||
|
||||
Equivalent to the legacy two-step calculation:
|
||||
1. Base check: CantImpo - CantRetornadaTemp - CantRetornada (general balance)
|
||||
2. CALCULA_SALDO_FECHA_EXPO: only count exits with operation_date <= export date
|
||||
|
||||
In the new ledger, ENTRY movements have no operation_date restriction (the
|
||||
lot exists from its import date). EXIT movements (CONSUMPTION, WASTE, etc.)
|
||||
are only counted if their operation_date <= as_of_date, mirroring the
|
||||
Clarion "FechaFactura > EqiFex:FechaFactura → CYCLE" guard.
|
||||
|
||||
balance = SUM(+qty for ENTRY-type movements)
|
||||
- SUM( qty for EXIT-type movements WHERE operation_date <= as_of_date)
|
||||
"""
|
||||
sign_expr = case(
|
||||
(BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), Decimal(-1)),
|
||||
else_=Decimal(1),
|
||||
)
|
||||
# Positive movements: always count (entries, returns, adjustments in)
|
||||
# Negative movements: only count if they occurred on or before the export date
|
||||
date_filter = case(
|
||||
(
|
||||
BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS),
|
||||
BalanceMovement.operation_date <= as_of_date,
|
||||
),
|
||||
else_=True,
|
||||
)
|
||||
result = db.execute(
|
||||
select(func.sum(sign_expr * BalanceMovement.quantity)).where(
|
||||
BalanceMovement.import_item_line_id == import_item_line_id,
|
||||
date_filter,
|
||||
)
|
||||
).scalar()
|
||||
return Decimal(str(result or 0))
|
||||
|
||||
|
||||
def _peps_order_for_lot(db: Session, import_item_line_id: int) -> int:
|
||||
"""Returns the minimum (oldest) order_peps for this lot."""
|
||||
result = db.execute(
|
||||
select(func.min(BalanceMovement.order_peps)).where(
|
||||
BalanceMovement.import_item_line_id == import_item_line_id,
|
||||
)
|
||||
).scalar()
|
||||
return int(result or 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fill_available_balances(
|
||||
db: Session,
|
||||
export_invoice: InvoiceHeader,
|
||||
to_discharge: List[DownloadEntry],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
LLENA_QUEUE_SALDOS_DISPONIBLES_FACTURA
|
||||
Validates each discharge entry and populates ``entry.available_lots``
|
||||
with the net balance available from the PEPS ledger.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : active SQLAlchemy session
|
||||
export_invoice : the export invoice being processed
|
||||
to_discharge : list of DownloadEntry objects (QueADescargar)
|
||||
errors : shared error collector
|
||||
"""
|
||||
export_date: datetime.date = (
|
||||
export_invoice.invoice_date.date()
|
||||
if hasattr(export_invoice.invoice_date, "date")
|
||||
else export_invoice.invoice_date
|
||||
)
|
||||
|
||||
# Sort mirrors Clarion: Sort(QueADescargar, FacturaImpo, LineaImpo)
|
||||
sorted_entries = sorted(
|
||||
to_discharge,
|
||||
key=lambda e: (e.import_invoice, e.import_line),
|
||||
)
|
||||
|
||||
# Track already-resolved (invoice, line) pairs to skip duplicates
|
||||
seen: set = set()
|
||||
|
||||
for entry in sorted_entries:
|
||||
key = (entry.import_invoice, entry.import_line)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
if not entry.import_invoice or entry.import_line == 0:
|
||||
continue
|
||||
|
||||
# ── 1. Validate import invoice ────────────────────────────────────────
|
||||
import_invoice = _fetch_import_invoice(db, entry.import_invoice, export_invoice)
|
||||
|
||||
if import_invoice is None:
|
||||
errors.add_error(
|
||||
field=f"line[{entry.export_line}].import_invoice",
|
||||
message=f"La Factura de Importación: '{entry.import_invoice}' no existe.",
|
||||
solution=["Seleccionar otra factura de Importación."],
|
||||
code="IMPORT_INVOICE_NOT_FOUND",
|
||||
value=entry.import_invoice,
|
||||
)
|
||||
continue
|
||||
|
||||
# Status 'NA' == not processed (Clarion: Estatus = 'NA')
|
||||
if import_invoice.status == InvoiceStatus.UNPROCESSED:
|
||||
errors.add_error(
|
||||
field=f"line[{entry.export_line}].import_invoice",
|
||||
message=f"La Factura de Importación: '{entry.import_invoice}' está Desactualizada.",
|
||||
solution=["Actualizar la factura de Importación."],
|
||||
code="IMPORT_INVOICE_UNPROCESSED",
|
||||
value=entry.import_invoice,
|
||||
)
|
||||
continue
|
||||
|
||||
# Import date must not be later than export date
|
||||
imp_date: datetime.date = (
|
||||
import_invoice.invoice_date.date()
|
||||
if hasattr(import_invoice.invoice_date, "date")
|
||||
else import_invoice.invoice_date
|
||||
)
|
||||
if imp_date > export_date:
|
||||
errors.add_error(
|
||||
field=f"line[{entry.export_line}].import_invoice",
|
||||
message=(
|
||||
f"La Factura de Importación: '{entry.import_invoice}' tiene una Fecha Mayor "
|
||||
f"a la Fecha de Descarga."
|
||||
),
|
||||
solution=[
|
||||
f"Seleccionar otra factura de Importación con Fecha Anterior al "
|
||||
f"{export_date.strftime('%d/%m/%Y')}."
|
||||
],
|
||||
code="IMPORT_INVOICE_DATE_AFTER_EXPORT",
|
||||
value={"import_date": str(imp_date), "export_date": str(export_date)},
|
||||
)
|
||||
continue
|
||||
|
||||
# ── 2. Validate import line ───────────────────────────────────────────
|
||||
import_line = _fetch_import_line(
|
||||
db,
|
||||
import_invoice.id,
|
||||
entry.import_line,
|
||||
export_invoice.tenant_id,
|
||||
export_invoice.company_id,
|
||||
)
|
||||
|
||||
if import_line is None:
|
||||
errors.add_error(
|
||||
field=f"line[{entry.export_line}].import_line",
|
||||
message=(
|
||||
f"La Factura de Importación: '{entry.import_invoice}' "
|
||||
f"con Línea: {entry.import_line} no existe."
|
||||
),
|
||||
solution=["Seleccionar otra Línea de Importación a Descargar."],
|
||||
code="IMPORT_LINE_NOT_FOUND",
|
||||
value={"import_invoice": entry.import_invoice, "import_line": entry.import_line},
|
||||
)
|
||||
continue
|
||||
|
||||
# ── 3. Compute net available balance from ledger (as of export date) ───
|
||||
# Equivalent to: CantImpo - CantRetornadaTemp - CantRetornada (general)
|
||||
# then CALCULA_SALDO_FECHA_EXPO (only exits on or before export_date).
|
||||
available = _net_balance_for_lot(db, import_line.id, export_date)
|
||||
if available <= 0:
|
||||
# No balance — skip this lot (equivalent to Clarion CYCLE)
|
||||
continue
|
||||
|
||||
# ── 4. Build AvailableLot and attach to entry ─────────────────────────
|
||||
fin = import_line.financial
|
||||
lot = AvailableLot(
|
||||
import_item_line_id=import_line.id,
|
||||
import_invoice_id=import_invoice.id,
|
||||
part_number_id=import_line.part_number_id,
|
||||
available_qty=available,
|
||||
value_me=Decimal(str(fin.value_usd or 0)) if fin else None,
|
||||
value_mn=Decimal(str(fin.value_mxn or 0)) if fin else None,
|
||||
order_peps=_peps_order_for_lot(db, import_line.id),
|
||||
)
|
||||
entry.available_lots.append(lot)
|
||||
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
finalize_invoice_no_discharge / finalize_invoice_with_discharge
|
||||
(TERMINA_AC_O_LP_NODES / TERMINA_AC_O_LP_NORMAL)
|
||||
|
||||
Last step of export invoice processing. Both variants:
|
||||
1. TODO: DO REVISACLASESHABILITADAS
|
||||
2. Validate SisExp quantity / weight / value limits (min and max).
|
||||
3. If no errors: assign invoice-level totals and mark as PROCESSED.
|
||||
|
||||
The "with_discharge" variant additionally:
|
||||
4. DO GENERAIMPODEFINITIVA (if is_regime_change and generate_id)
|
||||
5. DO REGISTRA_DESCARGA_IMPORTACION (update returned qty/value on import lines)
|
||||
6. DO REGISTRA_DESCARGA_SERIES (flag import series as exported)
|
||||
|
||||
The legacy 'Of LP' branch (print-preview / progress-bar UI) is not ported.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .register_import_discharge import register_import_discharge
|
||||
from .register_discharge_series import register_discharge_series
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .discharge_types import DownloadEntry
|
||||
|
||||
from api.v1.modules.a76.classes.models import Class
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from core.exceptions import ErrorCollector
|
||||
from .review_limits import limit_weight, limit_value
|
||||
from .generate_definitive_import import generate_definitive_import
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REVISACLASESHABILITADAS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _review_enabled_classes(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
lines: List[LineItem],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
REVISACLASESHABILITADAS
|
||||
Verifies that every line item's class is active (not disabled).
|
||||
|
||||
Clarion: loops QEqeMaq for the invoice, fetches QClaAct by class code,
|
||||
errors if HabilitaDeshabilitaClase = 1 → Python: Class.is_active = False.
|
||||
"""
|
||||
for line in lines:
|
||||
if not line.class_id:
|
||||
continue
|
||||
cls: Class | None = db.get(Class, line.class_id)
|
||||
if cls is not None and cls.is_active is False:
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].class",
|
||||
message=(
|
||||
f"La Clase: '{cls.class_code}' esta desactivada, "
|
||||
"no se pueden hacer movimientos."
|
||||
),
|
||||
solution=["Seleccionar una clase activa."],
|
||||
code="CLASS_DISABLED",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SisExp limit checks (shared by both public functions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _validate_sisexp_limits(
|
||||
invoice: InvoiceHeader,
|
||||
total_qty: Decimal,
|
||||
total_net_weight: Decimal,
|
||||
total_value: Decimal,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
Validates invoice totals against the SisExp min/max limit parameters.
|
||||
|
||||
TODO: Read actual SisExp parameters from the tenant system-config model.
|
||||
Until then all limits default to 0 (= disabled) so no checks fire.
|
||||
|
||||
Clarion names → Python (TODO):
|
||||
SisExp:CantLimiteMin / SisExp:CantLimite → qty min / max
|
||||
SisExp:PesoLimiteMin / SisExp:PesoLimite → weight min / max
|
||||
SisExp:ValorLimiteMin / SisExp:ValorLimite → value min / max
|
||||
"""
|
||||
# TODO: load from SisExp tenant config
|
||||
cant_limite_min: Decimal = Decimal(0)
|
||||
cant_limite: Decimal = Decimal(0)
|
||||
peso_limite_min: Decimal = Decimal(0)
|
||||
peso_limite: Decimal = Decimal(0)
|
||||
valor_limite_min: Decimal = Decimal(0)
|
||||
valor_limite: Decimal = Decimal(0)
|
||||
|
||||
solution = ["Consulte a su Administrador de sistema para parametrizar la factura."]
|
||||
code = "PAR.EXPO"
|
||||
|
||||
if cant_limite_min != 0 and cant_limite_min > total_qty:
|
||||
errors.add_error(
|
||||
field="invoice.total_quantity",
|
||||
message=(
|
||||
f"La cantidad total de la factura: {total_qty} "
|
||||
f"no supera a la cantidad mínima parametrizada: {cant_limite_min}."
|
||||
),
|
||||
solution=solution, code=code,
|
||||
)
|
||||
if cant_limite != 0 and cant_limite < total_qty:
|
||||
errors.add_error(
|
||||
field="invoice.total_quantity",
|
||||
message=(
|
||||
f"La cantidad total de la factura: {total_qty} "
|
||||
f"excede a la cantidad máxima parametrizada: {cant_limite}."
|
||||
),
|
||||
solution=solution, code=code,
|
||||
)
|
||||
if peso_limite_min != 0 and peso_limite_min > total_net_weight:
|
||||
errors.add_error(
|
||||
field="invoice.net_weight",
|
||||
message=(
|
||||
f"El Peso Neto total de la factura: {total_net_weight} "
|
||||
f"no supera el Peso mínimo parametrizado: {peso_limite_min}."
|
||||
),
|
||||
solution=solution, code=code,
|
||||
)
|
||||
if peso_limite != 0 and peso_limite < total_net_weight:
|
||||
errors.add_error(
|
||||
field="invoice.net_weight",
|
||||
message=(
|
||||
f"El Peso Neto total de la factura: {total_net_weight} "
|
||||
f"excede el Peso máximo parametrizado: {peso_limite}."
|
||||
),
|
||||
solution=solution, code=code,
|
||||
)
|
||||
if valor_limite_min != 0 and valor_limite_min > total_value:
|
||||
errors.add_error(
|
||||
field="invoice.total_value",
|
||||
message=(
|
||||
f"El Valor total de la factura: {total_value} "
|
||||
f"no supera el Valor mínimo parametrizado: {valor_limite_min}."
|
||||
),
|
||||
solution=solution, code=code,
|
||||
)
|
||||
if valor_limite != 0 and valor_limite < total_value:
|
||||
errors.add_error(
|
||||
field="invoice.total_value",
|
||||
message=(
|
||||
f"El Valor total de la factura: {total_value} "
|
||||
f"excede el Valor máximo parametrizado: {valor_limite}."
|
||||
),
|
||||
solution=solution, code=code,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DO ASIGNA_VALORES_FACTURA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _assign_invoice_totals(
|
||||
invoice: InvoiceHeader,
|
||||
lines: List[LineItem],
|
||||
) -> None:
|
||||
"""
|
||||
DO ASIGNA_VALORES_FACTURA
|
||||
Aggregates line-level values (MN, ME, qty, packages, net/gross weight)
|
||||
and writes the totals to the invoice header, then marks it as PROCESSED.
|
||||
|
||||
Clarion equivalent:
|
||||
SELECT SUM(ValorExpoMN), SUM(ValorExpoME), SUM(CantExpo),
|
||||
SUM(CantBultos), SUM(PesoNeto), SUM(PesoBruto)
|
||||
FROM QEqeMaq WHERE Consecutivo = <invoice.id>
|
||||
|
||||
TODO: SisGen:CalValBaseTCPedExpo = 1 → invoice.financials.exchange_rate = Loc:TipoCambio
|
||||
TODO: SisGen:CalValBaseTCPedExpo = 1 →
|
||||
invoice.process_log = 'Se Actualizó con el Tipo de Cambio de la Fecha de Pago de Pedimento.'
|
||||
TODO: SisGen:ActSeguridad = 1 → invoice.updated_by = current_user
|
||||
"""
|
||||
total_value_mn = Decimal(0)
|
||||
total_value_me = Decimal(0)
|
||||
total_qty = Decimal(0)
|
||||
total_packages = 0
|
||||
total_net_weight = Decimal(0)
|
||||
total_gross_weight = Decimal(0)
|
||||
|
||||
for line in lines:
|
||||
if line.financial:
|
||||
total_value_mn += Decimal(str(line.financial.value_mxn or 0))
|
||||
total_value_me += Decimal(str(line.financial.value_usd or 0))
|
||||
if line.quantity:
|
||||
total_qty += line.quantity.quantity or Decimal(0)
|
||||
total_packages += line.quantity.package_quantity or 0
|
||||
total_net_weight += line.quantity.net_weight or Decimal(0)
|
||||
total_gross_weight += line.quantity.gross_weight or Decimal(0)
|
||||
|
||||
if invoice.financials is not None:
|
||||
invoice.financials.value_mn = float(total_value_mn)
|
||||
invoice.financials.value_me = float(total_value_me)
|
||||
invoice.financials.total_quantity = float(total_qty)
|
||||
invoice.financials.total_packages = total_packages
|
||||
invoice.financials.net_weight = float(total_net_weight)
|
||||
invoice.financials.gross_weight = float(total_gross_weight)
|
||||
|
||||
invoice.party_count = len([l for l in lines if not (l.fa_data and l.fa_data.is_subitem)])
|
||||
invoice.updated_date = datetime.date.today()
|
||||
invoice.status = InvoiceStatus.PROCESSED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def finalize_invoice_no_discharge(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
lines: List[LineItem],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
TERMINA_AC_O_LP_NODES
|
||||
Finalizes a NODES-type export invoice (no inventory discharge).
|
||||
|
||||
Flow:
|
||||
1. Verify all line classes are active (REVISACLASESHABILITADAS).
|
||||
2. Validate SisExp limits (qty / weight / value).
|
||||
3. If no errors: write invoice totals and set status = PROCESSED.
|
||||
"""
|
||||
_review_enabled_classes(db, invoice, lines, errors)
|
||||
|
||||
total_qty, total_net_weight = limit_weight(lines)
|
||||
total_value = limit_value(lines)
|
||||
|
||||
_validate_sisexp_limits(invoice, total_qty, total_net_weight, total_value, errors)
|
||||
|
||||
if not errors.has_errors():
|
||||
_assign_invoice_totals(invoice, lines)
|
||||
|
||||
|
||||
def finalize_invoice_with_discharge(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
lines: List[LineItem],
|
||||
errors: ErrorCollector,
|
||||
to_discharge: List["DownloadEntry"] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
TERMINA_AC_O_LP_NORMAL
|
||||
Finalizes a discharge-type export invoice (AFIJO / DONAC / SCRAP / REEXP / VEMEX).
|
||||
|
||||
Flow:
|
||||
1. Verify all line classes are active (REVISACLASESHABILITADAS).
|
||||
2. Validate SisExp limits (qty / weight / value).
|
||||
3. If no errors:
|
||||
a. DO GENERAIMPODEFINITIVA (only if is_regime_change and generate_id)
|
||||
b. TODO: DO REGISTRA_DESCARGA_IMPORTACION (write a24 discharge movements)
|
||||
c. TODO: DO REGISTRA_DESCARGA_SERIES (write series discharge records)
|
||||
d. Write invoice totals and set status = PROCESSED.
|
||||
|
||||
Note: the legacy 'Of LP' branch (print-preview UI) is not ported.
|
||||
"""
|
||||
_review_enabled_classes(db, invoice, lines, errors)
|
||||
|
||||
total_qty, total_net_weight = limit_weight(lines)
|
||||
total_value = limit_value(lines)
|
||||
|
||||
_validate_sisexp_limits(invoice, total_qty, total_net_weight, total_value, errors)
|
||||
|
||||
if not errors.has_errors():
|
||||
if invoice.compliance_mx and invoice.compliance_mx.is_regime_change and invoice.generate_id:
|
||||
generate_definitive_import(db, invoice, errors)
|
||||
|
||||
if to_discharge:
|
||||
register_import_discharge(db, invoice, to_discharge)
|
||||
register_discharge_series(db, invoice, to_discharge)
|
||||
|
||||
_assign_invoice_totals(invoice, lines)
|
||||
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
GENERAIMPODEFINITIVA
|
||||
Generates a definitive import invoice header from the export invoice when
|
||||
processing a regime-change (cambio de régimen) export.
|
||||
|
||||
If an invoice with the same number already exists as a definitive import,
|
||||
the step is skipped (idempotent). After creating the header the function
|
||||
calls the appropriate lines sub-routine based on ``generate_desc_parties``:
|
||||
|
||||
'Todas' → generate_definitive_import_all_lines
|
||||
other → generate_definitive_import_discharged_lines
|
||||
|
||||
Legacy equivalent
|
||||
-----------------
|
||||
GENERAIMPODEFINITIVA Routine
|
||||
Access:QFacImpDef.TryFetch(EqiFID:FKFacImpoDef)
|
||||
If ErrorCode() = 35 Then ← not found → create
|
||||
INSERT INTO QFacImpDef (...)
|
||||
End
|
||||
IF EqiFex:GenPartidas = 'Todas' THEN
|
||||
DO GENERAIMPODEFINITIVA_PARTIDAS_TODAS
|
||||
ELSE
|
||||
DO GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA
|
||||
END
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus, OperationType
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
|
||||
from api.v1.modules.a76.items.line_customs.models import LineCustom
|
||||
from api.v1.modules.a76.items.line_descriptions.models import LineDescription
|
||||
from api.v1.modules.a76.items.series.models import Serie
|
||||
from core.exceptions import ErrorCollector
|
||||
from .discharge_types import DownloadEntry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _create_definitive_import_header(
|
||||
db: Session,
|
||||
export_invoice: InvoiceHeader,
|
||||
) -> InvoiceHeader:
|
||||
"""
|
||||
Inserts a new InvoiceHeader of type 'IMD' (Importación Definitiva) cloning
|
||||
the relevant fields from the export invoice.
|
||||
|
||||
Clarion field mapping (EqiFex → EqiFID):
|
||||
FacturaExpo → invoice_number
|
||||
FechaFactura → invoice_date / updated_date
|
||||
TipoCambio → financials.exchange_rate
|
||||
TipoPeso → logistics.weight_type
|
||||
Proveedor → provider_id
|
||||
VendidoConsignado → sold_to_header ('Vendido a:')
|
||||
VendidoA → sold_to_id
|
||||
EnviadoTransferido → shipped_to_header ('Enviado a:')
|
||||
EnviadoA → shipped_to_id
|
||||
AAduanal → customs_broker_id
|
||||
AAduanalAme → customs_broker_us_id
|
||||
Aduana_Cruce → compliance_mx.aduana
|
||||
Cant_Partidas → party_count
|
||||
Transportista → logistics.carrier_id (approx)
|
||||
Incoterm → logistics.incoterm
|
||||
Precinto → logistics.precinto (approx)
|
||||
SubEmpresa → sub_company (approx)
|
||||
Flete / Seguros / etc. → financials.*
|
||||
Observaciones → notes
|
||||
TipoMoneda → financials.currency_type
|
||||
ClaveMoneda → financials.currency (approx)
|
||||
ModTrans → logistics.transport_mode (approx)
|
||||
Ped_Pendiente_Asignar → compliance_mx.is_pedimento_pending
|
||||
PedimentoExpo → compliance_mx.pedimento_id (approx)
|
||||
Remesa → compliance_mx.remesa
|
||||
Estatus → status = PENDING ('NA')
|
||||
TipoDoc → invoice_type = 'IMD'
|
||||
ProvImpoDefCR → 'C' (fixed — always definitiva por cambio de régimen)
|
||||
Sujecion → 'MaqEquipo' (fixed)
|
||||
"""
|
||||
exp = export_invoice
|
||||
exp_fin = exp.financials
|
||||
exp_log = exp.logistics
|
||||
exp_comp = exp.compliance_mx
|
||||
|
||||
def_invoice = InvoiceHeader(
|
||||
tenant_id=exp.tenant_id,
|
||||
company_id=exp.company_id,
|
||||
system=exp.system,
|
||||
operation_type=OperationType.IMPORT,
|
||||
invoice_type="IMD",
|
||||
invoice_number=exp.invoice_number,
|
||||
invoice_date=exp.invoice_date,
|
||||
updated_date=exp.invoice_date,
|
||||
party_count=exp.party_count,
|
||||
generate_id=False,
|
||||
status=InvoiceStatus.PENDING,
|
||||
|
||||
# Clients / providers
|
||||
provider_id=exp.provider_id,
|
||||
sold_to_header="Vendido a:",
|
||||
sold_to_id=exp.sold_to_id,
|
||||
shipped_to_header="Enviado a:",
|
||||
shipped_to_id=exp.shipped_to_id,
|
||||
customs_broker_id=exp.customs_broker_id,
|
||||
customs_broker_us_id=exp.customs_broker_us_id,
|
||||
|
||||
# Notes
|
||||
notes=exp.notes,
|
||||
notes_english=exp.notes_english,
|
||||
)
|
||||
db.add(def_invoice)
|
||||
db.flush() # get def_invoice.id before creating child records
|
||||
|
||||
# ── Financials ────────────────────────────────────────────────────────
|
||||
if exp_fin is not None:
|
||||
from api.v1.modules.a76.invoices.models import InvoiceFinancials
|
||||
def_fin = InvoiceFinancials(
|
||||
tenant_id=exp.tenant_id,
|
||||
company_id=exp.company_id,
|
||||
invoice_id=def_invoice.id,
|
||||
currency=exp_fin.currency,
|
||||
currency_type=exp_fin.currency_type,
|
||||
exchange_rate=exp_fin.exchange_rate,
|
||||
freight=exp_fin.freight,
|
||||
insurance=exp_fin.insurance,
|
||||
insurance_value=exp_fin.insurance_value,
|
||||
packaging=exp_fin.packaging,
|
||||
other_increments=exp_fin.other_increments,
|
||||
)
|
||||
db.add(def_fin)
|
||||
|
||||
# ── Compliance / pedimento ────────────────────────────────────────────
|
||||
if exp_comp is not None:
|
||||
from api.v1.modules.a76.invoices.models import InvoiceComplianceMX
|
||||
def_comp = InvoiceComplianceMX(
|
||||
tenant_id=exp.tenant_id,
|
||||
company_id=exp.company_id,
|
||||
invoice_id=def_invoice.id,
|
||||
aduana=exp_comp.aduana,
|
||||
remesa=exp_comp.remesa,
|
||||
pedimento_id=exp_comp.pedimento_id,
|
||||
is_pedimento_pending=exp_comp.is_pedimento_pending,
|
||||
)
|
||||
db.add(def_comp)
|
||||
|
||||
# ── Logistics ─────────────────────────────────────────────────────────
|
||||
if exp_log is not None:
|
||||
from api.v1.modules.a76.invoices.models import InvoiceLogistics
|
||||
def_log = InvoiceLogistics(
|
||||
tenant_id=exp.tenant_id,
|
||||
company_id=exp.company_id,
|
||||
invoice_id=def_invoice.id,
|
||||
weight_type=exp_log.weight_type,
|
||||
incoterm=exp_log.incoterm,
|
||||
)
|
||||
db.add(def_log)
|
||||
|
||||
db.flush()
|
||||
return def_invoice
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_definitive_import(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
errors: ErrorCollector,
|
||||
) -> InvoiceHeader | None:
|
||||
"""
|
||||
GENERAIMPODEFINITIVA
|
||||
Creates a definitive import invoice header from ``invoice`` (export) when
|
||||
processing a regime-change export.
|
||||
|
||||
If a definitive import with the same ``invoice_number`` already exists,
|
||||
the function is a no-op and returns the existing record.
|
||||
|
||||
After creating (or finding) the header, delegates to the lines sub-routine:
|
||||
generate_desc_parties == 'Todas' → TODO: GENERAIMPODEFINITIVA_PARTIDAS_TODAS
|
||||
otherwise → TODO: GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : active SQLAlchemy session
|
||||
invoice : the export invoice being processed (regime-change type)
|
||||
errors : shared error collector
|
||||
|
||||
Returns
|
||||
-------
|
||||
The existing or newly created definitive import InvoiceHeader, or None if
|
||||
a non-blocking SQL error occurred.
|
||||
"""
|
||||
# ── 1. Check whether the definitive import already exists ────────────────
|
||||
existing: InvoiceHeader | None = db.execute(
|
||||
select(InvoiceHeader).where(
|
||||
InvoiceHeader.tenant_id == invoice.tenant_id,
|
||||
InvoiceHeader.company_id == invoice.company_id,
|
||||
InvoiceHeader.invoice_number == invoice.invoice_number,
|
||||
InvoiceHeader.invoice_type == "IMD",
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
def_invoice = existing
|
||||
else:
|
||||
# ── 2. Create the definitiva header ──────────────────────────────────
|
||||
def_invoice = _create_definitive_import_header(db, invoice)
|
||||
|
||||
# ── 3. Generate lines ────────────────────────────────────────────────────
|
||||
# to_discharge / all_lines must be passed by the caller after this returns.
|
||||
# See generate_definitive_import_all_lines() and
|
||||
# generate_definitive_import_discharged_lines() below.
|
||||
|
||||
return def_invoice
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helper — copies one export line into the definitive import invoice
|
||||
# (EqiPex → EqiPdf, identical body in both PARTIDAS_CON_DESCARGA and
|
||||
# PARTIDAS_TODAS Clarion routines)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _copy_line_to_definitive(
|
||||
db: Session,
|
||||
export_line: LineItem,
|
||||
def_invoice: InvoiceHeader,
|
||||
def_line_number: int,
|
||||
) -> None:
|
||||
"""
|
||||
Copies a single export LineItem (and its series) into a new definitive
|
||||
import LineItem under ``def_invoice``.
|
||||
|
||||
Clarion fixed values:
|
||||
EsSubPartida = 'P' → is_subitem = False
|
||||
ContieneSubP = 'N' → contains_subitems = False
|
||||
SubPartida = 0 → subitem_number = 0
|
||||
EsReparacion = 0 → (no repair flag needed)
|
||||
"""
|
||||
from api.v1.modules.a76.items.line_financials.models import LineFinancial
|
||||
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
|
||||
|
||||
def_line = LineItem(
|
||||
tenant_id=def_invoice.tenant_id,
|
||||
company_id=def_invoice.company_id,
|
||||
invoice_id=def_invoice.id,
|
||||
line_number=def_line_number,
|
||||
part_number_id=export_line.part_number_id,
|
||||
class_id=export_line.class_id,
|
||||
unit_of_measure=export_line.unit_of_measure,
|
||||
)
|
||||
db.add(def_line)
|
||||
db.flush() # get def_line.id
|
||||
|
||||
if export_line.quantity:
|
||||
src_q = export_line.quantity
|
||||
db.add(LineQuantity(
|
||||
item_line_id=def_line.id,
|
||||
quantity=src_q.quantity,
|
||||
net_weight=src_q.net_weight,
|
||||
gross_weight=src_q.gross_weight,
|
||||
package_quantity=src_q.package_quantity,
|
||||
package_id=src_q.package_id,
|
||||
))
|
||||
|
||||
if export_line.financial:
|
||||
db.add(LineFinancial(
|
||||
item_line_id=def_line.id,
|
||||
unit_cost_capture=export_line.financial.unit_cost_capture,
|
||||
))
|
||||
|
||||
if export_line.customs:
|
||||
src_c = export_line.customs
|
||||
db.add(LineCustom(
|
||||
item_line_id=def_line.id,
|
||||
fraction=src_c.fraction,
|
||||
fraction_type=src_c.fraction_type,
|
||||
rate=src_c.rate,
|
||||
sector=src_c.sector,
|
||||
origin_country=src_c.origin_country,
|
||||
))
|
||||
|
||||
if export_line.description:
|
||||
src_d = export_line.description
|
||||
db.add(LineDescription(
|
||||
item_line_id=def_line.id,
|
||||
description_spanish=src_d.description_spanish,
|
||||
extra_description=src_d.extra_description,
|
||||
description_english=src_d.description_english,
|
||||
package_description=src_d.package_description,
|
||||
brand=src_d.brand,
|
||||
model=src_d.model,
|
||||
has_serial=src_d.has_serial,
|
||||
))
|
||||
|
||||
db.add(FaLineItem(
|
||||
item_line_id=def_line.id,
|
||||
is_subitem=False,
|
||||
contains_subitems=False,
|
||||
subitem_number=0,
|
||||
))
|
||||
|
||||
# Series: QSeriesExpo → QSeriesDef
|
||||
export_series: list[Serie] = (
|
||||
db.execute(select(Serie).where(Serie.line_item_id == export_line.id))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for serie in export_series:
|
||||
db.add(Serie(
|
||||
tenant_id=def_invoice.tenant_id,
|
||||
company_id=def_invoice.company_id,
|
||||
line_item_id=def_line.id,
|
||||
row=serie.row,
|
||||
serial_numbers=serie.serial_numbers, # SerieImpo ← SerieExpo
|
||||
model=serie.model, # ModeloImpo ← ModeloExpo
|
||||
brand=serie.brand, # ParteImpo ← ParteExpo
|
||||
))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_definitive_import_discharged_lines(
|
||||
db: Session,
|
||||
export_invoice: InvoiceHeader,
|
||||
def_invoice: InvoiceHeader,
|
||||
to_discharge: list[DownloadEntry],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA
|
||||
Creates definitive import lines only for the lines in the discharge list,
|
||||
sorted by (import_invoice, import_line).
|
||||
|
||||
Clarion: Sort(QueADescargar, FacturaImpo, LineaImpo) → loop
|
||||
"""
|
||||
sorted_entries = sorted(
|
||||
to_discharge,
|
||||
key=lambda e: (e.import_invoice, e.import_line),
|
||||
)
|
||||
def_line_number = 0
|
||||
for entry in sorted_entries:
|
||||
export_line: LineItem | None = db.get(LineItem, entry.line_item_id)
|
||||
if export_line is None:
|
||||
continue
|
||||
def_line_number += 1
|
||||
_copy_line_to_definitive(db, export_line, def_invoice, def_line_number)
|
||||
|
||||
db.flush()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GENERAIMPODEFINITIVA_PARTIDAS_TODAS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_definitive_import_all_lines(
|
||||
db: Session,
|
||||
export_invoice: InvoiceHeader,
|
||||
def_invoice: InvoiceHeader,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
GENERAIMPODEFINITIVA_PARTIDAS_TODAS
|
||||
Creates definitive import lines for ALL lines of the export invoice,
|
||||
sorted by line_number (QueuePartidaID sorted by LineaExpo).
|
||||
|
||||
Clarion: Sort(QueuePartidaID, LineaExpo) → loop over all export lines
|
||||
"""
|
||||
export_lines: list[LineItem] = (
|
||||
db.execute(
|
||||
select(LineItem)
|
||||
.where(LineItem.invoice_id == export_invoice.id)
|
||||
.order_by(LineItem.line_number)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
def_line_number = 0
|
||||
for export_line in export_lines:
|
||||
def_line_number += 1
|
||||
_copy_line_to_definitive(db, export_line, def_invoice, def_line_number)
|
||||
|
||||
db.flush()
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
REGISTRA_DESCARGA_SERIES
|
||||
Marks each import serie that is being discharged by this export invoice by
|
||||
setting ``discharge = True`` (Clarion: ``SerImp:SerieExpo = 1``).
|
||||
|
||||
The routine iterates over every export serie with ``discharge = True``,
|
||||
resolves the corresponding import serie (via ``import_serie_row`` or serial
|
||||
number lookup), and flags it as exported so it cannot be discharged again.
|
||||
|
||||
Clarion mapping
|
||||
---------------
|
||||
QueueSeries records (SerDes) → export Serie rows with discharge=True,
|
||||
grouped by DownloadEntry
|
||||
SerDes:ConsectivoImpo → import InvoiceHeader.id (via invoice_number)
|
||||
SerDes:LineaImpo → import LineItem.line_number
|
||||
SerDes:Renglon → Serie.row on the import side
|
||||
SerDes:Procedencia → entry.origin_procedure ('TEM' | 'DEF')
|
||||
SerImp/SerDef:SerieExpo = 1 → import_serie.discharge = True
|
||||
|
||||
Legacy equivalent
|
||||
-----------------
|
||||
Sort(QueueSeries, -Procedencia, ConsectivoImpo, LineaImpo)
|
||||
Loop: GET import serie by (Consecutivo, LineaImpo, Renglon) → set SerieExpo=1
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from api.v1.modules.a76.items.series.models import Serie
|
||||
from .discharge_types import DownloadEntry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_import_invoice(
|
||||
db: Session,
|
||||
invoice_number: str,
|
||||
export_invoice: InvoiceHeader,
|
||||
) -> Optional[InvoiceHeader]:
|
||||
return (
|
||||
db.query(InvoiceHeader)
|
||||
.filter(
|
||||
InvoiceHeader.invoice_number == invoice_number,
|
||||
InvoiceHeader.tenant_id == export_invoice.tenant_id,
|
||||
InvoiceHeader.company_id == export_invoice.company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _fetch_import_line_id(
|
||||
db: Session,
|
||||
invoice_id: int,
|
||||
line_number: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
) -> Optional[int]:
|
||||
return db.execute(
|
||||
select(LineItem.id).where(
|
||||
LineItem.invoice_id == invoice_id,
|
||||
LineItem.line_number == line_number,
|
||||
LineItem.tenant_id == tenant_id,
|
||||
LineItem.company_id == company_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _fetch_import_serie(
|
||||
db: Session,
|
||||
import_line_id: int,
|
||||
row: int,
|
||||
) -> Optional[Serie]:
|
||||
"""Fetch the import serie by (line_item_id, row) — equiv. TryFetch PKConsec_Lin_Ren."""
|
||||
return db.execute(
|
||||
select(Serie).where(
|
||||
Serie.line_item_id == import_line_id,
|
||||
Serie.row == row,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _resolve_import_serie_row(
|
||||
db: Session,
|
||||
import_line_id: int,
|
||||
serial_number: str,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Fallback: find the import serie row by matching serial_number when
|
||||
export_serie.serie_row is not set.
|
||||
"""
|
||||
return db.execute(
|
||||
select(Serie.row).where(
|
||||
Serie.line_item_id == import_line_id,
|
||||
Serie.serial_numbers == serial_number,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register_discharge_series(
|
||||
db: Session,
|
||||
export_invoice: InvoiceHeader,
|
||||
to_discharge: List[DownloadEntry],
|
||||
) -> None:
|
||||
"""
|
||||
REGISTRA_DESCARGA_SERIES
|
||||
For every export serie with ``discharge = True`` on each entry in
|
||||
``to_discharge``, locates the matching import serie and marks it as
|
||||
discharged (``discharge = True``).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : active SQLAlchemy session
|
||||
export_invoice : the export invoice being processed
|
||||
to_discharge : list of DownloadEntry records (QueADescargar)
|
||||
"""
|
||||
# Sort mirrors Clarion: Sort(QueueSeries, -Procedencia, ConsectivoImpo, LineaImpo)
|
||||
# Descending procedencia puts 'TEM' before 'DEF' (T > D alphabetically)
|
||||
sorted_entries = sorted(
|
||||
to_discharge,
|
||||
key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
for entry in sorted_entries:
|
||||
if not entry.import_invoice or entry.import_line == 0:
|
||||
continue
|
||||
|
||||
# ── Resolve import invoice and line ───────────────────────────────────
|
||||
import_invoice = _fetch_import_invoice(db, entry.import_invoice, export_invoice)
|
||||
if import_invoice is None:
|
||||
continue
|
||||
|
||||
import_line_id = _fetch_import_line_id(
|
||||
db,
|
||||
import_invoice.id,
|
||||
entry.import_line,
|
||||
export_invoice.tenant_id,
|
||||
export_invoice.company_id,
|
||||
)
|
||||
if import_line_id is None:
|
||||
continue
|
||||
|
||||
# ── Fetch all export series for this discharge line ───────────────────
|
||||
export_series: List[Serie] = (
|
||||
db.execute(
|
||||
select(Serie).where(
|
||||
Serie.line_item_id == entry.line_item_id,
|
||||
Serie.discharge == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
for export_serie in export_series:
|
||||
# Resolve which row in the import series table this corresponds to
|
||||
import_row = export_serie.serie_row
|
||||
if import_row is None:
|
||||
import_row = _resolve_import_serie_row(
|
||||
db, import_line_id, export_serie.serial_numbers or ""
|
||||
)
|
||||
|
||||
if import_row is None:
|
||||
continue
|
||||
|
||||
import_serie = _fetch_import_serie(db, import_line_id, import_row)
|
||||
if import_serie is None:
|
||||
continue
|
||||
|
||||
# SerImp:SerieExpo = 1 (or SerDef:SerieExpo = 1)
|
||||
import_serie.discharge = True
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
REGISTRA_DESCARGA_IMPORTACION
|
||||
Updates the import line items (temporary or definitive) with the returned
|
||||
quantities and values consumed by this export invoice.
|
||||
|
||||
For each entry in ``to_discharge`` (QSaldoActual in the legacy) the routine:
|
||||
· Looks up the source import invoice header (TEM → QFacImp, DEF → QFacImpDef).
|
||||
· Looks up the corresponding import line item.
|
||||
· Increments quantity_returned, value_returned_mxn, value_returned_usd on the
|
||||
import line's quantity/financial sub-records.
|
||||
· For TEM invoices, also calculates vat_used_mxn / vat_used_usd when the
|
||||
import invoice date is on or after 2014-12-31 (Clarion date 78165).
|
||||
|
||||
Legacy equivalent
|
||||
-----------------
|
||||
Loop QSaldoActual:
|
||||
If TEM → fetch QFacImp + QEqiMaq, update CantRetornada, ValorRetornadoMN/ME,
|
||||
ValorIVAMNUsado / ValorIVAMEUsado
|
||||
Else → fetch QFacImpDef + QEqiDef, update CantRetornada, ValorRetornadoMN/ME
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from .discharge_types import DownloadEntry
|
||||
|
||||
# Cutoff date: Clarion day 78165 ≈ 2014-12-31
|
||||
_VAT_CUTOFF = datetime.date(2014, 12, 31)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_import_invoice(
|
||||
db: Session,
|
||||
invoice_number: str,
|
||||
export_invoice: InvoiceHeader,
|
||||
) -> Optional[InvoiceHeader]:
|
||||
"""Return the import InvoiceHeader that matches *invoice_number*."""
|
||||
return (
|
||||
db.query(InvoiceHeader)
|
||||
.filter(
|
||||
InvoiceHeader.invoice_number == invoice_number,
|
||||
InvoiceHeader.tenant_id == export_invoice.tenant_id,
|
||||
InvoiceHeader.company_id == export_invoice.company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _fetch_import_line(
|
||||
db: Session,
|
||||
invoice_id: int,
|
||||
line_number: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
) -> Optional[LineItem]:
|
||||
"""Return the LineItem for *invoice_id* / *line_number*, with financial and quantity loaded."""
|
||||
return (
|
||||
db.query(LineItem)
|
||||
.options(
|
||||
joinedload(LineItem.quantity),
|
||||
joinedload(LineItem.financial),
|
||||
)
|
||||
.filter(
|
||||
LineItem.invoice_id == invoice_id,
|
||||
LineItem.line_number == line_number,
|
||||
LineItem.tenant_id == tenant_id,
|
||||
LineItem.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register_import_discharge(
|
||||
db: Session,
|
||||
export_invoice: InvoiceHeader,
|
||||
to_discharge: List[DownloadEntry],
|
||||
) -> None:
|
||||
"""
|
||||
REGISTRA_DESCARGA_IMPORTACION
|
||||
Accumulates discharged quantities and values back onto the source import
|
||||
line items (temporal or definitive).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : active SQLAlchemy session
|
||||
export_invoice : the export invoice being processed
|
||||
to_discharge : list of DownloadEntry records (QSaldoActual equivalent);
|
||||
each entry's ``quantity`` holds the amount consumed
|
||||
(QSaldo:CantUsada in the legacy).
|
||||
"""
|
||||
# Sort mirrors the Clarion: Sort(QSaldoActual, -Procedencia, FacturaImpo, LineaImpo)
|
||||
# (descending procedencia puts 'TEM' before 'DEF' alphabetically reversed)
|
||||
sorted_entries = sorted(
|
||||
to_discharge,
|
||||
key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line),
|
||||
reverse=False,
|
||||
)
|
||||
|
||||
for entry in sorted_entries:
|
||||
qty_used = entry.quantity # CantUsada — full entry qty consumed
|
||||
|
||||
if not entry.import_invoice or entry.import_line == 0:
|
||||
continue
|
||||
|
||||
# ── Fetch source import invoice header ───────────────────────────────
|
||||
import_invoice = _fetch_import_invoice(db, entry.import_invoice, export_invoice)
|
||||
if import_invoice is None:
|
||||
continue
|
||||
|
||||
# ── Fetch source import line item ─────────────────────────────────────
|
||||
import_line = _fetch_import_line(
|
||||
db,
|
||||
import_invoice.id,
|
||||
entry.import_line,
|
||||
export_invoice.tenant_id,
|
||||
export_invoice.company_id,
|
||||
)
|
||||
if import_line is None:
|
||||
continue
|
||||
|
||||
# ── Calculate proportional values ─────────────────────────────────────
|
||||
# value_returned = qty_used * (line_value / line_qty)
|
||||
fin = import_line.financial
|
||||
qty_rec = import_line.quantity
|
||||
|
||||
if fin is None or qty_rec is None:
|
||||
continue
|
||||
|
||||
original_qty = qty_rec.quantity or Decimal(0)
|
||||
if original_qty == 0:
|
||||
continue
|
||||
|
||||
value_mn = Decimal(str(fin.value_mxn or 0))
|
||||
value_usd = Decimal(str(fin.value_usd or 0))
|
||||
|
||||
returned_mn = qty_used * value_mn / original_qty
|
||||
returned_usd = qty_used * value_usd / original_qty
|
||||
|
||||
# ── Accumulate returned qty and value ─────────────────────────────────
|
||||
qty_rec.quantity_returned = (qty_rec.quantity_returned or Decimal(0)) + qty_used
|
||||
|
||||
fin.value_returned_mxn = (fin.value_returned_mxn or Decimal(0)) + returned_mn
|
||||
fin.value_returned_usd = (fin.value_returned_usd or Decimal(0)) + returned_usd
|
||||
|
||||
# ── VAT used (TEM only, and only for invoices on/after cutoff date) ───
|
||||
# Clarion: IF EqiFim:FechaFactura > 78165 (≈ 2014-12-31)
|
||||
if entry.origin_procedure == "TEM":
|
||||
inv_date = import_invoice.invoice_date
|
||||
if isinstance(inv_date, datetime.datetime):
|
||||
inv_date = inv_date.date()
|
||||
|
||||
if inv_date and inv_date >= _VAT_CUTOFF:
|
||||
iva_factor = Decimal(0)
|
||||
if import_invoice.financials and import_invoice.financials.iva_factor:
|
||||
iva_factor = Decimal(str(import_invoice.financials.iva_factor))
|
||||
|
||||
fin.vat_used_mxn = (returned_mn * iva_factor) / 100
|
||||
fin.vat_used_usd = (returned_usd * iva_factor) / 100
|
||||
else:
|
||||
fin.vat_used_mxn = Decimal(0)
|
||||
fin.vat_used_usd = Decimal(0)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
REVISA_CLASE
|
||||
Validates that every line item of an export invoice has a valid class in the
|
||||
catalog (QClaAct) and that the associated tariff fraction exists in either
|
||||
the active fractions catalog (SFracciones) or the historical catalog
|
||||
(GFraccionesHistorico).
|
||||
|
||||
Two-pass logic (ported from legacy SCAII):
|
||||
Pass A – no class errors:
|
||||
Iterate all lines and validate their fractions.
|
||||
Pass B – class errors detected:
|
||||
Report each missing class and also validate its fraction.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.classes.models import Class
|
||||
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import (
|
||||
HistoricalTariffFraction,
|
||||
)
|
||||
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fraction helpers (shared with imports, same catalog sources)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fraction_exists_via_sitar(fraction_code: str) -> bool:
|
||||
"""
|
||||
Returns True when the fraction exists in SITAR (active or anterior).
|
||||
Falls back to False when SITAR is not configured or unreachable.
|
||||
|
||||
Format: first 8 chars = base fraction, chars 9-10 = NICO/country (optional).
|
||||
"""
|
||||
if not fraction_code:
|
||||
return True
|
||||
|
||||
base_frac = fraction_code[:8].strip()
|
||||
nico = fraction_code[8:10].strip() if len(fraction_code) > 8 else ""
|
||||
|
||||
try:
|
||||
from api.v1.modules.sitar.fracciones.service import FraccionesService
|
||||
from api.v1.modules.sitar.fracciones_anteriores.service import (
|
||||
FraccionesAnterioresService,
|
||||
)
|
||||
|
||||
results = FraccionesService.search_sync(
|
||||
fraccion=base_frac,
|
||||
nico=nico if nico else None,
|
||||
limit=1,
|
||||
)
|
||||
if results:
|
||||
return True
|
||||
|
||||
hist_results = FraccionesAnterioresService.search_sync(
|
||||
fraccion_anterior=base_frac,
|
||||
limit=1,
|
||||
)
|
||||
return len(hist_results) > 0
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _fraction_exists_in_local_db(db: Session, fraction_code: str) -> bool:
|
||||
"""Fallback: validates against local TariffFraction and HistoricalTariffFraction tables."""
|
||||
if not fraction_code:
|
||||
return True
|
||||
|
||||
base_frac = fraction_code[:8]
|
||||
nico = fraction_code[8:10] if len(fraction_code) > 8 else ""
|
||||
|
||||
tariff_q = db.query(TariffFraction).filter(
|
||||
func.left(TariffFraction.code, 8) == base_frac
|
||||
)
|
||||
if nico:
|
||||
tariff_q = tariff_q.filter(TariffFraction.nico == nico)
|
||||
else:
|
||||
tariff_q = tariff_q.filter(
|
||||
or_(TariffFraction.nico.is_(None), TariffFraction.nico == "")
|
||||
)
|
||||
if tariff_q.first() is not None:
|
||||
return True
|
||||
|
||||
hist_q = db.query(HistoricalTariffFraction).filter(
|
||||
HistoricalTariffFraction.historical_fraction == base_frac
|
||||
)
|
||||
if nico:
|
||||
hist_q = hist_q.filter(HistoricalTariffFraction.country == nico)
|
||||
else:
|
||||
hist_q = hist_q.filter(
|
||||
or_(
|
||||
HistoricalTariffFraction.country.is_(None),
|
||||
HistoricalTariffFraction.country == "",
|
||||
)
|
||||
)
|
||||
return hist_q.first() is not None
|
||||
|
||||
|
||||
def _fraction_exists_in_catalog(db: Session, fraction_code: str) -> bool:
|
||||
"""SITAR first, local DB as fallback."""
|
||||
if _fraction_exists_via_sitar(fraction_code):
|
||||
return True
|
||||
return _fraction_exists_in_local_db(db, fraction_code)
|
||||
|
||||
|
||||
def _validate_line_fraction(
|
||||
db: Session,
|
||||
line: LineItem,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""Adds a FRACCION error when the line's export fraction is not in any catalog."""
|
||||
fraction = line.customs.fraction if line.customs else None
|
||||
if not fraction:
|
||||
return
|
||||
|
||||
if _fraction_exists_in_catalog(db, fraction):
|
||||
return
|
||||
|
||||
class_code = line.class_info.class_code if line.class_info else ""
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].fraction",
|
||||
message=(
|
||||
f"La Factura contiene la fraccion: {fraction} asociada al Clase {class_code} "
|
||||
"que no existe en el catálogo de fracciones"
|
||||
),
|
||||
solution=["Agregar la fracción a fracciones históricas."],
|
||||
code="FRACCION",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def review_class(
|
||||
db: Session,
|
||||
lines: List[LineItem],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
Validates class and fraction integrity for all line items of an export invoice.
|
||||
|
||||
Logic ported from legacy REVISA_CLASE:
|
||||
|
||||
1. Count line items whose class_id is not present in the active classes catalog.
|
||||
2a. No class errors (TotalReg = 0):
|
||||
Iterate every line and validate its tariff fraction.
|
||||
2b. Class errors found (TotalReg > 0):
|
||||
For each invalid line: report a CLASE error, then validate its fraction.
|
||||
"""
|
||||
invalid_class_lines = [
|
||||
line for line in lines
|
||||
if line.class_id is None or db.get(Class, line.class_id) is None
|
||||
]
|
||||
has_class_errors = bool(invalid_class_lines)
|
||||
|
||||
if not has_class_errors:
|
||||
# Pass A: all classes exist — validate fractions for every line
|
||||
for line in lines:
|
||||
_validate_line_fraction(db, line, errors)
|
||||
else:
|
||||
# Pass B: report missing classes and validate their fractions
|
||||
for line in invalid_class_lines:
|
||||
class_code = line.class_info.class_code if line.class_info else ""
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].class",
|
||||
message=f"La clase: {class_code or '(vacía)'} no existe en catálogo de clases",
|
||||
solution=[
|
||||
f"Borrar la partida: {line.line_number}, "
|
||||
f"o dar de alta la clase: {class_code or '(vacía)'} en el catálogo de Clases"
|
||||
],
|
||||
code="CLASE",
|
||||
)
|
||||
_validate_line_fraction(db, line, errors)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
REVISA_TIPOCAMBIO
|
||||
Validates that the exchange rate captured on the export invoice matches the
|
||||
rate registered in the exchange-rate catalogue for the invoice date.
|
||||
|
||||
Only runs when SisGen:CalValBaseTCPedExpo = 0 (use invoice-date TC, not
|
||||
pedimento-payment-date TC). When the flag is 1 the TC is taken from the
|
||||
pedimento and this check is skipped — that branch is handled in the TODO
|
||||
for step 7 of main_process.
|
||||
|
||||
Clarion mapping
|
||||
---------------
|
||||
gtipocambio → a76.exchange_rate (ExchangeRate model)
|
||||
FECHA → ExchangeRate.date (cast to DATE for comparison)
|
||||
VALOR → ExchangeRate.value
|
||||
EqiFex:FechaFactura → invoice.invoice_date
|
||||
EqiFex:TipoCambio → invoice.financials.exchange_rate
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import cast, Date, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
|
||||
def review_exchange_rate(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
REVISA_TIPOCAMBIO
|
||||
Checks that the invoice's exchange rate matches the catalogue value for
|
||||
the invoice date.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : active SQLAlchemy session
|
||||
invoice : the export invoice being processed
|
||||
errors : shared error collector
|
||||
"""
|
||||
# TODO: skip when SisGen:CalValBaseTCPedExpo = 1
|
||||
# (TC is taken from pedimento payment date, validated elsewhere)
|
||||
|
||||
if not invoice.financials:
|
||||
return
|
||||
|
||||
invoice_date: datetime.date = (
|
||||
invoice.invoice_date.date()
|
||||
if hasattr(invoice.invoice_date, "date")
|
||||
else invoice.invoice_date
|
||||
)
|
||||
|
||||
# Look up the catalogue rate for the invoice date
|
||||
catalogue_rate: Optional[ExchangeRate] = db.execute(
|
||||
select(ExchangeRate).where(
|
||||
ExchangeRate.tenant_id == invoice.tenant_id,
|
||||
ExchangeRate.company_id == invoice.company_id,
|
||||
cast(ExchangeRate.date, Date) == invoice_date,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if catalogue_rate is None:
|
||||
# No rate registered for this date — cannot validate, skip
|
||||
# (the Clarion loop simply finds no rows and exits cleanly)
|
||||
return
|
||||
|
||||
invoice_tc = Decimal(str(invoice.financials.exchange_rate or 0))
|
||||
catalogue_tc = Decimal(str(catalogue_rate.value or 0))
|
||||
|
||||
if invoice_tc != catalogue_tc:
|
||||
errors.add_error(
|
||||
field="financials.exchange_rate",
|
||||
message="No está capturado correctamente el Tipo de Cambio.",
|
||||
solution=[
|
||||
"Capture o modifique el tipo de cambio que corresponda a la "
|
||||
"factura en el catálogo de Tipo de Cambio."
|
||||
],
|
||||
code="EXCHANGE_RATE_MISMATCH",
|
||||
value={
|
||||
"invoice_date": str(invoice_date),
|
||||
"invoice_tc": str(invoice_tc),
|
||||
"catalogue_tc": str(catalogue_tc),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
TOT_PAR_LIM_CANT_PESO / TOT_PAR_LIM_VALOR
|
||||
Computes invoice-level totals (quantity, net weight, capture value) from all
|
||||
line items and writes them back to the invoice financials.
|
||||
|
||||
These totals are used downstream to enforce the SisExp limit parameters
|
||||
(CantLimite, PesoLimite, ValorLimite — TODO when SisExp model is available).
|
||||
|
||||
Legacy equivalents
|
||||
------------------
|
||||
TOT_PAR_LIM_CANT_PESO:
|
||||
SELECT SUM(CantExpo), SUM(PesoNeto)
|
||||
FROM QEqeMaq
|
||||
WHERE Consecutivo = <invoice.id>
|
||||
→ stored in Loc:CantExpoLim, Loc:PesoNetoLim
|
||||
|
||||
TOT_PAR_LIM_VALOR:
|
||||
SELECT SUM(CostoUnitarioCaptura * CantExpo)
|
||||
FROM QEqeMaq
|
||||
WHERE Consecutivo = <invoice.id>
|
||||
→ stored in Loc:ValorExpoLim
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import List
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
|
||||
def limit_weight(
|
||||
lines: List[LineItem],
|
||||
) -> tuple[Decimal, Decimal]:
|
||||
"""
|
||||
TOT_PAR_LIM_CANT_PESO
|
||||
Sums exported quantity and net weight across all line items and Returns the totals.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(total_quantity, total_net_weight)
|
||||
after the call.
|
||||
"""
|
||||
total_qty = Decimal(0)
|
||||
total_net_weight = Decimal(0)
|
||||
|
||||
for line in lines:
|
||||
if line.quantity is None:
|
||||
continue
|
||||
total_qty += line.quantity.quantity or Decimal(0)
|
||||
total_net_weight += line.quantity.net_weight or Decimal(0)
|
||||
|
||||
return total_qty, total_net_weight
|
||||
|
||||
|
||||
def limit_value(
|
||||
lines: List[LineItem],
|
||||
) -> Decimal:
|
||||
"""
|
||||
TOT_PAR_LIM_VALOR
|
||||
Sums (unit_cost_capture × quantity) across all line items and writes the
|
||||
result to ``invoice.financials.value_mn`` as the capture-based total value.
|
||||
|
||||
Returns
|
||||
-------
|
||||
total_capture_value — also available on invoice.financials after the call.
|
||||
|
||||
Note: the legacy field Loc:ValorExpoLim is only used to compare against
|
||||
SisExp limit parameters (TODO when SisExp model is available).
|
||||
"""
|
||||
total_value = Decimal(0)
|
||||
|
||||
for line in lines:
|
||||
if line.financial is None or line.quantity is None:
|
||||
continue
|
||||
capture = line.financial.unit_cost_capture or Decimal(0)
|
||||
qty = line.quantity.quantity or Decimal(0)
|
||||
total_value += capture * qty
|
||||
|
||||
return total_value
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
REVISA_PROCEDENCIA_PAR
|
||||
Validates that every line item of the export invoice has an import origin
|
||||
procedure (TipoMovImpo) that matches what the invoice type requires:
|
||||
|
||||
· Regime-change (AFIJO / SCRAP with EsCambioRegimen='S') → all lines must be 'TEM'
|
||||
· REEXP / VEMEX → all lines must be 'DEF'
|
||||
|
||||
Clarion mapping
|
||||
---------------
|
||||
Loc:Procedencia → expected_procedure parameter ('TEM' | 'DEF')
|
||||
GSQLFile2.SQL2:C2 → line.customs.origin_procedure
|
||||
GSQLFile2.SQL2:C1 → line.line_number
|
||||
EqiFex:TipoFactura → invoice.invoice_type
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
|
||||
def review_origin_procedure(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
lines: List[LineItem],
|
||||
expected_procedure: str,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
REVISA_PROCEDENCIA_PAR
|
||||
Verifies that every export line's import origin procedure matches
|
||||
``expected_procedure``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : active SQLAlchemy session
|
||||
invoice : the export invoice being processed
|
||||
lines : all line items of the invoice
|
||||
expected_procedure : 'TEM' (regime-change) or 'DEF' (REEXP / VEMEX)
|
||||
errors : shared error collector
|
||||
"""
|
||||
expected = expected_procedure.strip().upper()
|
||||
|
||||
for line in lines:
|
||||
line_procedure = (
|
||||
(line.customs.origin_procedure or "").strip().upper()
|
||||
if line.customs
|
||||
else ""
|
||||
)
|
||||
|
||||
if line_procedure != expected:
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].origin_procedure",
|
||||
message=(
|
||||
f"La partida: {line.line_number} tiene una factura de importación "
|
||||
f"de procedencia: '{line_procedure}', diferente a la que acepta el "
|
||||
f"Tipo de Factura: '{invoice.invoice_type}'."
|
||||
),
|
||||
solution=[
|
||||
"Para Cambio de Régimen todo debe ser procedencia TEM, "
|
||||
"para Ventas y Reexpediciones debe ser procedencia DEF."
|
||||
],
|
||||
code="INVALID_ORIGIN_PROCEDURE",
|
||||
value={
|
||||
"line_number": line.line_number,
|
||||
"found": line_procedure,
|
||||
"expected": expected,
|
||||
"invoice_type": invoice.invoice_type,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
REVISA_CANT_vs_PESONETO_KGS / REVISA_CANT_vs_PESONETO_LBS
|
||||
Validates that the net weight of each export line matches its quantity
|
||||
when the line's unit of measure is weight-based (KGS or LBS).
|
||||
|
||||
Rule (identical for both variants, only the unit differs):
|
||||
- KGS: if UnitOfMeasure = 'KGS' → net_weight_kgs must equal quantity
|
||||
- LBS: if UnitOfMeasure = 'LBS' → net_weight_lbs must equal quantity
|
||||
|
||||
Legacy equivalents
|
||||
------------------
|
||||
KGS:
|
||||
SELECT COUNT(*) FROM QEqeMaq
|
||||
WHERE Consecutivo = <id> AND UnidadMedida = 'KGS' AND PesoNetoKGS <> CantExpo
|
||||
|
||||
LBS:
|
||||
SELECT COUNT(*) FROM QEqeMaq
|
||||
WHERE Consecutivo = <id> AND UnidadMedida = 'LBS' AND PesoNetoLBS <> CantExpo
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
_UNIT_KGS = "KGS"
|
||||
_UNIT_LBS = "LBS"
|
||||
|
||||
|
||||
def review_qty_vs_weight(
|
||||
lines: List[LineItem],
|
||||
unit_code: str,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
Generic validator used by both KGS and LBS variants.
|
||||
|
||||
For every line whose unit of measure code matches ``unit_code``,
|
||||
checks that the exported quantity equals the exported quantity. Adds a PESO_NETO error for each mismatch.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
lines : all LineItem rows for the invoice
|
||||
unit_code : 'KGS' or 'LBS' — only lines with this UOM are evaluated
|
||||
errors : collector for validation errors
|
||||
"""
|
||||
for line in lines:
|
||||
uom = line.unit_of_measure_info
|
||||
if uom is None:
|
||||
continue
|
||||
|
||||
line_uom_code = (uom.code or "").strip().upper()
|
||||
if line_uom_code != unit_code:
|
||||
continue
|
||||
|
||||
if line.quantity is None:
|
||||
continue
|
||||
|
||||
qty = line.quantity.quantity or Decimal(0)
|
||||
net_weight = getattr(line.quantity.quantity, None) or Decimal(0)
|
||||
|
||||
if net_weight != qty:
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].quantity",
|
||||
message=f"La cantidad es de {qty} {unit_code} y el Peso Neto es de {net_weight} {unit_code}.",
|
||||
solution=["Igualar el Peso Neto con la cantidad a Exportar."],
|
||||
code="PESO_NETO",
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
REVISA_COSTOS_0
|
||||
Validates that every principal line item (non sub-item) of an export invoice
|
||||
has a unit cost greater than zero.
|
||||
|
||||
A zero unit cost on a principal line is an error because values and customs
|
||||
declarations cannot be computed without it.
|
||||
|
||||
Legacy equivalent
|
||||
-----------------
|
||||
SELECT COUNT(*) FROM QEqeMaq EqiPex
|
||||
WHERE EqiPex.Consecutivo = <invoice.id>
|
||||
AND EqiPex.CostoUnitarioCaptura = 0
|
||||
AND EqiPex.EsSubPartida = 'P' -- 'P' = Principal (not a sub-item)
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import List
|
||||
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
|
||||
def review_unit_cost(
|
||||
lines: List[LineItem],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
REVISA_COSTOS_0
|
||||
|
||||
For every principal line (``fa_data.is_subitem`` is False or None) checks
|
||||
that ``financial.unit_cost_capture`` is not zero. Adds a COSTO_CERO error
|
||||
for each offending line.
|
||||
|
||||
Sub-items are skipped because their cost derives from the principal line
|
||||
and may legitimately be zero at this stage.
|
||||
"""
|
||||
for line in lines:
|
||||
# Skip sub-items — EsSubPartida = 'P' means is_subitem is False/None
|
||||
is_subitem = line.fa_data.is_subitem if line.fa_data else False
|
||||
if is_subitem:
|
||||
continue
|
||||
|
||||
unit_cost = (
|
||||
line.financial.unit_cost_capture
|
||||
if line.financial
|
||||
else None
|
||||
)
|
||||
if unit_cost is not None and unit_cost != Decimal(0):
|
||||
continue
|
||||
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].unit_cost_capture",
|
||||
message=f"La partida: {line.line_number} no tiene capturado el costo unitario",
|
||||
solution=[
|
||||
f"Asignar el costo unitario a la partida: {line.line_number}, "
|
||||
"o desactivar el parámetro de En Base al Costo de Captura."
|
||||
],
|
||||
code="COSTO_CERO",
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
REVISA_CANT_SERIES
|
||||
Validates series count against export quantity for each line that carries
|
||||
serial numbers (LlevaSerie = 1 / has_serial = True).
|
||||
|
||||
Rules (ported from legacy SCAII REVISA_CANT_SERIES):
|
||||
1. If the line carries series but no series records exist → error SERIES_VACIAS.
|
||||
2. If SisGen:CantvsCantSeries = 1:
|
||||
a. RFC-exception companies (hardcoded set):
|
||||
- If invoice is a cambio de régimen (is_regime_change): only validate
|
||||
when the line's unit of measure is 'PZA'.
|
||||
- Otherwise: always validate count vs quantity.
|
||||
b. All other companies: always validate count vs quantity.
|
||||
|
||||
Note: the GNiv:CantSerievsCant = 0 block (series > quantity warning) was
|
||||
commented-out in the original Clarion and is therefore not ported.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from api.v1.modules.a76.items.series.models import Serie
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
# TODO: Read SisGen:CantvsCantSeries from the tenant system-config model
|
||||
_SISGEN_CANT_VS_CANT_SERIES: int = 0 # 0 = disabled
|
||||
|
||||
# RFCs where qty-vs-series validation is conditional on UOM = PZA when is_regime_change
|
||||
_RFC_EXCEPCION_PZA = {
|
||||
"IMS030409FZ0",
|
||||
"TOP140430PB6",
|
||||
"AMA7504258K2",
|
||||
"BZG111091T9",
|
||||
}
|
||||
|
||||
|
||||
def _validate_line_series(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
line: LineItem,
|
||||
company_rfc: str,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""Validates series count for a single line that has has_serial = True."""
|
||||
series_count = (
|
||||
db.query(Serie)
|
||||
.filter(Serie.line_item_id == line.id)
|
||||
.count()
|
||||
)
|
||||
|
||||
# Rule 1: series flag active but no series records exist
|
||||
if series_count == 0:
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].series",
|
||||
message="La opción de contiene series esta activada y no existen registros de Series",
|
||||
solution=[
|
||||
"Desactivar la opción de Lleva series o registrar las series a esta partida."
|
||||
],
|
||||
code="SERIES_VACIAS",
|
||||
)
|
||||
return
|
||||
|
||||
# Rule 2: quantity vs series count check (controlled by SisGen flag)
|
||||
# TODO: Replace _SISGEN_CANT_VS_CANT_SERIES with the real config value
|
||||
if _SISGEN_CANT_VS_CANT_SERIES != 1:
|
||||
return
|
||||
|
||||
qty = line.quantity.quantity if line.quantity else None
|
||||
if qty is None:
|
||||
return
|
||||
|
||||
is_regime_change = bool(
|
||||
invoice.compliance_mx and invoice.compliance_mx.is_regime_change
|
||||
)
|
||||
|
||||
uom_code = ""
|
||||
if line.unit_of_measure_info:
|
||||
uom_code = (line.unit_of_measure_info.code or "").strip().upper()
|
||||
|
||||
if company_rfc in _RFC_EXCEPCION_PZA:
|
||||
# RFC-exception: when cambio de régimen only validate for PZA lines
|
||||
if is_regime_change and uom_code != "PZA":
|
||||
return
|
||||
# For all other companies (and exception RFCs without cambio de régimen),
|
||||
# always compare count vs quantity
|
||||
|
||||
if series_count != qty:
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].series",
|
||||
message="La Cantidad de Series No Coincide con la Cantidad de la Partida.",
|
||||
solution=[f"Nivelar las series de la Partida {line.line_number}."],
|
||||
code="SERIES_VS_CANT",
|
||||
)
|
||||
|
||||
|
||||
def review_qty_series(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
lines: List[LineItem],
|
||||
tenant_id: str,
|
||||
company_id: str,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
REVISA_CANT_SERIES
|
||||
Iterates all line items and validates series count for those that carry
|
||||
serial numbers (has_serial = True / LlevaSerie = 1).
|
||||
"""
|
||||
company = db.get(Company, company_id)
|
||||
company_rfc = (company.rfc or "").strip().upper() if company else ""
|
||||
|
||||
for line in lines:
|
||||
if not (line.description and line.description.has_serial):
|
||||
continue
|
||||
|
||||
_validate_line_series(db, invoice, line, company_rfc, errors)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
REVISA_SERIES_EXISTA
|
||||
Verifies that every export series marked for discharge (discharge=True / Marca=1)
|
||||
references a row that actually exists in the corresponding import invoice line.
|
||||
|
||||
Clarion mapping
|
||||
---------------
|
||||
QSeriesExpo → Serie (line_item_id = export LineItem.id)
|
||||
QSeriesImpo → Serie (line_item_id = import LineItem.id, for TEM invoices)
|
||||
QSeriesDef → Serie (line_item_id = import LineItem.id, for DEF invoices)
|
||||
|
||||
SerExpo.Marca = 1 → Serie.discharge = True
|
||||
SerExpo.LineaSerieImpo → Serie.serie_row
|
||||
SerImp.Renglon / SerDef.Renglon → Serie.row (on the import side)
|
||||
|
||||
Logic
|
||||
-----
|
||||
For each export serie with discharge=True on this line, check that
|
||||
``serie_row`` exists as a ``row`` in the series of the referenced
|
||||
import invoice line. If it does not → error.
|
||||
|
||||
The check differs by origin_procedure:
|
||||
TEM → look in import invoice (InvoiceType='TEM')
|
||||
DEF → look in import invoice (InvoiceType='DEF')
|
||||
"""
|
||||
|
||||
from typing import List, Set
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from api.v1.modules.a76.items.series.models import Serie
|
||||
from api.v1.modules.a76.invoices.exports.process.sub_process.discharge_types import (
|
||||
DownloadEntry,
|
||||
)
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
|
||||
def review_series_exist(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
line: LineItem,
|
||||
entry: DownloadEntry,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
REVISA_SERIES_EXISTA
|
||||
Checks that every export serie marked for discharge on ``line`` references
|
||||
an import serie row that actually exists in the import invoice line.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : active SQLAlchemy session
|
||||
invoice : the export invoice being processed
|
||||
line : the export LineItem whose series are being validated
|
||||
entry : the DownloadEntry for this line (provides import_invoice / import_line)
|
||||
errors : shared error collector
|
||||
"""
|
||||
# ── 1. Export series with discharge=True on this line ────────────────────
|
||||
export_series: List[Serie] = (
|
||||
db.execute(
|
||||
select(Serie).where(
|
||||
Serie.line_item_id == line.id,
|
||||
Serie.discharge == True, # noqa: E712 — SQLAlchemy requires ==
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
if not export_series:
|
||||
return
|
||||
|
||||
# ── 2. Resolve the import LineItem ───────────────────────────────────────
|
||||
invoice_type_filter = entry.origin_procedure.upper() # 'TEM' or 'DEF'
|
||||
|
||||
import_line_id: int | None = (
|
||||
db.execute(
|
||||
select(LineItem.id)
|
||||
.join(InvoiceHeader, LineItem.invoice_id == InvoiceHeader.id)
|
||||
.where(
|
||||
InvoiceHeader.tenant_id == invoice.tenant_id,
|
||||
InvoiceHeader.invoice_number == entry.import_invoice,
|
||||
InvoiceHeader.invoice_type == invoice_type_filter,
|
||||
LineItem.line_number == entry.import_line,
|
||||
)
|
||||
)
|
||||
.scalar_one_or_none()
|
||||
)
|
||||
|
||||
if import_line_id is None:
|
||||
# The import line itself was not found — already caught by fill_available_balances,
|
||||
# but add a targeted error here as well.
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].series",
|
||||
message=(
|
||||
f"No se encontró la línea {entry.import_line} de la factura de "
|
||||
f"importación '{entry.import_invoice}' para validar las series."
|
||||
),
|
||||
solution=["Verificar que la factura y línea de importación existen y están procesadas."],
|
||||
code="IMPORT_LINE_NOT_FOUND_FOR_SERIES",
|
||||
)
|
||||
return
|
||||
|
||||
# ── 3. Fetch the set of valid import serie rows ───────────────────────────
|
||||
valid_rows: Set[int] = set(
|
||||
db.execute(
|
||||
select(Serie.row).where(
|
||||
Serie.line_item_id == import_line_id,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
# ── 4. Validate each export serie ────────────────────────────────────────
|
||||
invoice_type_label = (
|
||||
"Impo. Tem." if invoice_type_filter == "TEM" else "Impo. Def."
|
||||
)
|
||||
invoice_type_code = (
|
||||
"FAC_IMPO_TEM" if invoice_type_filter == "TEM" else "FAC_IMPO_DEF"
|
||||
)
|
||||
|
||||
for serie in export_series:
|
||||
ref_row = serie.serie_row
|
||||
|
||||
if ref_row is None or ref_row not in valid_rows:
|
||||
errors.add_error(
|
||||
field=f"line[{line.line_number}].series[{serie.row}]",
|
||||
message=(
|
||||
f"La Línea: {serie.serie_row} "
|
||||
f"(Serie: {serie.serial_numbers or ''}) "
|
||||
f"no existe en la Factura de {invoice_type_label}: "
|
||||
f"'{entry.import_invoice}' con Línea: {entry.import_line}."
|
||||
),
|
||||
solution=[
|
||||
"Capturar un número de Serie que exista en la Factura "
|
||||
"y Línea a Descargar de Importación."
|
||||
],
|
||||
code=invoice_type_code,
|
||||
value={
|
||||
"export_serie_row": serie.row,
|
||||
"serie_row": ref_row,
|
||||
"import_invoice": entry.import_invoice,
|
||||
"import_line": entry.import_line,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
REVISA_SERIES_OTRAS_PAR
|
||||
Verifies that no export serie marked for discharge is already assigned for
|
||||
discharge on a different line of the same export invoice.
|
||||
|
||||
Clarion mapping
|
||||
---------------
|
||||
QueueSeries → ``seen_series: set[tuple]`` passed in from the caller.
|
||||
The set accumulates across all discharge lines so that a serie
|
||||
registered on line 1 is detected as duplicate when line 2 is
|
||||
processed.
|
||||
|
||||
SerExpo (QSeriesExpo) → Serie (line_item_id = export LineItem.id)
|
||||
SerImp (QSeriesImpo) → Serie (line_item_id = import LineItem.id, TEM)
|
||||
SerDef (QSeriesDef) → Serie (line_item_id = import LineItem.id, DEF)
|
||||
|
||||
Key tuple (equivalent to QueueSeries record used for GET/ADD):
|
||||
(export_invoice_number, import_line, serial_number,
|
||||
origin_procedure, serie_row, import_invoice_number)
|
||||
|
||||
Logic
|
||||
-----
|
||||
For each export serie with discharge=True on this line:
|
||||
1. Build the key tuple.
|
||||
2. Resolve ``serie_row`` if blank:
|
||||
TEM → look up the matching row in QSeriesImpo by serial_number
|
||||
DEF → look up the matching row in QSeriesDef by serial_number
|
||||
3. If the key is already in ``seen_series`` → duplicate error.
|
||||
4. Otherwise → add to ``seen_series`` (mark as seen for subsequent lines).
|
||||
"""
|
||||
|
||||
from typing import Optional, Set, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from api.v1.modules.a76.items.series.models import Serie
|
||||
from api.v1.modules.a76.invoices.exports.process.sub_process.discharge_types import (
|
||||
DownloadEntry,
|
||||
)
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
# Type alias for the duplicate-detection key (equiv. to QueueSeries record)
|
||||
_SeriesKey = Tuple[str, int, str, str, Optional[int], str]
|
||||
|
||||
|
||||
def review_series_other_lines(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
line: LineItem,
|
||||
entry: DownloadEntry,
|
||||
seen_series: Set[_SeriesKey],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
REVISA_SERIES_OTRAS_PAR
|
||||
Checks that no export serie on ``line`` (with discharge=True) is already
|
||||
registered for discharge on another line of the same invoice.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : active SQLAlchemy session
|
||||
invoice : the export invoice being processed
|
||||
line : the export LineItem whose series are being validated
|
||||
entry : the DownloadEntry for this line
|
||||
seen_series : mutable set shared across all calls within one invoice
|
||||
processing run — accumulates keys as lines are processed
|
||||
errors : shared error collector
|
||||
"""
|
||||
export_series = (
|
||||
db.execute(
|
||||
select(Serie).where(
|
||||
Serie.line_item_id == line.id,
|
||||
Serie.discharge == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
if not export_series:
|
||||
return
|
||||
|
||||
# Resolve the import line id once (needed for serie row lookup)
|
||||
import_line_id = _resolve_import_line_id(db, invoice, entry)
|
||||
|
||||
for serie in export_series:
|
||||
serial = serie.serial_numbers or ""
|
||||
import_serie_row = serie.serie_row
|
||||
|
||||
# If import_serie_row is not set on the export serie, resolve it from
|
||||
# the import series table by matching serial_number
|
||||
if import_serie_row is None and import_line_id is not None:
|
||||
import_serie_row = _resolve_import_serie_row(
|
||||
db, import_line_id, serial
|
||||
)
|
||||
|
||||
key: _SeriesKey = (
|
||||
invoice.invoice_number or "",
|
||||
entry.import_line,
|
||||
serial,
|
||||
entry.origin_procedure,
|
||||
import_serie_row,
|
||||
entry.import_invoice,
|
||||
)
|
||||
|
||||
if key in seen_series:
|
||||
# Find which export line already claimed this serie
|
||||
existing_line = _find_existing_export_line(
|
||||
db, invoice, line.id, serial, entry
|
||||
)
|
||||
errors.add_error(
|
||||
field=f"line[{entry.export_line}].series[{serie.row}]",
|
||||
message=(
|
||||
f"La Serie: '{serial}' ya fue descargada y está capturada "
|
||||
f"para ser Descargada en la Partida: {existing_line}."
|
||||
),
|
||||
solution=[
|
||||
"Capturar otro número de Serie o capturar el Renglón "
|
||||
"de la Serie de Importación."
|
||||
],
|
||||
code="SERIE_DUPLICATE_DISCHARGE",
|
||||
value={
|
||||
"serial": serial,
|
||||
"export_line": entry.export_line,
|
||||
"conflicting_line": existing_line,
|
||||
},
|
||||
)
|
||||
else:
|
||||
seen_series.add(key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_import_line_id(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
entry: DownloadEntry,
|
||||
) -> Optional[int]:
|
||||
"""Returns the import LineItem.id for the invoice/line referenced by the entry."""
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader as IH
|
||||
from api.v1.modules.a76.items.models import LineItem as LI
|
||||
|
||||
return db.execute(
|
||||
select(LI.id)
|
||||
.join(IH, LI.invoice_id == IH.id)
|
||||
.where(
|
||||
IH.tenant_id == invoice.tenant_id,
|
||||
IH.invoice_number == entry.import_invoice,
|
||||
LI.line_number == entry.import_line,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _resolve_import_serie_row(
|
||||
db: Session,
|
||||
import_line_id: int,
|
||||
serial_number: str,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Looks up the ``row`` of an import serie by serial_number on the given
|
||||
import line — equivalent to the SQL3 query in the Clarion for both TEM
|
||||
and DEF cases (both use the same Serie model now).
|
||||
"""
|
||||
return db.execute(
|
||||
select(Serie.row).where(
|
||||
Serie.line_item_id == import_line_id,
|
||||
Serie.serial_numbers == serial_number,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _find_existing_export_line(
|
||||
db: Session,
|
||||
invoice: InvoiceHeader,
|
||||
current_line_id: int,
|
||||
serial_number: str,
|
||||
entry: DownloadEntry,
|
||||
) -> int:
|
||||
"""
|
||||
Returns the export_line number of another line on the same invoice that
|
||||
already has this serial registered for discharge.
|
||||
Falls back to entry.export_line if not found (shouldn't happen in practice).
|
||||
"""
|
||||
from api.v1.modules.a76.items.models import LineItem as LI
|
||||
|
||||
# Find all export lines on this invoice that are not the current one
|
||||
other_line_ids = db.execute(
|
||||
select(LI.id, LI.line_number).where(
|
||||
LI.invoice_id == invoice.id,
|
||||
LI.id != current_line_id,
|
||||
)
|
||||
).all()
|
||||
|
||||
for row in other_line_ids:
|
||||
match = db.execute(
|
||||
select(Serie.id).where(
|
||||
Serie.line_item_id == row.id,
|
||||
Serie.serial_numbers == serial_number,
|
||||
Serie.discharge == True, # noqa: E712
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if match is not None:
|
||||
return row.line_number
|
||||
|
||||
return entry.export_line
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
VERIFICAQCONSOLIDADO
|
||||
Second-pass check after COMPARA_SALDOS_POR_FACTURA: iterates every discharge
|
||||
entry and reports an error for any line that still has unmet quantity
|
||||
(QADesc:Cantidad - QADesc:CantUsada <> 0).
|
||||
|
||||
The Clarion routine distinguishes TEM vs DEF in the error message; this
|
||||
translation preserves that distinction.
|
||||
|
||||
Note: compare_balances already raises INSUFFICIENT_BALANCE errors per entry.
|
||||
This routine acts as a final consolidation gate — if compare_balances is
|
||||
called with raise_if_errors() afterwards, this function may be redundant in
|
||||
practice. It is kept as a faithful port and can serve as the sole
|
||||
insufficient-balance check if compare_balances is ever made non-raising.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from core.exceptions import ErrorCollector
|
||||
from .discharge_types import DownloadEntry
|
||||
|
||||
|
||||
def verify_consolidated(
|
||||
db: Session,
|
||||
export_invoice: InvoiceHeader,
|
||||
to_discharge: List[DownloadEntry],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
VERIFICAQCONSOLIDADO
|
||||
Reports an error for every discharge entry whose quantity was not fully
|
||||
satisfied by ``compare_balances``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : active SQLAlchemy session
|
||||
export_invoice : the export invoice being processed
|
||||
to_discharge : list of DownloadEntry objects after compare_balances ran
|
||||
errors : shared error collector
|
||||
"""
|
||||
# Sort mirrors Clarion: Sort(QueADescargar, -Procedencia, FacturaImpo, LineaImpo)
|
||||
sorted_entries = sorted(
|
||||
to_discharge,
|
||||
key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
for entry in sorted_entries:
|
||||
remaining = entry.quantity - entry.quantity_used
|
||||
if remaining == 0:
|
||||
continue
|
||||
|
||||
uom = entry.unit_of_measure or ""
|
||||
procedure = (entry.origin_procedure or "").strip().upper()
|
||||
|
||||
if procedure == "TEM":
|
||||
message = (
|
||||
f"Insuficiencia TEM: La Linea: {entry.export_line} se quiere "
|
||||
f"descargar: {entry.quantity} {uom} y hay: {entry.quantity_used} {uom}."
|
||||
)
|
||||
else: # DEF or any other
|
||||
message = (
|
||||
f"Insuficiencia DEF.: La Linea: {entry.export_line} se quiere "
|
||||
f"descargar: {entry.quantity} {uom} y hay: {entry.quantity_used} {uom}."
|
||||
)
|
||||
|
||||
errors.add_error(
|
||||
field=f"line[{entry.export_line}].quantity",
|
||||
message=message,
|
||||
solution=["Asignar Facturas con Saldos Disponibles."],
|
||||
code="INSUFFICIENT_BALANCE_CONSOLIDATED",
|
||||
value={
|
||||
"export_line": entry.export_line,
|
||||
"required": str(entry.quantity),
|
||||
"available": str(entry.quantity_used),
|
||||
"shortage": str(remaining),
|
||||
"origin_procedure": procedure,
|
||||
},
|
||||
)
|
||||
50
backend/api/v1/modules/a76/invoices/exports/process/task.py
Normal file
50
backend/api/v1/modules/a76/invoices/exports/process/task.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from celery import Task
|
||||
|
||||
from core.celery_app import celery_app
|
||||
from core.database import CoreSessionLocal
|
||||
from core.exceptions import ValidationException
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from .main_process import main_process
|
||||
|
||||
|
||||
def _progress(task: Task, current: int, status: str) -> None:
|
||||
task.update_state(state="PROGRESS", meta={"current": current, "status": status})
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="process_export_invoice_task")
|
||||
def process_export_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str) -> dict:
|
||||
"""
|
||||
Procesa una factura de exportación ejecutando todas las validaciones y
|
||||
actualizaciones del proceso principal de exportación con reporte de progreso.
|
||||
"""
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
_progress(self, 5, "Cargando factura...")
|
||||
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
|
||||
if invoice is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Factura con id {invoice_id} no encontrada.",
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
_progress(self, 10, "Procesando factura de exportación...")
|
||||
result = main_process(db, invoice, tenant_id, company_id)
|
||||
|
||||
db.commit()
|
||||
_progress(self, 100, "Proceso completado.")
|
||||
return {**result, "invoice_id": invoice_id}
|
||||
|
||||
except ValidationException as exc:
|
||||
db.rollback()
|
||||
return {
|
||||
"status": "validation_error",
|
||||
"message": exc.message,
|
||||
"errors": exc.errors,
|
||||
}
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
raise exc
|
||||
finally:
|
||||
db.close()
|
||||
@@ -7,7 +7,9 @@ from core.celery_app import celery_app
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, OperationType
|
||||
from .task import process_invoice_task
|
||||
from ...exports.process.task import process_export_invoice_task
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -20,14 +22,25 @@ def trigger_invoice_process(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Inicia el procesamiento de una factura de importación como tarea Celery.
|
||||
Inicia el procesamiento de una factura como tarea Celery.
|
||||
Detecta automáticamente si es importación o exportación por el
|
||||
operation_type de la factura y despacha al proceso correspondiente.
|
||||
Retorna el task_id para hacer polling del progreso.
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
task = process_invoice_task.apply_async(
|
||||
args=[invoice_id, str(tenant_id), str(company_id)]
|
||||
)
|
||||
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
|
||||
if invoice is None:
|
||||
raise HTTPException(status_code=404, detail=f"Factura {invoice_id} no encontrada.")
|
||||
|
||||
if invoice.operation_type == OperationType.EXP:
|
||||
task = process_export_invoice_task.apply_async(
|
||||
args=[invoice_id, str(tenant_id), str(company_id)]
|
||||
)
|
||||
else:
|
||||
task = process_invoice_task.apply_async(
|
||||
args=[invoice_id, str(tenant_id), str(company_id)]
|
||||
)
|
||||
|
||||
return {"task_id": task.id}
|
||||
|
||||
@@ -62,7 +75,6 @@ def get_invoice_process_status(task_id: str):
|
||||
"result": task_result.result,
|
||||
}
|
||||
|
||||
# FAILURE u otro estado de error
|
||||
error_info = task_result.result
|
||||
if isinstance(error_info, Exception):
|
||||
error_msg = str(error_info)
|
||||
|
||||
@@ -19,6 +19,6 @@ class Serie(Base, TenantScopedMixin, TimestampMixin):
|
||||
brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCAEXPO
|
||||
number_id: Mapped[Optional[str]] = mapped_column(String(25)) # NUMIDEXPO
|
||||
discharge: Mapped[Optional[bool]] = mapped_column(Boolean) # MARCA
|
||||
|
||||
serie_row: Mapped[Optional[int]] = mapped_column(Integer) # LINEASERIEIMPO <-- IN CASE OF EXPO
|
||||
|
||||
|
||||
Reference in New Issue
Block a user