Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into feature/partePais_BOM

This commit is contained in:
2026-03-13 11:13:44 -05:00
84 changed files with 33338 additions and 1104 deletions

View File

@@ -43,6 +43,9 @@ class ClassCreateDTO(BaseModel):
iva_exempt_fraction: Optional[str] = Field(
None, max_length=4, description="IVA exempt fraction"
)
is_active: Optional[bool] = Field(
True, description="Indicates if the class is active (default: true)"
)
class Config:
from_attributes = True
@@ -76,6 +79,9 @@ class ClassCreateDTOFA(ClassCreateDTO):
class_enabled: Optional[bool] = Field(
True, description="Indica si la clase está habilitada"
)
is_active: Optional[bool] = Field(
True, description="Indicates if the class is active (default: true)"
)
class Config:
from_attributes = True
@@ -116,6 +122,9 @@ class ClassUpdateDTO(BaseModel):
iva_exempt_fraction: Optional[str] = Field(
None, max_length=4, description="IVA exempt fraction"
)
is_active: Optional[bool] = Field(
True, description="Indicates if the class is active (default: true)"
)
model_config = ConfigDict(from_attributes=True, extra='forbid') # Explicitly forbid extra fields
@@ -136,6 +145,7 @@ class ClassResponseDTO(BaseModel):
sub_key: Optional[str] = None
physical_review: Optional[int] = None
iva_exempt_fraction: Optional[str] = None
is_active: Optional[bool] = None
created_at: datetime
updated_at: datetime

View File

