Merge remote-tracking branch 'origin/development' into feature/process-invoices
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
Módulo de localización
|
||||
"""
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
DTOs (Data Transfer Objects) para módulo de localización
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LocationCreateDTO(BaseModel):
|
||||
"""DTO para crear una localización"""
|
||||
|
||||
code: str = Field(..., max_length=5, description="Location code")
|
||||
description: Optional[str] = Field(
|
||||
None, max_length=200, description="Location description"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LocationUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar una localización"""
|
||||
|
||||
code: Optional[str] = Field(
|
||||
None, max_length=5, description="Location code")
|
||||
description: Optional[str] = Field(
|
||||
None, max_length=200, description="Location description"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LocationResponseDTO(BaseModel):
|
||||
"""DTO para responder con datos de una localización"""
|
||||
|
||||
id: int
|
||||
code: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -1,36 +0,0 @@
|
||||
"""
|
||||
Modelos ORM para gestión de localización
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import Integer, PrimaryKeyConstraint, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class Location(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla Location - Localización
|
||||
"""
|
||||
|
||||
__tablename__ = "location" # SLocalizacion
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="location_pkey"),
|
||||
UniqueConstraint("code", name="location_code_unique"),
|
||||
{"schema": "a24"},
|
||||
)
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Location code (unique)
|
||||
code: Mapped[str] = mapped_column(String(5), nullable=False, unique=True)
|
||||
|
||||
# Location description
|
||||
description: Mapped[Optional[str]] = mapped_column(String(200))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Location(id={self.id}, code={self.code}, description={self.description})>"
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
Rutas para gestión de localización
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from .dto import LocationCreateDTO, LocationResponseDTO, LocationUpdateDTO
|
||||
from .models import Location
|
||||
from .service import LocationService
|
||||
|
||||
router = APIRouter(prefix="/locations", tags=["locations"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="Get all locations",
|
||||
)
|
||||
async def get_all_locations(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
code: str = Query(None),
|
||||
description: str = Query(None),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Get all locations with optional filtering and pagination"""
|
||||
filters = {}
|
||||
if code:
|
||||
filters["code"] = code
|
||||
if description:
|
||||
filters["description"] = description
|
||||
|
||||
locations, total = LocationService.get_all(db, skip, limit, filters)
|
||||
|
||||
return {
|
||||
"data": [LocationResponseDTO.model_validate(location) for location in locations],
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{location_id}",
|
||||
response_model=LocationResponseDTO,
|
||||
summary="Get location by ID",
|
||||
)
|
||||
async def get_location(
|
||||
location_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Get a location by its ID"""
|
||||
location = LocationService.get_by_id(db, location_id)
|
||||
if not location:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found",
|
||||
)
|
||||
return LocationResponseDTO.model_validate(location)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/code/{code}",
|
||||
response_model=LocationResponseDTO,
|
||||
summary="Get location by code",
|
||||
)
|
||||
async def get_location_by_code(
|
||||
code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Get a location by its code"""
|
||||
location = LocationService.get_by_code(db, code)
|
||||
if not location:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found",
|
||||
)
|
||||
return LocationResponseDTO.model_validate(location)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=LocationResponseDTO,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create location",
|
||||
)
|
||||
async def create_location(
|
||||
location_data: LocationCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Create a new location"""
|
||||
location = LocationService.create(db, location_data)
|
||||
return LocationResponseDTO.model_validate(location)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{location_id}",
|
||||
response_model=LocationResponseDTO,
|
||||
summary="Update location",
|
||||
)
|
||||
async def update_location(
|
||||
location_id: int,
|
||||
location_data: LocationUpdateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Update a location"""
|
||||
location = LocationService.update(db, location_id, location_data)
|
||||
if not location:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found",
|
||||
)
|
||||
return LocationResponseDTO.model_validate(location)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{location_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete location",
|
||||
)
|
||||
async def delete_location(
|
||||
location_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Delete a location"""
|
||||
success = LocationService.delete(db, location_id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found",
|
||||
)
|
||||
return None
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
Capa de servicio para lógica de negocio de localización
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import LocationCreateDTO, LocationResponseDTO, LocationUpdateDTO
|
||||
from .models import Location
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocationService:
|
||||
"""Servicio para gestión de localización"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def get_all(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[List[Location], int]:
|
||||
"""Get all locations with pagination"""
|
||||
query = db.query(Location)
|
||||
|
||||
if filters:
|
||||
if filters.get("code"):
|
||||
query = query.filter(
|
||||
Location.code.ilike(f"%{filters['code']}%"))
|
||||
if filters.get("description"):
|
||||
query = query.filter(
|
||||
Location.description.ilike(f"%{filters['description']}%")
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
locations = query.offset(skip).limit(limit).all()
|
||||
|
||||
return locations, total
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(db: Session, location_id: int) -> Optional[Location]:
|
||||
"""Get location by ID"""
|
||||
return db.query(Location).filter(Location.id == location_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_by_code(db: Session, code: str) -> Optional[Location]:
|
||||
"""Get location by code"""
|
||||
return db.query(Location).filter(Location.code == code).first()
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, location_data: LocationCreateDTO) -> Location:
|
||||
"""Create a new location"""
|
||||
try:
|
||||
db_location = Location(
|
||||
**location_data.model_dump(exclude_unset=True))
|
||||
|
||||
db.add(db_location)
|
||||
db.commit()
|
||||
db.refresh(db_location)
|
||||
|
||||
return db_location
|
||||
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
logger.error(f"IntegrityError creating location: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Location code already exists",
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error creating location: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error creating location")
|
||||
|
||||
@staticmethod
|
||||
def update(
|
||||
db: Session, location_id: int, location_data: LocationUpdateDTO
|
||||
) -> Optional[Location]:
|
||||
"""Update a location"""
|
||||
try:
|
||||
db_location = db.query(Location).filter(
|
||||
Location.id == location_id).first()
|
||||
|
||||
if not db_location:
|
||||
return None
|
||||
|
||||
for key, value in location_data.model_dump(exclude_unset=True).items():
|
||||
setattr(db_location, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_location)
|
||||
|
||||
return db_location
|
||||
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
logger.error(f"IntegrityError updating location: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Error updating location",
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error updating location: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error updating location")
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, location_id: int) -> bool:
|
||||
"""Delete a location"""
|
||||
try:
|
||||
db_location = db.query(Location).filter(
|
||||
Location.id == location_id).first()
|
||||
|
||||
if not db_location:
|
||||
return False
|
||||
|
||||
db.delete(db_location)
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error deleting location: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error deleting location")
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user