Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into feature/partePais_BOM
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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})>"
|
||||
)
|
||||
@@ -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")
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# a76 general_catalogs.location
|
||||
36
backend/api/v1/modules/a76/general_catalogs/location/dto.py
Normal file
36
backend/api/v1/modules/a76/general_catalogs/location/dto.py
Normal 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)
|
||||
@@ -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})>"
|
||||
)
|
||||
@@ -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
|
||||
154
backend/api/v1/modules/a76/general_catalogs/location/service.py
Normal file
154
backend/api/v1/modules/a76/general_catalogs/location/service.py
Normal 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
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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}"
|
||||
),
|
||||
})
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
122
backend/api/v1/modules/a76/invoices/imports/process/task.py
Normal file
122
backend/api/v1/modules/a76/invoices/imports/process/task.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
56
backend/api/v1/modules/a76/rule_octave/balances/models.py
Normal file
56
backend/api/v1/modules/a76/rule_octave/balances/models.py
Normal 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
|
||||
40
backend/api/v1/modules/a76/rule_octave/fractions/models.py
Normal file
40
backend/api/v1/modules/a76/rule_octave/fractions/models.py
Normal 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
|
||||
32
backend/api/v1/modules/a76/rule_octave/permissions/models.py
Normal file
32
backend/api/v1/modules/a76/rule_octave/permissions/models.py
Normal 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
|
||||
Reference in New Issue
Block a user