@@ -84,13 +84,17 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
String(4)
) # FRACCIONEXENTAIVA
is_active: Mapped[bool] = mapped_column(
default=True, server_default="true", nullable=False
) # Campo para habilitar/deshabilitar clases sin eliminarlas
# Relationships
material_type: Mapped[Optional["MaterialType"]] = relationship(
foreign_keys=[material_key]
)
unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
foreign_keys=[unit_of_measure]
)
)
# Inverse relationship with GParts that have this class
parts: Mapped[list["Part"]] = relationship(

View File

@@ -1,32 +0,0 @@
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
class FractionRuleOctave(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "fraction_rule_octave"
__table_args__ = (
PrimaryKeyConstraint("id", name="fraction_rule_octave_pkey"),
UniqueConstraint(
"tenant_id",
"company_id",
"permission",
"line",
"fraction",
name="uq_fraction_rule_octave_permission_line_fraction",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
permission: Mapped[str] = mapped_column(String(20))
line: Mapped[int] = mapped_column(Integer)
fraction: Mapped[str] = mapped_column(String(10))

View File

@@ -15,6 +15,7 @@ from .models import Company
from ...audit_log.services.service import AuditService
from ..units_of_measure.seed import seed as units_of_measure_seed
from ..fractions.historical_tariff_fractions.seed import seed as historical_tariff_fractions_seed
from ..fractions.warning_fractions.seed import seed as warning_fractions_seed
from core.context import get_user_context
from sqlalchemy import text
@@ -755,6 +756,24 @@ class CompanyService:
"""))
db.execute(text("SET session_replication_role = DEFAULT;"))
# 3. Warning Fractions
values_warning = ", ".join(
[
f"({format_value(fraction)}, {format_value(description)}, {format_value(warning_type)}, {tenant_id}, {company_id})"
for fraction, description, warning_type in warning_fractions_seed
]
)
if values_warning:
db.execute(text("ALTER TABLE public.warning_fractions DISABLE TRIGGER ALL;"))
db.execute(text(f"""
INSERT INTO public.warning_fractions
(fraction, description, warning_type, tenant_id, company_id)
VALUES {values_warning}
ON CONFLICT (fraction, company_id) DO NOTHING;
"""))
db.execute(text("ALTER TABLE public.warning_fractions ENABLE TRIGGER ALL;"))
def get_companies_by_tenant(self, tenant_id: int) -> List[Company]:
"""Get all companies for a tenant"""
return (

View File

@@ -0,0 +1,24 @@
from typing import Optional
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class PreviousFraction(Base, TenantScopedMixin, TimestampMixin):
"""
Tabla de correlación entre fracciones anteriores y actuales.
Paridad: SFraccionesAnterioresN (Clarion SCAII).
Se usa en REVISA_UM_REGLA_OCTAVA para aceptar fracciones cambiadas
cuando el permiso de RO tiene fecha de inicio anterior al corte (~2010-05-31).
"""
__tablename__ = "previous_fractions"
__table_args__ = {"schema": "a76"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
current_fraction: Mapped[Optional[str]] = mapped_column(String(50)) # FraccionActual
previous_fraction: Mapped[Optional[str]] = mapped_column(String(50)) # FraccionAnterior

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
"""
Modelos ORM para fracciones de alerta
"""
from typing import Optional
from sqlalchemy import Boolean, Integer, PrimaryKeyConstraint, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class WarningFraction(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para fracciones arancelarias con alerta.
Fracciones que requieren atención especial o documentación adicional.
"""
__tablename__ = "warning_fractions"
__table_args__ = (
PrimaryKeyConstraint("id", name="warning_fractions_pkey"),
UniqueConstraint(
"fraction", "company_id", name="uq_warning_fractions_fraction_company"
),
{"schema": "public"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Código de la fracción arancelaria (ej: 01012101)
fraction: Mapped[str] = mapped_column(String(15), nullable=False, index=True)
# Descripción de la alerta
description: Mapped[Optional[str]] = mapped_column(String(1000))
# Tipo de alerta (ej: PERMISO, RESTRICCION, NOM, etc.)
warning_type: Mapped[Optional[str]] = mapped_column(String(50), index=True)
# Indica si la alerta está activa
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
def __repr__(self) -> str:
return (
f"<WarningFraction(fraction='{self.fraction}', "
f"warning_type='{self.warning_type}', is_active={self.is_active})>"
)

View File

@@ -0,0 +1,141 @@
seed = [
("8701210100", "", "PROCESS"),
("8702100500", "", "PROCESS"),
("8702400600", "", "PROCESS"),
("8703310200", "", "PROCESS"),
("8703500200", "", "PROCESS"),
("8704210400", "", "PROCESS"),
("8704410200", "", "PROCESS"),
("8705400200", "", "PROCESS"),
("8711300401", "", "PROCESS"),
("8711409999", "", "PROCESS"),
("8711900100", "", "PROCESS"),
("8716200499", "", "PROCESS"),
("8716390400", "", "PROCESS"),
("8716399903", "", "PROCESS"),
("8716409100", "", "PROCESS"),
("8716900300", "", "PROCESS"),
("8716909901", "", "PROCESS"),
("8716909902", "", "PROCESS"),
("8716909999", "", "PROCESS"),
("8711100300", "", "PROCESS"),
("8711200501", "", "PROCESS"),
("8711200502", "", "PROCESS"),
("8711200599", "", "PROCESS"),
("8711300499", "", "PROCESS"),
("8711400100", "", "PROCESS"),
("8711400200", "", "PROCESS"),
("8711409901", "", "PROCESS"),
("8711500301", "", "PROCESS"),
("8711500302", "", "PROCESS"),
("8711500399", "", "PROCESS"),
("8711600100", "", "PROCESS"),
("8711909900", "", "PROCESS"),
("8716100100", "", "PROCESS"),
("8716200401", "", "PROCESS"),
("8716200402", "", "PROCESS"),
("8703210200", "", "PROCESS"),
("8703220200", "", "PROCESS"),
("8703230200", "", "PROCESS"),
("8703240200", "", "PROCESS"),
("8716310301", "", "PROCESS"),
("8716310302", "", "PROCESS"),
("8716310399", "", "PROCESS"),
("8716390300", "", "PROCESS"),
("8703320200", "", "PROCESS"),
("8703330200", "", "PROCESS"),
("8703400200", "", "PROCESS"),
("8703600200", "", "PROCESS"),
("8703700200", "", "PROCESS"),
("8703900200", "", "PROCESS"),
("8716390800", "", "PROCESS"),
("8716399901", "", "PROCESS"),
("8716399902", "", "PROCESS"),
("8701220100", "", "PROCESS"),
("8701230100", "", "PROCESS"),
("8701240100", "", "PROCESS"),
("8701290100", "", "PROCESS"),
("8704220700", "", "PROCESS"),
("8704230200", "", "PROCESS"),
("8704310500", "", "PROCESS"),
("8704320700", "", "PROCESS"),
("8704420200", "", "PROCESS"),
("8704430200", "", "PROCESS"),
("8704510300", "", "PROCESS"),
("8704520200", "", "PROCESS"),
("8716399904", "", "PROCESS"),
("8716399905", "", "PROCESS"),
("8716399999", "", "PROCESS"),
("8702200500", "", "PROCESS"),
("8702300500", "", "PROCESS")
]

View File

@@ -0,0 +1 @@
# a76 general_catalogs.location

View File

@@ -0,0 +1,36 @@
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from .models import LocationSystem
class LocationBase(BaseModel):
clave_localizacion: str = Field(
..., max_length=20, description="Clave/código de la localización"
)
localizacion: Optional[str] = Field(
None, max_length=200, description="Nombre o descripción"
)
system: LocationSystem = Field(
..., description="Contexto: fixed_asset (FA) o inventory"
)
class LocationCreate(LocationBase):
"""Optional extra fields for fixed_asset; used only when system == FIXED_ASSET."""
department: Optional[str] = Field(None, max_length=100)
responsible: Optional[str] = Field(None, max_length=200)
observations: Optional[str] = Field(None, description="Free text")
class LocationUpdate(BaseModel):
clave_localizacion: Optional[str] = Field(None, max_length=20)
localizacion: Optional[str] = Field(None, max_length=200)
system: Optional[LocationSystem] = None
class LocationResponse(LocationBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,84 @@
"""
Modelo ORM para catálogo de localización (a76).
Tabla compartida para contexto Fixed Asset (FA) e inventory.
"""
import enum
from typing import Optional
from sqlalchemy import Integer, String, Text, UniqueConstraint, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class LocationSystem(str, enum.Enum):
"""Contexto de uso de la localización."""
FIXED_ASSET = "fixed_asset"
INVENTORY = "inventory"
class Location(Base, TenantScopedMixin, TimestampMixin):
"""
Catálogo de localización (clave + localizacion), compartido por FA e inventory.
"""
__tablename__ = "location"
__table_args__ = (
UniqueConstraint(
"clave_localizacion",
"tenant_id",
"company_id",
"system",
name="uq_location_clave_tenant_company_system",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True
)
clave_localizacion: Mapped[str] = mapped_column(String(20), nullable=False)
localizacion: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
system: Mapped[str] = mapped_column(
String(20), nullable=False
) # 'fixed_asset' | 'inventory'
def __repr__(self) -> str:
return (
f"<Location(id={self.id}, clave_localizacion={self.clave_localizacion!r}, "
f"localizacion={self.localizacion!r}, system={self.system!r})>"
)
class FaLocationExt(Base, TenantScopedMixin, TimestampMixin):
"""
Extra info for Fixed Asset locations only (1:1 with Location).
Table: Fa_Location_Ext (fa_location_ext).
"""
__tablename__ = "fa_location_ext"
__table_args__ = (
UniqueConstraint("location_id", name="uq_fa_location_ext_location_id"),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True
)
location_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("a76.location.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
department: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
responsible: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
observations: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
def __repr__(self) -> str:
return (
f"<FaLocationExt(id={self.id}, location_id={self.location_id}, "
f"department={self.department!r})>"
)

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import LocationCreate, LocationResponse, LocationUpdate
from .models import Location
from .service import LocationService
router = TenantCRUDRoutes(
service=LocationService,
create_schema=LocationCreate,
update_schema=LocationUpdate,
response_schema=LocationResponse,
prefix="/locations",
tags=["a76.general_catalogs.locations"],
resource_name="Location",
enable_list=True,
enable_filters=True,
max_page_size=1000,
).router

View File

@@ -0,0 +1,154 @@
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from .models import Location, FaLocationExt
from .dto import LocationCreate, LocationUpdate
class LocationService:
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: Optional[int],
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Location], int]:
query = db.query(Location).filter(Location.tenant_id == tenant_id)
if company_id is not None:
query = query.filter(Location.company_id == company_id)
if filters:
if filters.get("clave_localizacion"):
query = query.filter(
Location.clave_localizacion.ilike(
f"%{filters['clave_localizacion']}%"
)
)
if filters.get("localizacion"):
query = query.filter(
Location.localizacion.ilike(f"%{filters['localizacion']}%")
)
if filters.get("system"):
query = query.filter(Location.system == filters["system"])
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session,
id: int,
tenant_id: int,
company_id: int,
) -> Optional[Location]:
return (
db.query(Location)
.filter(
Location.id == id,
Location.tenant_id == tenant_id,
Location.company_id == company_id,
)
.first()
)
@staticmethod
def get_by_clave(
db: Session,
clave_localizacion: str,
tenant_id: int,
company_id: int,
system: Optional[str] = None,
) -> Optional[Location]:
query = db.query(Location).filter(
Location.clave_localizacion == clave_localizacion,
Location.tenant_id == tenant_id,
Location.company_id == company_id,
)
if system is not None:
query = query.filter(Location.system == system)
return query.first()
@staticmethod
def create(
db: Session,
data: LocationCreate,
tenant_id: int,
company_id: int,
) -> Location:
db_obj = Location(
clave_localizacion=data.clave_localizacion,
localizacion=data.localizacion,
system=data.system.value,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(db_obj)
try:
db.flush() # get db_obj.id without committing
if data.system.value == "fixed_asset" and (
data.department is not None
or data.responsible is not None
or data.observations is not None
):
ext = FaLocationExt(
location_id=db_obj.id,
department=data.department,
responsible=data.responsible,
observations=data.observations,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(ext)
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError:
db.rollback()
raise ValueError("Ya existe una localización con esa clave y system")
@staticmethod
def update(
db: Session,
id: int,
tenant_id: int,
data: LocationUpdate,
company_id: int,
) -> Optional[Location]:
db_obj = LocationService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
if key == "system" and value is not None:
setattr(db_obj, key, value.value)
else:
setattr(db_obj, key, value)
try:
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError:
db.rollback()
raise ValueError("Ya existe una localización con esa clave y system")
@staticmethod
def delete(
db: Session,
id: int,
tenant_id: int,
company_id: int,
) -> bool:
db_obj = LocationService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True

View File

@@ -25,12 +25,14 @@ from .error_catalogs.routes import router as error_catalogs_router
from .doda.routes import router as doda_router
from .prevalidators.routes import router as prevalidators_router
from .electronic_notices.routes import router as electronic_notices_router
from .location.routes import router as location_router
router = APIRouter()
router.include_router(company_router, tags=["a76 / company"])
router.include_router(package_router)
router.include_router(ports_router)
router.include_router(location_router)
router.include_router(tariff_fractions_router)
router.include_router(us_tariff_fractions_router)
router.include_router(historical_tariff_fractions_router, prefix="/fractions/historical-tariff-fractions", tags=["a76 / historical_tariff_fractions"])

View File

@@ -65,7 +65,8 @@ class UnitOfMeasureCustoms(Base, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(10), nullable=False) # CLAVE
description: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
description: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
a76_unit_code: Mapped[Optional[str]] = mapped_column(String(5), nullable=True) # UnidadSCAII
# 5. GUniMedida (Main)

View File

@@ -82,14 +82,4 @@ def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, c
elif invoice.financials.currency == "foreign":
invoice.financials.currency_type = "USD"
elif invoice.financials.currency == "manual":
invoice.financials.currency_type = (invoice.financials.currency_type or "").upper()
invoice.financials.currency_type = (invoice.financials.currency_type or "").upper()

View File

@@ -281,4 +281,4 @@ def validate_update(
if not invoice.compliance_mx.location:
invoice.compliance_mx.location = existing_invoice.compliance_mx.location if existing_invoice.compliance_mx.location else None
else:
invoice.compliance_mx.location = clean_str(invoice.compliance_mx.location)
invoice.compliance_mx.location = clean_str(invoice.compliance_mx.location)

View File

@@ -0,0 +1,285 @@
from datetime import date, datetime
from decimal import Decimal
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.invoices.models import Currency, InvoiceHeader, InvoiceStatus
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.common.fractions import search_fraction_preference
from api.v1.modules.a76.general_catalogs.company.models import Company
from core.exceptions import ErrorCollector
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_series import review_series
from .sub_process.review_rule_octave import (
llena_impo_permiso_regla_octava,
revpermiso_regla_octava,
valida_imp_regla_octava,
descuenta_cupo_r_octava,
)
from .sub_process.review_uma import revisa_uma
from .sub_process.assing_values import assign_values_lines, assign_values_invoice
def _validate_lines(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> tuple[list, dict]:
"""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."
],
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."
),
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."
),
solution=["Seleccionar un número de parte activo."],
code="PART_DISABLED",
)
search_fraction_preference(
db=db,
country=line.customs.origin_country or "",
fraccion=line.customs.fraction if line.customs else "",
fraction_type=line.customs.fraction_type if line.customs else "",
sector=line.customs.sector if line.customs else "",
invoice_date=invoice.invoice_date,
errors=errors,
)
review_series(db, line, company_rfc, 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, ...",
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,
)
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,
)
if available is not None:
key = (available.octave_permit, available.ro_line, available.country_code)
octave_available.setdefault(key, available)
revisa_uma(db=db, line=line, tenant_id=tenant_id, company_id=company_id, errors=errors)
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 _update_invoice_totals(invoice: InvoiceHeader) -> 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.
"""
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)
invoice.financials.iva_me = float(Decimal(str(invoice.financials.value_me or 0)) * iva_factor / 100)
else:
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
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
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((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(invoice.financials.__dict__) # se sobreescribirá con el conteo real
# 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."
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)
"""
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.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)
# Paso 2: Revisión de clases y fracciones
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 3: Asignación de valores por partida y totalización de factura
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)
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,
)
# Paso 7: Actualizar totales, IVA e incrementables y marcar como procesada
_update_invoice_totals(invoice)
db.flush()

View File

@@ -0,0 +1,107 @@
from sqlalchemy import func
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.general_catalogs.fractions.warning_fractions.models import WarningFraction
from core.exceptions import ErrorCollector
def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector):
if invoice.status == InvoiceStatus.PROCESSED:
errors.add(
"status",
"La factura ya fue procesada y no puede ser exportada",
solution=["Verifique el estatus de la factura antes de intentar exportarla"],
code="ALREADY_PROCESSED",
value=invoice.status,
)
if not invoice.invoice_date:
errors.add_required_error("invoice_date")
if not invoice.document_type:
errors.add_required_error("document_type")
if not invoice.compliance_mx.provider_id:
errors.add_required_error("compliance_mx.provider_id")
if not invoice.compliance_mx.sold_to_id:
errors.add_required_error("compliance_mx.sold_to_id")
if not invoice.compliance_mx.shipped_to_id:
errors.add_required_error("compliance_mx.shipped_by_id")
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.
if not invoice.financials.exchange_rate or invoice.financials.exchange_rate <= 0:
errors.add_range_error(
"financials.exchange_rate",
min_value=0.0001,
)
if not invoice.financials.currency:
errors.add_required_error("El Tipo de Moneda esta vacio no se puede actualizar")
elif invoice.financials.currency == "manual" and not invoice.financials.currency_type:
errors.add_required_error("financials.currency_type")
# 3.- Validacion que deber de existir un pedimento cuando es requerido
if not invoice.compliance_mx.is_pedimento_pending and not invoice.compliance_mx.pedimento_id:
errors.add_required_error("compliance_mx.pedimento_number")
# 4.- Verificacion de que existan partidas para la factura, si no hay partidas no se puede procesar
item_count = (
db.query(func.count(LineItem.id))
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == invoice.tenant_id,
LineItem.company_id == invoice.company_id,
)
.scalar()
)
if item_count == 0:
errors.add_error(
field="items",
message="La factura no tiene partidas capturadas.",
solution=["Capture al menos una partida antes de procesar la factura."],
code="NO_ITEMS_FOUND",
)
# Advertencias para las fracciones y su horario
lines = db.query(LineItem).filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
).all()
fractions = {line.fraction for line in lines if line.fraction}
if fractions:
warned_fractions = {
row.fraction
for row in db.query(WarningFraction.fraction)
.filter(WarningFraction.fraction.in_(fractions), WarningFraction.tenant_id == tenant_id)
.all()
}
for line in lines:
if line.fraction in warned_fractions:
errors.add_warning(
field="fraction",
message="Advertencia: Esta mercancía, sólo podrá entrar al territorio nacional por las aduanas del país, de lunes a sábado de 8:00 a 13:00 hrs. Ley 10, 18, LIGIE 1, Capítulo 87, RGCE 4.5.31., Anexo 4.",
solution=[""],
code="WARNING_FRACTION",
)
return lines

View File

@@ -0,0 +1,75 @@
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from core.celery_app import celery_app
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .task import process_invoice_task
router = APIRouter()
@router.post("/invoices/{invoice_id}/process")
def trigger_invoice_process(
invoice_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Inicia el procesamiento de una factura de importación como tarea Celery.
Retorna el task_id para hacer polling del progreso.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
task = process_invoice_task.apply_async(
args=[invoice_id, str(tenant_id), str(company_id)]
)
return {"task_id": task.id}
@router.get("/invoices/process/{task_id}/status")
def get_invoice_process_status(task_id: str):
"""
Consulta el estado de progreso de una tarea de procesamiento de factura.
Retorna:
- state: 'PROCESSING' | 'SUCCESS' | 'FAILURE'
- info: { current: int, status: str } (cuando state == 'PROCESSING')
- result: dict (cuando state == 'SUCCESS' o 'FAILURE')
"""
task_result = celery_app.AsyncResult(task_id)
if task_result.state in ("PENDING", "STARTED"):
return {
"state": "PROCESSING",
"info": {"current": 0, "status": "Iniciando..."},
}
if task_result.state == "PROGRESS":
return {
"state": "PROCESSING",
"info": task_result.info or {"current": 0, "status": "Procesando..."},
}
if task_result.state == "SUCCESS":
return {
"state": "SUCCESS",
"result": task_result.result,
}
# FAILURE u otro estado de error
error_info = task_result.result
if isinstance(error_info, Exception):
error_msg = str(error_info)
else:
error_msg = str(error_info) if error_info else "Error desconocido"
return {
"state": "FAILURE",
"result": error_msg,
}

View File

@@ -0,0 +1,99 @@
from decimal import Decimal
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import Currency, InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
def assign_values_lines(
invoice: InvoiceHeader,
lines: List[LineItem],
) -> None:
"""
Calculates and assigns unit costs and values in all currency types for every
line item, based on the invoice currency and exchange rates.
Also resets inventory counters (quantity_returned, quantity_returned_temp,
quantity_existence) to zero, as done in the legacy ASIGNAVALORES_PARTIDA routine.
Currency mapping (legacy -> current enum):
ME (moneda extranjera / foreign) -> Currency.FOREIGN
MN (moneda nacional / local) -> Currency.LOCAL
MC (moneda de cuenta / manual) -> Currency.MANUAL
"""
currency = invoice.financials.currency
tc = Decimal(str(invoice.financials.exchange_rate or 0))
tc_mm = Decimal(str(invoice.financials.exchange_rate_mm or 0))
for line in lines:
if line.financial is None:
continue
capture = line.financial.unit_cost_capture or Decimal(0)
qty = (line.quantity.quantity or Decimal(0)) if line.quantity else Decimal(0)
if currency == Currency.FOREIGN: # ME
line.financial.unit_cost_usd = capture
line.financial.value_usd = capture * qty
line.financial.unit_cost_mxn = capture * tc
line.financial.value_mxn = capture * tc * qty
line.financial.value_mc = capture * qty
elif currency == Currency.LOCAL: # MN
line.financial.unit_cost_mxn = capture
line.financial.value_mxn = capture * qty
line.financial.unit_cost_usd = (capture / tc) if tc else Decimal(0)
line.financial.value_usd = (capture / tc * qty) if tc else Decimal(0)
line.financial.value_mc = capture * qty
elif currency == Currency.MANUAL: # MC
line.financial.unit_cost_usd = capture * tc_mm
line.financial.value_usd = capture * tc_mm * qty
line.financial.unit_cost_mxn = capture * tc_mm * tc
line.financial.value_mxn = capture * tc_mm * tc * qty
line.financial.value_mc = capture * qty
# Reset inventory counters
if line.quantity is not None:
line.quantity.quantity_returned = Decimal(0)
line.quantity.quantity_returned_temp = Decimal(0)
line.quantity.quantity_existence = Decimal(0)
def assign_values_invoice(
invoice: InvoiceHeader,
lines: List[LineItem],
) -> None:
"""
Totals quantity, weight, and value fields from all line items and writes the
aggregated results to invoice.financials.
Ported from legacy ASIGNAVALORES_FACTURA routine.
"""
total_quantity = Decimal(0)
total_net_weight = Decimal(0)
total_gross_weight = Decimal(0)
total_packages = 0
total_value_mxn = Decimal(0)
total_value_usd = Decimal(0)
total_value_mc = Decimal(0)
for line in lines:
if line.quantity:
total_quantity += line.quantity.quantity or Decimal(0)
total_net_weight += line.quantity.net_weight or Decimal(0)
total_gross_weight += line.quantity.gross_weight or Decimal(0)
total_packages += line.quantity.package_quantity or 0
if line.financial:
total_value_mxn += line.financial.value_mxn or Decimal(0)
total_value_usd += line.financial.value_usd or Decimal(0)
total_value_mc += line.financial.value_mc or Decimal(0)
if invoice.financials is not None:
invoice.financials.value_mn = float(total_value_mxn)
invoice.financials.value_me = float(total_value_usd)
invoice.financials.value_mc = float(total_value_mc)
invoice.financials.total_quantity = float(total_quantity)
invoice.financials.net_weight = float(total_net_weight)
invoice.financials.gross_weight = float(total_gross_weight)
invoice.financials.total_packages = total_packages

View File

@@ -0,0 +1,163 @@
from typing import List
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import HistoricalTariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
def _fraction_exists_in_catalog(db: Session, fraction_code: str) -> bool:
"""Returns True if the fraction exists in TariffFraction (SFracciones) or
HistoricalTariffFraction (GFraccionesHistorico).
Fraction format: first 8 chars = base fraction, chars 9-10 = NICO/country (optional).
"""
if not fraction_code:
return True
base_frac = fraction_code[:8]
nico = fraction_code[8:10] if len(fraction_code) > 8 else ""
# Check SFracciones (TariffFraction)
tariff_q = db.query(TariffFraction).filter(
func.left(TariffFraction.code, 8) == base_frac
)
if nico:
tariff_q = tariff_q.filter(TariffFraction.nico == nico)
else:
tariff_q = tariff_q.filter(
or_(TariffFraction.nico.is_(None), TariffFraction.nico == "")
)
if tariff_q.first() is not None:
return True
# Check GFraccionesHistorico (HistoricalTariffFraction)
hist_q = db.query(HistoricalTariffFraction).filter(
HistoricalTariffFraction.historical_fraction == base_frac
)
if nico:
hist_q = hist_q.filter(HistoricalTariffFraction.country == nico)
else:
hist_q = hist_q.filter(
or_(
HistoricalTariffFraction.country.is_(None),
HistoricalTariffFraction.country == "",
)
)
return hist_q.first() is not None
def _validate_line_fraction(
db: Session, line: LineItem, errors: ErrorCollector
) -> None:
"""Adds a FRACCION error if the line's fraction does not exist in either catalog."""
fraction = line.customs.fraction if line.customs else None
if not fraction:
return
if _fraction_exists_in_catalog(db, fraction):
return
class_code = line.class_info.class_code if line.class_info else ""
errors.add_error(
field="fraction",
message=(
f"La Factura contiene la fraccion: {fraction} asociada al Clase {class_code} "
"que no existe en el catálogo de fracciones"
),
solution=["Agregar la fracción a fracciones históricas."],
code="FRACCION",
)
def review_classes(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> None:
"""
Validates class and fraction integrity for all line items of an invoice.
Logic ported from legacy REVISA_CLASE routine:
1. Identify line items with no assigned class (class_id IS NULL).
2. If rule_3121_parties_ii: validate that every line has container_parts_ii set.
3a. If no class errors: validate fractions for ALL lines.
3b. If class errors exist: add a CLASE error per invalid line and validate
fractions only for those lines.
4. If the invoice has not been reviewed by the company, flag lines whose
class requires physical review (Class.physical_review == 1).
"""
# 1. Lines whose class is not assigned / not found in the catalog
invalid_class_lines = [line for line in lines if line.class_id is None]
has_class_errors = bool(invalid_class_lines)
# 2. Container validation (Regla 3.1.21 Partes II)
if invoice.compliance_mx.rule_3121_parties_ii:
for line in lines:
if not line.container_parts_ii:
class_code = line.class_info.class_code if line.class_info else ""
errors.add_error(
field="container_parts_ii",
message=f"La clase: {class_code} no tiene contenedor asignado",
solution=[f"Capturar el contenedor en la partida: {line.line_number}"],
code="CONTENEDOR",
)
# 3. Fraction and class validation
if not has_class_errors:
# All classes are valid — validate fractions for every line
for line in lines:
_validate_line_fraction(db, line, errors)
else:
# Some classes are missing — report CLASE errors and validate
# fractions only for the affected lines
for line in invalid_class_lines:
errors.add_error(
field="class",
message=f"La clase no existe en catálogo de clases (línea: {line.line_number})",
solution=[
f"Borrar la partida: {line.line_number}, "
"o dar de alta la clase en el catálogo de Clases"
],
code="CLASE",
)
_validate_line_fraction(db, line, errors)
# 4. Physical review check
if not invoice.compliance_mx.was_reviewed_by_company:
review_required_lines = (
db.query(LineItem)
.join(Class, LineItem.class_id == Class.id)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
Class.physical_review == 1,
)
.all()
)
for line in review_required_lines:
cls = db.get(Class, line.class_id)
class_code = cls.class_code if cls else ""
errors.add_error(
field="physical_review",
message=(
f"La Clase: {class_code} en la Linea: {line.line_number} "
"No ha sido Revisada."
),
solution=[
f"Revisar el Equipo de la Partida: {line.line_number} "
"y Asignar Como Revisado a Nivel Factura."
],
code="CLASE",
)

View File

@@ -0,0 +1,67 @@
from decimal import Decimal
from sqlalchemy import func
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from api.v1.modules.a76.invoices.models import InvoiceHeader
from core.exceptions import ErrorCollector
def review_exchange_rate(
db: Session,
invoice: InvoiceHeader,
errors: ErrorCollector,
) -> None:
"""
Validates that the invoice's exchange rate matches the one registered in the
catalog for the invoice date (gtipocambio / ExchangeRate table).
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.
"""
if not invoice.invoice_date:
return
catalog_rate: ExchangeRate | None = (
db.query(ExchangeRate)
.filter(
ExchangeRate.tenant_id == invoice.tenant_id,
ExchangeRate.company_id == invoice.company_id,
func.date(ExchangeRate.date) == invoice.invoice_date,
)
.first()
)
if catalog_rate is None:
errors.add_error(
field="exchange_rate",
message="No existe tipo de cambio registrado para la fecha de la factura",
solution=[
"Capture el tipo de cambio correspondiente a la fecha de la factura "
"en el catálogo de Tipo de Cambio"
],
code="TIPO_DE_CAMBIO",
)
return
invoice_tc = (
Decimal(str(invoice.financials.exchange_rate))
if invoice.financials and invoice.financials.exchange_rate is not None
else None
)
catalog_tc = catalog_rate.value
if invoice_tc is None or catalog_tc is None or invoice_tc != catalog_tc:
errors.add_error(
field="exchange_rate",
message="No esta capturado correctamente el Tipo de Cambio",
solution=[
"Capture o modifique el tipo de cambio que corresponda a la factura "
"en el catálogo de Tipo de Cambio"
],
code="TIPO_DE_CAMBIO",
)

View File

@@ -0,0 +1,944 @@
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Optional, List
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.fractions.previous_fractions.models import PreviousFraction
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.rule_octave.balances.models import OctaveBalance
from api.v1.modules.a76.rule_octave.country.models import CountryRuleOct
from api.v1.modules.a76.rule_octave.fractions.models import FractionRuleOctave
from api.v1.modules.a76.rule_octave.permissions.models import OctavePermission
from core.exceptions import ErrorCollector
# Clarion date 80354 ≈ 2010-05-31 (see previous_fractions/models.py)
_CLARION_DATE_CUTOFF = date(2010, 5, 31)
# RFCs where fraction comparison uses only the first 8 characters
_RFC_FRACTION_8_CHARS = {"CLA940831AZ5", "CTE9801305I8"}
@dataclass
class OctavePermitEntry:
"""
Entrada de permiso de Regla Octava para importación.
Paridad: registro en QueuePermReglaOctDesc / QROImp (Clarion SCAII).
"""
invoice_import: str # FacturaImpo — encabezado de la factura
import_line: int # LineaImpo
part_number: str # NumParte (número de parte o clase)
octave_permit: str # PermisoROctava
ro_line: int # LineaRO
origin_country: str # PaisOrigen
fraction_type: str # TipoFracImpo
import_fraction: str # FraccionImpo
sector: str # Sector
class_code: str # Clase
unit_of_measure: str # UniMed — se resuelve en _revisa_um
quantity: Decimal # Cantidad
value: Decimal # Valor
quantity_used: Decimal # CantUsada (inicia en 0)
value_used: Decimal # ValorUsado (inicia en 0)
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _get_unit_equivalence(
db: Session,
from_unit: str,
to_unit: str,
tenant_id: str,
company_id: str,
) -> tuple[str, Decimal]:
"""
Busca una conversión entre dos unidades de medida.
Paridad: REVEQUIVALENCIA (Clarion SCAII).
Retorna (multi_divide, factor_conv):
- ('M', factor) → multiplicar cantidad por factor
- ('D', factor) → dividir cantidad por factor
- ('', 0) → no existe equivalencia
"""
conv = (
db.query(UnitConversion)
.filter(
UnitConversion.tenant_id == tenant_id,
UnitConversion.company_id == company_id,
UnitConversion.from_unit_code == from_unit,
UnitConversion.to_unit_code == to_unit,
)
.first()
)
if conv and conv.conversion_factor:
return "M", conv.conversion_factor
conv_inv = (
db.query(UnitConversion)
.filter(
UnitConversion.tenant_id == tenant_id,
UnitConversion.company_id == company_id,
UnitConversion.from_unit_code == to_unit,
UnitConversion.to_unit_code == from_unit,
)
.first()
)
if conv_inv and conv_inv.conversion_factor:
return "D", conv_inv.conversion_factor
return "", Decimal(0)
def _previous_fraction_exists(
db: Session,
tenant_id: str,
company_id: str,
previous_fraction: str,
current_fraction: str,
) -> bool:
"""
Verifica si existe un mapeo fracción-anterior → fracción-actual.
Paridad: consulta a SFraccionesAnterioresN (Clarion SCAII).
"""
return (
db.query(PreviousFraction)
.filter(
PreviousFraction.tenant_id == tenant_id,
PreviousFraction.company_id == company_id,
PreviousFraction.previous_fraction == previous_fraction,
PreviousFraction.current_fraction == current_fraction,
)
.count()
) > 0
# ─────────────────────────────────────────────────────────────────────────────
# REVISA_UM_REGLA_OCTAVA
# ─────────────────────────────────────────────────────────────────────────────
def _revisa_um_regla_octava(
db: Session,
line: LineItem,
permission: OctavePermission,
entry: OctavePermitEntry,
company_rfc: str,
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> None:
"""
Revisión de unidades de medida para Regla Octava.
Paridad: REVISA_UM_REGLA_OCTAVA (Clarion SCAII).
- Busca la fracción del permiso RO a nivel de línea.
- Valida que la fracción de la partida coincida con la del permiso.
- Determina la cantidad y UM a registrar en el entry, usando conversión si aplica.
"""
ro_line = line.reference.ro_line if line.reference else 0
if not ro_line:
return
line_fraction = (
db.query(FractionRuleOctave)
.filter(
FractionRuleOctave.tenant_id == tenant_id,
FractionRuleOctave.company_id == company_id,
FractionRuleOctave.permission == line.octave_permit,
FractionRuleOctave.line == ro_line,
)
.first()
)
if line_fraction is None:
errors.add_error(
field=f"line[{line.line_number}].octave_permit",
message=(
f"El Permiso de Regla Octava: {line.octave_permit}"
f" con línea: {ro_line} no existe."
),
solution=["Revisar el Permiso de Regla Octava en la Partida de Importación."],
code="RO_FRACTION_NOT_FOUND",
)
return
# ── Validar fracción ─────────────────────────────────────────────────────
line_fraccion = (line.customs.fraction or "") if line.customs else ""
ro_fraccion = line_fraction.fraction or ""
use_8_chars = company_rfc.upper() in _RFC_FRACTION_8_CHARS
frac_line = line_fraccion[:8] if use_8_chars else line_fraccion
frac_ro = ro_fraccion[:8] if use_8_chars else ro_fraccion
if frac_line != frac_ro:
start_date = permission.start_date
if start_date and hasattr(start_date, "date"):
start_date = start_date.date()
allow_previous = start_date is not None and start_date <= _CLARION_DATE_CUTOFF
fraction_ok = False
if allow_previous:
fraction_ok = _previous_fraction_exists(
db,
tenant_id,
company_id,
previous_fraction=ro_fraccion[:8],
current_fraction=line_fraccion[:8],
)
if not fraction_ok:
errors.add_error(
field=f"line[{line.line_number}].customs.fraction",
message=f"La fracción: {frac_line} es diferente a la Fraccion: {frac_ro}",
solution=["Revisar la Partida de Importación y el Permiso de Regla Octava."],
code="RO_FRACTION_MISMATCH",
)
return
# ── Determinar cantidad y valor según unidad de medida ───────────────────
entry.unit_of_measure = line_fraction.unit_of_measure or ""
line_um = line.unit_of_measure_info.code if line.unit_of_measure_info else ""
quantity = Decimal(str(line.quantity.quantity or 0)) if line.quantity else Decimal(0)
value_me = Decimal(str(line.financial.value_usd or 0)) if line.financial else Decimal(0)
if line_fraction.unit_of_measure == line_um:
entry.quantity = quantity
entry.value = value_me
return
multi_divide, factor_conv = _get_unit_equivalence(
db, line_um, line_fraction.unit_of_measure or "", tenant_id, company_id
)
if multi_divide == "M":
entry.quantity = quantity * factor_conv
entry.value = value_me
elif multi_divide == "D":
entry.quantity = quantity / factor_conv if factor_conv else Decimal(0)
entry.value = value_me
else:
if line_fraction.unit_of_measure == "KGS":
net_weight = (
Decimal(str(line.quantity.net_weight or 0)) if line.quantity else Decimal(0)
)
entry.quantity = net_weight
entry.value = value_me
else:
errors.add_error(
field=f"line[{line.line_number}].unit_of_measure",
message=(
f"Regla Octava: No hay equivalencia entre la U.M. del Permiso de RO: "
f"{line_fraction.unit_of_measure} y la U.M. de captura {line_um}."
),
solution=["Capturar su equivalencia en el Catálogo de Equivalencias."],
code="EQUIVALENCIA",
)
# ─────────────────────────────────────────────────────────────────────────────
# LLENA_IMPO_PERMISO_REGLA_OCTAVA
# ─────────────────────────────────────────────────────────────────────────────
def llena_impo_permiso_regla_octava(
db: Session,
invoice: InvoiceHeader,
line: LineItem,
company_rfc: str,
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> Optional[OctavePermitEntry]:
"""
Llena lo que se quiere importar con los permisos de Regla Octava.
Paridad: LLENA_IMPO_PERMISO_REGLA_OCTAVA (Clarion SCAII).
Retorna un OctavePermitEntry listo para agregar a la cola de permisos RO,
o None si el permiso indicado en la partida no existe en el catálogo.
"""
octave_permit_code = line.octave_permit or ""
permission = (
db.query(OctavePermission)
.filter(
OctavePermission.tenant_id == tenant_id,
OctavePermission.company_id == company_id,
OctavePermission.permission == octave_permit_code,
)
.first()
)
if permission is None:
return None
# NumParte: preferir número de parte, caer en código de clase
part_number = ""
if line.part_info and line.part_info.part_number:
part_number = line.part_info.part_number
elif line.class_info and line.class_info.class_code:
part_number = line.class_info.class_code
class_code = (line.class_info.class_code or "") if line.class_info else ""
# Sector: preferir el de la partida, caer en el del permiso
if line.customs and line.customs.sector:
sector = line.customs.sector
else:
sector = permission.sector or ""
ro_line = line.reference.ro_line if line.reference else 0
entry = OctavePermitEntry(
invoice_import=invoice.invoice_number or "",
import_line=line.line_number,
part_number=part_number,
octave_permit=octave_permit_code,
ro_line=ro_line or 0,
origin_country=(line.customs.origin_country or "") if line.customs else "",
fraction_type=(line.customs.fraction_type or "") if line.customs else "",
import_fraction=(line.customs.fraction or "") if line.customs else "",
sector=sector,
class_code=class_code,
unit_of_measure="", # resuelto en _revisa_um_regla_octava
quantity=Decimal(0),
value=Decimal(0),
quantity_used=Decimal(0),
value_used=Decimal(0),
)
_revisa_um_regla_octava(
db=db,
line=line,
permission=permission,
entry=entry,
company_rfc=company_rfc,
tenant_id=tenant_id,
company_id=company_id,
errors=errors,
)
return entry
# ─────────────────────────────────────────────────────────────────────────────
# REVPERMISO_REGLA_OCTAVA
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class OctaveAvailableEntry:
"""
Cupo disponible de una línea de permiso de Regla Octava.
Paridad: registro en QueuePermReglaOctDis / QRODis (Clarion SCAII).
"""
octave_permit: str # Permiso
fraction: str # Fraccion
ro_line: int # LineaRO
country_code: str # ClaveM3 (PaisOrigen)
sector: str # Sector (del permiso)
available_quantity: Decimal # CantidadDisponible = CantidadCupo - CantUsada
quantity_used: Decimal # CantUsada (inicia en 0)
available_value: Decimal # ValorDisponible = ValorCupo - ValorUsada
value_used: Decimal # ValorUsado (inicia en 0)
def _validate_fraction_ro(
db: Session,
line: LineItem,
permission: OctavePermission,
line_fraction: FractionRuleOctave,
company_rfc: str,
tenant_id: str,
company_id: str,
errors: ErrorCollector,
octave_permit_code: str,
) -> bool:
"""
Valida que la fracción de la partida coincida con la del permiso de RO.
Reutilizada por REVPERMISO_REGLA_OCTAVA.
Retorna True si es válida, False si se añadió un error.
"""
line_fraccion = (line.customs.fraction or "") if line.customs else ""
ro_fraccion = line_fraction.fraction or ""
use_8_chars = company_rfc.upper() in _RFC_FRACTION_8_CHARS
frac_line = line_fraccion[:8] if use_8_chars else line_fraccion
frac_ro = ro_fraccion[:8] if use_8_chars else ro_fraccion
if frac_line == frac_ro:
return True
start_date = permission.start_date
if start_date and hasattr(start_date, "date"):
start_date = start_date.date()
allow_previous = start_date is not None and start_date <= _CLARION_DATE_CUTOFF
if allow_previous and _previous_fraction_exists(
db, tenant_id, company_id,
previous_fraction=ro_fraccion[:8],
current_fraction=line_fraccion[:8],
):
return True
errors.add_error(
field=f"line[{line.line_number}].customs.fraction",
message=f"La fracción: {frac_line} es diferente a la Fraccion: {frac_ro}.",
solution=[f"Agregarla al Número de Permiso de Regla Octava: {octave_permit_code}."],
code="PERMISO_REG_8VA",
)
return False
def revpermiso_regla_octava(
db: Session,
invoice: InvoiceHeader,
line: LineItem,
company_rfc: str,
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> Optional[OctaveAvailableEntry]:
"""
Revisión del Permiso de Regla Octava.
Paridad: REVPERMISO_REGLA_OCTAVA (Clarion SCAII).
Valida:
- Existencia del permiso en el catálogo.
- Coherencia de sector (cuando fracción es PROSEC).
- Rango de fechas del permiso vs fecha de la factura.
- Existencia de la línea/fracción dentro del permiso.
- Concordancia de fracción arancelaria.
- Cupo disponible no agotado.
- Existencia del país de origen dentro de la línea del permiso.
Retorna un OctaveAvailableEntry si no hay errores (para ser agregado a la
cola QueuePermReglaOctDis del proceso principal), o None en caso contrario.
"""
octave_permit_code = (line.octave_permit or "").strip()
ro_line = line.reference.ro_line if line.reference else 0
origin_country = (line.customs.origin_country or "") if line.customs else ""
fraction_type = (line.customs.fraction_type or "") if line.customs else ""
line_sector = (line.customs.sector or "") if line.customs else ""
invoice_date = invoice.invoice_date
has_error = False
if ro_line:
if not octave_permit_code:
# Tiene LineaRO pero no hay permiso capturado
errors.add_error(
field=f"line[{line.line_number}].octave_permit",
message=(
"No existe capturado un Permiso de Regla Octava "
"y si una Línea de algún permiso de Regla Octava"
),
solution=[
"Capturar un Permiso de Regla Octava para la Linea "
"correspondiente del Permiso de Regla Octava."
],
code="PERMISO_REG_8VA",
)
return None
# ── Buscar el permiso ─────────────────────────────────────────────
permission = (
db.query(OctavePermission)
.filter(
OctavePermission.tenant_id == tenant_id,
OctavePermission.company_id == company_id,
OctavePermission.permission == octave_permit_code,
)
.first()
)
if permission is None:
errors.add_error(
field=f"line[{line.line_number}].octave_permit",
message=(
f"La Partida: {line.line_number} tiene el permiso "
f"{octave_permit_code} de Regla Octava y no esta dado de alta."
),
solution=[
f"Dar de alta el permiso de Regla Octava o Borrar "
f"la partida: {line.line_number}."
],
code="PERMISO_REG_8VA",
)
return None
# ── Validar sector cuando la preferencia es PROSEC ────────────────
if fraction_type == "PROSEC" and line_sector != (permission.sector or ""):
errors.add_error(
field=f"line[{line.line_number}].customs.sector",
message=(
f"El sector: {line_sector} de la partida es diferente al sector: "
f"{permission.sector} del permiso de Regla Octava."
),
solution=[
f"Cambiar de sector en la Partida o en el Permiso: {octave_permit_code}."
],
code="PERMISO_REG_8VA",
)
has_error = True
# ── Validar rango de fechas del permiso ───────────────────────────
perm_start = permission.start_date
perm_end = permission.end_date
if perm_start and hasattr(perm_start, "date"):
perm_start = perm_start.date()
if perm_end and hasattr(perm_end, "date"):
perm_end = perm_end.date()
inv_date = invoice_date
if inv_date and hasattr(inv_date, "date"):
inv_date = inv_date.date()
if inv_date and perm_start and perm_end:
if inv_date < perm_start or inv_date > perm_end:
errors.add_error(
field=f"line[{line.line_number}].octave_permit",
message=(
f"La fecha de la factura no corresponde al rango de fechas "
f"del Permiso: {octave_permit_code} de Regla Octava."
),
solution=[
"Modificar el Rango de fechas del Permiso de Regla Octava "
"o seleccionar uno que en su rango entre la fecha de la Factura."
],
code="PERMISO_REG_8VA",
)
has_error = True
# ── Buscar línea/fracción dentro del permiso ──────────────────────
line_fraction = (
db.query(FractionRuleOctave)
.filter(
FractionRuleOctave.tenant_id == tenant_id,
FractionRuleOctave.company_id == company_id,
FractionRuleOctave.permission == octave_permit_code,
FractionRuleOctave.line == ro_line,
)
.first()
)
if line_fraction is None:
errors.add_error(
field=f"line[{line.line_number}].octave_permit",
message=(
f"No existe la Línea: {ro_line} amparada en el permiso "
f"seleccionado {octave_permit_code} de Regla Octava."
),
solution=["Verificar los Permisos de Regla Octava"],
code="PERMISO_REG_8VA",
)
return None
# ── Validar fracción arancelaria ──────────────────────────────────
if not _validate_fraction_ro(
db, line, permission, line_fraction, company_rfc,
tenant_id, company_id, errors, octave_permit_code,
):
has_error = True
# ── Validar cupo disponible ───────────────────────────────────────
quota_qty = line_fraction.quota_quantity or Decimal(0)
qty_used = line_fraction.quantity_used or Decimal(0)
available_qty = quota_qty - qty_used
if available_qty == 0:
errors.add_error(
field=f"line[{line.line_number}].octave_permit",
message=(
"La Cantidad Cupo ya esta agotada, no se puede importar "
"más para este permiso de Regla Octava."
),
solution=["Tramitar otro permiso o seleccione otro Permiso."],
code="PERMISO_REG_8VA",
)
has_error = True
# ── Validar país de origen dentro de la línea del permiso ─────────
country = (
db.query(CountryRuleOct)
.filter(
CountryRuleOct.tenant_id == tenant_id,
CountryRuleOct.company_id == company_id,
CountryRuleOct.permission == octave_permit_code,
CountryRuleOct.line == ro_line,
CountryRuleOct.fraction == (line_fraction.fraction or ""),
CountryRuleOct.country_code == origin_country,
)
.first()
)
if country is None:
errors.add_error(
field=f"line[{line.line_number}].customs.origin_country",
message=(
f"No existe la Línea: {ro_line} con país: {origin_country} "
f"amparada en el permiso seleccionado"
),
solution=[
f"Agregar el País: {origin_country} al Número de Permiso "
f"de Regla Octava: {octave_permit_code}."
],
code="PERMISO_REG_8VA",
)
has_error = True
if has_error:
return None
# ── Construir entry de cupo disponible ────────────────────────────
quota_val = line_fraction.quota_value or Decimal(0)
val_used = line_fraction.value_used or Decimal(0)
return OctaveAvailableEntry(
octave_permit=octave_permit_code,
fraction=line_fraction.fraction or "",
ro_line=ro_line,
country_code=origin_country,
sector=permission.sector or "",
available_quantity=available_qty,
quantity_used=Decimal(0),
available_value=quota_val - val_used,
value_used=Decimal(0),
)
else:
# ro_line == 0: si hay permiso capturado es un error
if octave_permit_code:
errors.add_error(
field=f"line[{line.line_number}].octave_permit",
message=(
f"No existe una Línea capturada para esta partida de importación "
f"y se tiene el Permiso de Regla Octava: {octave_permit_code}."
),
solution=["Capturar una Linea de Permiso de Regla Octava."],
code="PERMISO_REG_8VA",
)
return None
# ─────────────────────────────────────────────────────────────────────────────
# VALIDA_IMP_REGLA_OCTAVA
# ─────────────────────────────────────────────────────────────────────────────
def valida_imp_regla_octava(
db: Session,
desc_list: List[OctavePermitEntry],
dis_dict: dict,
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> None:
"""
Valida lo que se quiere importar contra los cupos disponibles de Regla Octava.
Paridad: VALIDA_IMP_REGLA_OCTAVA (Clarion SCAII).
Fase 1 — Asignación de saldos:
Itera QueuePermReglaOctDesc (qué se quiere importar) contra
QueuePermReglaOctDis (cupo disponible) y distribuye la cantidad/valor
disponible entre las partidas, acumulando lo "usado" en cada lista.
Fase 2 — Validación final:
- Cantidad == 0: error (sin conversión de U.M.).
- Cantidad CantUsada != 0: error (cupo insuficiente).
Parámetros:
desc_list Lista de OctavePermitEntry construida por llena_impo_permiso_regla_octava.
dis_dict Diccionario {(permiso, ro_line, country_code): OctaveAvailableEntry}
construido por revpermiso_regla_octava.
"""
# ── Fase 1: Balancear saldos ──────────────────────────────────────────────
desc_sorted = sorted(
desc_list,
key=lambda e: (e.octave_permit, e.ro_line, e.origin_country),
)
for desc in desc_sorted:
key = (desc.octave_permit, desc.ro_line, desc.origin_country)
dis = dis_dict.get(key)
if dis is None:
continue
# BREAK: la partida ya está completamente consumida
if (
desc.quantity - desc.quantity_used == 0
or desc.value - desc.value_used == 0
):
continue
# CYCLE: el cupo disponible ya está agotado
if (
dis.available_quantity - dis.quantity_used == 0
or dis.available_value - dis.value_used == 0
):
continue
# ── Cantidad ──────────────────────────────────────────────────────
qty_needed = desc.quantity - desc.quantity_used
qty_avail = dis.available_quantity - dis.quantity_used
if qty_needed <= qty_avail:
desc.quantity_used += qty_needed
dis.quantity_used += qty_needed
else:
desc.quantity_used += qty_avail
dis.quantity_used += qty_avail
# ── Valor ─────────────────────────────────────────────────────────
val_needed = desc.value - desc.value_used
val_avail = dis.available_value - dis.value_used
if val_needed <= val_avail:
# Asignación directa al total (no acumulativa) — paridad Clarion
desc.value_used = desc.value
dis.value_used += val_needed
else:
# Asignación directa al disponible — paridad Clarion
desc.value_used = dis.available_value
dis.value_used += val_avail
# ── Fase 2: Validación final ──────────────────────────────────────────────
for desc in desc_list:
line_fraction = (
db.query(FractionRuleOctave)
.filter(
FractionRuleOctave.tenant_id == tenant_id,
FractionRuleOctave.company_id == company_id,
FractionRuleOctave.permission == desc.octave_permit,
FractionRuleOctave.line == desc.ro_line,
)
.first()
)
# line_fraction reservado para validación de costo unitario (comentada en Clarion)
_ = line_fraction
if desc.quantity == 0:
errors.add_error(
field=f"line[{desc.import_line}].octave_permit",
message=(
"La Cantidad a Descargar del Permiso de Regla Octava es Cero "
"debido a que No hay conversión entre la U.M. de la Partida "
"vs la del Permiso"
),
solution=[
"Revisar las U.M. de la Partida de Importación "
"y del Permiso de Regla Octava."
],
code="PERMISO_REG_8VA",
)
elif desc.quantity - desc.quantity_used != 0:
errors.add_error(
field=f"line[{desc.import_line}].octave_permit",
message=(
"No se puede importar más de este material "
"bajo el amparo de Regla Octava."
),
solution=[
"Revisar la cantidad a Importar de la línea "
"o solicitar otro permiso."
],
code="PERMISO_REG_8VA",
)
# ─────────────────────────────────────────────────────────────────────────────
# LLENASALDOS_REGLA_OCTAVA
# ─────────────────────────────────────────────────────────────────────────────
def _llena_fraccion_regla_oct(sector: str) -> str:
"""
Devuelve la fracción arancelaria de Regla Octava según el sector.
Paridad: LLENA_FRACCION_REGLA_OCT (Clarion SCAII).
El orden de comparación es deliberado: los prefijos más largos (XIX, XXIII)
se evalúan antes que los más cortos (XX, II) para evitar falsos positivos,
exactamente como advierte el comentario original del Clarion.
"""
s = (sector or "").strip()
if s == "I": return "98020001"
if s[:2] == "II": return "98020002"
if s == "III": return "98020003"
if s == "IV": return "98020004"
if s == "V": return "98020005"
if s == "VI": return "98020006"
if s == "VII": return "98020007"
if s == "VIII": return "98020008"
if s == "IX": return "98020009"
if s == "X": return "98020010"
if s == "XI": return "98020011"
if s == "XII": return "98020012"
if s == "XIII": return "98020013"
if s == "XIV": return "98020014"
if s == "XV": return "98020015"
if s == "XVI": return "98020016"
if s == "XVII": return "98020017"
if s == "XVIII": return "98020018"
if s[:3] == "XIX": return "98020019" # antes de XX
if s == "XXI": return "98020021"
if s == "XXII": return "98020022"
if s[:5] == "XXIII": return "98020023" # antes de XX
if s == "XXIV": return "98020024"
if s == "XXV": return "98020025"
if s[:2] == "XX": return "98020020" # al último — paridad comentario Clarion
return ""
def llenasaldos_regla_octava(
db: Session,
entry: "OctavePermitEntry",
unit_cost_me: Decimal,
tenant_id: str,
company_id: str,
sql_errors: list,
consecutive_ref: list,
) -> None:
"""
Llena / actualiza el saldo de Regla Octava en SSaldosReglaOctava.
Paridad: LLENASALDOS_REGLA_OCTAVA (Clarion SCAII).
- Si el registro no existe (ERRORCODE = 35 en Clarion → no encontrado):
inserta uno nuevo con CantExitencia = quantity_used y
ValorME = quantity_used * unit_cost_me.
- Si ya existe: acumula CantExitencia y ValorME.
consecutive_ref es una lista de un elemento [int] que actúa como
contador mutable compartido con descuenta_cupo_r_octava.
"""
fraction = _llena_fraccion_regla_oct(entry.sector)
valor_me = entry.quantity_used * unit_cost_me
existing = (
db.query(OctaveBalance)
.filter(
OctaveBalance.tenant_id == tenant_id,
OctaveBalance.company_id == company_id,
OctaveBalance.invoice_import == entry.invoice_import,
OctaveBalance.part_number == entry.part_number,
OctaveBalance.origin_country == entry.origin_country,
OctaveBalance.fraction_type == entry.fraction_type,
OctaveBalance.sector == entry.sector,
OctaveBalance.octave_permit == entry.octave_permit,
OctaveBalance.origin == "TEM",
OctaveBalance.system == "SCAF",
OctaveBalance.line == entry.ro_line,
)
.first()
)
try:
if existing is None:
new_balance = OctaveBalance(
tenant_id=tenant_id,
company_id=company_id,
invoice_import=entry.invoice_import,
part_number=entry.part_number,
origin_country=entry.origin_country,
fraction_type=entry.fraction_type,
sector=entry.sector,
octave_permit=entry.octave_permit,
origin="TEM",
system="SCAF",
line=entry.ro_line,
ro_fraction=fraction,
class_code=entry.class_code,
import_fraction=entry.import_fraction,
unit_of_measure=entry.unit_of_measure,
quantity_stock=entry.quantity_used,
value_me=valor_me,
)
db.add(new_balance)
db.flush([new_balance])
else:
existing.quantity_stock = (existing.quantity_stock or Decimal(0)) + entry.quantity_used
existing.value_me = (existing.value_me or Decimal(0)) + valor_me
db.flush([existing])
except Exception as exc:
consecutive_ref[0] += 1
action = "Agregar" if existing is None else "Actualizar"
sql_errors.append({
"consecutive": consecutive_ref[0],
"error": (
f"Error al {action} en (SSaldosReglaOctava) {exc}"
),
})
# ─────────────────────────────────────────────────────────────────────────────
# DESCUENTA_CUPO_R_OCTAVA
# ─────────────────────────────────────────────────────────────────────────────
def descuenta_cupo_r_octava(
db: Session,
desc_list: List[OctavePermitEntry],
tenant_id: str,
company_id: str,
sql_errors: list,
) -> None:
"""
Descuenta los cupos de la Regla Octava en GFracROctava.
Paridad: DESCUENTA_CUPO_R_OCTAVA (Clarion SCAII).
Por cada entrada en desc_list que tenga cantidad y valor usados distintos
de cero:
1. Llama a llenasaldos_regla_octava para registrar/actualizar SSaldosReglaOctava.
2. Actualiza FractionRuleOctave acumulando:
- quantity_used += entry.quantity_used
- value_used += entry.quantity_used * unit_cost_me
Los errores de actualización se acumulan en sql_errors como dicts con
las claves 'consecutive' y 'error'.
"""
consecutive_ref = [0]
for entry in desc_list:
if entry.quantity_used == 0 or entry.value_used == 0:
continue
fra_oct = (
db.query(FractionRuleOctave)
.filter(
FractionRuleOctave.tenant_id == tenant_id,
FractionRuleOctave.company_id == company_id,
FractionRuleOctave.permission == entry.octave_permit,
FractionRuleOctave.line == entry.ro_line,
)
.first()
)
if fra_oct is None:
continue
unit_cost_me = fra_oct.unit_cost_me or Decimal(0)
llenasaldos_regla_octava(
db=db,
entry=entry,
unit_cost_me=unit_cost_me,
tenant_id=tenant_id,
company_id=company_id,
sql_errors=sql_errors,
consecutive_ref=consecutive_ref,
)
fra_oct.quantity_used = (fra_oct.quantity_used or Decimal(0)) + entry.quantity_used
fra_oct.value_used = (fra_oct.value_used or Decimal(0)) + entry.quantity_used * unit_cost_me
try:
db.flush([fra_oct])
except Exception as exc:
consecutive_ref[0] += 1
sql_errors.append({
"consecutive": consecutive_ref[0],
"error": (
f"Error al regresar el Cupo en (Permiso de Regla Octava) {exc}"
),
})

View File

@@ -0,0 +1,93 @@
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.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 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",
"TOP140430PB6",
"AMA7504258K2",
"BZG111091T9",
}
def review_series(
db: Session,
line: LineItem,
company_rfc: int,
errors: ErrorCollector,
) -> None:
"""
Revisa la cantidad de series vs la cantidad a importar, por partida.
Paridad: REVISA_CANT_SERIES (Clarion SCAII).
Reglas:
- Si la partida tiene LlevaSerie / has_serial activado:
1. Si no existen registros de series → error SERIES_VACIAS.
2. Si SisGen:CantvsCantSeries = 1:
- Para RFC excepcionales: solo valida la coincidencia si UnidadMedida = 'PZA'.
- Para el resto: valida siempre que count(series) != cantidad.
"""
# Resolver RFC de la empresa una sola vez
# Solo aplica a partidas que llevan serie
if not (line.description and line.description.has_serial):
return
series_count = (
db.query(Serie)
.filter(Serie.line_item_id == line.id)
.count()
)
if series_count == 0:
errors.add_error(
field=f"line[{line.line_number}].series",
message="La opción de contiene series esta activada y no existen registros de Series.",
solution=[
"Desactivar la opción de Lleva series o registrar las series a esta partida."
],
code="SERIES_VACIAS",
)
return
# 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:
return
qty = line.quantity.quantity if line.quantity else None
if qty is None:
return
# Determinar si se debe validar series
validate_series = (
series_count != qty and
(company_rfc not in _RFC_EXCEPCION_PZA or line.unit_of_measure == "PZA")
)
if validate_series:
errors.add_error(
field=f"line[{line.line_number}].series",
message="La Cantidad de Series No Coincide con la Cantidad de la Partida.",
solution=[f"Nivelar las series de la Partida {line.line_number}."],
code="SERIES_VS_CANT",
)

View File

@@ -0,0 +1,129 @@
from decimal import Decimal
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasureCustoms
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
from .review_rule_octave import _get_unit_equivalence
def revisa_uma(
db: Session,
line: LineItem,
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> None:
"""
Revisa la unidad de medida americana (UMA) de aduana para una partida.
Paridad: REVISA_UMA (Clarion SCAII).
Flujo:
1. Verifica que la UM de la partida exista en el catálogo GUnimedida.
2. Verifica que esa UM tenga asignada una Clave_AMex (UM aduana mexicana).
3. Verifica que la Clave_AMex exista en el catálogo GUMAduana.
4. Verifica que el registro de aduana tenga una UnidadSCAII (a76_unit_code).
5. Calcula la cantidad UMA:
- Si la UM de la partida coincide con UnidadSCAII → cantidad directa.
- Si no → busca conversión en el catálogo de equivalencias.
6. Escribe CantImpoUMA y ClaveUMA de regreso en la partida.
"""
uom = line.unit_of_measure_info
line_um_code = uom.code if uom else ""
# ── 1. Verificar existencia de la UM en el catálogo ──────────────────────
if uom is None:
errors.add_error(
field=f"line[{line.line_number}].unit_of_measure",
message=(
f"No existe la unidad de medida {line_um_code}"
" en el catálogo de Unidades de Medida."
),
solution=["Capturarla en el catálogo de Unidades de Medida."],
code="UNIMEDIDA",
)
return
# ── 2. Verificar que tenga Clave_AMex asignada ───────────────────────────
customs_code = uom.customs_code or ""
if not customs_code:
errors.add_error(
field=f"line[{line.line_number}].unit_of_measure",
message=(
f"No existe la unidad de medida de la aduana para la unidad de medida "
f"{line_um_code} en el catálogo de Unidades de Medida."
),
solution=["Asignar la U.M.A en el catálogo de Unidades de Medida."],
code="UNIMEDIDA",
)
return
# ── 3. Buscar la UM en el catálogo de Aduana Mex (GUMAduana) ─────────────
customs_uom = (
db.query(UnitOfMeasureCustoms)
.filter(UnitOfMeasureCustoms.code == customs_code)
.first()
)
if customs_uom is None:
errors.add_error(
field=f"line[{line.line_number}].unit_of_measure",
message=(
f"No existe la unidad de medida {customs_code}"
" en el catálogo de Unidades de Medida de la Aduana Mex."
),
solution=["Actualizar sus Catálogos Fijos o llamar a su proveedor."],
code="UNIMEDIDA",
)
return
# ── 4. Verificar que tenga UnidadSCAII asignada ──────────────────────────
scaii_unit = customs_uom.a76_unit_code or ""
if not scaii_unit:
errors.add_error(
field=f"line[{line.line_number}].unit_of_measure",
message=(
f"No tiene asignada la U.M. Equivalente SCAII la unidad de medida "
f"{customs_code} en el catálogo de Unidades de Medida de la Aduana Mex."
),
solution=["Actualizar sus Catálogos Fijos o llamar a su proveedor."],
code="UNIMEDIDA",
)
return
# ── 5. Calcular cantidad UMA ──────────────────────────────────────────────
line_qty = Decimal(str(line.quantity.quantity or 0)) if line.quantity else Decimal(0)
cant_uma = Decimal(0)
if line_um_code == scaii_unit:
cant_uma = line_qty
else:
multi_divide, factor_conv = _get_unit_equivalence(
db, line_um_code, scaii_unit, tenant_id, company_id
)
if multi_divide == "M":
cant_uma = line_qty * factor_conv
elif multi_divide == "D":
cant_uma = line_qty / factor_conv if factor_conv else Decimal(0)
else:
errors.add_error(
field=f"line[{line.line_number}].unit_of_measure",
message=(
f"No hay equivalencia entre la U.M. Partida: {line_um_code}"
f" y la U.M. Aduana {scaii_unit}."
),
solution=[
"Capturar su equivalencia en el Catálogo de Conversiones "
"o configurar la U.M.Aduana correcta en la U.M. Comercial."
],
code="EQUIVALENCIA",
)
return
# ── 6. Escribir resultados en la partida ──────────────────────────────────
if line.quantity:
line.quantity.quantity_uma = cant_uma
line.uma_key = customs_code

View File

@@ -0,0 +1,102 @@
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
def review_weights_kgs(
db: Session,
lines: List[LineItem],
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> None:
"""
Validates that, for lines whose unit of measure is KGS, the net weight
equals the import quantity.
Ported from legacy REVISA_CANT_vs_PESONETO_KGS routine.
"""
# Resolve the id of the KGS unit of measure for this tenant/company
kgs_uom = (
db.query(UnitOfMeasure)
.filter(
UnitOfMeasure.code == "KGS",
UnitOfMeasure.tenant_id == tenant_id,
UnitOfMeasure.company_id == company_id,
)
.first()
)
if kgs_uom is None:
# No KGS unit defined for this tenant — nothing to validate
return
for line in lines:
if line.unit_of_measure != kgs_uom.id:
continue
if line.quantity is None:
continue
net_weight = line.quantity.net_weight
qty = line.quantity.quantity
if net_weight is None or qty is None or net_weight != qty:
errors.add_error(
field=f"line[{line.line_number}].net_weight",
message=(
f"Existe diferencia entre la cantidad y el Peso Neto, "
f"la cantidad es de {qty} KGS y el Peso Neto es de {net_weight} KGS."
),
solution=["Igualar el Peso Neto con la cantidad a importar."],
code="PESO_NETO_KGS",
)
def review_weights_lbs(
db: Session,
lines: List[LineItem],
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> None:
"""
Validates that, for lines whose unit of measure is LBS, the net weight
equals the import quantity.
Ported from legacy REVISA_CANT_vs_PESONETO_LBS routine.
"""
lbs_uom = (
db.query(UnitOfMeasure)
.filter(
UnitOfMeasure.code == "LBS",
UnitOfMeasure.tenant_id == tenant_id,
UnitOfMeasure.company_id == company_id,
)
.first()
)
if lbs_uom is None:
return
for line in lines:
if line.unit_of_measure != lbs_uom.id:
continue
if line.quantity is None:
continue
net_weight = line.quantity.net_weight
qty = line.quantity.quantity
if net_weight is None or qty is None or net_weight != qty:
errors.add_error(
field=f"line[{line.line_number}].net_weight",
message=(
f"Existe diferencia entre la cantidad y el Peso Neto, "
f"la cantidad es de {qty} LBS y el Peso Neto es de {net_weight} LBS."
),
solution=["Igualar el Peso Neto con la cantidad a importar."],
code="PESO_NETO_LBS",
)

View File

@@ -0,0 +1,122 @@
from celery import Task
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.exceptions import ErrorCollector, 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 .main_process import _validate_sisimp_limits, _update_invoice_totals, _validate_lines
def _progress(task: Task, current: int, status: str) -> None:
task.update_state(state="PROGRESS", meta={"current": current, "status": status})
@celery_app.task(bind=True, name="process_invoice_task")
def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str) -> dict:
"""
Procesa una factura de importación ejecutando todas las validaciones y
actualizaciones del proceso principal (main_process) con reporte de progreso.
"""
db = CoreSessionLocal()
try:
# ── Paso 1: Cargar factura ────────────────────────────────────────────
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
}
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...")
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 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)
db.flush()
db.commit()
return {
"status": "success",
"invoice_id": invoice_id,
"sql_errors": sql_errors,
}
except ValidationException as exc:
db.rollback()
return {
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
except Exception as exc:
db.rollback()
raise exc
finally:
db.close()

View File

@@ -63,6 +63,11 @@ class TransportType(str, Enum):
AIRPLANE = "airplane"
GONDOLA = "gondola"
FLATBED = "flatbed"
class InvoiceStatus(str, Enum):
PENDING = "pending"
PROCESSED = "processed"
REVERSED = "reversed"
# --- 1. Invoice Header (invoice_header) ---
@@ -108,17 +113,17 @@ class InvoiceHeader(Base, TenantScopedMixin, TimestampMixin):
emission_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAEMISION
# Status & Control
is_updated: Mapped[bool] = mapped_column(Boolean) # ESTATUS
is_updated_rec: Mapped[Optional[bool]] = mapped_column(
Boolean
status: Mapped[InvoiceStatus] = mapped_column(String(10)) # ESTATUS
status_rec: Mapped[Optional[InvoiceStatus]] = mapped_column(
String(10)
) # ESTATUSREC / Estatus de recepción
is_updated_rep: Mapped[Optional[bool]] = mapped_column(
Boolean
status_rep: Mapped[Optional[InvoiceStatus]] = mapped_column(
String(10)
) # ESTATUSREP / Estatus de reporte
updated_date: Mapped[Optional[datetime]] = mapped_column(
processed_date: Mapped[Optional[datetime]] = mapped_column(
TIMESTAMP(timezone=False)
) # FECHAACTUALIZACION / FECHAACTUAL
who_updated: Mapped[Optional[str]] = mapped_column(
who_processed: Mapped[Optional[str]] = mapped_column(
String(20)
) # USUARIOACT / Quien actualizó
capture_user: Mapped[Optional[str]] = mapped_column(
@@ -526,6 +531,9 @@ class InvoiceFinancials(Base, TenantScopedMixin, TimestampMixin):
total_quantity: Mapped[Optional[float]] = mapped_column(
Numeric(19, 8)
) # CANTEXPO/CANTIMPO / Cantidad total
total_packages: Mapped[Optional[int]] = mapped_column(
Integer
) # CANTBULTOS / Cantidad de bultos
gross_weight: Mapped[Optional[float]] = mapped_column(
Numeric(19, 8)
) # PESOBRUTO / Peso bruto

View File

@@ -275,6 +275,7 @@ class InvoiceFinancialsBase(BaseModel):
)
seal_value_2500: Optional[bool] = Field(None, description="Seal value 2500")
total_quantity: Optional[Decimal] = Field(0.00, description="Total quantity")
total_packages: Optional[int] = Field(0, description="Total packages")
gross_weight: Optional[Decimal] = Field(0.00, description="Gross weight")
net_weight: Optional[Decimal] = Field(0.00, description="Net weight")
bundle_count: Optional[int] = Field(0, description="Bundle count")

View File

@@ -69,9 +69,10 @@ def apply_calculations(
line.depreciation_date = invoice_date
if (not line.description.description_spanish and not line.description.description_english) and (line.part_info.description_spanish and line.part_info.description_english):
line.description.description_spanish = line.part_info.description_spanish
line.description.description_english = line.part_info.description_english
part_info = getattr(line, "part_info", None)
if (not line.description.description_spanish and not line.description.description_english) and part_info and (part_info.description_spanish and part_info.description_english):
line.description.description_spanish = part_info.description_spanish
line.description.description_english = part_info.description_english
else:
if not line.description.description_spanish:
class_desc = (
@@ -184,10 +185,14 @@ def calculate_values(
# ==========================================
# CÁLCULOS DE VALORES EN MONEDA
# foreign=ME, local=MN, manual=MC
# Una sola fuente: currency_type (USD/ME, MXN/MN) cuando está presente, paridad con import y CSV.
# ==========================================
result = (
db.query(InvoiceFinancials.currency, InvoiceFinancials.exchange_rate)
db.query(
InvoiceFinancials.currency,
InvoiceFinancials.currency_type,
InvoiceFinancials.exchange_rate,
)
.filter(
InvoiceFinancials.invoice_id == line.invoice_id,
InvoiceFinancials.tenant_id == tenant_id,
@@ -198,23 +203,27 @@ def calculate_values(
if not result:
return
currency, exchange_rate = result
currency, currency_type, exchange_rate = result
if currency_type in ("USD", "ME"):
currency = "foreign"
elif currency_type in ("MXN", "MN"):
currency = "local"
if currency == "foreign": # ME
line.financial.unit_cost_usd = line.financial.unit_cost_capture
line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity
line.financial.unit_cost_mxn = line.financial.unit_cost_capture * exchange_rate
line.financial.unit_cost_mxn = line.financial.unit_cost_capture * (exchange_rate or 1)
line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity
line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity
elif currency == "local": # MN
line.financial.unit_cost_mxn = line.financial.unit_cost_capture
line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity
line.financial.unit_cost_usd = line.financial.unit_cost_capture / exchange_rate
line.financial.unit_cost_usd = line.financial.unit_cost_capture / (exchange_rate or 1)
line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity
line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity
elif currency == "manual": # MC
line.financial.unit_cost_usd = line.financial.unit_cost_capture / exchange_rate
line.financial.unit_cost_usd = line.financial.unit_cost_capture / (exchange_rate or 1)
line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity
line.financial.unit_cost_mxn = line.financial.unit_cost_usd * exchange_rate
line.financial.unit_cost_mxn = line.financial.unit_cost_usd * (exchange_rate or 1)
line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity
line.financial.value_mc = line.financial.unit_cost_capture * line.quantity.quantity

View File

@@ -17,6 +17,17 @@ from api.v1.modules.public.reference_data.payment_methods.models import PaymentM
from .common import validate_common
def _normalize_weight_type(logistics) -> str:
"""Paridad con CSV: enum o string a 'KGS'/'LBS' para comparaciones."""
if logistics is None:
return "KGS"
wt = getattr(logistics, "weight_type", None) or "KGS"
if hasattr(wt, "value"):
wt = wt.value
weight_str = str(wt).upper() if wt else "KGS"
return weight_str if weight_str in ("KGS", "LBS") else "KGS"
def validate_create(
db: Session,
line: LineItem,
@@ -212,16 +223,28 @@ def validate_create(
line.financial.value_temp_material_usd = line.financial.value_usd
line.financial.value_temp_material_mxn = line.financial.value_mxn
# value_mc: paridad con calculate_values y cargas CSV
currency = getattr(invoice.financials, "currency", None)
if currency == "foreign":
line.financial.value_mc = line.financial.value_usd
elif currency == "local":
line.financial.value_mc = line.financial.value_usd
elif currency == "manual":
line.financial.value_mc = unit_cost_capture * quantity
else:
line.financial.value_mc = line.financial.value_usd
# ==========================================
# VALIDAR Y CONVERTIR PESOS NETOS
# ==========================================
invoice_weight_type = invoice.logistics.weight_type # 'kgs' o 'lbs'
invoice_weight_type = _normalize_weight_type(invoice.logistics)
quantity = line.quantity.quantity or Decimal("0")
net_weight_input = line.quantity.net_weight or Decimal("0")
# Determinar si la unidad de medida es de peso
unit_is_kgs = line.unit_of_measure and line.unit_of_measure == "24" #KGS
unit_is_lbs = line.unit_of_measure and line.unit_of_measure == "25" #LBS
# UOM peso: aceptar 24/"24" y 25/"25" (paridad con CSV)
uom = line.unit_of_measure
unit_is_kgs = uom is not None and (str(uom) == "24" or uom == 24)
unit_is_lbs = uom is not None and (str(uom) == "25" or uom == 25)
# Calcular peso neto en kilogramos (estándar interno)
if unit_is_kgs:

View File

@@ -1,8 +1,7 @@
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceFinancials, InvoiceHeader, InvoiceLogistics
from api.v1.modules.a76.invoices.models import InvoiceFinancials, InvoiceHeader
from core.exceptions import ErrorCollector
from ...models import LineItem
from ...models import LineItem
from api.v1.modules.a76.classes.models import Class
@@ -13,57 +12,97 @@ def apply_calculations(
#TODO: SSisGen Logic
# if ssisgen.calcularcostounitarioenbaseavalortotalscaf = 1:
# unit_cost_capture = line.financial.total_value / line.financial.total_value <-- habria que revisar por que esta asi, por que para mi no tiene sentido, pero es lo que esta en clarion
caluclate_values(db, line, tenant_id, company_id)
if not line.fa_data.is_subitem:
line.fa_data.subitem_number = None
invoice_date = db.query(InvoiceHeader.invoice_date).filter(InvoiceHeader.id == line.invoice_id, InvoiceHeader.tenant_id == tenant_id, InvoiceHeader.company_id == company_id).scalar()
line.depreciation_date = invoice_date
if (not line.description.description_spanish and not line.description.description_english) and (line.part_info.description_spanish and line.part_info.description_english):
line.description.description_spanish = line.part_info.description_spanish
line.description.description_english = line.part_info.description_english
else:
if not line.description.description_spanish:
class_desc = (
db.query(Class.description_es, Class.description_en)
.filter(Class.id == line.class_id, Class.tenant_id == tenant_id, Class.company_id == company_id)
.first()
)
if class_desc:
line.description.description_spanish, line.description.description_english = class_desc
calculate_values(db, line, tenant_id, company_id)
apply_calculations_after_values(db, line, tenant_id, company_id, line_number)
def caluclate_values(
def apply_calculations_after_values(
db: Session, line: LineItem, tenant_id: int, company_id: int, line_number: int
):
"""
Aplica solo depreciation_date, descripción desde part/class y subitem_number.
Usado por el flujo CSV para no sobrescribir los valores ya calculados por currency_type.
"""
fa_data = getattr(line, "fa_data", None)
if fa_data is not None and not getattr(fa_data, "is_subitem", True):
fa_data.subitem_number = None
invoice_date = db.query(InvoiceHeader.invoice_date).filter(
InvoiceHeader.id == line.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
).scalar()
if invoice_date is not None:
line.depreciation_date = invoice_date
if not getattr(line.description, "description_spanish", None) and not getattr(
line.description, "description_english", None
):
part_info = getattr(line, "part_info", None)
if part_info and getattr(part_info, "description_spanish", None) and getattr(part_info, "description_english", None):
line.description.description_spanish = part_info.description_spanish
line.description.description_english = part_info.description_english
else:
if not getattr(line.description, "description_spanish", None):
class_desc = (
db.query(Class.description_es, Class.description_en)
.filter(
Class.id == line.class_id,
Class.tenant_id == tenant_id,
Class.company_id == company_id,
)
.first()
)
if class_desc:
line.description.description_spanish, line.description.description_english = class_desc
def calculate_values(
db: Session, line: LineItem, tenant_id: int, company_id: int
):
"""
Una sola fuente de verdad: usa currency_type (USD/ME, MXN/MN) cuando está presente,
para paridad con create.py y cargas CSV. Si no hay currency_type, usa currency (foreign/local/manual).
"""
result = (
db.query(InvoiceFinancials.currency, InvoiceFinancials.exchange_rate)
.filter(InvoiceFinancials.invoice_id == line.invoice_id, InvoiceFinancials.tenant_id == tenant_id, InvoiceFinancials.company_id == company_id)
db.query(
InvoiceFinancials.currency,
InvoiceFinancials.currency_type,
InvoiceFinancials.exchange_rate,
)
.filter(
InvoiceFinancials.invoice_id == line.invoice_id,
InvoiceFinancials.tenant_id == tenant_id,
InvoiceFinancials.company_id == company_id,
)
.first()
)
if not result:
return
currency, exchange_rate = result
currency, currency_type, exchange_rate = result
# Prioridad: currency_type para alinear con create.py y CSV
if currency_type in ("USD", "ME"):
currency = "foreign"
elif currency_type in ("MXN", "MN"):
currency = "local"
# si currency_type es otro o None, se usa currency tal cual
if currency == "foreign":
line.financial.unit_cost_usd = line.financial.unit_cost_capture
line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity
line.financial.unit_cost_mxn = line.financial.unit_cost_capture * exchange_rate
line.financial.unit_cost_mxn = line.financial.unit_cost_capture * (exchange_rate or 1)
line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity
line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity
elif currency == "local":
line.financial.unit_cost_mxn = line.financial.unit_cost_capture
line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity
line.financial.unit_cost_usd = line.financial.unit_cost_capture / exchange_rate
line.financial.unit_cost_usd = line.financial.unit_cost_capture / (exchange_rate or 1)
line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity
line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity
elif currency == "manual":
line.financial.unit_cost_usd = line.financial.unit_cost_capture/exchange_rate
line.financial.unit_cost_usd = line.financial.unit_cost_capture / (exchange_rate or 1)
line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity
line.financial.unit_cost_mxn = line.financial.unit_cost_usd * exchange_rate
line.financial.unit_cost_mxn = line.financial.unit_cost_usd * (exchange_rate or 1)
line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity
line.financial.value_mc = line.financial.unit_cost_capture * line.quantity.quantity

View File

@@ -16,6 +16,17 @@ from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models im
from .common import validate_common
def _normalize_weight_type(logistics) -> str:
"""Paridad con CSV: enum o string a 'KGS'/'LBS' para comparaciones."""
if logistics is None:
return "KGS"
wt = getattr(logistics, "weight_type", None) or "KGS"
if hasattr(wt, "value"):
wt = wt.value
weight_str = str(wt).upper() if wt else "KGS"
return weight_str if weight_str in ("KGS", "LBS") else "KGS"
def validate_create(
db: Session,
line: LineItem,
@@ -196,16 +207,28 @@ def validate_create(
line.financial.value_temp_material_usd = line.financial.value_usd
line.financial.value_temp_material_mxn = line.financial.value_mxn
# value_mc: paridad con calculate_values y cargas CSV
currency = getattr(invoice.financials, "currency", None)
if currency == "foreign":
line.financial.value_mc = line.financial.value_usd
elif currency == "local":
line.financial.value_mc = line.financial.value_usd
elif currency == "manual":
line.financial.value_mc = unit_cost_capture * quantity
else:
line.financial.value_mc = line.financial.value_usd
# ==========================================
# VALIDAR Y CONVERTIR PESOS NETOS
# ==========================================
invoice_weight_type = invoice.logistics.weight_type # 'kgs' o 'lbs'
invoice_weight_type = _normalize_weight_type(invoice.logistics)
quantity = line.quantity.quantity or Decimal("0")
net_weight_input = line.quantity.net_weight or Decimal("0")
# Determinar si la unidad de medida es de peso
unit_is_kgs = line.unit_of_measure and line.unit_of_measure == "24" #KGS
unit_is_lbs = line.unit_of_measure and line.unit_of_measure == "25" #LBS
# UOM peso: aceptar 24/"24" y 25/"25" (paridad con CSV)
uom = line.unit_of_measure
unit_is_kgs = uom is not None and (str(uom) == "24" or uom == 24)
unit_is_lbs = uom is not None and (str(uom) == "25" or uom == 25)
# Calcular peso neto en kilogramos (estándar interno)
if unit_is_kgs:

View File

@@ -2,6 +2,7 @@
Tareas Celery para importación CSV de Clases de Materiales.
Flujo: scan_file (validación) → insert_valid_rows (commit).
Usa layouts_csv.common (storage, normalize, csv_reader, meta, responses) y common.fk_loader, validators, mappers.
Sin ClassService de creación en API; los mappers CSV (row_to_class_data, row_to_class_data_merge_existing) son la fuente de verdad para reglas de negocio al crear/actualizar.
"""
import json
import logging

View File

@@ -65,6 +65,12 @@ async def upload_import_file(
"tenant_id": tenant_id,
"company_id": company_id,
"user_id": current_user.get("id"),
"capture_user": (
current_user.get("preferred_username")
or current_user.get("email")
or current_user.get("sub")
or "CSV"
),
"footer_config": footer_config,
"operation_type": operation_type or "exp",
"template_id": template_id or default_template,

View File

@@ -0,0 +1,12 @@
"""
Enriquecimiento de partidas (line items) en la carga CSV de facturas.
Aplica las mismas reglas que el proceso manual (items/imports y items/exports validators)
para que los datos insertados por CSV coincidan con la API.
"""
from .import_enrichment import apply_import_defaults_and_calculations_for_csv
from .export_enrichment import apply_export_defaults_and_calculations_for_csv
__all__ = [
"apply_import_defaults_and_calculations_for_csv",
"apply_export_defaults_and_calculations_for_csv",
]

View File

@@ -0,0 +1,99 @@
"""
Enriquecimiento de partidas de exportación cargadas por CSV.
Aplica las mismas reglas que items/exports/validators (create + calculations):
hereda de línea de importación y calcula valores por currency; defaults de export.
"""
from sqlalchemy.orm import Session, joinedload
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a24.fa.fa_item_lines.dto import FaLineItemCreateDTO
from api.v1.modules.a76.items.schemas import LineItemCreate
from api.v1.modules.a76.items.exports.validators.calculations import (
calculate_values,
apply_calculations,
)
def _ensure_nested(line_data: LineItemCreate) -> None:
"""Asegura que existan los objetos anidados para calculate_values y apply_calculations."""
from api.v1.modules.a76.items.line_financials.schemas import LineFinancialCreate
from api.v1.modules.a76.items.line_quantities.schemas import LineQuantityCreate
from api.v1.modules.a76.items.line_customs.schemas import LineCustomCreate
from api.v1.modules.a76.items.line_descriptions.schemas import LineDescriptionCreate
if line_data.financial is None:
line_data.financial = LineFinancialCreate()
if line_data.quantity is None:
line_data.quantity = LineQuantityCreate()
if line_data.customs is None:
line_data.customs = LineCustomCreate()
if line_data.description is None:
line_data.description = LineDescriptionCreate()
if line_data.fa_data is None:
line_data.fa_data = FaLineItemCreateDTO(is_subitem=False, contains_subitems=False)
def apply_export_defaults_and_calculations_for_csv(
db: Session,
line_data: LineItemCreate,
tenant_id: int,
company_id: int,
line_number: int,
) -> bool:
"""
Aplica defaults y cálculos de partida de exportación según reglas del proceso manual
(copia desde línea de importación, currency, defaults has_fda_code, tax_payment, etc.).
Se invoca tras armar LineItemCreate desde el CSV.
Devuelve False si faltan factura/financials; True en caso contrario.
"""
_ensure_nested(line_data)
invoice: InvoiceHeader = (
db.query(InvoiceHeader)
.options(joinedload(InvoiceHeader.financials))
.filter(
InvoiceHeader.id == line_data.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
if not invoice or not invoice.financials:
return False
part: Part | None = None
if line_data.part_number_id:
part = (
db.query(Part)
.filter(
Part.id == line_data.part_number_id,
Part.tenant_id == tenant_id,
Part.company_id == company_id,
)
.first()
)
setattr(line_data, "part_info", part)
if not line_data.unit_of_measure and line_data.class_id:
class_info = (
db.query(Class)
.filter(
Class.id == line_data.class_id,
Class.tenant_id == tenant_id,
Class.company_id == company_id,
)
.first()
)
if class_info:
line_data.unit_of_measure = class_info.unit_of_measure
# Copia desde línea de importación y cálculos por currency (reglas manual)
calculate_values(db, line_data, tenant_id, company_id)
# Defaults: has_fda_code, tax_payment, payment_method, depreciation_date, descripciones
apply_calculations(db, line_data, tenant_id, company_id, line_number)
return True

View File

@@ -0,0 +1,246 @@
"""
Enriquecimiento de partidas de importación cargadas por CSV.
Aplica las mismas reglas que items/imports/validators (create + calculations)
en base a currency_type y peso de la factura; no re-sobrescribe con currency
para evitar discrepancias con el proceso manual.
"""
from decimal import Decimal
from sqlalchemy.orm import Session, joinedload
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from api.v1.modules.a24.fa.fa_item_lines.dto import FaLineItemCreateDTO
from api.v1.modules.a76.items.schemas import LineItemCreate
from api.v1.modules.a76.items.imports.validators.calculations import (
apply_calculations_after_values,
)
def _normalize_weight_type(invoice) -> str:
"""Obtiene weight_type de la factura como 'KGS' o 'LBS' (paridad con proceso manual)."""
wt = getattr(invoice.logistics, "weight_type", None) or "KGS"
if hasattr(wt, "value"):
wt = wt.value
weight_str = str(wt).upper() if wt else "KGS"
return weight_str if weight_str in ("KGS", "LBS") else "KGS"
def apply_import_defaults_and_calculations_for_csv(
db: Session,
line_data: LineItemCreate,
tenant_id: int,
company_id: int,
line_number: int,
) -> bool:
"""
Aplica defaults y cálculos de partida de importación según reglas del proceso manual
(currency_type, peso, descripciones). Se invoca tras armar LineItemCreate desde el CSV.
Devuelve False si faltan factura/financials/logistics; True en caso contrario.
"""
invoice: InvoiceHeader = (
db.query(InvoiceHeader)
.options(
joinedload(InvoiceHeader.financials),
joinedload(InvoiceHeader.logistics),
)
.filter(
InvoiceHeader.id == line_data.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
if not invoice or not invoice.financials or not invoice.logistics:
return False
class_info: Class | None = None
if line_data.class_id:
class_info = (
db.query(Class)
.filter(
Class.id == line_data.class_id,
Class.tenant_id == tenant_id,
Class.company_id == company_id,
)
.first()
)
part: Part | None = None
if line_data.part_number_id:
part = (
db.query(Part)
.filter(
Part.id == line_data.part_number_id,
Part.tenant_id == tenant_id,
Part.company_id == company_id,
)
.first()
)
setattr(line_data, "part_info", part)
if not line_data.fa_data:
line_data.fa_data = FaLineItemCreateDTO(
is_subitem=False,
contains_subitems=False,
)
exchange_rate = invoice.financials.exchange_rate or Decimal("1.0")
if not line_data.unit_of_measure and class_info:
line_data.unit_of_measure = class_info.unit_of_measure
# Reglas de moneda igual que proceso manual (create.py): currency_type
currency_type = getattr(invoice.financials, "currency_type", None) or "USD"
unit_cost_capture = line_data.financial.unit_cost_capture or Decimal("0")
if currency_type in ("USD", "ME"):
line_data.financial.unit_cost_capture = unit_cost_capture
line_data.financial.unit_cost_usd = unit_cost_capture
line_data.financial.unit_cost_mxn = unit_cost_capture * exchange_rate
elif currency_type in ("MXN", "MN"):
line_data.financial.unit_cost_capture = unit_cost_capture
line_data.financial.unit_cost_usd = (
unit_cost_capture / exchange_rate if exchange_rate else Decimal("0")
)
line_data.financial.unit_cost_mxn = unit_cost_capture
else:
line_data.financial.unit_cost_capture = unit_cost_capture
line_data.financial.unit_cost_usd = unit_cost_capture
line_data.financial.unit_cost_mxn = unit_cost_capture * exchange_rate
quantity = line_data.quantity.quantity or Decimal("0")
if line_data.financial.unit_cost_usd is not None:
line_data.financial.value_usd = line_data.financial.unit_cost_usd * quantity
if line_data.financial.unit_cost_mxn is not None:
line_data.financial.value_mxn = line_data.financial.unit_cost_mxn * quantity
line_data.financial.customs_value_usd = line_data.financial.value_usd
line_data.financial.customs_value_mxn = line_data.financial.value_mxn
line_data.financial.value_temp_material_usd = line_data.financial.value_usd
line_data.financial.value_temp_material_mxn = line_data.financial.value_mxn
# value_mc según currency de la factura (paridad con calculate_values manual)
currency = getattr(invoice.financials, "currency", None)
if currency == "foreign":
line_data.financial.value_mc = line_data.financial.value_usd
elif currency == "local":
line_data.financial.value_mc = line_data.financial.value_usd
elif currency == "manual":
line_data.financial.value_mc = unit_cost_capture * quantity
else:
line_data.financial.value_mc = line_data.financial.value_usd
# Peso: misma lógica que proceso manual (create.py) con weight_type normalizado
weight_str = _normalize_weight_type(invoice)
quantity = line_data.quantity.quantity or Decimal("0")
net_weight_input = line_data.quantity.net_weight or Decimal("0")
uom = line_data.unit_of_measure
unit_is_kgs = uom is not None and (str(uom) == "24" or uom == 24)
unit_is_lbs = uom is not None and (str(uom) == "25" or uom == 25)
if unit_is_kgs:
if weight_str == "KGS":
line_data.quantity.net_weight = quantity
else:
line_data.quantity.net_weight = quantity * Decimal("2.204624")
elif unit_is_lbs:
if weight_str == "KGS":
line_data.quantity.net_weight = quantity / Decimal("2.204624")
else:
line_data.quantity.net_weight = quantity
else:
if weight_str == "KGS":
line_data.quantity.net_weight = net_weight_input
else:
line_data.quantity.net_weight = net_weight_input / Decimal("2.204624")
gross_weight_input = line_data.quantity.gross_weight
package_quantity = line_data.quantity.package_quantity or 0
package_weight_unit = Decimal("0")
if line_data.quantity.package_id:
package = (
db.query(Package)
.filter(
Package.id == line_data.quantity.package_id,
Package.tenant_id == tenant_id,
Package.company_id == company_id,
)
.first()
)
if package and package.weight_unit:
package_weight_unit = package.weight_unit
if not gross_weight_input or gross_weight_input == 0:
if weight_str == "KGS":
line_data.quantity.gross_weight = line_data.quantity.net_weight + (
package_weight_unit * package_quantity
)
else:
line_data.quantity.gross_weight = line_data.quantity.net_weight + (
(package_weight_unit * Decimal("2.204624")) * package_quantity
)
else:
if weight_str == "KGS":
line_data.quantity.gross_weight = gross_weight_input
else:
line_data.quantity.gross_weight = gross_weight_input / Decimal("2.204624")
if line_data.quantity.gross_weight < line_data.quantity.net_weight:
line_data.quantity.gross_weight = line_data.quantity.net_weight + (
package_weight_unit * package_quantity
)
if package_quantity and package_quantity > 0 and line_data.quantity.package_id:
package = (
db.query(Package)
.filter(
Package.id == line_data.quantity.package_id,
Package.tenant_id == tenant_id,
Package.company_id == company_id,
)
.first()
)
if package:
line_data.description.package_description = package.description_es
else:
line_data.quantity.package_quantity = 0
line_data.quantity.package_id = None
line_data.description.package_description = None
if not line_data.customs.american_fraction and class_info and class_info.us_fraction:
line_data.customs.american_fraction = class_info.us_fraction
if line_data.customs.american_fraction:
us_fraction = (
db.query(USTariffFraction)
.filter(
USTariffFraction.code == line_data.customs.american_fraction,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.first()
)
if us_fraction:
if getattr(us_fraction, "type_code", None) == "foreign":
line_data.customs.advalorem_american = us_fraction.fixed_cost
else:
line_data.customs.advalorem_american = us_fraction.ad_valorem
if not line_data.description.description_spanish and class_info:
line_data.description.description_spanish = class_info.description_es
if not line_data.description.description_english and class_info:
line_data.description.description_english = class_info.description_en
if line_data.description.brand:
line_data.description.brand = line_data.description.brand.upper().strip()
if line_data.description.model:
line_data.description.model = line_data.description.model.upper().strip()
# Solo depreciation_date, descripción part/class y subitem_number (sin re-calcular moneda)
apply_calculations_after_values(db, line_data, tenant_id, company_id, line_number)
return True

View File

@@ -1,178 +1,184 @@
from datetime import datetime
from uuid import uuid4
import base64
import os
import json
import logging
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Query
from sqlalchemy.orm import Session
from typing import Optional, Literal, Dict, Any
from core.celery_app import celery_app
from core.config import settings
from core.database import get_core_db
from core.paths import layout_path
from core.security import get_current_user, validate_access_to_resource
from .tasks import (
scan_file,
insert_valid_rows,
IMPORT_FILE_KEY_PREFIX,
IMPORT_META_KEY_PREFIX,
IMPORT_REDIS_TTL,
)
from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest
router = APIRouter()
logger = logging.getLogger(__name__)
def _get_redis():
"""Redis client (same broker as Celery so worker can read)."""
import redis
url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0"))
return redis.Redis.from_url(url, decode_responses=False)
@router.post("/upload/{model_target}", response_model=ImportJobResponse)
async def upload_import_file(
model_target: Literal["invoice_header", "invoice_details", "invoice_series"],
file: UploadFile = File(...),
footer_config: Optional[str] = Form(None), # JSON string with settings
template_id: Optional[str] = Form(None), # id de la plantilla (ej. imp_temp_header) para respetar columnas
company_id: int = Query(..., description="Company ID"), # Required for context
operation_type: Optional[str] = Query("imp"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Step 1: Upload CSV, save to temp, trigger scan task.
Si se envía template_id, solo se leen las columnas de esa plantilla.
"""
# 1. Validate Access & Get Tenant
try:
tenant_id = validate_access_to_resource(db, company_id, current_user)
except Exception as e:
logger.error(f"Access validation failed: {e}")
raise HTTPException(status_code=403, detail="Invalid company access")
if not file.filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="Only .csv files allowed")
job_id = str(uuid4())
contents = await file.read()
meta_data = {
"tenant_id": tenant_id,
"company_id": company_id,
"user_id": current_user.get("id"),
"footer_config": footer_config,
"operation_type": operation_type,
"template_id": template_id,
}
# Store file and meta in Redis so the Celery worker can read them (no shared filesystem needed)
try:
redis_client = _get_redis()
redis_client.set(
f"{IMPORT_FILE_KEY_PREFIX}{job_id}",
base64.b64encode(contents),
ex=IMPORT_REDIS_TTL,
)
redis_client.set(
f"{IMPORT_META_KEY_PREFIX}{job_id}",
json.dumps(meta_data).encode("utf-8"),
ex=IMPORT_REDIS_TTL,
)
except Exception as e:
logger.error(f"Redis store error: {e}")
raise HTTPException(status_code=500, detail="Failed to queue file for processing.")
# Optional: also write to local disk (e.g. for same-machine worker or debugging)
try:
upload_dir = layout_path("imports", "temp")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, f"{job_id}.csv")
meta_path = os.path.join(upload_dir, f"{job_id}.meta.json")
with open(file_path, "wb") as f:
f.write(contents)
with open(meta_path, "w") as f:
json.dump(meta_data, f)
except Exception as e:
logger.warning(f"Local file save failed (worker will use Redis): {e}")
# Trigger Celery Task (Async). Worker loads file from Redis.
scan_file.apply_async(args=[job_id, model_target, footer_config], task_id=job_id)
return ImportJobResponse(
job_id=job_id,
status="queued",
message="File uploaded. Scanning started."
)
@router.get("/{job_id}/status")
async def get_import_status(job_id: str):
"""
Poll to get progress or final report. Always returns an object with "status".
"""
task_result = celery_app.AsyncResult(job_id)
if task_result.state == "PENDING":
return {"status": "processing", "progress": 0}
if task_result.state == "PROGRESS":
return {
"status": "processing",
"progress": (task_result.info or {}).get("current", 0),
"total": (task_result.info or {}).get("total", 0),
}
if task_result.state == "SUCCESS":
result = task_result.result
if isinstance(result, dict) and "status" in result:
return result
return {"status": "finished", "result": result}
# FAILURE: obtener mensaje real (traceback, result o get(propagate=False))
logger.warning("Import task %s failed: state=%s", job_id, task_result.state)
err_msg = None
tb = getattr(task_result, "traceback", None)
if tb:
logger.debug("Task traceback: %s", tb[:500] if isinstance(tb, str) else tb)
if tb and isinstance(tb, str):
lines = [l.strip() for l in tb.strip().split("\n") if l.strip()]
if lines:
err_msg = lines[-1]
if not err_msg and len(lines) > 1:
err_msg = lines[-2] + " " + (lines[-1] or "")
if not err_msg:
try:
exc = task_result.get(propagate=False)
if exc is not None:
err_msg = str(exc)
except Exception:
pass
if not err_msg:
result = getattr(task_result, "result", None)
info = getattr(task_result, "info", None)
if result is not None and not isinstance(result, dict):
err_msg = str(result)
elif isinstance(result, dict) and (result.get("error") or result.get("message")):
err_msg = result.get("error") or result.get("message")
if not err_msg and isinstance(info, str):
err_msg = info
elif not err_msg and isinstance(info, dict) and "error" in info:
err_msg = str(info["error"])
if not err_msg:
err_msg = "Task failed"
return {"status": "failed", "error": err_msg}
@router.post("/{job_id}/commit")
async def commit_import_job(job_id: str, body: CommitRequest):
"""
Step 2: User confirms import. Trigger bulk insert.
"""
task = insert_valid_rows.delay(job_id, body.model_target)
return {
"status": "committing",
"message": "Bulk insert started.",
"commit_job_id": task.id
}
from datetime import datetime
from uuid import uuid4
import base64
import os
import json
import logging
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Query
from sqlalchemy.orm import Session
from typing import Optional, Literal, Dict, Any
from core.celery_app import celery_app
from core.config import settings
from core.database import get_core_db
from core.paths import layout_path
from core.security import get_current_user, validate_access_to_resource
from .tasks import (
scan_file,
insert_valid_rows,
IMPORT_FILE_KEY_PREFIX,
IMPORT_META_KEY_PREFIX,
IMPORT_REDIS_TTL,
)
from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest
router = APIRouter()
logger = logging.getLogger(__name__)
def _get_redis():
"""Redis client (same broker as Celery so worker can read)."""
import redis
url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0"))
return redis.Redis.from_url(url, decode_responses=False)
@router.post("/upload/{model_target}", response_model=ImportJobResponse)
async def upload_import_file(
model_target: Literal["invoice_header", "invoice_details", "invoice_series"],
file: UploadFile = File(...),
footer_config: Optional[str] = Form(None), # JSON string with settings
template_id: Optional[str] = Form(None), # id de la plantilla (ej. imp_temp_header) para respetar columnas
company_id: int = Query(..., description="Company ID"), # Required for context
operation_type: Optional[str] = Query("imp"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Step 1: Upload CSV, save to temp, trigger scan task.
Si se envía template_id, solo se leen las columnas de esa plantilla.
"""
# 1. Validate Access & Get Tenant
try:
tenant_id = validate_access_to_resource(db, company_id, current_user)
except Exception as e:
logger.error(f"Access validation failed: {e}")
raise HTTPException(status_code=403, detail="Invalid company access")
if not file.filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="Only .csv files allowed")
job_id = str(uuid4())
contents = await file.read()
meta_data = {
"tenant_id": tenant_id,
"company_id": company_id,
"user_id": current_user.get("id"),
"capture_user": (
current_user.get("preferred_username")
or current_user.get("email")
or current_user.get("sub")
or "CSV"
),
"footer_config": footer_config,
"operation_type": operation_type,
"template_id": template_id,
}
# Store file and meta in Redis so the Celery worker can read them (no shared filesystem needed)
try:
redis_client = _get_redis()
redis_client.set(
f"{IMPORT_FILE_KEY_PREFIX}{job_id}",
base64.b64encode(contents),
ex=IMPORT_REDIS_TTL,
)
redis_client.set(
f"{IMPORT_META_KEY_PREFIX}{job_id}",
json.dumps(meta_data).encode("utf-8"),
ex=IMPORT_REDIS_TTL,
)
except Exception as e:
logger.error(f"Redis store error: {e}")
raise HTTPException(status_code=500, detail="Failed to queue file for processing.")
# Optional: also write to local disk (e.g. for same-machine worker or debugging)
try:
upload_dir = layout_path("imports", "temp")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, f"{job_id}.csv")
meta_path = os.path.join(upload_dir, f"{job_id}.meta.json")
with open(file_path, "wb") as f:
f.write(contents)
with open(meta_path, "w") as f:
json.dump(meta_data, f)
except Exception as e:
logger.warning(f"Local file save failed (worker will use Redis): {e}")
# Trigger Celery Task (Async). Worker loads file from Redis.
scan_file.apply_async(args=[job_id, model_target, footer_config], task_id=job_id)
return ImportJobResponse(
job_id=job_id,
status="queued",
message="File uploaded. Scanning started."
)
@router.get("/{job_id}/status")
async def get_import_status(job_id: str):
"""
Poll to get progress or final report. Always returns an object with "status".
"""
task_result = celery_app.AsyncResult(job_id)
if task_result.state == "PENDING":
return {"status": "processing", "progress": 0}
if task_result.state == "PROGRESS":
return {
"status": "processing",
"progress": (task_result.info or {}).get("current", 0),
"total": (task_result.info or {}).get("total", 0),
}
if task_result.state == "SUCCESS":
result = task_result.result
if isinstance(result, dict) and "status" in result:
return result
return {"status": "finished", "result": result}
# FAILURE: obtener mensaje real (traceback, result o get(propagate=False))
logger.warning("Import task %s failed: state=%s", job_id, task_result.state)
err_msg = None
tb = getattr(task_result, "traceback", None)
if tb:
logger.debug("Task traceback: %s", tb[:500] if isinstance(tb, str) else tb)
if tb and isinstance(tb, str):
lines = [l.strip() for l in tb.strip().split("\n") if l.strip()]
if lines:
err_msg = lines[-1]
if not err_msg and len(lines) > 1:
err_msg = lines[-2] + " " + (lines[-1] or "")
if not err_msg:
try:
exc = task_result.get(propagate=False)
if exc is not None:
err_msg = str(exc)
except Exception:
pass
if not err_msg:
result = getattr(task_result, "result", None)
info = getattr(task_result, "info", None)
if result is not None and not isinstance(result, dict):
err_msg = str(result)
elif isinstance(result, dict) and (result.get("error") or result.get("message")):
err_msg = result.get("error") or result.get("message")
if not err_msg and isinstance(info, str):
err_msg = info
elif not err_msg and isinstance(info, dict) and "error" in info:
err_msg = str(info["error"])
if not err_msg:
err_msg = "Task failed"
return {"status": "failed", "error": err_msg}
@router.post("/{job_id}/commit")
async def commit_import_job(job_id: str, body: CommitRequest):
"""
Step 2: User confirms import. Trigger bulk insert.
"""
task = insert_valid_rows.delay(job_id, body.model_target)
return {
"status": "committing",
"message": "Bulk insert started.",
"commit_job_id": task.id
}

View File

@@ -1,3 +1,13 @@
"""
Tareas Celery para importación CSV de facturas (encabezados, partidas, series).
Flujo: scan_file (validación) → insert_valid_rows (commit).
Objetivo en BD (paridad con flujo normal): al terminar el commit, los datos deben quedar
igual que por UI/API: encabezados con capture_user/who_updated; partidas con costos,
pesos y descripciones calculados/heredados según items/imports/validators; series con
campos no presentes en CSV en null. No se modifican plantillas CSV; no se inventan
datos sin fuente (p. ej. LineReference solo si hay fuente explícita).
"""
import os
from datetime import datetime
from decimal import Decimal
@@ -4233,6 +4243,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
return {"status": "failed", "error": str(e)}
# --- Series de Importación Temporal: commit (INSERT/UPDATE item_line_series) ---
# Paridad CSV: campos no presentes en CSV se persisten como null; no se exigen campos que no están en la plantilla.
if use_series_flow:
try:
from api.v1.modules.a76.invoices.models import InvoiceHeader
@@ -4490,6 +4501,17 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_customs.models import LineCustom
from api.v1.modules.a76.items.line_descriptions.models import LineDescription
from api.v1.modules.a76.items.schemas import LineItemCreate
from api.v1.modules.a76.items.line_financials.schemas import LineFinancialCreate
from api.v1.modules.a76.items.line_quantities.schemas import LineQuantityCreate
from api.v1.modules.a76.items.line_customs.schemas import LineCustomCreate
from api.v1.modules.a76.items.line_descriptions.schemas import LineDescriptionCreate
from api.v1.modules.a24.fa.fa_item_lines.dto import FaLineItemCreateDTO
from api.v1.modules.a76.layouts_csv.facturas.line_item_enrichment import (
apply_import_defaults_and_calculations_for_csv,
apply_export_defaults_and_calculations_for_csv,
)
from api.v1.modules.a76.items.service import ItemService
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
@@ -4569,12 +4591,28 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
_fc_insert = parse_footer_config(meta.get("footer_config"))
autonumerar_remesas_insert = _fc_insert.get("autonumerar_remesas", False)
class_id_by_code: Dict[str, int] = {}
class_uom_by_code: Dict[str, Optional[str]] = {}
class_fraction_by_code: Dict[str, Optional[str]] = {}
class_desc_es_by_code: Dict[str, Optional[str]] = {}
class_desc_en_by_code: Dict[str, Optional[str]] = {}
uom_id_by_code: Dict[str, int] = {}
package_id_by_key: Dict[str, int] = {}
if model_target == 'invoice_details':
for c in session.query(Class.id, Class.class_code).filter(Class.tenant_id == tenant_id, Class.company_id == company_id).all():
for c in session.query(
Class.id,
Class.class_code,
Class.unit_of_measure,
Class.fraction,
Class.description_es,
Class.description_en,
).filter(Class.tenant_id == tenant_id, Class.company_id == company_id).all():
if c[1]:
class_id_by_code[(c[1] or "").strip().upper()] = c[0]
class_code_key = (c[1] or "").strip().upper()
class_id_by_code[class_code_key] = c[0]
class_uom_by_code[class_code_key] = (c[2] or "").strip().upper() or None
class_fraction_by_code[class_code_key] = (c[3] or "").strip() or None
class_desc_es_by_code[class_code_key] = (c[4] or "").strip() or None
class_desc_en_by_code[class_code_key] = (c[5] or "").strip() or None
for u in session.query(UnitOfMeasure.id, UnitOfMeasure.code).filter(UnitOfMeasure.tenant_id == tenant_id, UnitOfMeasure.company_id == company_id).all():
if u[1]:
uom_id_by_code[(u[1] or "").strip().upper()] = u[0]
@@ -4871,12 +4909,18 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
remesa_val = (max_rem or 0) + 1
if existing_header:
# UPDATE existing header
# UPDATE existing header (paridad con InvoiceService.update)
header = existing_header
header.invoice_date = invoice_date
header.operation_type = op_type_value
header.is_updated = True # Mark as updated
header.updated_date = datetime.utcnow()
capture_user = meta.get("capture_user") or "CSV"
header.who_updated = capture_user
# Backfill capture_user if missing or generic (paridad con service)
if not header.capture_user or header.capture_user == "System":
if capture_user != "CSV":
header.capture_user = capture_user
header.document_type = (
None if inv_type_value == "MEX" else
resolve_public_code(
@@ -4902,7 +4946,8 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
# SQLAlchemy relationship assignment usually handles 1-to-1 updates correctly.
else:
# CREATE new header
# CREATE new header (paridad con InvoiceService.create: capture_user, who_updated)
capture_user = meta.get("capture_user") or "CSV"
header = InvoiceHeader(
invoice_number=invoice_number,
invoice_date=invoice_date,
@@ -4910,6 +4955,8 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
is_updated=False,
system="CSV",
capture_date=datetime.utcnow(),
capture_user=capture_user,
who_updated=capture_user,
invoice_type=inv_type_value,
document_type=(
None if inv_type_value == "MEX" else
@@ -5099,7 +5146,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
skipped_missing_invoice += 1
continue
# --- Partidas Exportación Definitiva: inserción real ---
# --- Partidas Exportación Definitiva: paridad con flujo normal (validators + ItemService) ---
if _template_id_insert == "exp_def_partidas":
part_num = (row_norm.get('NUM. PARTE') or row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or row_norm.get('NUM PARTE') or '').strip()
part_id = part_cache.get(part_num) if part_num else None
@@ -5107,7 +5154,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
p = session.query(Part.id).filter(Part.part_number == part_num, Part.tenant_id == tenant_id, Part.company_id == company_id).first()
if p:
part_id = p.id
part_cache[part_num] = part_id
part_cache[part_num] = p.id
line_num_val = (row_norm.get('LINEA EXPO') or row_norm.get('LINEA EXPO.') or row_norm.get('RENGLON EXPO'))
line_num = parse_int(line_num_val) or (len(details_to_insert) + 1)
@@ -5137,83 +5184,83 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False)
cleared_invoices.add(invoice_id)
line = LineItem(
invoice_id=invoice_id,
line_number=line_num,
tenant_id=tenant_id,
company_id=company_id,
part_number_id=part_id,
unit_of_measure=uom_id,
order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or row_norm.get('ORDEN DE VENTA') or None),
tax_payment=(se_pago == 'SI'),
payment_method=forma_pago,
)
session.add(line)
session.flush()
# FaLineItem (a24 extension: subpartidas, descarga, factura impo ref)
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
fa_line = FaLineItem(
id=line.id,
tenant_id=tenant_id,
company_id=company_id,
search_invoice=factura_impo or None,
search_line=parse_int(linea_impo_val),
search_type=tipo_impo or None,
download=(descarga_val == 'SI'),
is_subitem=is_subitem,
contains_subitems=contains_subitems,
subitem_number=parse_int(linea_principal_val) if is_subitem else None,
)
session.add(fa_line)
price = parse_decimal(row_norm.get('COSTO UNITARIO') or row_norm.get('COSTOUNITARIO'))
qty = parse_decimal(row_norm.get('CANTIDAD EXPORTADA/DESCARGAR') or row_norm.get('CANTIDAD EXPORTADA') or row_norm.get('CANTIDAD'))
commercial_total = (price * qty) if price and qty else None
session.add(LineFinancial(
item_line_id=line.id,
unit_cost_capture=decimal_or_zero(price),
total_commercial_value=decimal_or_zero(commercial_total),
))
net_w = parse_decimal(row_norm.get('PESO NETO') or row_norm.get('PESONETO'))
gross_w = parse_decimal(row_norm.get('PESO BRUTO') or row_norm.get('PESOBRUTO'))
session.add(LineQuantity(
item_line_id=line.id,
quantity=decimal_or_zero(qty),
net_weight=decimal_or_zero(net_w),
gross_weight=decimal_or_zero(gross_w),
package_quantity=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')),
package_id=package_id,
))
origin = (row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') or row_norm.get('PAIS') or '').strip()
fraction = (row_norm.get('FRACCION ARANCELARIA') or row_norm.get('FRACCIONARANCELARIA') or '').strip()
american_fraction = (row_norm.get('FRACCION AMERICANA') or row_norm.get('FRACCIONAMERICANA') or '').strip()
session.add(LineCustom(
item_line_id=line.id,
origin_country=origin or None,
fraction=fraction or None,
american_fraction=american_fraction or None,
))
extra_desc = (row_norm.get('DESCRIPCION EXTRA') or row_norm.get('DESCRIPCIONEXTRA') or '').strip()
additional_info = (row_norm.get('INFORMACION ADICIONAL') or row_norm.get('INFORMACIONADICIONAL') or '').strip()
lot = (row_norm.get('LOTE') or '').strip()
entry_number = (row_norm.get('NUMERO ENTRADA') or row_norm.get('NUM ENTRADA') or '').strip()
session.add(LineDescription(
item_line_id=line.id,
extra_description=extra_desc or None,
additional_info_spanish=additional_info or None,
lot=lot or None,
entry_number=entry_number or None,
))
order_compra = (row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or row_norm.get('ORDEN DE VENTA') or None)
line_data = LineItemCreate(
invoice_id=invoice_id,
line_number=line_num,
part_number_id=part_id,
class_id=None,
unit_of_measure=uom_id,
order=order_compra,
tax_payment=(se_pago == 'SI'),
payment_method=forma_pago,
financial=LineFinancialCreate(
unit_cost_capture=decimal_or_zero(price),
total_commercial_value=decimal_or_zero(commercial_total),
),
quantity=LineQuantityCreate(
quantity=decimal_or_zero(qty),
net_weight=decimal_or_zero(net_w),
gross_weight=decimal_or_zero(gross_w),
package_quantity=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')),
package_id=package_id,
),
customs=LineCustomCreate(
origin_country=origin or None,
fraction=fraction or None,
american_fraction=american_fraction or None,
),
description=LineDescriptionCreate(
extra_description=extra_desc or None,
additional_info_spanish=additional_info or None,
lot=lot or None,
entry_number=entry_number or None,
),
fa_data=FaLineItemCreateDTO(
search_invoice=factura_impo or None,
search_line=parse_int(linea_impo_val),
search_type=tipo_impo or None,
download=(descarga_val == 'SI'),
is_subitem=is_subitem,
contains_subitems=contains_subitems,
subitem_number=parse_int(linea_principal_val) if is_subitem else 0,
),
)
if not apply_export_defaults_and_calculations_for_csv(
session, line_data, tenant_id, company_id, line_num
):
skipped_invalid += 1
skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": "Factura sin datos financieros para enriquecer partida de exportación."})
continue
item_dict = line_data.model_dump(
exclude={"financial", "quantity", "customs", "description", "reference", "fa_data", "series"}
)
item_dict["tenant_id"] = tenant_id
item_dict["company_id"] = company_id
item_dict["line_number"] = line_num
line = LineItem(**item_dict)
session.add(line)
session.flush()
ItemService._create_line_nested_data(session, line, line_data, tenant_id, company_id)
session.add(InvoiceSalesDetails(
invoice_id=invoice_id,
line_number=line_num,
sales_order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or row_norm.get('ORDEN DE VENTA') or None),
sales_order=order_compra,
line_bundles=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')),
tenant_id=tenant_id,
company_id=company_id,
@@ -5242,7 +5289,9 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False)
cleared_invoices.add(invoice_id)
# --- Partidas: LineItem with invoice_id (no Item parent) + full CSV mapping ---
# --- Partidas importación: paridad con flujo normal (validators + ItemService) ---
# LineReference: solo se crea si line_data.reference viene informado; no inventar datos sin fuente (plan paridad CSV).
# Build LineItemCreate from CSV, apply import defaults/calculations, then persist.
part_num = (row_norm.get('NUM. PARTE') or row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or row_norm.get('NUM PARTE') or '').strip()
part_id = part_cache.get(part_num) if part_num else None
if part_id is None and part_num:
@@ -5257,27 +5306,12 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
class_code = (row_norm.get('CLASE') or '').strip().upper()
class_id = class_id_by_code.get(class_code) if class_code else None
uom_code = (row_norm.get('UNIDAD DE MEDIDA') or row_norm.get('UNIDAD MEDIDA') or '').strip().upper()
if not uom_code and class_code:
uom_code = class_uom_by_code.get(class_code) or ''
uom_id = uom_id_by_code.get(uom_code) if uom_code else None
bulk_key = (row_norm.get('CLAVE BULTOS') or row_norm.get('CLAVEBULTOS') or '').strip()
package_id = package_id_by_key.get(bulk_key) if bulk_key else None
line = LineItem(
invoice_id=invoice_id,
line_number=line_num,
tenant_id=tenant_id,
company_id=company_id,
part_number_id=part_id,
class_id=class_id,
unit_of_measure=uom_id,
order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or None),
material_type=(row_norm.get('ID TYPE') or row_norm.get('IDTYPE') or None),
tax_payment=(str(row_norm.get('SE PAGO IMPUESTO') or row_norm.get('SEPAGOIMPUESTO') or '').strip().upper() == 'SI'),
payment_method=(row_norm.get('FORMA DE PAGO') or row_norm.get('FORMADEPAGO') or row_norm.get('FORMA PAGO') or None),
valuation_method=(row_norm.get('METODO DE VALORACION') or row_norm.get('METODODEVALORACION') or row_norm.get('METODO VALORACION') or None),
)
session.add(line)
session.flush()
price = parse_decimal(row_norm.get('COSTO UNITARIO') or row_norm.get('PRECIO UNITARIO') or row_norm.get('PRECIOUNITARIO'))
if price is None:
total_val = parse_decimal(row_norm.get('TOTAL'))
@@ -5285,58 +5319,88 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
price = (total_val / qty) if (total_val and qty and qty != 0) else None
qty = parse_decimal(row_norm.get('CANTIDAD IMPORTADA') or row_norm.get('CANTIDAD'))
commercial_total = (price * qty) if price and qty else parse_decimal(row_norm.get('TOTAL'))
session.add(LineFinancial(
item_line_id=line.id,
unit_cost_capture=decimal_or_zero(price),
total_commercial_value=decimal_or_zero(commercial_total),
))
net_w = parse_decimal(row_norm.get('PESO NETO') or row_norm.get('PESONETO'))
gross_w = parse_decimal(row_norm.get('PESO BRUTO') or row_norm.get('PESOBRUTO'))
session.add(LineQuantity(
item_line_id=line.id,
quantity=decimal_or_zero(qty),
net_weight=decimal_or_zero(net_w),
gross_weight=decimal_or_zero(gross_w),
package_quantity=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')),
package_id=package_id,
))
origin = (row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') or row_norm.get('PAIS') or '').strip()
fraction = (row_norm.get('FRACCION ARANCELARIA') or row_norm.get('FRACCION') or row_norm.get('FRACCIONARANCELARIA') or '').strip()
if not fraction and class_code:
fraction = class_fraction_by_code.get(class_code) or ''
fraction_type = (row_norm.get('PREFERENCIA ARANCELARIA') or row_norm.get('PREFERENCIA') or '').strip()
sector = (row_norm.get('SECTOR') or '').strip()
american_fraction = (row_norm.get('FRACCION AMERICANA') or row_norm.get('FRACCIONAMERICANA') or '').strip()
session.add(LineCustom(
item_line_id=line.id,
origin_country=origin or None,
fraction=fraction or None,
fraction_type=fraction_type or None,
sector=sector or None,
american_fraction=american_fraction or None,
))
desc_es = (row_norm.get('DESCRIPCION ESPAÑOL') or row_norm.get('DESCRIPCIONE') or row_norm.get('DESCRIPCION') or '').strip()
if not desc_es and class_code:
desc_es = class_desc_es_by_code.get(class_code) or ''
desc_en = (row_norm.get('DESCRIPCION INGLES') or row_norm.get('DESCRIPCIONI') or '').strip()
if not desc_en and class_code:
desc_en = class_desc_en_by_code.get(class_code) or ''
brand = (row_norm.get('MARCA') or '').strip()
model = (row_norm.get('MODELO') or '').strip()
extra_desc = (row_norm.get('DESCRIPCION EXTRA') or row_norm.get('DESCRIPCIONEXTRA') or '').strip()
additional_info = (row_norm.get('INFORMACION ADICIONAL') or row_norm.get('INFORMACIONADICIONAL') or '').strip()
lot = (row_norm.get('LOTE') or '').strip()
entry_number = (row_norm.get('NUMERO ENTRADA') or row_norm.get('NUMEROENTRADA') or row_norm.get('NUM ENTRADA') or '').strip()
session.add(LineDescription(
item_line_id=line.id,
description_spanish=desc_es or None,
description_english=desc_en or None,
brand=brand or None,
model=model or None,
extra_description=extra_desc or None,
additional_info_spanish=additional_info or None,
lot=lot or None,
entry_number=entry_number or None,
))
line_data = LineItemCreate(
invoice_id=invoice_id,
line_number=line_num,
part_number_id=part_id,
class_id=class_id,
unit_of_measure=uom_id,
order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or None),
material_type=(row_norm.get('ID TYPE') or row_norm.get('IDTYPE') or None),
tax_payment=(str(row_norm.get('SE PAGO IMPUESTO') or row_norm.get('SEPAGOIMPUESTO') or '').strip().upper() == 'SI'),
payment_method=(row_norm.get('FORMA DE PAGO') or row_norm.get('FORMADEPAGO') or row_norm.get('FORMA PAGO') or None),
valuation_method=(row_norm.get('METODO DE VALORACION') or row_norm.get('METODODEVALORACION') or row_norm.get('METODO VALORACION') or None),
financial=LineFinancialCreate(
unit_cost_capture=decimal_or_zero(price),
total_commercial_value=decimal_or_zero(commercial_total),
),
quantity=LineQuantityCreate(
quantity=decimal_or_zero(qty),
net_weight=decimal_or_zero(net_w),
gross_weight=decimal_or_zero(gross_w),
package_quantity=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')),
package_id=package_id,
),
customs=LineCustomCreate(
origin_country=origin or None,
fraction=fraction or None,
fraction_type=fraction_type or None,
sector=sector or None,
american_fraction=american_fraction or None,
),
description=LineDescriptionCreate(
description_spanish=desc_es or None,
description_english=desc_en or None,
brand=brand or None,
model=model or None,
extra_description=extra_desc or None,
additional_info_spanish=additional_info or None,
lot=lot or None,
entry_number=entry_number or None,
),
fa_data=FaLineItemCreateDTO(is_subitem=False, contains_subitems=False),
)
if not apply_import_defaults_and_calculations_for_csv(
session, line_data, tenant_id, company_id, line_num
):
skipped_invalid += 1
skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": "Factura sin datos financieros/logísticos para enriquecer partida."})
continue
item_dict = line_data.model_dump(
exclude={"financial", "quantity", "customs", "description", "reference", "fa_data", "series"}
)
item_dict["tenant_id"] = tenant_id
item_dict["company_id"] = company_id
item_dict["line_number"] = line_num
line = LineItem(**item_dict)
session.add(line)
session.flush()
ItemService._create_line_nested_data(session, line, line_data, tenant_id, company_id)
session.add(InvoiceSalesDetails(
invoice_id=invoice_id,
line_number=line_num,

View File

@@ -2,6 +2,7 @@
Tareas Celery para importación CSV de Números de Parte.
Flujo: scan_file (validación) → insert_valid_rows (commit).
Paridad Clarion: actualizar (ACT), validación full/parcial, merge existente, reemplazar_sin_preguntar, RFC desde clase.
Sin PartService de creación en API; los mappers CSV (row_to_part_data, apply_rfc_exception_from_class) son la fuente de verdad para reglas de negocio al crear/actualizar.
"""
import json
import logging

View File

@@ -2,6 +2,8 @@
Tareas Celery para importación CSV de Pedimentos.
Flujo: scan_file (validación) → insert_valid_rows (commit).
Usa layouts_csv.common (storage, normalize, meta, responses, csv_reader), fk_loader, validators, mappers.
Create/update delegan en PedimentosService; defaults de fechas (pedimento_dates) y merge vs replace
están alineados con el servicio para paridad con el flujo API.
"""
import json
import logging

View File

@@ -10,8 +10,9 @@ from .customs_brokers.routes import router as customs_broker_router
# Importar routers de módulos
from .general_catalogs.router import router as general_catalogs_router
from .invoices.routes import router as invoices_router
from .invoices.imports.process.routes import router as invoice_process_router
from .items.routes import router as items_router
from .classes import router as classes_router
from .classes.routes import router as classes_router
from .clients_and_providers import router as client_and_provider_router
from .layouts_csv.facturas.routes import router as imports_router
@@ -20,11 +21,10 @@ from .layouts_csv.cambio_regimen_regularizacion.routes import router as cambio_r
from .csv_templates.routes import router as csv_templates_router
from .invoice_settings.routes import router as invoice_settings_router
from .item_presets.routes import router as item_presets_router
from .general_catalogs.company import router as company_router
from .country_rule_oct.routes import router as country_rule_oct_router
from .transportation.drivers.routes import router as drivers_router
from .doc_types_dig.routes import router as doc_types_dig_router
from .fraction_rule_octave.routes import router as fraction_rule_octave_router
from .rule_octave.fractions.routes import router as fraction_rule_octave_router
from .rule_octave.country.routes import router as country_rule_oct_router
from .parts import router as parts_router
from .boms import router as boms_router
from .pedmientos.router import router as pedimentos_router
@@ -42,14 +42,15 @@ from .reports.exportacion.aviso_consolidado.routes import router as aviso_consol
from .reports.movements.invoices.routes import router as movement_invoices_router
from .reports.movements.saldos.routes import router as movement_saldos_router
from .reports.exportacion.descargo.routes import router as discharge_reports_router
from .manifests.manifest.routes import router as manifests_router
from .manifests.driver.routes import router as manifest_drivers_router
from .manifests.manifiesto_anexo.routes import router as manifest_anexos_router
from .reports.exportacion.transmission.MAINX30.routes import router as transmission_router
from .reports.importacion.transmission.temporal.MAINX30.routes import router as transmission_temporal_router
from .reports.importacion.transmission.definitive.MAINX30.routes import router as transmission_definitive_router
from .reports.importacion.winsaai.router import router as winsaai_router
from .manifests.manifest.routes import router as manifests_router
from .manifests.driver.routes import router as manifest_drivers_router
from .manifests.manifiesto_anexo.routes import router as manifest_anexos_router
# Router principal
router = APIRouter()
@@ -57,6 +58,7 @@ router = APIRouter()
# Registrar módulos
router.include_router(general_catalogs_router, prefix="/a76", tags=["a76 / general_catalogs"])
router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"])
router.include_router(invoice_process_router, prefix="/a76", tags=["a76 / invoices"])
router.include_router(items_router, prefix="/a76", tags=["a76 / items"])
router.include_router(imports_router, prefix="/a76/imports", tags=["a76 / imports"])
router.include_router(exportacion_imports_router, prefix="/a76/imports/exportacion", tags=["a76 / imports / exportacion"])

View File

@@ -0,0 +1,56 @@
from decimal import Decimal
from typing import Optional
from sqlalchemy import Integer, Numeric, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class OctaveBalance(Base, TenantScopedMixin, TimestampMixin):
"""
Saldos de Regla Octava por factura/partida.
Paridad: SSaldosReglaOctava (Clarion SCAII).
La PK es compuesta natural, igual que ROctSal_PKFaParPaiTiSeROProSisLin.
Se agregan tenant_id y company_id al inicio para el scope multi-tenant.
"""
__tablename__ = "octave_balance"
__table_args__ = (
PrimaryKeyConstraint(
"tenant_id",
"company_id",
"invoice_import",
"part_number",
"origin_country",
"fraction_type",
"sector",
"octave_permit",
"origin",
"system",
"line",
name="pk_octave_balance",
),
{"schema": "a76"},
)
# ── PK compuesta ──────────────────────────────────────────────────────────
invoice_import: Mapped[str] = mapped_column(String(15)) # FACTURAIMPO
part_number: Mapped[str] = mapped_column(String(70)) # NUMPARTE
origin_country: Mapped[str] = mapped_column(String(3)) # PAISORIGEN
fraction_type: Mapped[str] = mapped_column(String(7)) # TIPOFRACIMPO
sector: Mapped[str] = mapped_column(String(8)) # SECTOR
octave_permit: Mapped[str] = mapped_column(String(20)) # PERMISOROCTAVA
origin: Mapped[str] = mapped_column(String(3)) # PROCEDENCIA ('TEM')
system: Mapped[str] = mapped_column(String(5)) # SISTEMA ('fixed_asset | inventory')
line: Mapped[int] = mapped_column(Integer) # LINEA
# ── Datos ─────────────────────────────────────────────────────────────────
quantity_stock: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTEXITENCIA
unit_of_measure: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMED
class_code: Mapped[Optional[str]] = mapped_column(String(8)) # CLASE
import_fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCIONIMPO
ro_fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCIONROCTAVA
value_me: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORME

View File

@@ -0,0 +1,40 @@
from decimal import Decimal
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import Integer, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
class FractionRuleOctave(Base, TenantScopedMixin, TimestampMixin):
"""
Fracciones por permiso de Regla Octava.
Paridad: GFracROctava (Clarion SCAII).
PK natural: (permission, line, fraction) — se usa junto a tenant/company.
"""
__tablename__ = "fraction_rule_octave"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"company_id",
"permission",
"line",
"fraction",
name="uq_fraction_rule_octave_permission_line_fraction",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
permission: Mapped[str] = mapped_column(String(20)) # PERMISO
line: Mapped[int] = mapped_column(Integer) # LINEA
fraction: Mapped[str] = mapped_column(String(10)) # FRACCION
quota_quantity: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTIDADCUPO
quantity_used: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # CANTUSADA
quota_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORCUPO
value_used: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORUSADA
unit_cost_me: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOUNITARIOME
unit_of_measure: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMED

View File

@@ -0,0 +1,32 @@
from typing import Optional
from sqlalchemy import DateTime, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class OctavePermission(Base, TenantScopedMixin, TimestampMixin):
"""
Permisos para la Regla Octava.
Paridad: GPermisoReglaOct (Clarion SCAII).
"""
__tablename__ = "permission_rule_octave"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"company_id",
"permission",
name="uq_permissions_rule_octave_permission",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
permission: Mapped[str] = mapped_column(String(20), nullable=False) # PERMISO
start_date: Mapped[Optional[DateTime]] = mapped_column(DateTime()) # FECHAINICIO
end_date: Mapped[Optional[DateTime]] = mapped_column(DateTime()) # FECHAFINAL
sector: Mapped[Optional[str]] = mapped_column(String(8)) # SECTOR
system: Mapped[Optional[str]] = mapped_column(String(5)) # SISTEMA