TODOS de configuracion general lista
This commit is contained in:
@@ -103,8 +103,7 @@ class AuditService:
|
||||
)
|
||||
|
||||
db.add(log)
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
db.flush()
|
||||
return log
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user