TODOS de configuracion general lista

This commit is contained in:
2026-04-13 18:02:39 -05:00
parent 1d53ca9562
commit e5fa5f0326
49 changed files with 1678 additions and 1233 deletions

View File

@@ -103,8 +103,7 @@ class AuditService:
)
db.add(log)
db.commit()
db.refresh(log)
db.flush()
return log
@staticmethod

View File

@@ -68,6 +68,10 @@ def invoice_exists_by_id(
company_id: int,
errors: Optional[ErrorCollector],
):
"""
Check if an invoice exists by ID.
WARNING: Adds a DUPLICATE error if found (to be used when creating NEW invoices with specific IDs).
"""
invoice = (
db.query(models.InvoiceHeader)
.filter(
@@ -88,6 +92,41 @@ def invoice_exists_by_id(
return invoice
return None
def invoice_id_required(
db: Session,
invoice_id: int,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
) -> Optional[models.InvoiceHeader]:
"""
Validates that an invoice exists by ID.
Adds a NOT_FOUND error if it doesn't exist.
"""
if not invoice_id:
errors.add_required_error(field="invoice_id")
return None
invoice = (
db.query(models.InvoiceHeader)
.filter(
models.InvoiceHeader.id == invoice_id,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
)
.first()
)
if not invoice:
errors.add_error(
field="invoice_id",
message=f"La factura con ID '{invoice_id}' no existe.",
solution=["Seleccionar una factura válida."],
code="NOT_FOUND",
value=str(invoice_id),
)
return invoice
def invoice_processed(
db: Session,
invoice_id: str,

View File

@@ -1,204 +1,119 @@
import logging
logger = logging.getLogger(__name__)
from datetime import datetime
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 api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
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.assign_no_discharges import assign_no_discharges_items, assign_no_discharges_series
# Parameter Service
from api.v1.modules.a76.app_settings.service import AppSettingsService
from .sub_process.finalize_invoice import finalize_invoice_no_discharge, finalize_invoice_with_discharge
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, 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)
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 ----------------------------------------
def pre_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector) -> list:
"""Validaciones previas y obtención de partidas."""
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()
errors.add_error(field="line_items", message="La factura debe contener partidas.", solution=["Agregue partidas."], code="NO_LINE_ITEMS")
return lines
# --- 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 ------------------------------------------
def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, username: str = "SYSTEM") -> dict:
"""Proceso principal para facturas de exportación."""
errors = ErrorCollector()
lines = pre_process(db, invoice, tenant_id, company_id, errors)
errors.raise_if_errors()
# TODO: COMMIT TRAN (en el legacy: gSQLFile{PROP:SQL} = 'COMMIT TRAN')
# TODO: GBitacora('ACTUALIZAR FACTURA', invoice.invoice_number)
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
q_settings = settings.get("qsisgen", {})
s_settings = settings.get("ssisgen", {})
# invoice.status and totals are set inside finalize_invoice_no_discharge / termina_ac_o_lp_normal
cal_val_base_tc = int(q_settings.get("calvalbasetcpedexpo") or s_settings.get("calvalbasetcpedexpo", 0))
# El TC ahora se resuelve dentro de assign_values (para per-line)
# o dentro de _assign_invoice_totals (para base-pedimento-global).
# Sin embargo, para mantener compatibilidad con validaciones intermedias, lo dejamos aquí también:
exchange_rate = invoice.financials.exchange_rate if invoice.financials else 0
which_exchange_rate = "TCFAC"
if cal_val_base_tc == 1:
if invoice.compliance_mx and invoice.compliance_mx.pedimento:
ped = invoice.compliance_mx.pedimento
if ped.pedimento_dates and ped.pedimento_dates.payment_date:
payment_date = ped.pedimento_dates.payment_date
from sqlalchemy import select
stmt = select(ExchangeRate).where(
ExchangeRate.tenant_id == int(tenant_id),
ExchangeRate.company_id == int(company_id),
ExchangeRate.date == payment_date.date())
ex_rate_row = db.execute(stmt).scalar_one_or_none()
if ex_rate_row:
exchange_rate = float(ex_rate_row.value)
which_exchange_rate = "TCPED"
if invoice.compliance_mx: invoice.compliance_mx.which_exchange_rate = which_exchange_rate
if invoice.financials: invoice.financials.exchange_rate = exchange_rate
db.flush()
review_exchange_rate(db, invoice, cal_val_base_tc, errors)
errors.raise_if_errors()
savepoint = db.begin_nested()
try:
invoice_type = (invoice.invoice_type or "").strip().upper()
# 1. Lógica específica de NODES
if invoice_type == "NODES":
assign_no_discharges_items(lines, errors)
assign_no_discharges_series(db, lines, errors)
# 2. Asignar valores (costos/pesos) a las partidas
assign_values(db, invoice, lines, tenant_id, company_id, cal_val_base_tc, errors)
errors.raise_if_errors()
# 3. Finalización (Límites, TC global, Auditoría, Descargas A24)
if invoice_type == "NODES":
finalize_invoice_no_discharge(db, invoice, lines, tenant_id, company_id, errors, username=username)
else:
# Lógica de descarga PEPS
to_discharge = collect_lines_to_discharge(db, invoice, lines, errors)
errors.raise_if_errors()
if to_discharge:
fill_available_balances(db, invoice, to_discharge, errors)
errors.raise_if_errors()
compare_balances(db, invoice, to_discharge, errors)
errors.raise_if_errors()
verify_consolidated(db, invoice, to_discharge, errors)
errors.raise_if_errors()
finalize_invoice_with_discharge(
db, invoice, lines, tenant_id, company_id, errors,
to_discharge=to_discharge, username=username
)
errors.raise_if_errors()
savepoint.commit()
except Exception as e:
savepoint.rollback()
raise e
db.flush()
return {"status": "success", "invoice_id": str(invoice.id)}

View File

@@ -4,6 +4,7 @@ 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 api.v1.modules.a76.app_settings.service import AppSettingsService
from core.exceptions import ErrorCollector
def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector):
@@ -72,10 +73,24 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_
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.
# Validación de estatus de pedimento
# Validation of Pedimento status (CERRADO / PAGADO)
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
expo_params = settings.get("ssisexpo", {})
# Resolver validarestatusped (podría estar en ssisexpo o qsisgen según el tipo de factura)
valida_estatus = int(expo_params.get("validarestatusped", 0))
if valida_estatus == 1:
if invoice.compliance_mx and invoice.compliance_mx.pedimento:
ped = invoice.compliance_mx.pedimento
if ped.pedimento_dates and ped.pedimento_dates.payment_date:
errors.add_error(
field="compliance_mx.pedimento",
message=f"No se puede procesar la factura porque el pedimento {ped.pedimento_number} ya se encuentra pagado el {ped.pedimento_dates.payment_date.date()}.",
solution=["Desactive el parámetro 'validarestatusped' o rectifique el pedimento si requiere cambios."],
code="PEDIMENTO_ALREADY_PAID"
)
errors.raise_if_errors()
# 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:

View File

@@ -26,16 +26,18 @@ def trigger_invoice_process(
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
username = current_user.get("preferred_username") or current_user.get("email") or current_user.get("sub") or "SYSTEM"
task = track_and_dispatch(
db=db,
task=process_export_invoice_task,
tenant_id=tenant_id,
company_id=company_id,
requested_by_user=current_user.get("preferred_username") or current_user.get("email") or current_user.get("sub"),
requested_by_user=username,
task_name="process_export_invoice_task",
task_group="invoices",
task_origin="a76/invoices/exports/process",
args=[invoice_id, str(tenant_id), str(company_id)],
args=[invoice_id, str(tenant_id), str(company_id), username],
)
return {"task_id": task.id}

View File

@@ -3,7 +3,7 @@ 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):
Two cost-assignment strategies (controlled by SisExp:ValFactTC):
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).
@@ -140,12 +140,12 @@ def _get_source_invoice_tc(
Legacy fields:
EqiPex:TipoMovImpo → line.customs.origin_procedure
EqiPex:FacturaImpo → line.reference.import_invoice (TODO: confirm field)
EqiPex:FacturaImpo → line.fa_data.search_invoice
"""
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 ""
import_invoice_number = (line.fa_data.search_invoice if getattr(line, "fa_data", None) else None) or ""
if not import_invoice_number:
return fallback_tc
@@ -200,6 +200,7 @@ def assign_values(
lines: List[LineItem],
tenant_id: str,
company_id: str,
cal_val_base_tc: int,
errors: ErrorCollector,
) -> None:
"""
@@ -216,11 +217,29 @@ def assign_values(
# --- 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
# Resolve Settings for Strategy
from api.v1.modules.a76.app_settings.service import AppSettingsService
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
inv_type = (invoice.invoice_type or "").strip().upper()
op_type = "exp"
# Hierarchical resolve: invoices.types.exp.{type}.ssisgen.ssimpFormData
inv_map = settings.get("invoices", {}).get("types", {}).get(op_type, {}).get(inv_type, {})
form_data = inv_map.get("ssisgen", {}).get("ssimpFormData", {}) or inv_map.get("qsisgen", {}).get("ssimpFormData", {}) or settings.get("ssisexpo", {})
# Parameter ValFactTC: TCE (Encabezado) vs PER_LINE (Partida)
val_fact_tc = (form_data.get("valfacttc") or form_data.get("ValFactTC") or "").strip().upper()
# Fallback/Auto-resolve:
# Si la configuración indica usar el TC del Pedimento (cal_val_base_tc = 1),
# usualmente forzamos TCE porque el encabezado ya fue nivelado al pago del pedimento.
if cal_val_base_tc == 1:
val_fact_tc = "TCE"
# Default if empty
if not val_fact_tc:
val_fact_tc = "PER_LINE"
if val_fact_tc == "TCE":
_assign_costs_tce(lines, currency, tc, tc_mm)

View File

@@ -152,15 +152,9 @@ def fill_available_balances(
) -> 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
Refactorizada para PEPS Flexible:
Busca automáticamente todos los lotes con saldo disponible para el número de parte
de cada partida a descargar.
"""
export_date: datetime.date = (
export_invoice.invoice_date.date()
@@ -168,109 +162,60 @@ def fill_available_balances(
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:
for entry in to_discharge:
# Recuperar la partida de exportación original para obtener IDs precisos
export_line = db.get(LineItem, entry.line_item_id)
if not export_line:
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,
part_id = export_line.part_number_id
class_id = export_line.class_id
# 1. Buscar todos los lotes candidatos (Importaciones del mismo número de parte y clase)
# Solo facturas procesadas y con fecha <= export_date
candidates = (
db.query(LineItem)
.join(InvoiceHeader, InvoiceHeader.id == LineItem.invoice_id)
.filter(
LineItem.tenant_id == export_invoice.tenant_id,
LineItem.company_id == export_invoice.company_id,
LineItem.part_number_id == part_id,
LineItem.class_id == class_id,
InvoiceHeader.operation_type == "imp",
InvoiceHeader.status != InvoiceStatus.PENDING,
InvoiceHeader.invoice_date <= export_date
)
continue
# Status 'NA' == not processed (Clarion: Estatus = 'NA')
if import_invoice.status == InvoiceStatus.PENDING:
errors.add_error(
field=f"line[{entry.export_line}].import_invoice",
message=f"La Factura de Importación: '{entry.import_invoice}' está Desactualizada.",
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,
.all()
)
if import_line is None:
# 2. Calcular saldos para cada candidato y llenar available_lots
found_any_balance = False
for imp_line in candidates:
available = _net_balance_for_lot(db, imp_line.id, export_date)
if available > 0:
found_any_balance = True
fin = imp_line.financial
lot = AvailableLot(
import_item_line_id=imp_line.id,
import_invoice_id=imp_line.invoice_id,
part_number_id=imp_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, imp_line.id),
)
entry.available_lots.append(lot)
# 3. Ordenar por PEPS (FIFO)
entry.available_lots.sort(key=lambda x: x.order_peps)
# 4. Manejo de Errores: Solo si no se encontró ABSOLUTAMENTE NADA de saldo
if not found_any_balance:
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},
field=f"line[{entry.export_line}].quantity",
message=f"No se encontró saldo disponible en inventario para el número de parte: {entry.part_number}",
solution=["Verificar que existan facturas de importación procesadas con saldo."],
code="NO_BALANCE_FOUND_ANYWHERE",
value=entry.part_number,
)
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)

View File

@@ -3,14 +3,14 @@ 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
1. Review enabled classes (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)
4. Generate definitive import (GENERAIMPODEFINITIVA) (if is_regime_change and generate_id)
5. Register discharge in import lines (REGISTRA_DESCARGA_IMPORTACION)
6. Register series discharge (REGISTRA_DESCARGA_SERIES)
The legacy 'Of LP' branch (print-preview / progress-bar UI) is not ported.
"""
@@ -33,7 +33,7 @@ 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
from .generate_definitive_import import generate_definitive_import, is_regime_change_detected
# ---------------------------------------------------------------------------
@@ -69,118 +69,38 @@ def _review_enabled_classes(
)
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
from api.v1.modules.a76.app_settings.service import AppSettingsService
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from sqlalchemy import select
from .review_limits import review_sisexpo_limits
def _assign_invoice_totals(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
tenant_id: str,
company_id: str,
username: str = "SYSTEM"
) -> 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
Calcula totales, resuelve TC base pedimento si aplica, y marca como PROCESADO.
"""
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
inv_type = (invoice.invoice_type or "").strip().upper()
op_type = (invoice.operation_type or "").strip().lower() # 'imp' or 'exp'
# Hierarchical resolve: invoices.types.{op}.{type}.ssisgen.ssimpFormData
inv_map = settings.get("invoices", {}).get("types", {}).get(op_type, {}).get(inv_type, {})
form_data = inv_map.get("ssisgen", {}).get("ssimpFormData", {}) or inv_map.get("qsisgen", {}).get("ssimpFormData", {}) or settings.get("ssisgen", {}) or settings.get("qsisgen", {})
cal_val_base_tc = int(form_data.get("calvalbasetcpedexpo") or form_data.get("CalValBaseTCPedExpo") or 0)
act_seguridad = int(form_data.get("actseguridad") or form_data.get("ActSeguridad") or 0)
total_value_mn = Decimal(0)
total_value_me = Decimal(0)
total_qty = Decimal(0)
@@ -198,6 +118,23 @@ def _assign_invoice_totals(
total_net_weight += line.quantity.net_weight or Decimal(0)
total_gross_weight += line.quantity.gross_weight or Decimal(0)
# 1. Resolver TC base pedimento si aplica
if cal_val_base_tc == 1:
if invoice.compliance_mx and invoice.compliance_mx.pedimento:
ped = invoice.compliance_mx.pedimento
if ped.pedimento_dates and ped.pedimento_dates.payment_date:
payment_date = ped.pedimento_dates.payment_date
stmt = select(ExchangeRate).where(
ExchangeRate.tenant_id == int(tenant_id),
ExchangeRate.company_id == int(company_id),
ExchangeRate.date == payment_date.date())
ex_rate_row = db.execute(stmt).scalar_one_or_none()
if ex_rate_row and invoice.financials:
invoice.financials.exchange_rate = float(ex_rate_row.value)
invoice.compliance_mx.which_exchange_rate = "TCPED"
invoice.process_log = "Se Actualizó con el Tipo de Cambio de la Fecha de Pago de Pedimento."
# 2. Asignar totales al header
if invoice.financials is not None:
invoice.financials.value_mn = float(total_value_mn)
invoice.financials.value_me = float(total_value_me)
@@ -210,6 +147,16 @@ def _assign_invoice_totals(
invoice.updated_date = datetime.date.today()
invoice.status = InvoiceStatus.PROCESSED
# 3. Auditoría (ActSeguridad)
if act_seguridad == 1:
invoice.updated_by = username
from api.v1.modules.a76.audit_log.services.service import AuditService
AuditService.create_audit_log(
db=db, reference=invoice.invoice_number, procedure="ACTUALIZAR FACTURA", movement="EXPORTACION",
username=username, tenant_id=int(tenant_id), company_id=int(company_id),
table_name="invoice_header", record_id=str(invoice.id), operation_type="UPDATE"
)
# ---------------------------------------------------------------------------
# Public entry points
@@ -219,60 +166,63 @@ def finalize_invoice_no_discharge(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
tenant_id: str,
company_id: str,
errors: ErrorCollector,
username: str = "SYSTEM",
) -> 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)
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
review_sisexpo_limits(invoice, settings, total_qty, total_net_weight, total_value, errors)
if not errors.has_errors():
_assign_invoice_totals(invoice, lines)
_assign_invoice_totals(db, invoice, lines, tenant_id, company_id, username)
def finalize_invoice_with_discharge(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
tenant_id: str,
company_id: str,
errors: ErrorCollector,
to_discharge: List["DownloadEntry"] | None = None,
username: str = "SYSTEM",
) -> 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.
"""
# Refrescar factura para asegurar info de compliance/pedimentos fresca
db.refresh(invoice)
_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)
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
review_sisexpo_limits(invoice, settings, 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)
# Unified detection of Regime Change (F4, CR type, or Manual Flag)
if is_regime_change_detected(invoice, settings):
def_invoice, created = generate_definitive_import(db, invoice, to_discharge or [], settings, errors)
if created and not errors.has_errors():
from .generate_definitive_import import (
generate_definitive_import_all_lines,
generate_definitive_import_discharged_lines
)
# Choice based on generate_desc_parties
if (invoice.generate_desc_parties or "").strip() == 'Todas':
generate_definitive_import_all_lines(db, invoice, def_invoice, errors)
else:
generate_definitive_import_discharged_lines(
db, invoice, def_invoice, to_discharge or [], errors
)
if to_discharge:
# Write BalanceMovement (CONSUMPTION) + DischargeHeader + DischargeDetail
@@ -281,4 +231,4 @@ def finalize_invoice_with_discharge(
register_import_discharge(db, invoice, to_discharge)
register_discharge_series(db, invoice, to_discharge)
_assign_invoice_totals(invoice, lines)
_assign_invoice_totals(db, invoice, lines, tenant_id, company_id, username)

View File

@@ -24,6 +24,8 @@ GENERAIMPODEFINITIVA Routine
END
"""
from decimal import Decimal
from typing import List
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -89,27 +91,17 @@ def _create_definitive_import_header(
tenant_id=exp.tenant_id,
company_id=exp.company_id,
system=exp.system,
operation_type=OperationType.IMPORT,
invoice_type="IMD",
operation_type=OperationType.IMP,
invoice_type="DEF",
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,
# Comments (Mapped from original OBSERVACIONE/I)
observation_es=exp.observation_es,
observation_en=exp.observation_en,
)
db.add(def_invoice)
db.flush() # get def_invoice.id before creating child records
@@ -134,8 +126,8 @@ def _create_definitive_import_header(
# ── Compliance / pedimento ────────────────────────────────────────────
if exp_comp is not None:
from api.v1.modules.a76.invoices.models import InvoiceComplianceMX
def_comp = InvoiceComplianceMX(
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,
@@ -143,6 +135,15 @@ def _create_definitive_import_header(
remesa=exp_comp.remesa,
pedimento_id=exp_comp.pedimento_id,
is_pedimento_pending=exp_comp.is_pedimento_pending,
# Reubicados aquí (Correcto en el esquema unificado)
provider_id=exp_comp.provider_id,
sold_to_id=exp_comp.sold_to_id,
shipped_to_id=exp_comp.shipped_to_id,
customs_broker_id=exp_comp.customs_broker_id,
customs_broker_us_id=exp_comp.customs_broker_us_id,
provider_header=exp_comp.provider_header,
sold_to_header=exp_comp.sold_to_header,
shipped_to_header=exp_comp.shipped_to_header,
)
db.add(def_comp)
@@ -166,11 +167,78 @@ def _create_definitive_import_header(
# Public entry point
# ---------------------------------------------------------------------------
def is_regime_change_detected(invoice: InvoiceHeader, settings: any = None) -> bool:
"""
Detecta si la factura debe activar la generación de Importación Definitiva (DEF).
Usa el parámetro 'incambioregdesc' como interruptor maestro.
"""
# 0. Interruptor Maestro (Configuración Global)
# Soporta Dict de JSON o Objetos Pydantic/SQLA
if settings:
# Búsqueda exhaustiva y recursiva del parámetro maestro
def find_in_obj(obj, target_key):
if isinstance(obj, dict):
for k, v in obj.items():
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
elif hasattr(obj, '__dict__'):
# Soporte para objetos Pydantic/SQLAlchemy
dict_rep = obj.__dict__ if hasattr(obj, '__dict__') else {}
if hasattr(obj, 'dict') and callable(getattr(obj, 'dict')):
try: dict_rep = obj.dict()
except: pass
elif hasattr(obj, 'model_dump') and callable(getattr(obj, 'model_dump')):
try: dict_rep = obj.model_dump()
except: pass
for k, v in dict_rep.items():
# Ignorar propiedades privadas de SQLAlchemy
if k.startswith('_'): continue
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
return None
# Buscamos el valor en todo el árbol de configuración
val = find_in_obj(settings, 'incambioregdesc')
# LOG DE EMERGENCIA en consola para debugging
print(f"[REGIME_CHANGE_DETECT] Factura: {invoice.invoice_number}, Valor de incambioregdesc: {val}")
# Comprobación segura (1, '1', True, 'true')
master_on = str(val).strip().lower() in ['1', 'true']
if not master_on:
return False
# 1. Export + Pedimento F4 (Art. 114)
op_type = str(invoice.operation_type or "").lower()
if op_type == "exp":
if invoice.compliance_mx:
ped = invoice.compliance_mx.pedimento
if ped and (ped.pedimento_code or "").strip().upper() == "F4":
return True
# 2. Tipo de Factura 'CR' o Flag Manual
if (invoice.invoice_type or "").strip().upper() == "CR":
return True
if invoice.compliance_mx and invoice.compliance_mx.is_regime_change:
return True
return False
def generate_definitive_import(
db: Session,
invoice: InvoiceHeader,
to_discharge: List[DownloadEntry],
settings: any,
errors: ErrorCollector,
) -> InvoiceHeader | None:
) -> tuple[InvoiceHeader, bool]:
"""
GENERAIMPODEFINITIVA
Creates a definitive import invoice header from ``invoice`` (export) when
@@ -200,22 +268,16 @@ def generate_definitive_import(
InvoiceHeader.tenant_id == invoice.tenant_id,
InvoiceHeader.company_id == invoice.company_id,
InvoiceHeader.invoice_number == invoice.invoice_number,
InvoiceHeader.invoice_type == "IMD",
InvoiceHeader.invoice_type == "DEF",
)
).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)
return existing, False
# ── 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
# ── 2. Create the definitiva header ──────────────────────────────────
def_invoice = _create_definitive_import_header(db, invoice)
return def_invoice, True
# ---------------------------------------------------------------------------
@@ -229,20 +291,27 @@ def _copy_line_to_definitive(
export_line: LineItem,
def_invoice: InvoiceHeader,
def_line_number: int,
custom_qty: Decimal | None = None,
) -> 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)
If ``custom_qty`` is provided, the function scales weights and values
proportionally (Regime Change with Discharge logic).
"""
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
# Calculate proportionality ratio if custom_qty is provided
original_qty = export_line.quantity.quantity if export_line.quantity else Decimal(1)
if original_qty == 0:
original_qty = Decimal(1)
ratio = Decimal(1)
if custom_qty is not None:
ratio = custom_qty / original_qty
def_line = LineItem(
tenant_id=def_invoice.tenant_id,
company_id=def_invoice.company_id,
@@ -257,19 +326,36 @@ def _copy_line_to_definitive(
if export_line.quantity:
src_q = export_line.quantity
qty_val = custom_qty if custom_qty is not None else src_q.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,
quantity=qty_val,
net_weight=(src_q.net_weight * ratio) if src_q.net_weight is not None else None,
gross_weight=(src_q.gross_weight * ratio) if src_q.gross_weight is not None else None,
package_quantity=int(src_q.package_quantity * ratio) if src_q.package_quantity is not None else None,
package_id=src_q.package_id,
))
if export_line.financial:
src_f = export_line.financial
# Helper to scale optional Decimal fields
def scale(val: Decimal | None) -> Decimal | None:
return (val * ratio) if val is not None else None
db.add(LineFinancial(
item_line_id=def_line.id,
unit_cost_capture=export_line.financial.unit_cost_capture,
unit_cost_capture=src_f.unit_cost_capture,
unit_cost_usd=src_f.unit_cost_usd,
unit_cost_mxn=src_f.unit_cost_mxn,
# Scale total values
value_mxn=scale(src_f.value_mxn),
value_usd=scale(src_f.value_usd),
customs_value_mxn=scale(src_f.customs_value_mxn),
customs_value_usd=scale(src_f.customs_value_usd),
value_added_mxn=scale(src_f.value_added_mxn),
value_added_usd=scale(src_f.value_added_usd),
))
if export_line.customs:
@@ -297,7 +383,9 @@ def _copy_line_to_definitive(
))
db.add(FaLineItem(
item_line_id=def_line.id,
id=def_line.id,
tenant_id=def_line.tenant_id,
company_id=def_line.company_id,
is_subitem=False,
contains_subitems=False,
subitem_number=0,
@@ -329,27 +417,51 @@ def generate_definitive_import_discharged_lines(
db: Session,
export_invoice: InvoiceHeader,
def_invoice: InvoiceHeader,
to_discharge: list[DownloadEntry],
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).
Creates definitive import lines only for the lots consumed in the discharge,
effectively splitting export lines if they came from multiple import batches.
Clarion: Sort(QueADescargar, FacturaImpo, LineaImpo) → loop
"""
# Sort by import lot origin to match legacy behavior
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)
# If the entry has LOTS assigned (PEPS), create one IMD line per lot consumed
lots_to_transfer = [lot for lot in entry.available_lots if lot.consumed_qty > 0]
if lots_to_transfer:
for lot in lots_to_transfer:
def_line_number += 1
_copy_line_to_definitive(
db,
export_line,
def_invoice,
def_line_number,
custom_qty=lot.consumed_qty
)
else:
# Fallback: if no lot info but entry exists, use entry quantity
def_line_number += 1
_copy_line_to_definitive(
db,
export_line,
def_invoice,
def_line_number,
custom_qty=entry.quantity
)
db.flush()

View File

@@ -32,6 +32,7 @@ from core.exceptions import ErrorCollector
def review_exchange_rate(
db: Session,
invoice: InvoiceHeader,
cal_val_base_tc: int,
errors: ErrorCollector,
) -> None:
"""
@@ -43,10 +44,12 @@ def review_exchange_rate(
----------
db : active SQLAlchemy session
invoice : the export invoice being processed
cal_val_base_tc : flag from settings (1 = skip validation as TC comes from pedimento)
errors : shared error collector
"""
# TODO: skip when SisGen:CalValBaseTCPedExpo = 1
# (TC is taken from pedimento payment date, validated elsewhere)
if cal_val_base_tc == 1:
# SKIP: TC is taken from pedimento payment date, resolved in main_process Step 7
return
if not invoice.financials:
return

View File

@@ -1,80 +1,96 @@
"""
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
"""
import logging
from decimal import Decimal
from typing import List
from typing import Dict, Any, List
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
logger = logging.getLogger(__name__)
def limit_weight(
lines: List[LineItem],
) -> tuple[Decimal, Decimal]:
def review_sisexpo_limits(
invoice: InvoiceHeader,
settings: Dict[str, Any],
total_qty: Decimal,
total_net_weight: Decimal,
total_value: Decimal,
errors: ErrorCollector
) -> None:
"""
TOT_PAR_LIM_CANT_PESO
Sums exported quantity and net weight across all line items and Returns the totals.
Validates invoice totals against the SisExpo min/max limit parameters.
Uses robust resolution for deep nested JSON structure.
"""
invoice_type = (invoice.invoice_type or "").strip().upper()
op_type = "exp"
# 1. Start from ssisexpo root
params = settings.get("ssisexpo", {})
cat_name = "ssisexpo"
Returns
-------
(total_quantity, total_net_weight)
after the call.
"""
# 2. Deep resolution if not in root (Frontend structure)
# Search for any limit field to decide if we should deep dive
has_root_limits = any(params.get(k) is not None for k in ["cantlimite", "CantLimite", "pesolimite", "PesoLimite", "valorlimite", "ValorLimite"])
if not has_root_limits:
invoice_map = settings.get("invoices", {}).get("types", {}).get(op_type, {}).get(invoice_type, {})
# Buscar en ssimpFormData (shared schema) dentro de qsisgen o ssisgen
params = invoice_map.get("qsisgen", {}).get("ssimpFormData", {}) or invoice_map.get("ssisgen", {}).get("ssimpFormData", {}) or params
cat_name = f"invoices.types.{op_type}.{invoice_type}.ssimpFormData"
# Obtener límites Máximos
limit_qty = Decimal(str(params.get("cantlimite") or params.get("CantLimite") or 0))
limit_weight = Decimal(str(params.get("pesolimite") or params.get("PesoLimite") or 0))
limit_value = Decimal(str(params.get("valorlimite") or params.get("ValorLimite") or 0))
# Obtener límites Mínimos
min_limit_qty = Decimal(str(params.get("cantlimitemin") or params.get("CantLimiteMin") or 0))
min_limit_weight = Decimal(str(params.get("pesolimitemin") or params.get("PesoLimiteMin") or 0))
min_limit_value = Decimal(str(params.get("valorlimitemin") or params.get("ValorLimiteMin") or 0))
logger.info(f"DEBUG_EXPO_LIMITS: Invoice={invoice.invoice_number} | DetectedType={invoice_type} | ResolvedCat={cat_name}")
logger.info(f"DEBUG_EXPO_LIMITS: RAW_PARAMS_FOR_VAL={params}") # Cuidado, esto puede ser largo pero nos dirá la verdad
logger.info(f"DEBUG_EXPO_LIMITS: Qty: Current={total_qty} Max={limit_qty} Min={min_limit_qty}")
logger.info(f"LIMIT_CHECK_EXPO: Weight: Current={total_net_weight} Max={limit_weight} Min={min_limit_weight}")
logger.info(f"LIMIT_CHECK_EXPO: Value: Current={total_value} Max={limit_value} Min={min_limit_value}")
solution = ["Ajuste los valores de la factura o consulte a su Administrador para parametrizar la factura."]
code = "PAR.EXPO"
# --- Max Validations ---
if limit_qty > 0 and total_qty > limit_qty:
errors.add_error(field="financials.total_quantity", message=f"La cantidad total ({total_qty}) excede el máximo permitido ({limit_qty}).", solution=solution, code=code)
if limit_weight > 0 and total_net_weight > limit_weight:
errors.add_error(field="financials.net_weight", message=f"El peso neto total ({total_net_weight}) excede el máximo permitido ({limit_weight}).", solution=solution, code=code)
if limit_value > 0 and total_value > limit_value:
errors.add_error(field="financials.value_me", message=f"El valor total en USD ({total_value}) excede el máximo permitido ({limit_value}).", solution=solution, code=code)
# --- Min Validations ---
if min_limit_qty > 0 and total_qty < min_limit_qty:
errors.add_error(field="financials.total_quantity", message=f"La cantidad total ({total_qty}) es inferior al mínimo requerido ({min_limit_qty}).", solution=solution, code=code)
if min_limit_weight > 0 and total_net_weight < min_limit_weight:
errors.add_error(field="financials.net_weight", message=f"El peso neto total ({total_net_weight}) es inferior al mínimo requerido ({min_limit_weight}).", solution=solution, code=code)
if min_limit_value > 0 and total_value < min_limit_value:
errors.add_error(field="financials.value_me", message=f"El valor total en USD ({total_value}) es inferior al mínimo requerido ({min_limit_value}).", solution=solution, code=code)
def limit_weight(lines: List[Any]) -> tuple[Decimal, Decimal]:
"""Sums exported quantity and net weight across all line items."""
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)
if line.quantity:
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).
"""
def limit_value(lines: List[Any]) -> Decimal:
"""Sums (unit_cost_capture × quantity) across all line items."""
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
if line.financial and line.quantity:
capture = line.financial.unit_cost_capture or Decimal(0)
qty = line.quantity.quantity or Decimal(0)
total_value += capture * qty
return total_value

View File

@@ -25,11 +25,9 @@ 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 api.v1.modules.a76.app_settings.service import AppSettingsService
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",
@@ -45,6 +43,7 @@ def _validate_line_series(
line: LineItem,
company_rfc: str,
errors: ErrorCollector,
valida_cant_series: int = 0,
) -> None:
"""Validates series count for a single line that has has_serial = True."""
series_count = (
@@ -66,8 +65,7 @@ def _validate_line_series(
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:
if valida_cant_series != 1:
return
qty = line.quantity.quantity if line.quantity else None
@@ -114,8 +112,13 @@ def review_qty_series(
company = db.get(Company, company_id)
company_rfc = (company.rfc or "").strip().upper() if company else ""
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
q_gen = settings.get("qsisgen", {})
s_gen = settings.get("ssisgen", {})
valida_cant_series = int(q_gen.get("cantvscantseries") or s_gen.get("cantvscantseries", 0))
for line in lines:
if not (line.description and line.description.has_serial):
continue
_validate_line_series(db, invoice, line, company_rfc, errors)
_validate_line_series(db, invoice, line, company_rfc, errors, valida_cant_series)

View File

@@ -13,7 +13,7 @@ def _progress(task: Task, current: int, status: str) -> None:
@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:
def process_export_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str, username: str = "SYSTEM") -> dict:
"""
Procesa una factura de exportación ejecutando todas las validaciones y
actualizaciones del proceso principal de exportación con reporte de progreso.
@@ -30,7 +30,7 @@ def process_export_invoice_task(self: Task, invoice_id: int, tenant_id: str, com
}
_progress(self, 10, "Procesando factura de exportación...")
result = main_process(db, invoice, tenant_id, company_id)
result = main_process(db, invoice, tenant_id, company_id, username=username)
db.commit()
_progress(self, 100, "Proceso completado.")

View File

@@ -1,3 +1,4 @@
from api.v1.modules.a76.app_settings.service import AppSettingsService
import datetime
from decimal import Decimal
from typing import List, Optional
@@ -28,15 +29,18 @@ def _validate_regime_change_definitive_invoice_exists(
invoice: InvoiceHeader,
errors: ErrorCollector,
) -> None:
"""
Clarion mapping:
If EqiFex:EsCambioRegimen='S' then count QFacImpDef where
FacturaImpoDef = FacturaExpo and ProvImpoDefCR='C'.
Python approximation:
Search an import invoice with same invoice_number and invoice_type='IMD'.
"""
if not (invoice.compliance_mx and invoice.compliance_mx.is_regime_change):
# Robust detection logic (same as generator)
is_cr = False
if invoice.compliance_mx and invoice.compliance_mx.is_regime_change:
is_cr = True
elif (invoice.invoice_type or "").strip().upper() == "CR":
is_cr = True
elif invoice.operation_type == OperationType.EXP:
if invoice.compliance_mx and invoice.compliance_mx.pedimento:
if (invoice.compliance_mx.pedimento.pedimento_code or "").strip().upper() == "F4":
is_cr = True
if not is_cr:
return
if not invoice.invoice_number:
@@ -348,10 +352,7 @@ def revert_process(
_ = (db, tenant_id, company_id) # reserved for future TO DO implementations
sql_errors: list = []
# INICIALIZA QUEUES (Python: collector ya llega limpio por tarea)
# TODO: Compartir QSisGen / parámetros globales del Clarion.
# TODO: BEGIN TRAN (managed by SQLAlchemy session in task)
# PROCESO DE REVERSIÓN
_todo_check_access_lock(invoice)
# VERIFICAR SI HAY PARTIDAS DE EXPORTACION
@@ -370,4 +371,19 @@ def revert_process(
_set_invoice_unprocessed(invoice, line_count)
# TODO: COMMIT/ROLLBACK TRAN + QueueErrorSQL file handling + GBitacora
# Auditoría de Desactualización
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
q_gen = settings.get("qsisgen", {})
s_gen = settings.get("ssisgen", {})
act_seguridad = int(q_gen.get("actseguridad") or s_gen.get("actseguridad") or q_gen.get("ActSeguridad") or s_gen.get("ActSeguridad", 0))
if act_seguridad == 1:
from api.v1.modules.a76.audit_log.services.service import AuditService
AuditService.create_audit_log(
db=db, reference=invoice.invoice_number, procedure="ANULAR FACTURA", movement="DESACTUALIZACION",
username=cancelled_by or "SYSTEM", tenant_id=int(tenant_id), company_id=int(company_id),
table_name="invoice_header", record_id=str(invoice.id), operation_type="UPDATE"
)
return sql_errors

View File

@@ -24,7 +24,7 @@ def validate_update(
errors: ErrorCollector,
) -> None:
"""
Valida y procesa la actualización parcial de una factura de importación temporal.
Valida y procesa la actualización parcial de una factura de exportación.
Lógica: Si un campo viene con valor, se limpia/valida.
Si no, se mantiene el valor existente de la factura.

View File

@@ -1,4 +1,5 @@
import logging
logger = logging.getLogger(__name__)
from datetime import date, datetime
from decimal import Decimal
from typing import List
@@ -30,7 +31,10 @@ from .sub_process.assing_values_def_mex import (
assign_values_invoice_totals,
)
from ..balance.create_balance_entries import create_balance_entries
from .sub_process.review_limits import review_limits
# Parameter Service
from api.v1.modules.a76.app_settings.service import AppSettingsService
def _validate_lines(
@@ -44,44 +48,34 @@ def _validate_lines(
"""Recorre cada partida y ejecuta las validaciones individuales."""
company = db.get(Company, invoice.company_id)
company_rfc = (company.rfc or "").strip().upper() if company else ""
# Deduplicación de cupos disponibles: (permiso, ro_line, pais) → OctaveAvailableEntry
octave_available: dict = {}
# Partidas a descargar: se pasa a valida_imp_regla_octava
octave_desc: list = []
for line in lines:
# Validar costo unitario capturado en partidas principales
if (line.financial and line.fa_data) and not line.fa_data.is_subitem and (line.financial.unit_cost_capture or Decimal(0)) == 0:
errors.add_error(
field=f"line[{line.line_number}].unit_cost_capture",
message="No existe el costo unitario para la Partida.",
solution=[
f"Entrar a la partida: {line.line_number} y capturar el Costo Unitario."
],
solution=[f"Entrar a la partida: {line.line_number} y capturar el Costo Unitario."],
code="UNIT_COST_REQUIRED",
)
# Validar clase habilitada/deshabilitada
if line.class_id is not None:
cls: Class | None = db.get(Class, line.class_id)
if cls and cls.is_active is False:
errors.add_error(
field=f"line[{line.line_number}].class",
message=(
f"El número de parte: {cls.class_code} esta desactivado, no se pueden hacer movimientos."
),
message=f"El número de parte: {cls.class_code} esta desactivado, no se pueden hacer movimientos.",
solution=["Seleccionar un número de parte activo."],
code="CLASS_DISABLED",
)
# Validar número de parte habilitado/deshabilitado
if line.part_number_id is not None:
part: Part | None = db.get(Part, line.part_number_id)
if part and part.is_active is False:
errors.add_error(
field=f"line[{line.line_number}].part_number",
message=(
f"El número de parte: {part.part_number} esta desactivado, no se pueden hacer movimientos."
),
message=f"El número de parte: {part.part_number} esta desactivado, no se pueden hacer movimientos.",
solution=["Seleccionar un número de parte activo."],
code="PART_DISABLED",
)
@@ -96,37 +90,26 @@ def _validate_lines(
errors=errors,
)
review_series(db, line, company_rfc, errors)
review_series(db, line, company_rfc, tenant_id, company_id, errors)
# Validación de la Regla Octava
if line.octave_permit:
if not company.prosec:
errors.add_error(
field=f"line[{line.line_number}].octave_permit",
message="No se puede hacer uso de la Regla Octava, ...",
message="No se puede hacer uso de la Regla Octava...",
solution=["Borrar el permiso o dar de alta el permiso PROSEC..."],
code="OCTAVA_SIN_PROSEC",
)
else:
desc_entry = llena_impo_permiso_regla_octava(
db=db,
invoice=invoice,
line=line,
company_rfc=company_rfc,
tenant_id=tenant_id,
company_id=company_id,
errors=errors,
db=db, invoice=invoice, line=line, company_rfc=company_rfc,
tenant_id=tenant_id, company_id=company_id, errors=errors,
)
if desc_entry is not None:
octave_desc.append(desc_entry)
available = revpermiso_regla_octava(
db=db,
invoice=invoice,
line=line,
company_rfc=company_rfc,
tenant_id=tenant_id,
company_id=company_id,
errors=errors,
db=db, invoice=invoice, line=line, company_rfc=company_rfc,
tenant_id=tenant_id, company_id=company_id, errors=errors,
)
if available is not None:
key = (available.octave_permit, available.ro_line, available.country_code)
@@ -137,35 +120,16 @@ def _validate_lines(
return octave_desc, octave_available
def _validate_sisimp_limits(
invoice: InvoiceHeader,
errors: ErrorCollector,
) -> None:
"""
Valida los límites de cantidad, peso y valor configurados en SisImp.
TODO: Leer los parámetros SisImp desde la configuración del sistema:
- SisImp:CantLimiteMin / SisImp:CantLimite
- SisImp:PesoLimiteMin / SisImp:PesoLimite
- SisImp:ValorLimiteMin / SisImp:ValorLimite
Una vez disponibles, usar invoice.financials.total_quantity, net_weight y value_mc.
"""
# TODO: Implementar cuando SisImp esté disponible en la configuración del tenant
pass
def _validate_sisimp_limits(invoice: InvoiceHeader, settings: dict, errors: ErrorCollector) -> None:
"""Valida los límites de cantidad, peso y valor configurados en SisImpo/SisDef."""
review_limits(invoice, settings, errors)
def _update_invoice_totals(invoice: InvoiceHeader, lines: List[LineItem]) -> None:
"""
Copia los totales calculados de financials/logistics al encabezado de la factura
y calcula IVA, incrementables y valores de aduanas.
TODO: Validar SSisGen:ActSeguridad para asignar el usuario que actualizó.
TODO: Validar SSisGen:CalValBaseTCPed para registrar el mensaje de procesamiento.
"""
"""Actualiza totales, IVA e incrementables en la factura."""
tc = Decimal(str(invoice.financials.exchange_rate or 0))
tc_mm = Decimal(str(invoice.financials.exchange_rate_mm or 0))
# Calcular IVA sólo para facturas con fecha posterior al corte (78165 en Clarion = 2004-06-01 aprox.)
iva_factor = Decimal(str(invoice.financials.iva_factor or 0)) if invoice.financials.iva_factor else Decimal(0)
if invoice.invoice_date and invoice.invoice_date >= date(2014, 12, 31):
invoice.financials.iva_mn = float(Decimal(str(invoice.financials.value_mn or 0)) * iva_factor / 100)
@@ -174,83 +138,93 @@ def _update_invoice_totals(invoice: InvoiceHeader, lines: List[LineItem]) -> Non
invoice.financials.iva_mn = 0.0
invoice.financials.iva_me = 0.0
# Calcular total de incrementables por tipo de moneda
freight = Decimal(str(invoice.financials.freight or 0))
insurance = Decimal(str(invoice.financials.insurance or 0))
packaging = Decimal(str(invoice.financials.packaging or 0))
other = Decimal(str(invoice.financials.other_increments or 0))
base_increm = freight + insurance + packaging + other
if invoice.financials.currency == Currency.FOREIGN: # ME
if invoice.financials.currency == Currency.FOREIGN:
val_seguro = Decimal(str(invoice.financials.total_increments_me or 0)) - base_increm
invoice.financials.total_increments_me = float(base_increm + val_seguro)
invoice.financials.total_increments_mn = float(Decimal(str(invoice.financials.total_increments_me)) * tc)
elif invoice.financials.currency == Currency.LOCAL: # MN
elif invoice.financials.currency == Currency.LOCAL:
val_seguro = Decimal(str(invoice.financials.total_increments_mn or 0)) - base_increm
invoice.financials.total_increments_mn = float(base_increm + val_seguro)
invoice.financials.total_increments_me = float(
(Decimal(str(invoice.financials.total_increments_mn)) / tc) if tc else Decimal(0)
)
elif invoice.financials.currency == Currency.MANUAL: # MC
val_seguro = (
(Decimal(str(invoice.financials.total_increments_me or 0)) / tc_mm) if tc_mm else Decimal(0)
) - base_increm
invoice.financials.total_increments_me = float((Decimal(str(invoice.financials.total_increments_mn)) / tc) if tc else Decimal(0))
elif invoice.financials.currency == Currency.MANUAL:
val_seguro = ((Decimal(str(invoice.financials.total_increments_me or 0)) / tc_mm) if tc_mm else Decimal(0)) - base_increm
invoice.financials.total_increments_me = float((base_increm + val_seguro) * tc_mm)
invoice.financials.total_increments_mn = float(Decimal(str(invoice.financials.total_increments_me)) * tc)
# Marcar la factura como procesada
invoice.status = InvoiceStatus.PROCESSED
invoice.party_count = len(lines)
# TODO: SSisGen:ActSeguridad = 1 → invoice.updated_by = current_user
# TODO: SSisGen:CalValBaseTCPed = 1 →
# invoice.process_log = "Se Actualizó con el Tipo de Cambio de la Fecha de Pago de Pedimento."
if getattr(invoice, "_cal_val_base_tc", 0) == 1:
invoice.process_log = "Se Actualizó con el Tipo de Cambio de la Fecha de Pago de Pedimento."
if getattr(invoice, "_act_seguridad", 0) == 1 and hasattr(invoice, "_username"):
invoice.updated_by = invoice._username
def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str) -> dict:
"""
Proceso principal para importar facturas.
Flujo (porta la rutina principal del legacy SCAII):
1. Validación previa de datos (pre_validators)
2. Tipo de cambio del pedimento (TODO: SSisGen:CalValBaseTCPed)
3. Revisión de clases, tipo de cambio y pesos
4. Asignación de valores por partida y totalización
5. Validaciones per-línea (costo, clase, número de parte, Regla Octava, UMA)
6. Validación de límites SisImp (TODO)
7. Si no hay errores: actualizar totales e incrementables en la factura y hacer commit
8. Si hay errores: rollback (SQLAlchemy lo maneja con la excepción)
"""
def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, username: str = "SYSTEM") -> dict:
"""Proceso principal para importar facturas."""
errors = ErrorCollector()
# Paso 1: Validación previa
lines = pre_validators(db, invoice, tenant_id, company_id, errors)
if not lines:
errors.add_error(
field="line_items",
message="La factura debe contener al menos una partida para ser importada",
solution=["Agregue partidas a la factura antes de intentar importarla"],
code="NO_LINE_ITEMS",
)
errors.add_error(field="line_items", message="La factura debe contener al menos una partida", solution=["Agregue partidas"], code="NO_LINE_ITEMS")
errors.raise_if_errors()
# TODO: SSisGen:CalValBaseTCPed = 1 → obtener tipo de cambio de la fecha de pago del pedimento
# y asignarlo a invoice.financials.exchange_rate antes de continuar.
# invoice.which_exchange_rate = 'TCPED' (o 'TCFAC' si CalValBaseTCPed = 0)
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
inv_type = (invoice.invoice_type or "").strip().upper()
op_type = "imp"
# Hierarchical resolve: invoices.types.imp.{type}.ssisgen.ssimpFormData
inv_map = settings.get("invoices", {}).get("types", {}).get(op_type, {}).get(inv_type, {})
form_data = inv_map.get("ssisgen", {}).get("ssimpFormData", {}) or inv_map.get("qsisgen", {}).get("ssimpFormData", {}) or settings.get("ssisgen", {}) or settings.get("qsisgen", {})
cal_val_base_tc = int(form_data.get("calvalbasetcped") or form_data.get("CalValBaseTCPed") or 0)
act_seguridad = int(form_data.get("actseguridad") or form_data.get("ActSeguridad") or 0)
logger.info(f"AUDIT_DEBUG: act_seguridad resolve result = {act_seguridad} for Invoice={invoice.invoice_number}")
invoice._cal_val_base_tc = cal_val_base_tc
invoice._act_seguridad = act_seguridad
invoice._username = username
exchange_rate = invoice.financials.exchange_rate if invoice.financials else 0
which_exchange_rate = "TCFAC"
if cal_val_base_tc == 1:
if invoice.compliance_mx and invoice.compliance_mx.pedimento:
pedimento = invoice.compliance_mx.pedimento
if pedimento.pedimento_dates and pedimento.pedimento_dates.payment_date:
payment_date = pedimento.pedimento_dates.payment_date
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from sqlalchemy import select
stmt = select(ExchangeRate).where(ExchangeRate.tenant_id == int(tenant_id), ExchangeRate.company_id == int(company_id), ExchangeRate.date == payment_date.date())
ex_rate_row = db.execute(stmt).scalar_one_or_none()
if ex_rate_row:
exchange_rate = float(ex_rate_row.value)
which_exchange_rate = "TCPED"
else:
errors.add_error(field="exchange_rate", message=f"No se encontró tipo de cambio para {payment_date.date()}.", solution=["Capturar TC en catálogos."], code="EXCHANGE_RATE_NOT_FOUND")
else:
errors.add_error(field="pedimento", message="El pedimento no tiene fecha de pago.", solution=["Capturar fecha de pago."], code="PEDIMENTO_NO_PAYMENT_DATE")
else:
errors.add_error(field="compliance_mx.pedimento", message="Se requiere pedimento para el TC.", solution=["Asignar pedimento."], code="PEDIMENTO_REQUIRED_FOR_TC")
if invoice.compliance_mx: invoice.compliance_mx.which_exchange_rate = which_exchange_rate
if invoice.financials: invoice.financials.exchange_rate = exchange_rate
db.flush()
# Paso 2: Revisión de clases y fracciones
review_classes(db, invoice, lines, tenant_id, company_id, errors)
review_exchange_rate(db, invoice, errors)
review_exchange_rate(db, invoice, cal_val_base_tc, errors)
if invoice.logistics and invoice.logistics.weight_type == "kgs":
review_weights_kgs(db, lines, tenant_id, company_id, errors)
elif invoice.logistics and invoice.logistics.weight_type == "lbs":
review_weights_lbs(db, lines, tenant_id, company_id, errors)
# Paso 3: Asignación de valores por partida y totalización de factura
# Para IMPO DEF / Compras Mexicanas se usa la versión con IVA por partida.
invoice_type = (invoice.invoice_type or "").strip().upper()
if invoice_type in {"DEF", "MEX"}:
assign_values_iva_lines(invoice, lines)
@@ -259,42 +233,30 @@ def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id
assign_values_lines(invoice, lines)
assign_values_invoice(invoice, lines)
# Paso 4: Validaciones per-línea
octave_desc, octave_available = _validate_lines(db, invoice, lines, tenant_id, company_id, errors)
company = db.get(Company, invoice.company_id)
if company.prosec and octave_desc:
valida_imp_regla_octava(
db=db,
desc_list=octave_desc,
dis_dict=octave_available,
tenant_id=tenant_id,
company_id=company_id,
errors=errors,
)
# Paso 5: Límites de SisImp
_validate_sisimp_limits(invoice, errors)
valida_imp_regla_octava(db=db, desc_list=octave_desc, dis_dict=octave_available, tenant_id=tenant_id, company_id=company_id, errors=errors)
_validate_sisimp_limits(invoice, settings, errors)
errors.raise_if_errors()
# Paso 6: Descontar cupos de Regla Octava
sql_errors: list = []
if octave_desc:
descuenta_cupo_r_octava(
db=db,
desc_list=octave_desc,
tenant_id=tenant_id,
company_id=company_id,
sql_errors=sql_errors,
)
descuenta_cupo_r_octava(db=db, desc_list=octave_desc, tenant_id=tenant_id, company_id=company_id, sql_errors=sql_errors)
# Paso 7: Actualizar totales, IVA e incrementables y marcar como procesada
_update_invoice_totals(invoice, lines)
# Paso 8: Generar saldos en a24.balance_movement (una entrada por partida)
if invoice_type not in {"DEF", "MEX"}:
create_balance_entries(db, invoice, lines)
db.flush()
db.flush()
if act_seguridad == 1:
from api.v1.modules.a76.audit_log.services.service import AuditService
AuditService.create_audit_log(
db=db, reference=invoice.invoice_number, procedure="ACTUALIZAR FACTURA", movement="IMPORTACION",
username=username, tenant_id=int(tenant_id), company_id=int(company_id),
table_name="invoice_header", record_id=str(invoice.id), operation_type="UPDATE"
)
db.flush()
return {"status": "success", "invoice_id": str(invoice.id)}

View File

@@ -3,6 +3,7 @@ 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.app_settings.service import AppSettingsService
from core.exceptions import ErrorCollector
def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector):
@@ -35,10 +36,34 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_
if not invoice.compliance_mx.customs_broker_id:
errors.add_required_error("compliance_mx.customs_broker_id")
#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.
# Validación de estatus de pedimento (CERRADO / PAGADO)
if invoice.compliance_mx and invoice.compliance_mx.pedimento:
ped = invoice.compliance_mx.pedimento
if ped.pedimento_dates and ped.pedimento_dates.payment_date:
from datetime import datetime
today = datetime.now().date()
pay_date = ped.pedimento_dates.payment_date.date()
# Obtener parámetros de seguridad
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
# Revisar actseguridad en ssisgen o qsisgen (según el sistema origen)
gen_params = settings.get("ssisgen", {})
if not gen_params:
gen_params = settings.get("qsisgen", {})
act_seguridad = int(gen_params.get("actseguridad", 1))
# Si la fecha es futura (> hoy) o si la seguridad está desactivada (0), permitimos con advertencia
if pay_date > today or act_seguridad == 0:
pass # Permitir la actualización, el proceso continuará
else:
errors.add_error(
field="compliance_mx.pedimento",
message=f"No se puede procesar la factura porque el pedimento {ped.pedimento_number} ya se encuentra pagado el {pay_date}.",
solution=["Si requiere hacer cambios, desactive 'Solicitar Autorización para Actualizar Facturas' en la configuración general o rectifique el pedimento."],
code="PEDIMENTO_ALREADY_PAID"
)
errors.raise_if_errors()
if not invoice.financials.exchange_rate or invoice.financials.exchange_rate <= 0:

View File

@@ -11,6 +11,7 @@ from core.exceptions import ErrorCollector
def review_exchange_rate(
db: Session,
invoice: InvoiceHeader,
cal_val_base_tc: int,
errors: ErrorCollector,
) -> None:
"""
@@ -19,10 +20,11 @@ def review_exchange_rate(
Ported from legacy REVISA_TIPOCAMBIO routine.
Only runs when the system is NOT configured to use the pedimento's exchange
rate (SisGen:CalValBaseTCPed = 0), which corresponds to the TODO comment in
main_process: the caller is responsible for skipping this call when that flag
is active.
rate (SisGen:CalValBaseTCPed = 0).
"""
if cal_val_base_tc == 1:
# SKIP: TC is derived from pedimento payment date
return
if not invoice.invoice_date:
return

View File

@@ -0,0 +1,80 @@
import logging
from decimal import Decimal
from typing import Dict, Any, Optional
from api.v1.modules.a76.invoices.models import InvoiceHeader
from core.exceptions import ErrorCollector
logger = logging.getLogger(__name__)
def review_limits(
invoice: InvoiceHeader,
settings: Dict[str, Any],
errors: ErrorCollector
) -> None:
"""
Valida que los totales de la factura no excedan los límites configurados
en SisImpo o SisDef.
"""
if not invoice.financials:
logger.warning(f"LIMIT_CHECK: No financials found for invoice {invoice.id}")
return
# Determinamos qué categoría de parámetros usar según el tipo de factura
invoice_type = (invoice.invoice_type or "").strip().upper()
op_type = "imp" # Por ahora enfocado en importación
# 1. Intentar obtener de la raíz (ssisimpo/ssisdef)
if invoice_type in {"DEF", "MEX"}:
params = settings.get("ssisdef", {})
cat_name = "ssisdef"
else:
params = settings.get("ssisimpo", {})
cat_name = "ssisimpo"
# 2. Si no están en la raíz, intentar en la estructura profunda (invoices.types...)
# Esta es la estructura que viene del frontend según el JSON "hermoso"
if not params.get("CantLimite") and not params.get("cantlimite"):
invoice_map = settings.get("invoices", {}).get("types", {}).get(op_type, {}).get(invoice_type, {})
# Buscar en ssimpFormData dentro de qsisgen o ssisgen
params = invoice_map.get("qsisgen", {}).get("ssimpFormData", {}) or invoice_map.get("ssisgen", {}).get("ssimpFormData", {}) or params
cat_name = f"invoices.types.{op_type}.{invoice_type}.ssimpFormData"
# Obtener límites Máximos
limit_qty = Decimal(str(params.get("cantlimite") or params.get("CantLimite") or 0))
limit_weight = Decimal(str(params.get("pesolimite") or params.get("PesoLimite") or 0))
limit_value = Decimal(str(params.get("valorlimite") or params.get("ValorLimite") or 0))
# Obtener límites Mínimos
min_limit_qty = Decimal(str(params.get("cantlimitemin") or params.get("CantLimiteMin") or 0))
min_limit_weight = Decimal(str(params.get("pesolimitemin") or params.get("PesoLimiteMin") or 0))
min_limit_value = Decimal(str(params.get("valorlimitemin") or params.get("ValorLimiteMin") or 0))
# Totales actuales de la factura
current_qty = Decimal(str(invoice.financials.total_quantity or 0))
current_weight = Decimal(str(invoice.financials.net_weight or 0))
current_value = Decimal(str(invoice.financials.value_me or 0))
logger.info(f"LIMIT_CHECK: Invoice={invoice.invoice_number} Type={invoice_type} Cat={cat_name}")
logger.info(f"LIMIT_CHECK: Qty: Current={current_qty} Max={limit_qty} Min={min_limit_qty}")
logger.info(f"LIMIT_CHECK: Weight: Current={current_weight} Max={limit_weight} Min={min_limit_weight}")
logger.info(f"LIMIT_CHECK: Value: Current={current_value} Max={limit_value} Min={min_limit_value}")
# --- Validaciones de Máximos ---
if limit_qty > 0 and current_qty > limit_qty:
errors.add_error(field="financials.total_quantity", message=f"La cantidad total ({current_qty}) excede el máximo permitido ({limit_qty}).", solution=["Ajuste las cantidades."], code="LIMIT_QTY_EXCEEDED")
if limit_weight > 0 and current_weight > limit_weight:
errors.add_error(field="financials.net_weight", message=f"El peso neto total ({current_weight}) excede el máximo permitido ({limit_weight}).", solution=["Ajuste los pesos."], code="LIMIT_WEIGHT_EXCEEDED")
if limit_value > 0 and current_value > limit_value:
errors.add_error(field="financials.value_me", message=f"El valor total en USD ({current_value}) excede el máximo permitido ({limit_value}).", solution=["Ajuste los costos."], code="LIMIT_VALUE_EXCEEDED")
# --- Validaciones de Mínimos ---
if min_limit_qty > 0 and current_qty < min_limit_qty:
errors.add_error(field="financials.total_quantity", message=f"La cantidad total ({current_qty}) es inferior al mínimo requerido ({min_limit_qty}).", solution=["Aumente las cantidades."], code="MIN_LIMIT_QTY_NOT_MET")
if min_limit_weight > 0 and current_weight < min_limit_weight:
errors.add_error(field="financials.net_weight", message=f"El peso neto total ({current_weight}) es inferior al mínimo requerido ({min_limit_weight}).", solution=["Aumente los pesos."], code="MIN_LIMIT_WEIGHT_NOT_MET")
if min_limit_value > 0 and current_value < min_limit_value:
errors.add_error(field="financials.value_me", message=f"El valor total en USD ({current_value}) es inferior al mínimo requerido ({min_limit_value}).", solution=["Aumente los costos."], code="MIN_LIMIT_VALUE_NOT_MET")

View File

@@ -6,12 +6,9 @@ from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.series.models import Serie
from api.v1.modules.a76.app_settings.service import AppSettingsService
from core.exceptions import ErrorCollector
# Clarion: SisGen:CantvsCantSeries
# TODO: leer desde configuración del tenant cuando SisGen esté disponible
_SISIMP_CANT_VS_CANT_SERIES: int = 0 # 0 = desactivado
# RFCs donde la validación de cantidad vs series únicamente aplica a PZA (paridad Clarion)
_RFC_EXCEPCION_PZA = {
"IMS030409FZ0",
@@ -24,7 +21,9 @@ _RFC_EXCEPCION_PZA = {
def review_series(
db: Session,
line: LineItem,
company_rfc: int,
company_rfc: str,
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> None:
"""
@@ -64,8 +63,13 @@ def review_series(
return
# GNiv:CantSerievsCant = 0 → el bloque series > cantidad estaba comentado en Clarion original
# TODO: leer SisGen:CantvsCantSeries desde la configuración del tenant
if _SISIMP_CANT_VS_CANT_SERIES != 1:
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
q_gen = settings.get("qsisgen", {})
s_gen = settings.get("ssisgen", {})
valida_cant_series = int(q_gen.get("cantvscantseries") or s_gen.get("cantvscantseries", 0))
if valida_cant_series != 1:
return
qty = line.quantity.quantity if line.quantity else None

View File

@@ -4,22 +4,10 @@ from celery import Task
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.exceptions import ErrorCollector, ValidationException
from core.exceptions import ValidationException
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.company.models import Company
from .pre_validators import pre_validators
from .sub_process.review_classes import review_classes
from .sub_process.review_exchange_rate import review_exchange_rate
from .sub_process.review_weights import review_weights_kgs, review_weights_lbs
from .sub_process.review_rule_octave import valida_imp_regla_octava, descuenta_cupo_r_octava
from .sub_process.assing_values import assign_values_lines, assign_values_invoice
from .sub_process.assing_values_def_mex import (
assign_values_iva_lines,
assign_values_invoice_totals,
)
from ..balance.create_balance_entries import create_balance_entries
from .main_process import _validate_sisimp_limits, _update_invoice_totals, _validate_lines
from .main_process import main_process
logger = logging.getLogger(__name__)
@@ -29,7 +17,7 @@ def _progress(task: Task, current: int, status: str) -> None:
@celery_app.task(bind=True, name="process_invoice_task")
def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str) -> dict:
def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str, username: str = "SYSTEM") -> dict:
"""
Procesa una factura de importación ejecutando todas las validaciones y
actualizaciones del proceso principal (main_process) con reporte de progreso.
@@ -46,95 +34,23 @@ def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id
"errors": [],
}
errors = ErrorCollector()
# ── Paso 2: Pre-validaciones ──────────────────────────────────────────
_progress(self, 10, "Validando datos de la factura...")
lines = pre_validators(db, invoice, tenant_id, company_id, errors)
if not lines:
errors.add_error(
field="line_items",
message="La factura debe contener al menos una partida para ser importada",
solution=["Agregue partidas a la factura antes de intentar importarla"],
code="NO_LINE_ITEMS",
)
errors.raise_if_errors()
# ── Paso 3: Revisión clases, tipo de cambio y pesos ──────────────────
_progress(self, 30, "Revisando clases y tipo de cambio...")
review_classes(db, invoice, lines, tenant_id, company_id, errors)
review_exchange_rate(db, invoice, errors)
if invoice.logistics and invoice.logistics.weight_type == "kgs":
review_weights_kgs(db, lines, tenant_id, company_id, errors)
elif invoice.logistics and invoice.logistics.weight_type == "lbs":
review_weights_lbs(db, lines, tenant_id, company_id, errors)
# ── Paso 4: Asignación de valores ─────────────────────────────────────
_progress(self, 50, "Calculando valores por partida...")
raw_type = invoice.invoice_type
invoice_type = (raw_type or "").strip().upper()
logger.info(
"celery import process invoice_type: invoice_id=%s raw=%r normalized=%r document_type=%r",
invoice.id,
raw_type,
invoice_type,
getattr(invoice, "document_type", None),
)
if invoice_type in {"DEF", "MEX"}:
assign_values_iva_lines(invoice, lines)
assign_values_invoice_totals(invoice, lines)
else:
assign_values_lines(invoice, lines)
assign_values_invoice(invoice, lines)
# ── Paso 5: Validaciones por partida ──────────────────────────────────
_progress(self, 70, "Validando partidas...")
octave_desc, octave_available = _validate_lines(
db, invoice, lines, tenant_id, company_id, errors
# ── Paso 2: Ejecutar Proceso Principal ───────────────────────────────
# Unificamos lógica: El task solo llama al main_process centralizado.
_progress(self, 20, "Iniciando procesamiento de factura...")
result = main_process(
db=db,
invoice=invoice,
tenant_id=tenant_id,
company_id=company_id,
username=username
)
# ── Paso 6: Regla Octava y límites SisImp ─────────────────────────────
_progress(self, 85, "Validando cupos de Regla Octava...")
company: Company | None = db.get(Company, invoice.company_id)
if company and company.prosec and octave_desc:
valida_imp_regla_octava(
db=db,
desc_list=octave_desc,
dis_dict=octave_available,
tenant_id=tenant_id,
company_id=company_id,
errors=errors,
)
_validate_sisimp_limits(invoice, errors)
errors.raise_if_errors()
# ── Paso 7: Descuento de cupos y actualización de totales ─────────────
_progress(self, 95, "Actualizando totales...")
sql_errors: list = []
if octave_desc:
descuenta_cupo_r_octava(
db=db,
desc_list=octave_desc,
tenant_id=tenant_id,
company_id=company_id,
sql_errors=sql_errors,
)
_update_invoice_totals(invoice, lines)
# ── Paso 8: Generar saldos en a24.balance_movement ───────────────────
_progress(self, 98, "Generando saldos de inventario...")
if invoice_type not in {"DEF", "MEX"}:
create_balance_entries(db, invoice, lines)
db.flush()
# ── Paso 3: Confirmar transacción ─────────────────────────────────────
_progress(self, 95, "Confirmando cambios...")
db.commit()
return {
"status": "success",
"invoice_id": invoice_id,
"sql_errors": sql_errors,
}
_progress(self, 100, "Proceso completado.")
return result
except ValidationException as exc:
db.rollback()
@@ -145,6 +61,7 @@ def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id
}
except Exception as exc:
db.rollback()
logger.error(f"Error en process_invoice_task: {str(exc)}", exc_info=True)
raise exc
finally:
db.close()

View File

@@ -1,3 +1,4 @@
from api.v1.modules.a76.app_settings.service import AppSettingsService
from decimal import Decimal
from typing import List
@@ -20,7 +21,7 @@ def _validate_returned_quantities(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
errors: ErrorCollector,
errors: ErrorCollector, cancelled_by: str = "SYSTEM",
) -> None:
"""
Verifica que ninguna partida tenga saldos pendientes por exportaciones
@@ -162,7 +163,7 @@ def revert_process(
lines: List[LineItem],
tenant_id: str,
company_id: str,
errors: ErrorCollector,
errors: ErrorCollector, cancelled_by: str = "SYSTEM",
) -> list:
"""
Proceso principal de des-actualización de una factura de importación
@@ -210,4 +211,19 @@ def revert_process(
# activos (ya validado arriba, pero se mantiene como doble seguro).
void_balance_entries(db, invoice)
# Auditoría de Desactualización
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
q_gen = settings.get("qsisgen", {})
s_gen = settings.get("ssisgen", {})
act_seguridad = int(q_gen.get("actseguridad") or s_gen.get("actseguridad") or q_gen.get("ActSeguridad") or s_gen.get("ActSeguridad", 0))
if act_seguridad == 1:
from api.v1.modules.a76.audit_log.services.service import AuditService
AuditService.create_audit_log(
db=db, reference=invoice.invoice_number, procedure="ANULAR FACTURA", movement="DESACTUALIZACION",
username=cancelled_by or "SYSTEM", tenant_id=int(tenant_id), company_id=int(company_id),
table_name="invoice_header", record_id=str(invoice.id), operation_type="UPDATE"
)
return sql_errors

View File

@@ -1,6 +1,6 @@
from sqlalchemy import exists
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.common.common_validators import invoice_exists_by_id
from api.v1.modules.a76.invoices.common.common_validators import invoice_id_required
from core.exceptions import ErrorCollector
from sqlalchemy import func
@@ -32,7 +32,7 @@ def validate_common(
errors: ErrorCollector,
line_number: int,
):
invoice: InvoiceHeader = invoice_exists_by_id(
invoice: InvoiceHeader = invoice_id_required(
db, line.invoice_id, tenant_id, company_id, errors
)
line_item: LineItem = item_exists(db, line.line_number, tenant_id, company_id)

View File

@@ -53,8 +53,16 @@ def validate_create(
if not line.class_id:
errors.add_required_error(field=f"line[{line_number}].class_id")
if not line.quantity or not line.quantity.quantity or line.quantity.quantity <= 0:
if not line.quantity or line.quantity.quantity is None:
errors.add_required_error(field=f"line[{line_number}].quantity.quantity")
elif line.quantity.quantity <= 0:
errors.add_error(
field=f"line[{line_number}].quantity.quantity",
message=f"La cantidad debe ser mayor a cero (recibido: {line.quantity.quantity})",
solution=["Capturar una cantidad válida mayor a cero."],
code="INVALID_QUANTITY",
value=float(line.quantity.quantity)
)
# TODO: Añadir validacion SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf <-- de la tabla de preferencias de el sistema
# if SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf == False:
@@ -68,8 +76,16 @@ def validate_create(
field=f"line[{line_number}].financial.unit_cost_capture"
)
if not line.quantity or not line.quantity.net_weight or line.quantity.net_weight <= 0:
if not line.quantity or line.quantity.net_weight is None:
errors.add_required_error(field=f"line[{line_number}].quantity.net_weight")
elif line.quantity.net_weight <= 0:
errors.add_error(
field=f"line[{line_number}].quantity.net_weight",
message=f"El peso neto debe ser mayor a cero (recibido: {line.quantity.net_weight})",
solution=["Capturar un peso neto válido mayor a cero."],
code="INVALID_NET_WEIGHT",
value=float(line.quantity.net_weight)
)
if not line.customs or not line.customs.origin_country:
errors.add_required_error(field=f"line[{line_number}].customs.origin_country")

View File

@@ -184,28 +184,28 @@ def validate_update(
line.order = existing_line.order
# Descripciones
if not line.description.description_spanish:
if line.description.description_spanish is None:
line.description.description_spanish = (
existing_line.description.description_spanish
)
if not line.description.description_english:
if line.description.description_english is None:
line.description.description_english = (
existing_line.description.description_english
)
if not line.description.extra_description:
if line.description.extra_description is None:
line.description.extra_description = (
existing_line.description.extra_description
)
# Marca y modelo
if line.description.brand:
if line.description.brand is not None:
line.description.brand = line.description.brand.upper().strip()
else:
line.description.brand = existing_line.description.brand
if line.description.model:
if line.description.model is not None:
line.description.model = line.description.model.upper().strip()
else:
line.description.model = existing_line.description.model

View File

@@ -81,6 +81,11 @@ def calculate_values(
return
currency, currency_type, exchange_rate = result
# Safety guard: Ensure nested objects exist before calculating
if not line.financial or not line.quantity:
return
# Prioridad: currency_type para alinear con create.py y CSV
if currency_type in ("USD", "ME"):
currency = "foreign"

View File

@@ -1,6 +1,6 @@
from sqlalchemy import exists
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.common.common_validators import invoice_exists_by_id
from api.v1.modules.a76.invoices.common.common_validators import invoice_id_required
from api.v1.modules.a76.items.imports.validators.calculations import apply_calculations
from core.exceptions import ErrorCollector
from sqlalchemy import func
@@ -36,7 +36,7 @@ def validate_common(
errors: ErrorCollector,
line_number: int,
):
invoice: InvoiceHeader = invoice_exists_by_id(
invoice: InvoiceHeader = invoice_id_required(
db, line.invoice_id, tenant_id, company_id, errors
)
line_item: LineItem = item_exists(db, line.line_number, tenant_id, company_id)

View File

@@ -52,8 +52,16 @@ def validate_create(
if not line.class_id:
errors.add_required_error(field=f"line[{line_number}].class_id")
if not line.quantity or not line.quantity.quantity or line.quantity.quantity <= 0:
if not line.quantity or line.quantity.quantity is None:
errors.add_required_error(field=f"line[{line_number}].quantity.quantity")
elif line.quantity.quantity <= 0:
errors.add_error(
field=f"line[{line_number}].quantity.quantity",
message=f"La cantidad debe ser mayor a cero (recibido: {line.quantity.quantity})",
solution=["Capturar una cantidad válida mayor a cero."],
code="INVALID_QUANTITY",
value=float(line.quantity.quantity)
)
# TODO: Añadir validacion SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf <-- de la tabla de preferencias de el sistema
# if SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf == False:
@@ -67,8 +75,16 @@ def validate_create(
field=f"line[{line_number}].financial.unit_cost_capture"
)
if not line.quantity or not line.quantity.net_weight or line.quantity.net_weight <= 0:
if not line.quantity or line.quantity.net_weight is None:
errors.add_required_error(field=f"line[{line_number}].quantity.net_weight")
elif line.quantity.net_weight <= 0:
errors.add_error(
field=f"line[{line_number}].quantity.net_weight",
message=f"El peso neto debe ser mayor a cero (recibido: {line.quantity.net_weight})",
solution=["Capturar un peso neto válido mayor a cero."],
code="INVALID_NET_WEIGHT",
value=float(line.quantity.net_weight)
)
if not line.customs or not line.customs.origin_country:
errors.add_required_error(field=f"line[{line_number}].customs.origin_country")

View File

@@ -183,35 +183,51 @@ def validate_update(
line.order = existing_line.order
# Descripciones
if not line.description.description_spanish:
if line.description.description_spanish is None:
line.description.description_spanish = (
existing_line.description.description_spanish
)
if not line.description.description_english:
if line.description.description_english is None:
line.description.description_english = (
existing_line.description.description_english
)
if not line.description.extra_description:
if line.description.extra_description is None:
line.description.extra_description = (
existing_line.description.extra_description
)
# Marca y modelo
if line.description.brand:
# Brand and model
if line.description.brand is not None:
line.description.brand = line.description.brand.upper().strip()
else:
line.description.brand = existing_line.description.brand
if line.description.model:
if line.description.model is not None:
line.description.model = line.description.model.upper().strip()
else:
line.description.model = existing_line.description.model
# Subpartidas (si aplica)
# TODO: Implementar lógica de subpartidas si Loc:LevantarSubpartidas = 'S'
# --- Resolve Settings for inherited parameters ---
from api.v1.modules.a76.app_settings.service import AppSettingsService
settings = AppSettingsService.get_resolved_settings(db, tenant_id, company_id)
inv_type = (invoice.invoice_type or "").strip().upper()
op_type = (invoice.operation_type or "").strip().lower() # 'imp' or 'exp'
# Helper to get nested value from invoices.types.{op}.{type}.ssisgen.ssimpFormData
inv_map = settings.get("invoices", {}).get("types", {}).get(op_type, {}).get(inv_type, {})
# Prefeir ssisgen for this type, then qsisgen, then root ssimpo
form_data = inv_map.get("ssisgen", {}).get("ssimpFormData", {}) or inv_map.get("qsisgen", {}).get("ssimpFormData", {}) or settings.get("ssimpo", {})
# Subpartidas (si aplica)
# Clarion: LOC:LevantarSubpartidas = S
levantar_sub = bool(form_data.get("levantar_subpartidas") or form_data.get("LevantarSubpartidas") or False)
if levantar_sub:
# TODO: Add specific sub-item validation if needed (e.g. parent_line mandatory if it's a subpartida)
# Currently we just ensure the field is carried over if not provided
pass
# Número de parte
if not line.part_number_id:
@@ -229,7 +245,9 @@ def validate_update(
if not line.valuation_method:
if existing_line.valuation_method:
line.valuation_method = existing_line.valuation_method
# else: TODO: Tomar de SisImp:MetValor (preferencias del sistema)
else:
# Tomar de SisImp/SisDef:MetValor (preferencias del sistema)
line.valuation_method = form_data.get("metvalor") or form_data.get("MetValor")
# Número de entrada
if not line.description.entry_number:

View File

@@ -14,7 +14,6 @@ from core.database import Base
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
if TYPE_CHECKING:
from .line_financials.models import LineFinancial
@@ -22,8 +21,6 @@ if TYPE_CHECKING:
from .line_customs.models import LineCustom
from .line_descriptions.models import LineDescription
from .line_references.models import LineReference
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.invoices.models import InvoiceHeader
@@ -222,7 +219,7 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
uselist=False,
)
identifiers: Mapped[List["IdentifierDetail"]] = relationship(
IdentifierDetail,
"IdentifierDetail",
back_populates="line",
cascade="all, delete-orphan",
)
@@ -231,6 +228,11 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
foreign_keys=[part_number_id],
viewonly=True,
)
component_part_info: Mapped[Optional["Part"]] = relationship(
"Part",
foreign_keys=[component_part_number_id],
viewonly=True,
)
# ============================================================================
@@ -387,3 +389,14 @@ MIGRATION STRATEGY FROM ORIGINAL TABLES TO NORMALIZED SCHEMA:
)
```
"""
# ============================================================================
# RUNTIME IMPORTS FOR MAPPER RESOLUTION
# ============================================================================
# We import these specialized models at the bottom to ensure they are registered
# in the SQLAlchemy metadata for relationship resolution while avoiding
# circular import issues in the module head.
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.items.series.models import Serie
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail

View File

@@ -44,7 +44,7 @@ async def create_item(
- Each LineItem has one LineReference
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = ItemService()
return service.create(db, item_data, tenant_id, company_id)

View File

@@ -290,13 +290,15 @@ class LineItemResponse(LineItemBase):
# Part identification
part_number_id: Optional[int] = Field(
None, alias="part_number", serialization_alias="part_number_id"
None, alias="part_number_id_input", serialization_alias="part_number_id"
)
part_number: Optional[str] = None
component_part_number_id: Optional[int] = Field(
None,
alias="component_part_number",
alias="component_part_number_id_input",
serialization_alias="component_part_number_id",
)
component_part_number: Optional[str] = None
class_id: Optional[int] = None
# Nested data
@@ -325,33 +327,65 @@ class LineItemResponse(LineItemBase):
@model_validator(mode="before")
@classmethod
def extract_relationship_info(cls, data: Any) -> Any:
"""Extract class_code, class_description and unit_of_measure_code from relationships"""
"""Extract information from joined relationships to provide flat mapping for UI."""
if isinstance(data, dict):
# If already a dict, ensure description syncs to top-level if missing
desc = data.get("description", {})
if isinstance(desc, dict):
if not data.get("part_description_es"):
data["part_description_es"] = desc.get("description_spanish")
if not data.get("part_description_en"):
data["part_description_en"] = desc.get("description_english")
return data
# It's an ORM object
result = {}
for key in cls.model_fields.keys():
if hasattr(data, key):
result[key] = getattr(data, key)
# 1. Start with model attributes (columns)
if hasattr(data, "__table__"):
for k in data.__table__.columns.keys():
result[k] = getattr(data, k, None)
else:
# Fallback for non-table objects if any
for k, v in data.__dict__.items():
if not k.startswith("_"):
result[k] = v
# Map model field names to schema field names for aliased fields
if hasattr(data, "part_number"):
result["part_number_id"] = data.part_number
if hasattr(data, "component_part_number"):
result["component_part_number_id"] = data.component_part_number
# Alias mapping for part numbers
if hasattr(data, "part_number_id") and "part_number_id" not in result:
result["part_number_id"] = data.part_number_id
if hasattr(data, "component_part_number_id") and "component_part_number_id" not in result:
result["component_part_number_id"] = data.component_part_number_id
# Extract part info (string part numbers) from relationship objects
if hasattr(data, "part_info") and data.part_info is not None:
result["part_number"] = getattr(data.part_info, "part_number", None)
if hasattr(data, "component_part_info") and data.component_part_info is not None:
result["component_part_number"] = getattr(data.component_part_info, "part_number", None)
# Extract class info
if hasattr(data, "class_info") and data.class_info is not None:
result["class_code"] = data.class_info.class_code
result["class_description"] = data.class_info.description_es
result["class_code"] = getattr(data.class_info, "class_code", None)
result["class_description"] = getattr(data.class_info, "description_es", None)
# Extract unit of measure code
if (
hasattr(data, "unit_of_measure_info")
and data.unit_of_measure_info is not None
):
result["unit_of_measure_code"] = data.unit_of_measure_info.code
if hasattr(data, "unit_of_measure_info") and data.unit_of_measure_info is not None:
result["unit_of_measure_code"] = getattr(data.unit_of_measure_info, "code", None)
# 2. Extract nested objects and populate redundant descriptions
# We MUST use explicit getattr for relationships to ensure SQLAlchemy loads/uses joined-loaded ones
for key in ["financial", "quantity", "customs", "description", "reference", "fa_data", "series", "identifiers"]:
val = getattr(data, key, None)
if val is not None:
result[key] = val
# Sync to top-level for description redundancy (huge boost for UI stability)
if key == "description":
result["part_description_es"] = getattr(val, "description_spanish", None)
result["part_description_en"] = getattr(val, "description_english", None)
else:
# Provide default empty dict for core containers to help frontend
if key in ["financial", "quantity", "customs", "description"]:
result[key] = {}
return result

View File

@@ -16,10 +16,13 @@ class Serie(Base, TenantScopedMixin, TimestampMixin):
serial_numbers: Mapped[Optional[str]] = mapped_column(String(50)) # SERIEEXPO
model: Mapped[Optional[str]] = mapped_column(String(50)) # MODELOEXPO
sub_model: Mapped[Optional[str]] = mapped_column(String(50)) # SUBMODELOEXPO
brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCAEXPO
brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCA
# expo_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
# import_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURAIMPO
# import_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEAIMPO
image_path: Mapped[Optional[str]] = mapped_column(String(255)) # PATH DE IMAGEN (MEX)

View File

@@ -8,10 +8,10 @@ class SerieBase(BaseModel):
model: Optional[str] = Field(None, max_length=50, description="Model (MODELOEXPO)")
sub_model: Optional[str] = Field(None, max_length=50, description="Sub model (SUBMODELOEXPO)")
brand: Optional[str] = Field(None, max_length=50, description="Brand (MARCA)")
expo_brad: Optional[str] = Field(None, max_length=50, description="Expo brand (MARCAEXPO)")
# expo_brand: Optional[str] = Field(None, max_length=50, description="Expo brand (MARCAEXPO)")
number_id: Optional[str] = Field(None, max_length=25, description="Number ID (NUMIDEXPO)")
import_invoice: Optional[str] = Field(None, max_length=15, description="Import invoice (FACTURAIMPO)")
import_line: Optional[int] = Field(None, description="Import line (LINEAIMPO)")
# import_invoice: Optional[str] = Field(None, max_length=15, description="Import invoice (FACTURAIMPO)")
# import_line: Optional[int] = Field(None, description="Import line (LINEAIMPO)")
image_path: Optional[str] = Field(None, max_length=255, description="Image path (IMAGEPATHMEX)")

View File

@@ -15,7 +15,7 @@ There is no intermediate Item entity anymore. Each LineItem belongs directly to
import datetime
import logging
from decimal import Decimal
from typing import Optional, List, Tuple
from typing import Any, Optional, List, Tuple
from fastapi import HTTPException
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.exc import IntegrityError
@@ -145,6 +145,16 @@ class ItemService:
)
return None
@staticmethod
def _filter_model_data(data: dict, model_class: Any) -> dict:
"""Filter a dictionary to only include keys that exist as attributes in the model class."""
if not data:
return {}
from sqlalchemy import inspect
mapper = inspect(model_class)
valid_keys = set(mapper.columns.keys())
return {k: v for k, v in data.items() if k in valid_keys}
@staticmethod
def _create_line_nested_data(
db: Session, line: LineItem, line_data, tenant_id: int, company_id: int
@@ -166,7 +176,9 @@ class ItemService:
else data.model_dump()
)
nested_dict["item_line_id"] = line.id
db.add(model_class(**nested_dict))
# Filter dict against model attributes
filtered_dict = ItemService._filter_model_data(nested_dict, model_class)
db.add(model_class(**filtered_dict))
# FA data uses line.id as primary key
if line_data.fa_data:
@@ -185,7 +197,8 @@ class ItemService:
if isinstance(line_data.series, list)
else [line_data.series]
)
for s in series_list:
new_series = []
for i, s in enumerate(series_list):
serie_dict = (
s.model_dump(exclude_unset=True)
if hasattr(s, "model_dump")
@@ -193,12 +206,16 @@ class ItemService:
)
if not serie_dict:
continue
serie_dict["line_item_id"] = line.id
serie_dict["tenant_id"] = tenant_id
serie_dict["company_id"] = company_id
serie_dict.update({
"tenant_id": tenant_id,
"company_id": company_id
})
if serie_dict.get("row") is None:
serie_dict["row"] = 1
db.add(Serie(**serie_dict))
serie_dict["row"] = i + 1
# Filter dict against model attributes
filtered_s = ItemService._filter_model_data(serie_dict, Serie)
db.add(Serie(**filtered_s))
# Identifier Detail data
if hasattr(line_data, "identifiers") and line_data.identifiers:
@@ -207,6 +224,7 @@ class ItemService:
if isinstance(line_data.identifiers, list)
else [line_data.identifiers]
)
new_ids = []
for d in id_list:
id_dict = (
d.model_dump(exclude_unset=True)
@@ -215,10 +233,15 @@ class ItemService:
)
if not id_dict:
continue
id_dict["item_line_id"] = line.id
id_dict["tenant_id"] = tenant_id
id_dict["company_id"] = company_id
db.add(IdentifierDetail(**id_dict))
id_dict.update({
"tenant_id": tenant_id,
"company_id": company_id
})
# Filter dict against model attributes
filtered_id = ItemService._filter_model_data(id_dict, IdentifierDetail)
new_ids.append(IdentifierDetail(**filtered_id))
line.identifiers = new_ids
@staticmethod
def _attach_series(db: Session, item: LineItem) -> None:
@@ -241,6 +264,7 @@ class ItemService:
)
item.identifiers = list(identifiers)
@staticmethod
def get_by_id(
db: Session, item_id: int, tenant_id: int, company_id: int
@@ -257,6 +281,8 @@ class ItemService:
joinedload(LineItem.class_info),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.fa_data),
joinedload(LineItem.part_info),
joinedload(LineItem.component_part_info),
)
.filter(
LineItem.id == item_id,
@@ -293,6 +319,8 @@ class ItemService:
joinedload(LineItem.class_info),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.fa_data),
joinedload(LineItem.part_info),
joinedload(LineItem.component_part_info),
)
.filter(
LineItem.tenant_id == tenant_id,
@@ -366,6 +394,8 @@ class ItemService:
joinedload(LineItem.description),
joinedload(LineItem.reference),
joinedload(LineItem.fa_data),
joinedload(LineItem.part_info),
joinedload(LineItem.component_part_info),
)
.filter(
LineItem.invoice_id == invoice_id,
@@ -505,20 +535,42 @@ class ItemService:
)
# Create the item
# Filter main item_dict against LineItem model attributes
item_dict = ItemService._filter_model_data(item_dict, LineItem)
db_item = LineItem(**item_dict)
db.add(db_item)
db.flush() # Get the item ID
# Create all nested data
# Create nested data
ItemService._create_line_nested_data(
db, db_item, item_data, tenant_id, company_id
)
db.commit()
db.refresh(db_item)
ItemService._attach_series(db, db_item)
ItemService._attach_identifiers(db, db_item)
return db_item
# Eager load EVERYTHING needed for the response before returning
final_item = (
db.query(LineItem)
.options(
joinedload(LineItem.financial),
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.description),
joinedload(LineItem.reference),
joinedload(LineItem.class_info),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.fa_data),
joinedload(LineItem.part_info),
joinedload(LineItem.component_part_info),
)
.filter(LineItem.id == db_item.id)
.first()
)
if final_item:
ItemService._attach_series(db, final_item)
ItemService._attach_identifiers(db, final_item)
return final_item
except IntegrityError as e:
db.rollback()
@@ -648,53 +700,111 @@ class ItemService:
"reference",
"fa_data",
"series",
"identifiers",
},
exclude_unset=True,
)
# Update item fields
# CONDITIONAL update of nested data to prevent data loss
# Perform in-place updates for one-to-one relations, full replacement for one-to-many
# Update item attributes
# Filter main item_dict against LineItem model attributes
item_dict = ItemService._filter_model_data(item_dict, LineItem)
for key, value in item_dict.items():
setattr(db_item, key, value)
# Delete existing nested data
db.query(LineFinancial).filter(
LineFinancial.item_line_id == db_item.id
).delete()
db.query(LineQuantity).filter(
LineQuantity.item_line_id == db_item.id
).delete()
db.query(LineCustom).filter(LineCustom.item_line_id == db_item.id).delete()
db.query(LineDescription).filter(
LineDescription.item_line_id == db_item.id
).delete()
db.query(LineReference).filter(
LineReference.item_line_id == db_item.id
).delete()
db.query(FaLineItem).filter(FaLineItem.id == db_item.id).delete()
db.query(Serie).filter(Serie.line_item_id == db_item.id).delete()
db.query(IdentifierDetail).filter(IdentifierDetail.item_line_id == db_item.id).delete()
db.flush()
# 2. Update nested one-to-one objects (In-place update)
nested_relations = [
('financial', LineFinancial, 'item_line_id'),
('quantity', LineQuantity, 'item_line_id'),
('customs', LineCustom, 'item_line_id'),
('description', LineDescription, 'item_line_id'),
('reference', LineReference, 'item_line_id')
]
# Create new nested data
ItemService._create_line_nested_data(
db, db_item, item_data, tenant_id, company_id
)
for attr_name, model_class, fk_name in nested_relations:
attr_data = getattr(item_data, attr_name)
if attr_data is not None:
db_nested = getattr(db_item, attr_name)
nested_dict = attr_data.model_dump(exclude_unset=True)
if db_nested:
# Update existing
for k, v in nested_dict.items():
setattr(db_nested, k, v)
else:
# Create new
nested_dict[fk_name] = db_item.id
new_nested = model_class(**nested_dict)
setattr(db_item, attr_name, new_nested)
db.add(new_nested)
# 3. Handle fa_data (special case as PK is shared)
if item_data.fa_data is not None:
fa_dict = item_data.fa_data.model_dump(
exclude_unset=True, exclude={"line_item_id", "includes_subitems"}
)
if db_item.fa_data:
for k, v in fa_dict.items():
setattr(db_item.fa_data, k, v)
else:
fa_dict.update({
"id": db_item.id,
"tenant_id": tenant_id,
"company_id": company_id
})
db_item.fa_data = FaLineItem(**fa_dict)
db.add(db_item.fa_data)
# 4. Handle one-to-many arrays (Full replacement as these are collections)
if item_data.series is not None:
# Use synchronize_session='fetch' to ensure the session knows about the deletions
db.query(Serie).filter(Serie.line_item_id == db_item.id).delete(synchronize_session='fetch')
for s_data in item_data.series:
s_dict = s_data.model_dump(exclude_unset=True)
s_dict.update({
"line_item_id": db_item.id,
"tenant_id": tenant_id,
"company_id": company_id
})
if s_dict.get("row") is None:
s_dict["row"] = 1
# Filter dict against model attributes
filtered_s = ItemService._filter_model_data(s_dict, Serie)
db.add(Serie(**filtered_s))
if item_data.identifiers is not None:
db.query(IdentifierDetail).filter(IdentifierDetail.item_line_id == db_item.id).delete()
for d in item_data.identifiers:
id_dict = d.model_dump(exclude_unset=True)
id_dict.update({
"item_line_id": db_item.id,
"tenant_id": tenant_id,
"company_id": company_id
})
# Filter dict against model attributes
filtered_id = ItemService._filter_model_data(id_dict, IdentifierDetail)
db.add(IdentifierDetail(**filtered_id))
db.flush()
# Renumber all lines for this invoice to ensure consecutive numbering
ItemService._renumber_all_invoice_lines(db, db_item.invoice_id)
db.commit()
db.refresh(db_item)
ItemService._attach_series(db, db_item)
ItemService._attach_identifiers(db, db_item)
db.refresh(db_item, ["financial", "quantity", "customs", "description", "reference", "fa_data", "identifiers"])
return db_item
except HTTPException:
raise
except Exception as e:
db.rollback()
import traceback
logger.error(f"Unexpected error updating item: {e}")
raise HTTPException(status_code=500, detail="Error updating item")
raise HTTPException(status_code=500, detail=f"Error updating item: {str(e)}")
@staticmethod
def delete(

View File

@@ -11,7 +11,7 @@ from fastapi import APIRouter, File, HTTPException, Query, UploadFile, Depends
from sqlalchemy.orm import Session
from typing import Dict, Any
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.core.tasks_tracking import track_and_dispatch
@@ -109,6 +109,7 @@ async def upload_import_file(
@router.get("/{job_id}/status")
async def get_import_status(job_id: str):
from core.celery_app import celery_app
task_result = celery_app.AsyncResult(job_id)
if task_result.state == "PENDING":

View File

@@ -191,6 +191,7 @@ class Pedimentos(Base, TenantScopedMixin, TimestampMixin):
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
lazy="joined",
)
pedimento_decrementables: Mapped["PedimentoDecrementables"] = relationship(
"PedimentoDecrementables",

View File

@@ -5,7 +5,6 @@ from celery.result import AsyncResult
from sqlalchemy import asc, desc, func, or_
from sqlalchemy.orm import Session
from core.celery_app import celery_app
from .models import TaskRun, TaskStatus
@@ -142,6 +141,7 @@ class TaskTrackerService:
return row
def sync_task(self, task_run: TaskRun) -> TaskRun:
from core.celery_app import celery_app
async_result = celery_app.AsyncResult(task_run.task_id)
raw_state = (async_result.state or "PENDING").upper()
normalized = normalize_celery_state(raw_state)