feat: Implement help center functionality and transportation general catalogs with associated UI components and API endpoints.

This commit is contained in:
2026-03-03 09:12:10 -06:00
39 changed files with 2774 additions and 290 deletions

View File

@@ -221,6 +221,10 @@ class ClassWithFADataResponse(BaseModel):
# FA-specific fields (embedded from a24.fa_classes)
fa_class_id: Optional[int] = None
import_tariff_code: Optional[str] = None
import_tariff_type: Optional[str] = None
export_tariff_code: Optional[str] = None
export_tariff_type: Optional[str] = None
depreciation_rate: Optional[Decimal] = None
fda_code: Optional[str] = None
eccn_code: Optional[str] = None

View File

@@ -150,6 +150,10 @@ class ClassService:
"updated_at": base_class.updated_at,
# FA extension fields (None if no FA record exists)
"fa_class_id": fa_class.id if fa_class else None,
"import_tariff_code": fa_class.import_tariff_code if fa_class else None,
"import_tariff_type": fa_class.import_tariff_type if fa_class else None,
"export_tariff_code": fa_class.export_tariff_code if fa_class else None,
"export_tariff_type": fa_class.export_tariff_type if fa_class else None,
"depreciation_rate": fa_class.depreciation_rate if fa_class else None,
"fda_code": fa_class.fda_code if fa_class else None,
"eccn_code": fa_class.eccn_code if fa_class else None,

View File

@@ -39,6 +39,7 @@ class FDACatalogUpdate(BaseModel):
}
class FDACatalogResponse(BaseModel):
"""DTO para respuesta del catálogo FDA"""
id: int
@@ -51,9 +52,121 @@ class FDACatalogResponse(BaseModel):
storage_status: Optional[str]
warehouse_code: Optional[str]
call_atl: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
model_config = {
"from_attributes": True
}
# DTOs para FDA01 - Especificaciones
class FDASpecificationsBase(BaseModel):
prod_code: Optional[str] = None
commodity_desc: Optional[str] = None
brand_name: Optional[str] = None
disclaimer: Optional[str] = None
pgm_code: Optional[str] = None
proc_code: Optional[str] = None
intnd_use_code: Optional[str] = None
intnd_use_desc: Optional[str] = None
temp_qual: Optional[str] = None
temp_type: Optional[str] = None
temp_degrees: Optional[float] = None
temp_negative: Optional[float] = None
temp_location: Optional[str] = None
quantity_1: Optional[float] = None
qty_uom_1: Optional[str] = None
quantity_2: Optional[float] = None
qty_uom_2: Optional[str] = None
quantity_3: Optional[float] = None
qty_uom_3: Optional[str] = None
ctry_prod: Optional[str] = None
ctry_source: Optional[str] = None
ctry_growth: Optional[str] = None
ctry_refusal: Optional[str] = None
ctry_shipping: Optional[str] = None
manuf_key: Optional[str] = None
shipper_key: Optional[str] = None
ult_cons_key: Optional[str] = None
fda_imp_key: Optional[str] = None
pn_subm_key: Optional[str] = None
consol_key: Optional[str] = None
producer_key: Optional[str] = None
owner_key: Optional[str] = None
deli_party_key: Optional[str] = None
grower_key: Optional[str] = None
dev_ini_imp_key: Optional[str] = None
lacf_cont_1: Optional[str] = None
lacf_cont_2: Optional[str] = None
lacf_cont_3: Optional[str] = None
pn_transmitter_key: Optional[str] = None
class FDASpecificationsCreate(FDASpecificationsBase):
pass
class FDASpecificationsUpdate(FDASpecificationsBase):
pass
class FDASpecificationsResponse(FDASpecificationsBase):
id: int
fda_catalog_id: int
model_config = {"from_attributes": True}
# DTOs para FDA04 - Elementos Constitutivos
class FDAConstituentElementsBase(BaseModel):
line: int
ele_name: str
ele_qty: Optional[float] = None
ele_qty_uom: Optional[str] = None
ele_pctg: Optional[float] = None
class FDAConstituentElementsCreate(FDAConstituentElementsBase):
pass
class FDAConstituentElementsUpdate(FDAConstituentElementsBase):
line: Optional[int] = None
ele_name: Optional[str] = None
class FDAConstituentElementsResponse(FDAConstituentElementsBase):
id: int
fda_catalog_id: int
model_config = {"from_attributes": True}
# DTOs para FDA23 - Códigos de Afirmación
class FDAAffirmationCodesBase(BaseModel):
line: int
aoc_code: str
aoc_qual: Optional[str] = None
class FDAAffirmationCodesCreate(FDAAffirmationCodesBase):
pass
class FDAAffirmationCodesUpdate(FDAAffirmationCodesBase):
line: Optional[int] = None
aoc_code: Optional[str] = None
class FDAAffirmationCodesResponse(FDAAffirmationCodesBase):
id: int
fda_catalog_id: int
model_config = {"from_attributes": True}
# DTOs para FDA25 - Lotes de Producción
class FDALotProductionBase(BaseModel):
line: int
lot_number: str
production_start_date: Optional[str] = None
production_end_date: Optional[str] = None
class FDALotProductionCreate(FDALotProductionBase):
pass
class FDALotProductionUpdate(FDALotProductionBase):
line: Optional[int] = None
lot_number: Optional[str] = None
class FDALotProductionResponse(FDALotProductionBase):
id: int
fda_catalog_id: int
model_config = {"from_attributes": True}

View File

@@ -1,9 +1,9 @@
"""
Modelo para el catálogo de claves FDA
"""
from typing import Optional
from sqlalchemy import String, Integer, UniqueConstraint, PrimaryKeyConstraint
from sqlalchemy.orm import Mapped, mapped_column
from typing import Optional, List
from sqlalchemy import String, Integer, Numeric, Date, ForeignKey, UniqueConstraint, PrimaryKeyConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -27,3 +27,124 @@ class FDACatalog(Base, TenantScopedMixin, TimestampMixin):
storage_status: Mapped[Optional[str]] = mapped_column(String(100))
warehouse_code: Mapped[Optional[str]] = mapped_column(String(20))
call_atl: Mapped[Optional[str]] = mapped_column(String(20))
# Relationships
specifications: Mapped[Optional["FDASpecifications"]] = relationship("FDASpecifications", back_populates="fda_catalog", cascade="all, delete-orphan", uselist=False)
constituent_elements: Mapped[List["FDAConstituentElements"]] = relationship("FDAConstituentElements", back_populates="fda_catalog", cascade="all, delete-orphan")
affirmation_codes: Mapped[List["FDAAffirmationCodes"]] = relationship("FDAAffirmationCodes", back_populates="fda_catalog", cascade="all, delete-orphan")
lot_productions: Mapped[List["FDALotProduction"]] = relationship("FDALotProduction", back_populates="fda_catalog", cascade="all, delete-orphan")
class FDASpecifications(Base, TenantScopedMixin, TimestampMixin):
"""FDA01 - Especificaciones del Producto FDA"""
__tablename__ = "fda_specifications"
__table_args__ = (
PrimaryKeyConstraint("id", name="fda_specifications_pkey"),
{'schema': 'a76'}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
fda_catalog_id: Mapped[int] = mapped_column(ForeignKey("a76.fda_catalog.id", ondelete="CASCADE"), nullable=False, index=True)
prod_code: Mapped[Optional[str]] = mapped_column(String(50))
commodity_desc: Mapped[Optional[str]] = mapped_column(String(200))
brand_name: Mapped[Optional[str]] = mapped_column(String(100))
disclaimer: Mapped[Optional[str]] = mapped_column(String(100))
pgm_code: Mapped[Optional[str]] = mapped_column(String(50))
proc_code: Mapped[Optional[str]] = mapped_column(String(50))
intnd_use_code: Mapped[Optional[str]] = mapped_column(String(50))
intnd_use_desc: Mapped[Optional[str]] = mapped_column(String(200))
temp_qual: Mapped[Optional[str]] = mapped_column(String(50))
temp_type: Mapped[Optional[str]] = mapped_column(String(50))
temp_degrees: Mapped[Optional[float]] = mapped_column(Numeric(10, 2))
temp_negative: Mapped[Optional[float]] = mapped_column(Numeric(10, 2))
temp_location: Mapped[Optional[str]] = mapped_column(String(100))
quantity_1: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
qty_uom_1: Mapped[Optional[str]] = mapped_column(String(20))
quantity_2: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
qty_uom_2: Mapped[Optional[str]] = mapped_column(String(20))
quantity_3: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
qty_uom_3: Mapped[Optional[str]] = mapped_column(String(20))
ctry_prod: Mapped[Optional[str]] = mapped_column(String(50))
ctry_source: Mapped[Optional[str]] = mapped_column(String(50))
ctry_growth: Mapped[Optional[str]] = mapped_column(String(50))
ctry_refusal: Mapped[Optional[str]] = mapped_column(String(50))
ctry_shipping: Mapped[Optional[str]] = mapped_column(String(50))
manuf_key: Mapped[Optional[str]] = mapped_column(String(50))
shipper_key: Mapped[Optional[str]] = mapped_column(String(50))
ult_cons_key: Mapped[Optional[str]] = mapped_column(String(50))
fda_imp_key: Mapped[Optional[str]] = mapped_column(String(50))
pn_subm_key: Mapped[Optional[str]] = mapped_column(String(50))
consol_key: Mapped[Optional[str]] = mapped_column(String(50))
producer_key: Mapped[Optional[str]] = mapped_column(String(50))
owner_key: Mapped[Optional[str]] = mapped_column(String(50))
deli_party_key: Mapped[Optional[str]] = mapped_column(String(50))
grower_key: Mapped[Optional[str]] = mapped_column(String(50))
dev_ini_imp_key: Mapped[Optional[str]] = mapped_column(String(50))
lacf_cont_1: Mapped[Optional[str]] = mapped_column(String(100))
lacf_cont_2: Mapped[Optional[str]] = mapped_column(String(100))
lacf_cont_3: Mapped[Optional[str]] = mapped_column(String(100))
pn_transmitter_key: Mapped[Optional[str]] = mapped_column(String(50))
fda_catalog: Mapped["FDACatalog"] = relationship("FDACatalog", back_populates="specifications")
class FDAConstituentElements(Base, TenantScopedMixin, TimestampMixin):
"""FDA04 - Constituent Elements"""
__tablename__ = "fda_constituent_elements"
__table_args__ = (
PrimaryKeyConstraint("id", name="fda_constituent_elements_pkey"),
{'schema': 'a76'}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
fda_catalog_id: Mapped[int] = mapped_column(ForeignKey("a76.fda_catalog.id", ondelete="CASCADE"), nullable=False, index=True)
line: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
ele_name: Mapped[str] = mapped_column(String(200), nullable=False)
ele_qty: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
ele_qty_uom: Mapped[Optional[str]] = mapped_column(String(20))
ele_pctg: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
fda_catalog: Mapped["FDACatalog"] = relationship("FDACatalog", back_populates="constituent_elements")
class FDAAffirmationCodes(Base, TenantScopedMixin, TimestampMixin):
"""FDA23 - Affirmation of Compliance"""
__tablename__ = "fda_affirmation_codes"
__table_args__ = (
PrimaryKeyConstraint("id", name="fda_affirmation_codes_pkey"),
{'schema': 'a76'}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
fda_catalog_id: Mapped[int] = mapped_column(ForeignKey("a76.fda_catalog.id", ondelete="CASCADE"), nullable=False, index=True)
line: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
aoc_code: Mapped[str] = mapped_column(String(50), nullable=False)
aoc_qual: Mapped[Optional[str]] = mapped_column(String(100))
fda_catalog: Mapped["FDACatalog"] = relationship("FDACatalog", back_populates="affirmation_codes")
class FDALotProduction(Base, TenantScopedMixin, TimestampMixin):
"""FDA25 - Lot and Production Dates"""
__tablename__ = "fda_lot_production"
__table_args__ = (
PrimaryKeyConstraint("id", name="fda_lot_production_pkey"),
{'schema': 'a76'}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
fda_catalog_id: Mapped[int] = mapped_column(ForeignKey("a76.fda_catalog.id", ondelete="CASCADE"), nullable=False, index=True)
line: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
lot_number: Mapped[str] = mapped_column(String(100), nullable=False)
production_start_date: Mapped[Optional[str]] = mapped_column(String(50))
production_end_date: Mapped[Optional[str]] = mapped_column(String(50))
fda_catalog: Mapped["FDACatalog"] = relationship("FDACatalog", back_populates="lot_productions")

View File

@@ -67,3 +67,204 @@ async def get_fda_catalog(
"warehouse_code": entry.warehouse_code,
"call_atl": entry.call_atl
}
from api.v1.modules.a76.general_catalogs.fda_catalog.dto import FDACatalogCreate, FDACatalogUpdate
from sqlalchemy.exc import IntegrityError
@router.post("/", response_model=FDACatalogResponse, status_code=status.HTTP_201_CREATED)
async def create_fda_catalog(
data: FDACatalogCreate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Crear nueva entrada en el catálogo FDA"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
try:
return FDACatalogService.create(db, tenant_id, company_id, data)
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="La Clave FDA ya existe o hay un conflicto de datos.")
@router.put("/{id}", response_model=FDACatalogResponse)
async def update_fda_catalog(
id: int,
data: FDACatalogUpdate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Actualizar entrada del catálogo FDA"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
try:
entry = FDACatalogService.update(db, tenant_id, company_id, id, data)
if not entry:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entrada no encontrada")
return entry
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="Error de integridad de datos al actualizar la Clave FDA.")
@router.delete("/{id}")
async def delete_fda_catalog(
id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Eliminar entrada del catálogo FDA"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
success = FDACatalogService.delete(db, tenant_id, company_id, id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entrada no encontrada")
return {"message": "Eliminado correctamente"}
from typing import List
from api.v1.modules.a76.general_catalogs.fda_catalog.service import FDADetailsService
from api.v1.modules.a76.general_catalogs.fda_catalog.dto import (
FDASpecificationsCreate, FDASpecificationsResponse,
FDAConstituentElementsCreate, FDAConstituentElementsUpdate, FDAConstituentElementsResponse,
FDAAffirmationCodesCreate, FDAAffirmationCodesUpdate, FDAAffirmationCodesResponse,
FDALotProductionCreate, FDALotProductionUpdate, FDALotProductionResponse
)
# === FDA01: Especificaciones del Producto ===
@router.get("/{id}/specifications", response_model=Optional[FDASpecificationsResponse])
async def get_fda_specifications(
id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
catalog_entry = FDACatalogService.get_by_id(db, tenant_id, company_id, id)
if not catalog_entry:
raise HTTPException(status_code=404, detail="Catálogo FDA no encontrado")
return FDADetailsService.get_specifications(db, tenant_id, company_id, id)
@router.put("/{id}/specifications", response_model=FDASpecificationsResponse)
async def save_fda_specifications(
id: int,
data: FDASpecificationsCreate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
catalog_entry = FDACatalogService.get_by_id(db, tenant_id, company_id, id)
if not catalog_entry:
raise HTTPException(status_code=404, detail="Catálogo FDA no encontrado")
return FDADetailsService.save_specifications(db, tenant_id, company_id, id, data.model_dump(exclude_unset=True))
# === FDA04: Elementos Constitutivos ===
@router.get("/{id}/constituent-elements", response_model=List[FDAConstituentElementsResponse])
async def list_fda_constituent_elements(
id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
return FDADetailsService.get_constituent_elements(db, tenant_id, company_id, id)
@router.post("/{id}/constituent-elements", response_model=FDAConstituentElementsResponse)
async def create_fda_constituent_element(
id: int,
data: FDAConstituentElementsCreate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
return FDADetailsService.create_constituent_element(db, tenant_id, company_id, id, data.model_dump())
@router.delete("/{id}/constituent-elements/{element_id}")
async def delete_fda_constituent_element(
id: int,
element_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
success = FDADetailsService.delete_constituent_element(db, tenant_id, company_id, element_id)
if not success:
raise HTTPException(status_code=404, detail="Elemento no encontrado")
return {"message": "Eliminado correctamente"}
# === FDA23: Códigos de Afirmación ===
@router.get("/{id}/affirmation-codes", response_model=List[FDAAffirmationCodesResponse])
async def list_fda_affirmation_codes(
id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
return FDADetailsService.get_affirmation_codes(db, tenant_id, company_id, id)
@router.post("/{id}/affirmation-codes", response_model=FDAAffirmationCodesResponse)
async def create_fda_affirmation_code(
id: int,
data: FDAAffirmationCodesCreate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
return FDADetailsService.create_affirmation_code(db, tenant_id, company_id, id, data.model_dump())
@router.delete("/{id}/affirmation-codes/{code_id}")
async def delete_fda_affirmation_code(
id: int,
code_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
success = FDADetailsService.delete_affirmation_code(db, tenant_id, company_id, code_id)
if not success:
raise HTTPException(status_code=404, detail="Código no encontrado")
return {"message": "Eliminado correctamente"}
# === FDA25: Lotes de Producción ===
@router.get("/{id}/lot-productions", response_model=List[FDALotProductionResponse])
async def list_fda_lot_productions(
id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
return FDADetailsService.get_lot_productions(db, tenant_id, company_id, id)
@router.post("/{id}/lot-productions", response_model=FDALotProductionResponse)
async def create_fda_lot_production(
id: int,
data: FDALotProductionCreate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
return FDADetailsService.create_lot_production(db, tenant_id, company_id, id, data.model_dump())
@router.delete("/{id}/lot-productions/{lot_id}")
async def delete_fda_lot_production(
id: int,
lot_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
success = FDADetailsService.delete_lot_production(db, tenant_id, company_id, lot_id)
if not success:
raise HTTPException(status_code=404, detail="Lote no encontrado")
return {"message": "Eliminado correctamente"}

View File

@@ -106,3 +106,162 @@ class FDACatalogService:
db.delete(entry)
db.commit()
return True
class FDADetailsService:
"""Servicio CRUD para los detalles adicionales de FDA (Especificaciones, Lotes, etc)"""
# FDA01 - Especificaciones
@staticmethod
def get_specifications(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDASpecifications
return db.execute(
select(FDASpecifications).where(
(FDASpecifications.fda_catalog_id == fda_catalog_id) &
(FDASpecifications.tenant_id == tenant_id) &
(FDASpecifications.company_id == company_id)
)
).scalar_one_or_none()
@staticmethod
def save_specifications(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int, data: dict):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDASpecifications
existing = FDADetailsService.get_specifications(db, tenant_id, company_id, fda_catalog_id)
if existing:
for field, value in data.items():
setattr(existing, field, value)
entry = existing
else:
entry = FDASpecifications(
tenant_id=tenant_id,
company_id=company_id,
fda_catalog_id=fda_catalog_id,
**data
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
# FDA04 - Elementos Constitutivos
@staticmethod
def get_constituent_elements(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDAConstituentElements
items = db.execute(
select(FDAConstituentElements).where(
(FDAConstituentElements.fda_catalog_id == fda_catalog_id) &
(FDAConstituentElements.tenant_id == tenant_id) &
(FDAConstituentElements.company_id == company_id)
).order_by(FDAConstituentElements.line.asc())
).scalars().all()
return items
@staticmethod
def create_constituent_element(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int, data: dict):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDAConstituentElements
entry = FDAConstituentElements(
tenant_id=tenant_id,
company_id=company_id,
fda_catalog_id=fda_catalog_id,
**data
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
@staticmethod
def delete_constituent_element(db: Session, tenant_id: int, company_id: int, element_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDAConstituentElements
entry = db.execute(select(FDAConstituentElements).where(
(FDAConstituentElements.id == element_id) &
(FDAConstituentElements.tenant_id == tenant_id) &
(FDAConstituentElements.company_id == company_id)
)).scalar_one_or_none()
if entry:
db.delete(entry)
db.commit()
return True
return False
# FDA23 - Códigos de Afirmación
@staticmethod
def get_affirmation_codes(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDAAffirmationCodes
items = db.execute(
select(FDAAffirmationCodes).where(
(FDAAffirmationCodes.fda_catalog_id == fda_catalog_id) &
(FDAAffirmationCodes.tenant_id == tenant_id) &
(FDAAffirmationCodes.company_id == company_id)
).order_by(FDAAffirmationCodes.line.asc())
).scalars().all()
return items
@staticmethod
def create_affirmation_code(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int, data: dict):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDAAffirmationCodes
entry = FDAAffirmationCodes(
tenant_id=tenant_id,
company_id=company_id,
fda_catalog_id=fda_catalog_id,
**data
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
@staticmethod
def delete_affirmation_code(db: Session, tenant_id: int, company_id: int, code_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDAAffirmationCodes
entry = db.execute(select(FDAAffirmationCodes).where(
(FDAAffirmationCodes.id == code_id) &
(FDAAffirmationCodes.tenant_id == tenant_id) &
(FDAAffirmationCodes.company_id == company_id)
)).scalar_one_or_none()
if entry:
db.delete(entry)
db.commit()
return True
return False
# FDA25 - Lotes de Producción
@staticmethod
def get_lot_productions(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDALotProduction
items = db.execute(
select(FDALotProduction).where(
(FDALotProduction.fda_catalog_id == fda_catalog_id) &
(FDALotProduction.tenant_id == tenant_id) &
(FDALotProduction.company_id == company_id)
).order_by(FDALotProduction.line.asc())
).scalars().all()
return items
@staticmethod
def create_lot_production(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int, data: dict):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDALotProduction
entry = FDALotProduction(
tenant_id=tenant_id,
company_id=company_id,
fda_catalog_id=fda_catalog_id,
**data
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
@staticmethod
def delete_lot_production(db: Session, tenant_id: int, company_id: int, lot_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDALotProduction
entry = db.execute(select(FDALotProduction).where(
(FDALotProduction.id == lot_id) &
(FDALotProduction.tenant_id == tenant_id) &
(FDALotProduction.company_id == company_id)
)).scalar_one_or_none()
if entry:
db.delete(entry)
db.commit()
return True
return False

View File

@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from core.security import get_current_user, get_tenant_from_token
from .dto import (
TariffFractionCreateDTO,
@@ -27,6 +27,7 @@ router = APIRouter(prefix="/tariff-fractions", tags=["a76 / general catalogs / t
description="Get paginated list of Tariff Fractions with optional search filter (global catalog)",
)
async def list_tariff_fractions(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=10000, description="Page size"),
search: Optional[str] = Query(None, description="Search in code, fraction, description, nico, or umt"),
@@ -47,8 +48,9 @@ async def list_tariff_fractions(
# Service.get_all calls Sitar (async) or DB (sync).
# This should be fine.
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id") # Assuming user is context-aware or we use a default?
tenant_id = get_tenant_from_token(current_user)
if tenant_id is None:
tenant_id = current_user.get("tenant_id")
# If using headers for selected company, it might be in current_user context if middleware sets it.
items, total = await TariffFractionService.get_all(
@@ -121,7 +123,7 @@ async def create_tariff_fraction(
pass
us_dto = USTariffFractionCreateDTO(
code=fraction_data.code,
code=fraction_data.fraction, # Store the punctuated fraction in the DB
description=fraction_data.description,
unit_of_measure=fraction_data.umt,
ad_valorem=ad_valorem,
@@ -131,7 +133,7 @@ async def create_tariff_fraction(
fixed_cost=None
)
created = USTariffFractionService.create(db, tenant_id, company_id, us_dto)
created = USTariffFractionService.create(db, us_dto, tenant_id, company_id)
return TariffFractionService.to_domain_usa_local(created)
else:
@@ -171,12 +173,13 @@ async def update_tariff_fraction(
pass
us_dto = USTariffFractionUpdateDTO(
code=fraction_data.fraction,
description=fraction_data.description,
unit_of_measure=fraction_data.umt,
ad_valorem=ad_valorem
)
updated = USTariffFractionService.update(db, tenant_id, company_id, tariff_fraction_id, us_dto)
updated = USTariffFractionService.update(db, tariff_fraction_id, tenant_id, us_dto, company_id)
if not updated:
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return TariffFractionService.to_domain_usa_local(updated)
@@ -202,7 +205,7 @@ async def delete_tariff_fraction(
if catalog == "american":
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
success = USTariffFractionService.delete(db, tenant_id, company_id, tariff_fraction_id)
success = USTariffFractionService.delete(db, tariff_fraction_id, tenant_id, company_id)
if not success:
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return {"ok": True}

View File

@@ -96,10 +96,14 @@ class TariffFractionService:
# US format: 1234.56.78.90. For now return as is or use helper if available.
# item is USTariffFraction (imported inside method to avoid circular import if needed, or assumed available)
# Remove formatting (e.g. dots) for the 'code' property
code_str = str(item.code)
clean_code = code_str.replace(".", "").replace("-", "")
return TariffFraction(
id=item.id,
code=item.code,
fraction=item.code, # TODO: Format if needed
code=clean_code,
fraction=code_str,
description=item.description or "(Sin descripción)",
nico=None,
umt=item.unit_of_measure,
@@ -133,11 +137,11 @@ class TariffFractionService:
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
# Use local service directly
usa_items, total = USTariffFractionService._get_all_local(
usa_items, total = await USTariffFractionService.get_all(
db, tenant_id, company_id, skip, limit, filters
)
items = [TariffFractionMapper.to_domain_usa_local(item) for item in usa_items]
items = [TariffFractionService.to_domain_usa_local(item) for item in usa_items]
return items, total
# USA CATALOG HANDLING (API - 'Fracciones US')

View File

@@ -3,9 +3,9 @@ DTOs para fracciones arancelarias americanas
"""
from datetime import datetime
from typing import Optional
from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field, ConfigDict, model_validator
class USTariffFractionCreateDTO(BaseModel):
@@ -23,6 +23,7 @@ class USTariffFractionCreateDTO(BaseModel):
class USTariffFractionUpdateDTO(BaseModel):
"""DTO para actualizar fracción arancelaria americana"""
code: Optional[str] = Field(None, max_length=16)
prefix: Optional[str] = Field(None, max_length=10)
type_code: Optional[str] = Field(None, max_length=10)
ad_valorem: Optional[float] = None
@@ -38,6 +39,7 @@ class USTariffFractionResponseDTO(BaseModel):
id: int
code: str
fraction: Optional[str] = None
prefix: Optional[str] = None
type_code: Optional[str] = None
ad_valorem: Optional[float] = None
@@ -46,3 +48,36 @@ class USTariffFractionResponseDTO(BaseModel):
description: Optional[str] = None
created_at: datetime
updated_at: datetime
@model_validator(mode="before")
@classmethod
def format_code_and_fraction(cls, data: Any) -> Any:
# Check if data is an ORM model or dict
if hasattr(data, "code"):
raw_code = data.code
elif isinstance(data, dict):
raw_code = data.get("code")
else:
return data
if raw_code:
code_str = str(raw_code)
# fraction keeps the original formatted string
fraction = code_str
# code strips dots and hyphens
code = code_str.replace(".", "").replace("-", "")
if isinstance(data, dict):
data["code"] = code
data["fraction"] = fraction
else:
# If it's an ORM object, we can't easily modify the object's attribute
# cleanly without side effects for other things, so we convert it to dict
new_data = {
c.name: getattr(data, c.name) for c in data.__table__.columns
}
new_data["code"] = code
new_data["fraction"] = fraction
return new_data
return data

View File

@@ -17,10 +17,20 @@ from .dto import (
)
from .service import USTariffFractionService
# Create base router with generic CRUD routes - REMOVED strictly read-only from Sitar
# Writes are disabled at API level, but Service still supports fallback writes if needed internally
# Create router using TenantCRUDRoutes factory for basic CRUD operations
crud_router = TenantCRUDRoutes(
service=USTariffFractionService,
create_schema=USTariffFractionCreateDTO,
update_schema=USTariffFractionUpdateDTO,
response_schema=USTariffFractionResponseDTO,
prefix="/us-tariff-fractions",
tags=["a76 / general catalogs / us tariff fractions"],
resource_name="US Tariff Fraction",
id_name="id",
enable_list=False, # We implement our custom list endpoint
)
router = APIRouter(prefix="/us-tariff-fractions", tags=["a76 / general catalogs / us tariff fractions"])
router = crud_router.router
# Custom list endpoint with search filter
@router.get(
@@ -44,7 +54,6 @@ async def list_us_tariff_fractions(
if search:
filters["search"] = search
# Updated to async call with Sitar integration
items, total = await USTariffFractionService.get_all(
db, tenant_id, company_id, skip, page_size, filters
)
@@ -56,24 +65,3 @@ async def list_us_tariff_fractions(
"page_size": page_size,
"pages": (total + page_size - 1) // page_size,
}
@router.get(
"/{us_tariff_fraction_id}",
response_model=USTariffFractionResponseDTO,
summary="Get US Tariff Fraction by ID",
description="Get a specific US tariff fraction by ID (Lookups in Local DB for legacy compatibility)",
)
async def get_us_tariff_fraction(
us_tariff_fraction_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
item = USTariffFractionService.get_by_id(db, tenant_id, company_id, us_tariff_fraction_id)
if not item:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return USTariffFractionResponseDTO.model_validate(item)

View File

@@ -1,66 +1,49 @@
"""
Service para fracciones arancelarias americanas
"""
from typing import List, Optional, Tuple, Dict, Any
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
import zlib
import logging
import re
from decimal import Decimal
from .models import USTariffFraction
from .dto import USTariffFractionCreateDTO, USTariffFractionUpdateDTO
from api.v1.modules.sitar.fracciones_usa.service import FraccionesUSAService
from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse
logger = logging.getLogger(__name__)
class USTariffFractionMapper:
"""Helper to map Sitar USA responses to Local domain objects"""
"""Maps Sitar FraccionesUSAResponse objects to USTariffFraction domain objects"""
@staticmethod
def to_domain(fraccion: FraccionesUSAResponse, tenant_id: int, company_id: int) -> USTariffFraction:
# Generate a deterministic numeric ID based on the unique code
# We use CRC32 to get a consistent integer implementation-independent
fake_id = zlib.crc32((fraccion.FRACCION_SIN_PUNTO or "").encode('utf-8'))
# Parse numeric values safely
ad_valorem = None
if fraccion.TARIFA1:
def to_domain(
item: Any, tenant_id: int, company_id: int
) -> USTariffFraction:
"""Convert a FraccionesUSAResponse to a USTariffFraction instance (not persisted)"""
code = item.FRACCION_CON_PUNTO or item.FRACCION_MOSTRAR or item.FRACCION_SIN_PUNTO or ""
ad_valorem: Optional[float] = None
if item.TARIFA1:
try:
# Extract numbers from string like "5.2%" or similar if present
# Assuming TARIFA1 might be clean number or percentage string
clean_val = re.sub(r'[^\d.]', '', str(fraccion.TARIFA1))
if clean_val:
ad_valorem = Decimal(clean_val)
except:
pass
fixed_cost = None
if fraccion.ESPECIFICO:
try:
clean_val = re.sub(r'[^\d.]', '', str(fraccion.ESPECIFICO))
if clean_val:
fixed_cost = Decimal(clean_val)
except:
pass
ad_valorem = float(str(item.TARIFA1).replace("%", "").strip())
except (ValueError, TypeError):
ad_valorem = None
return USTariffFraction(
id=fake_id, # Updated to use fake_id instead of sitar consecutive if needed, or consistent hash
tenant_id=tenant_id,
company_id=company_id,
code=fraccion.FRACCION_SIN_PUNTO or "",
prefix=None, # Not mapped from Sitar response currently
type_code=None,
ad_valorem=ad_valorem,
fixed_cost=fixed_cost,
unit_of_measure=fraccion.UNIDADCANTIDAD,
description=fraccion.DESCRIPCION
)
fraction = USTariffFraction()
fraction.id = item.CONSECUTIVO
fraction.tenant_id = tenant_id
fraction.company_id = company_id
fraction.code = code
fraction.prefix = item.FRACCION_SIN_PUNTO
fraction.type_code = str(item.NIVEL) if item.NIVEL is not None else None
fraction.ad_valorem = ad_valorem
fraction.fixed_cost = None
fraction.unit_of_measure = item.UNIDADCANTIDAD
fraction.description = item.DESCRIPCION
now = datetime.now()
fraction.created_at = now
fraction.updated_at = now
return fraction
class USTariffFractionService:
@@ -80,44 +63,43 @@ class USTariffFractionService:
Estrategia: Sitar API -> Fallback Local DB
"""
# 1. Try Sitar API
try:
sitar_service = FraccionesUSAService.get_instance()
sitar_fraccion = None
has_filters = False
if filters and filters.get("search"):
term = filters["search"]
# Sitar only filters by fraction code
if term.replace(".", "").isdigit():
sitar_fraccion = term
has_filters = True
sitar_items = await sitar_service.search(
fraccion=sitar_fraccion,
skip=skip,
limit=limit
)
# If Sitar returns empty list AND we didn't have specific filters, attempt fallback
if not sitar_items and not has_filters:
logger.warning("Sitar return empty list for USA broad query. Attempting fallback to local DB.")
return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters)
## 1. Try Sitar API
#try:
# sitar_service = FraccionesUSAService.get_instance()
#
# sitar_fraccion = None
# has_filters = False
#
# if filters and filters.get("search"):
# term = filters["search"]
# # Sitar only filters by fraction code
# if term.replace(".", "").isdigit():
# sitar_fraccion = term
# has_filters = True
#
# sitar_items = await sitar_service.search(
# fraccion=sitar_fraccion,
# skip=skip,
# limit=limit
# )
#
# # If Sitar returns empty list, attempt fallback to local DB
# if not sitar_items:
# logger.info(f"Sitar returned no results for USA query (filters={has_filters}). Attempting fallback to local DB.")
# return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters)
#
# # Map items
# items = [USTariffFractionMapper.to_domain(item, tenant_id, company_id) for item in sitar_items]
#
# # Estimate total
# total = len(items) + skip
# if len(items) == limit:
# total += 1
#
# return items, total
# Map items
items = [USTariffFractionMapper.to_domain(item, tenant_id, company_id) for item in sitar_items]
# Estimate total
total = len(items) + skip
if len(items) == limit:
total += 1
return items, total
except Exception as e:
logger.error(f"Error fetching USA Fractions from Sitar API, falling back to local DB: {e}")
return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters)
#except Exception as e:
return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters)
@staticmethod
def _get_all_local(
@@ -148,15 +130,14 @@ class USTariffFractionService:
total = query.count()
items = query.order_by(USTariffFraction.code).offset(skip).limit(limit).all()
return items, total
return items, total
@staticmethod
def get_by_id(
db: Session, tenant_id: int, company_id: int, fraction_id: int
db: Session, fraction_id: int, tenant_id: int, company_id: int
) -> Optional[USTariffFraction]:
"""
Obtiene por ID.
Legacy: Consulta Local DB.
Obtiene por ID local.
"""
return (
db.query(USTariffFraction)
@@ -168,14 +149,12 @@ class USTariffFractionService:
.first()
)
# WRITE OPERATIONS - DEPRECATED / LOCAL ONLY
@staticmethod
def create(
db: Session,
fraction_data: USTariffFractionCreateDTO,
tenant_id: int,
company_id: int,
fraction_data: USTariffFractionCreateDTO,
) -> USTariffFraction:
try:
db_fraction = USTariffFraction(
@@ -198,13 +177,13 @@ class USTariffFractionService:
@staticmethod
def update(
db: Session,
tenant_id: int,
company_id: int,
fraction_id: int,
tenant_id: int,
fraction_data: USTariffFractionUpdateDTO,
company_id: int,
) -> Optional[USTariffFraction]:
db_fraction = USTariffFractionService.get_by_id(
db, tenant_id, company_id, fraction_id
db, fraction_id, tenant_id, company_id
)
if not db_fraction:
return None
@@ -219,10 +198,10 @@ class USTariffFractionService:
@staticmethod
def delete(
db: Session, tenant_id: int, company_id: int, fraction_id: int
db: Session, fraction_id: int, tenant_id: int, company_id: int
) -> bool:
db_fraction = USTariffFractionService.get_by_id(
db, tenant_id, company_id, fraction_id
db, fraction_id, tenant_id, company_id
)
if not db_fraction:
return False

View File

@@ -29,9 +29,13 @@ celery_app.conf.update(
"api.v1.modules.a76.reports.exportacion.transmission.MAINX30.task",
"api.v1.modules.a76.reports.importacion.transmission.temporal.MAINX30.task",
"api.v1.modules.a76.reports.importacion.transmission.definitive.MAINX30.task",
<<<<<<< HEAD
"api.v1.modules.a76.reports.importacion.winsaai.invoices.task",
"api.v1.modules.a76.reports.importacion.winsaai.pedimentos.task",
"api.v1.modules.core.help_center.tasks",
=======
"api.v1.modules.core.help_center.tasks"
>>>>>>> 3e0072897d22dc9ee7ebbc3be471aab2aecb46f6
] # Ruta al módulo donde están las tareas
)

View File

@@ -135,7 +135,7 @@ async def sqlalchemy_error_handler(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": "DATABASE_ERROR",
"message": "Error en la operación de base de datos",
"message": f"Error en la operación de base de datos: {str(exc)}",
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
},
)

View File

@@ -150,146 +150,13 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
def run_migrations():
subprocess.run(["alembic", "upgrade", "head"], check=True)
def create_transportation_tables():
"""Crea las tablas de transporte directamente si no existen.
Se usa en lugar de una migración Alembic para evitar gestionar versiones.
"""
from sqlalchemy import text
from core.database import core_engine
ddl_statements = [
"""
CREATE TABLE IF NOT EXISTS a76.transporter (
transporter_key VARCHAR(23) PRIMARY KEY,
name VARCHAR(256),
short_name VARCHAR(10),
responsible VARCHAR(100),
rfc VARCHAR(30),
streets VARCHAR(100),
postal_code VARCHAR(15),
city VARCHAR(30),
state VARCHAR(30),
country VARCHAR(3),
loader_code VARCHAR(9),
caat_code VARCHAR(49),
transport_code VARCHAR(8),
transport_interface_type VARCHAR(20),
ftp_server VARCHAR(200),
ftp_user VARCHAR(200),
ftp_password VARCHAR(100),
ftp_directory VARCHAR(1000),
filler_code VARCHAR(20),
tenant_id INTEGER NOT NULL REFERENCES core.tenants(id),
company_id INTEGER NOT NULL REFERENCES a76.company(id),
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
deleted_at TIMESTAMP
);
""",
"""
CREATE TABLE IF NOT EXISTS a76.trailer (
trailer_number VARCHAR(20) PRIMARY KEY,
ace_trailer_number VARCHAR(10),
trailer_type_key VARCHAR(2),
seal VARCHAR(15),
entity_code VARCHAR(1),
plate_number VARCHAR(17),
state VARCHAR(30),
country VARCHAR(3),
container_key VARCHAR(3),
tenant_id INTEGER NOT NULL REFERENCES core.tenants(id),
company_id INTEGER NOT NULL REFERENCES a76.company(id),
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
deleted_at TIMESTAMP
);
""",
"""
CREATE TABLE IF NOT EXISTS a76.vehicle (
vehicle_key VARCHAR(14) PRIMARY KEY,
ace_vehicle_key VARCHAR(10),
transporter_key VARCHAR(23),
transport_identifier VARCHAR(30),
transport_type VARCHAR(2),
entity_code VARCHAR(1),
transponder_number VARCHAR(16),
dot_number VARCHAR(8),
plate_number VARCHAR(17),
city VARCHAR(30),
state VARCHAR(30),
country VARCHAR(3),
seal VARCHAR(49),
insurance_company_name VARCHAR(30),
insurance_number VARCHAR(20),
insurance_amount NUMERIC(13, 2),
insurance_date INTEGER,
box_number VARCHAR(300),
brand VARCHAR(20),
year VARCHAR(4),
series VARCHAR(30),
description VARCHAR(100),
engine_number VARCHAR(50),
sct_permission VARCHAR(40),
color VARCHAR(20),
container_key VARCHAR(3),
tenant_id INTEGER NOT NULL REFERENCES core.tenants(id),
company_id INTEGER NOT NULL REFERENCES a76.company(id),
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
deleted_at TIMESTAMP
);
""",
]
with core_engine.connect() as conn:
for stmt in ddl_statements:
conn.execute(text(stmt))
# Ampliar columnas que pudieron haberse creado con tamaño incorrecto
conn.execute(text("""
DO $$
BEGIN
-- Fix transporter_key si fue creada como VARCHAR(5)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='a76' AND table_name='transporter'
AND column_name='transporter_key'
AND character_maximum_length < 23
) THEN
ALTER TABLE a76.transporter ALTER COLUMN transporter_key TYPE VARCHAR(23);
END IF;
-- Fix filler_code si fue creada como VARCHAR(4)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='a76' AND table_name='transporter'
AND column_name='filler_code'
AND character_maximum_length < 20
) THEN
ALTER TABLE a76.transporter ALTER COLUMN filler_code TYPE VARCHAR(20);
END IF;
-- Eliminar constraint de trailer_type_key en trailer si existe
IF EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name='trailer_trailer_type_key_fkey'
AND table_schema='a76' AND table_name='trailer'
) THEN
ALTER TABLE a76.trailer DROP CONSTRAINT trailer_trailer_type_key_fkey;
END IF;
END$$;
"""))
conn.commit()
logger.info("Tablas de transporte verificadas/creadas correctamente.")
# Inicializar la base de datos
@app.on_event("startup")
async def on_startup():
"""Evento de inicio de la aplicación"""
logger.info("Iniciando la aplicación Anexo76...")
init_db()
run_migrations()
create_transportation_tables()
run_migrations()
logger.info("Base de datos inicializada correctamente.")

View File

@@ -332,9 +332,6 @@ services:
- backend_uploads:/app/uploads
networks:
- backend-net
volumes:
- ./backend:/app
valkey:
image: valkey/valkey:7.2

View File

@@ -76,7 +76,8 @@
"goods": {
"title": "Goods",
"classes": "Classes",
"parts": "Parts"
"parts": "Parts",
"fda_codes": "FDA Codes"
},
"pedimentos": {
"title": "Pedimentos",

View File

@@ -76,7 +76,8 @@
"goods": {
"title": "Mercancías",
"classes": "Clases",
"parts": "Partes"
"parts": "Partes",
"fda_codes": "Códigos F.D.A."
},
"pedimentos": {
"title": "Pedimentos",

View File

@@ -270,7 +270,7 @@ async function fetchApi<T = any>(
}
return {
error: data.message || data.detail || 'Error en la petición',
error: data.message || (typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)) || 'Error en la petición',
status: response.status
};
}

View File

@@ -541,7 +541,7 @@
class="uppercase {validationErrors.class_code
? 'border-red-500 focus-visible:ring-red-500'
: ''}"
maxlength={20}
maxlength={8}
oninput={() => {
if (validationErrors.class_code) {
const errors = { ...validationErrors };

View File

@@ -66,6 +66,13 @@
}
});
// Sync code with fractionFormatted for US catalog
$effect(() => {
if (catalog === 'usa' || catalog === 'american') {
code = fractionFormatted.replace(/\./g, '').replace(/-/g, '');
}
});
async function handleSubmit() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
@@ -126,17 +133,23 @@
<div class="grid gap-4 py-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="code">Clave / Código {catalog === 'mex' ? '(Sin puntos)' : ''}</Label>
<Input id="code" bind:value={code} disabled={!!fraction} placeholder="Ej. 01012101" />
{#if fraction}
<p class="text-xs text-muted-foreground">
El código no se puede modificar una vez creado.
</p>
{/if}
<Label for="code">{catalog === 'mex' ? 'Clave / Código (Sin puntos)' : 'Clave'}</Label>
<Input id="code" bind:value={code} disabled={true} placeholder="Ej. 01012101" />
<p class="text-[10px] text-muted-foreground">
{catalog === 'mex'
? 'El código se genera automáticamente.'
: 'La clave se deriva de la fracción sin puntos.'}
</p>
</div>
<div class="space-y-2">
<Label for="fraction">Fracción {catalog === 'mex' ? '(Con puntos)' : ''}</Label>
<Input id="fraction" bind:value={fractionFormatted} placeholder="Ej. 0101.21.01" />
<Label for="fraction"
>{catalog === 'mex' ? 'Fracción (Con puntos)' : 'Fracción (HTS Code)'}</Label
>
<Input
id="fraction"
bind:value={fractionFormatted}
placeholder={catalog === 'mex' ? 'Ej. 0101.21.01' : 'Ej. 1234.56.78'}
/>
</div>
</div>

View File

@@ -310,7 +310,7 @@
fraction={selectedFraction}
{catalog}
onSuccess={() => {
loadFractions();
loadFractions(true);
isFormDialogOpen = false;
}}
/>

View File

@@ -17,7 +17,7 @@ import {
Ship,
Truck,
Users,
} from 'lucide-svelte';
} from '@lucide/svelte';
import * as m from "$lib/paraglide/messages.js";
import { Title } from '../ui/alert';
@@ -366,6 +366,10 @@ export function getSidebarData(): SidebarData {
{
title: m["sidebar.goods.parts"](),
url: "/dashboard/goods/parts",
},
{
title: m["sidebar.goods.fda_codes"](),
url: "/dashboard/goods/fda-codes",
}
],
},

View File

@@ -0,0 +1,283 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
import * as fs from 'fs';
// Helper to log to a file for debugging
const debugLog = (msg: string) => {
try {
const timestamp = new Date().toISOString();
fs.appendFileSync('/tmp/fda_proxy_debug.log', `[${timestamp}] ${msg}\n`);
} catch (e) { }
};
// Precise cleaners to ensure only business data is sent to backend
const cleanFDA01 = (d: any) => {
if (!d) return null;
return {
prod_code: d.prod_code || null,
commodity_desc: d.commodity_desc || null,
brand_name: d.brand_name || null,
disclaimer: d.disclaimer || null,
pgm_code: d.pgm_code || null,
proc_code: d.proc_code || null,
intnd_use_code: d.intnd_use_code || null,
intnd_use_desc: d.intnd_use_desc || null,
temp_qual: d.temp_qual || null,
temp_type: d.temp_type || null,
temp_degrees: d.temp_degrees || null,
temp_negative: d.temp_negative || null,
temp_location: d.temp_location || null,
quantity_1: d.quantity_1 || null,
qty_uom_1: d.qty_uom_1 || null,
quantity_2: d.quantity_2 || null,
qty_uom_2: d.qty_uom_2 || null,
quantity_3: d.quantity_3 || null,
qty_uom_3: d.qty_uom_3 || null,
ctry_prod: d.ctry_prod || null,
ctry_source: d.ctry_source || null,
ctry_growth: d.ctry_growth || null,
ctry_refusal: d.ctry_refusal || null,
ctry_shipping: d.ctry_shipping || null,
manuf_key: d.manuf_key || null,
shipper_key: d.shipper_key || null,
ult_cons_key: d.ult_cons_key || null,
fda_imp_key: d.fda_imp_key || null,
pn_subm_key: d.pn_subm_key || null,
consol_key: d.consol_key || null,
producer_key: d.producer_key || null,
owner_key: d.owner_key || null,
deli_party_key: d.deli_party_key || null,
grower_key: d.grower_key || null,
dev_ini_imp_key: d.dev_ini_imp_key || null,
lacf_cont_1: d.lacf_cont_1 || null,
lacf_cont_2: d.lacf_cont_2 || null,
lacf_cont_3: d.lacf_cont_3 || null,
pn_transmitter_key: d.pn_transmitter_key || null
};
};
const cleanFDA23 = (d: any) => ({
line: typeof d.line === 'number' ? d.line : (parseInt(d.line) || 1),
aoc_code: d.aoc_code || '',
aoc_qual: d.aoc_qual || null
});
const cleanFDA04 = (d: any) => ({
line: typeof d.line === 'number' ? d.line : (parseInt(d.line) || 1),
ele_name: d.ele_name || '',
ele_qty: (d.ele_qty === '' || d.ele_qty === null || d.ele_qty === undefined) ? null : parseFloat(d.ele_qty),
ele_qty_uom: d.ele_qty_uom || null,
ele_pctg: (d.ele_pctg === '' || d.ele_pctg === null || d.ele_pctg === undefined) ? null : parseFloat(d.ele_pctg)
});
const cleanFDA25 = (d: any) => ({
line: typeof d.line === 'number' ? d.line : (parseInt(d.line) || 1),
lot_number: d.lot_number || '',
production_start_date: d.production_start_date || null,
production_end_date: d.production_end_date || null
});
async function saveNestedData(id: string, companyId: string, nested: any, cookies: any, fetch: any) {
debugLog(`saveNestedData started for id: ${id}`);
const { fda01, fda23_codes, fda04_elements, fda25_lots } = nested;
// 1. FDA01 Specifications
if (fda01) {
debugLog(`Saving FDA01`);
const res = await authenticatedFetch(
`v1/a76/fda-catalog/${id}/specifications?company_id=${companyId}`,
{ method: 'PUT', body: JSON.stringify(cleanFDA01(fda01)) },
cookies, fetch
);
if (!res.ok) {
const txt = await res.text();
debugLog(`Error FDA01: ${res.status} - ${txt}`);
throw new Error(`Error en FDA01: ${res.status} - ${txt}`);
}
}
// 2. FDA23 Affirmation Codes
debugLog(`Processing FDA23`);
const resExist23 = await authenticatedFetch(`v1/a76/fda-catalog/${id}/affirmation-codes?company_id=${companyId}`, { method: 'GET' }, cookies, fetch);
if (resExist23.ok) {
const items = await resExist23.json();
debugLog(`Deleting ${items.length} existing FDA23 items`);
for (const item of items) {
await authenticatedFetch(`v1/a76/fda-catalog/${id}/affirmation-codes/${item.id}?company_id=${companyId}`, { method: 'DELETE' }, cookies, fetch);
}
}
if (fda23_codes && Array.isArray(fda23_codes)) {
for (const code of fda23_codes) {
if (!code.aoc_code) continue;
const res = await authenticatedFetch(`v1/a76/fda-catalog/${id}/affirmation-codes?company_id=${companyId}`, { method: 'POST', body: JSON.stringify(cleanFDA23(code)) }, cookies, fetch);
if (!res.ok) {
const txt = await res.text();
debugLog(`Error FDA23: ${res.status} - ${txt}`);
throw new Error(`Error en FDA23: ${res.status} - ${txt}`);
}
}
}
// 3. FDA04 Constituent Elements
debugLog(`Processing FDA04`);
const resExist04 = await authenticatedFetch(`v1/a76/fda-catalog/${id}/constituent-elements?company_id=${companyId}`, { method: 'GET' }, cookies, fetch);
if (resExist04.ok) {
const items = await resExist04.json();
debugLog(`Deleting ${items.length} existing FDA04 items`);
for (const item of items) {
await authenticatedFetch(`v1/a76/fda-catalog/${id}/constituent-elements/${item.id}?company_id=${companyId}`, { method: 'DELETE' }, cookies, fetch);
}
}
if (fda04_elements && Array.isArray(fda04_elements)) {
for (const ele of fda04_elements) {
if (!ele.ele_name) continue;
debugLog(`Saving FDA04 item: ${ele.ele_name}`);
const res = await authenticatedFetch(`v1/a76/fda-catalog/${id}/constituent-elements?company_id=${companyId}`, { method: 'POST', body: JSON.stringify(cleanFDA04(ele)) }, cookies, fetch);
if (!res.ok) {
const txt = await res.text();
debugLog(`Error FDA04: ${res.status} - ${txt}`);
throw new Error(`Error en FDA04: ${res.status} - ${txt}`);
}
}
}
// 4. FDA25 Lot Productions
debugLog(`Processing FDA25`);
const resExist25 = await authenticatedFetch(`v1/a76/fda-catalog/${id}/lot-productions?company_id=${companyId}`, { method: 'GET' }, cookies, fetch);
if (resExist25.ok) {
const items = await resExist25.json();
debugLog(`Deleting ${items.length} existing FDA25 items`);
for (const item of items) {
await authenticatedFetch(`v1/a76/fda-catalog/${id}/lot-productions/${item.id}?company_id=${companyId}`, { method: 'DELETE' }, cookies, fetch);
}
}
if (fda25_lots && Array.isArray(fda25_lots)) {
for (const lot of fda25_lots) {
if (!lot.lot_number) continue;
const res = await authenticatedFetch(`v1/a76/fda-catalog/${id}/lot-productions?company_id=${companyId}`, { method: 'POST', body: JSON.stringify(cleanFDA25(lot)) }, cookies, fetch);
if (!res.ok) {
const txt = await res.text();
debugLog(`Error FDA25: ${res.status} - ${txt}`);
throw new Error(`Error en FDA25: ${res.status} - ${txt}`);
}
}
}
debugLog(`saveNestedData finished successfully`);
}
export const GET: RequestHandler = async ({ url, cookies, fetch }) => {
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
const skip = url.searchParams.get('skip') || '0';
const limit = url.searchParams.get('limit') || '50';
const search = url.searchParams.get('search') || '';
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
let apiUrl = `v1/a76/fda-catalog/?company_id=${companyId}&skip=${skip}&limit=${limit}`;
if (search) apiUrl += `&search=${encodeURIComponent(search)}`;
const response = await authenticatedFetch(apiUrl, { method: 'GET' }, cookies, fetch);
if (!response.ok) return json({ error: 'Error al cargar registros FDA' }, { status: response.status });
const data = await response.json();
return json(data);
} catch (error) {
return json({ error: 'Error de conexión' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies, fetch }) => {
debugLog(`POST started`);
const companyId = cookies.get('active_company_id');
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
const body = await request.json();
const mainRecord = {
fda_key: body.fda_key,
description: body.description,
fda_code: body.fda_code || null,
requirements: body.requirements || null,
manufacturer_number: body.manufacturer_number || null,
country_of_production: body.country_of_production || null,
storage_status: body.storage_status || null,
warehouse_code: body.warehouse_code || null,
call_atl: body.call_atl || null
};
debugLog(`Creating main record`);
const res = await authenticatedFetch(`v1/a76/fda-catalog/?company_id=${companyId}`, { method: 'POST', body: JSON.stringify(mainRecord) }, cookies, fetch);
const data = await res.json();
if (!res.ok) {
debugLog(`Error creating main record: ${res.status}`);
return json({ error: data.detail || 'Error al crear FDA' }, { status: res.status });
}
await saveNestedData(data.id.toString(), companyId, body, cookies, fetch);
debugLog(`POST finished successfully`);
return json(data);
} catch (error: any) {
debugLog(`POST Crash: ${error.message}`);
console.error('POST Error:', error);
return json({ error: error.message || 'Error al procesar el guardado' }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ request, url, cookies, fetch }) => {
debugLog(`PUT started`);
const id = url.searchParams.get('id');
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
debugLog(`PUT params: id=${id}, companyId=${companyId}`);
if (!id || !companyId) return json({ error: 'ID o compañía no proporcionada' }, { status: 400 });
try {
const body = await request.json();
const mainRecord = {
fda_key: body.fda_key,
description: body.description,
fda_code: body.fda_code || null,
requirements: body.requirements || null,
manufacturer_number: body.manufacturer_number || null,
country_of_production: body.country_of_production || null,
storage_status: body.storage_status || null,
warehouse_code: body.warehouse_code || null,
call_atl: body.call_atl || null
};
debugLog(`Updating main record id=${id}`);
const res = await authenticatedFetch(`v1/a76/fda-catalog/${id}?company_id=${companyId}`, { method: 'PUT', body: JSON.stringify(mainRecord) }, cookies, fetch);
const data = await res.json();
if (!res.ok) {
debugLog(`Error updating main record: ${res.status}`);
return json({ error: data.detail || 'Error al actualizar FDA' }, { status: res.status });
}
await saveNestedData(id, companyId, body, cookies, fetch);
debugLog(`PUT finished successfully`);
return json(data);
} catch (error: any) {
debugLog(`PUT Crash: ${error.message}`);
console.error('PUT Error:', error);
return json({ error: error.message || 'Error al procesar la actualización' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ url, cookies, fetch }) => {
const id = url.searchParams.get('id');
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
if (!id || !companyId) return json({ error: 'ID o compañía no proporcionada' }, { status: 400 });
try {
const res = await authenticatedFetch(`v1/a76/fda-catalog/${id}?company_id=${companyId}`, { method: 'DELETE' }, cookies, fetch);
if (!res.ok) {
const data = await res.json();
return json({ error: data.detail || 'Error al eliminar FDA' }, { status: res.status });
}
return json({ success: true });
} catch (error) {
return json({ error: 'Error de conexión' }, { status: 500 });
}
};

View File

@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, url, cookies, fetch }) => {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) return json({ error: 'No autorizado' }, { status: 401 });
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
const id = params.id;
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
const response = await authenticatedFetch(
`v1/a76/fda-catalog/${id}?company_id=${companyId}`,
{ method: 'GET', cache: 'no-store' },
cookies, fetch
);
if (!response.ok) {
return json({ error: 'Error al cargar registro FDA' }, { status: response.status });
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Error in fda-catalog detail API:', error);
return json({ error: 'Error de conexión' }, { status: 500 });
}
};

View File

@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, url, cookies, fetch }) => {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) return json({ error: 'No autorizado' }, { status: 401 });
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
const id = params.id;
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
const response = await authenticatedFetch(
`v1/a76/fda-catalog/${id}/affirmation-codes?company_id=${companyId}`,
{ method: 'GET', cache: 'no-store' },
cookies, fetch
);
if (!response.ok) {
return json({ error: 'Error al cargar affirmation codes' }, { status: response.status });
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Error in affirmation-codes API:', error);
return json({ error: 'Error de conexión' }, { status: 500 });
}
};

View File

@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, url, cookies, fetch }) => {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) return json({ error: 'No autorizado' }, { status: 401 });
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
const id = params.id;
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
const response = await authenticatedFetch(
`v1/a76/fda-catalog/${id}/constituent-elements?company_id=${companyId}`,
{ method: 'GET', cache: 'no-store' },
cookies, fetch
);
if (!response.ok) {
return json({ error: 'Error al cargar elementos constitutivos' }, { status: response.status });
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Error in constituent-elements API:', error);
return json({ error: 'Error de conexión' }, { status: 500 });
}
};

View File

@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, url, cookies, fetch }) => {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) return json({ error: 'No autorizado' }, { status: 401 });
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
const id = params.id;
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
const response = await authenticatedFetch(
`v1/a76/fda-catalog/${id}/lot-productions?company_id=${companyId}`,
{ method: 'GET', cache: 'no-store' },
cookies, fetch
);
if (!response.ok) {
return json({ error: 'Error al cargar lot productions' }, { status: response.status });
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Error in lot-productions API:', error);
return json({ error: 'Error de conexión' }, { status: 500 });
}
};

View File

@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, url, cookies, fetch }) => {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) return json({ error: 'No autorizado' }, { status: 401 });
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
const id = params.id;
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
const response = await authenticatedFetch(
`v1/a76/fda-catalog/${id}/specifications?company_id=${companyId}`,
{ method: 'GET', cache: 'no-store' },
cookies, fetch
);
if (!response.ok) {
return json({ error: 'Error al cargar especificaciones' }, { status: response.status });
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Error in specifications API:', error);
return json({ error: 'Error de conexión' }, { status: 500 });
}
};

View File

@@ -0,0 +1,469 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { companyStore } from '$lib/stores/company.svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Badge } from '$lib/components/ui/badge';
import {
Plus,
RefreshCw,
FileText,
Pencil,
Trash2,
Search,
Filter,
Building2,
Globe,
Box,
CheckSquare,
Square,
AlertCircle
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { Checkbox } from '$lib/components/ui/checkbox';
import { cn } from '$lib/utils';
let fdaList = $state<any[]>([]);
let loading = $state(false);
let selectedRows = $state<number[]>([]);
let mounted = $state(false);
let searchQuery = $state('');
let hoveredRow = $state<number | null>(null);
onMount(() => {
mounted = true;
});
$effect(() => {
const companyId = companyStore.activeCompany?.id;
if (mounted && companyId) {
fdaList = [];
loadAllFdaData();
}
});
// Filtrar por búsqueda
const filteredFdaList = $derived(
searchQuery.trim()
? fdaList.filter(
(fda) =>
fda.fda_key?.toLowerCase().includes(searchQuery.toLowerCase()) ||
fda.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
fda.fda_code?.toLowerCase().includes(searchQuery.toLowerCase()) ||
fda.country_of_production?.toLowerCase().includes(searchQuery.toLowerCase())
)
: fdaList
);
async function loadAllFdaData() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
loading = true;
try {
const res = await fetch(`/api-sveltekit/fda-catalog?company_id=${companyId}`);
if (res.ok) {
const data = await res.json();
fdaList = data.items || [];
selectedRows = [];
}
} catch (error) {
console.error('Error cargando lista de FDA:', error);
} finally {
loading = false;
}
}
function handleCreateClick() {
goto('/dashboard/goods/fda-codes/edit/new');
}
function handleEditClick(fdaId: number) {
goto(`/dashboard/goods/fda-codes/edit/${fdaId}`);
}
async function deleteFdaCode(id: number, event?: MouseEvent) {
event?.stopPropagation();
if (!confirm('¿Está seguro de eliminar este código FDA?')) return;
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
try {
const res = await fetch(`/api-sveltekit/fda-catalog?id=${id}&company_id=${companyId}`, {
method: 'DELETE'
});
if (res.ok) {
toast.success('Registro eliminado correctamente');
selectedRows = selectedRows.filter((r) => r !== id);
fdaList = fdaList.filter((f) => f.id !== id);
} else {
const error = await res.json();
toast.error(error.error || 'Error al eliminar');
}
} catch (err) {
console.error('Error:', err);
toast.error('Error de conexión');
}
}
function toggleRowSelection(id: number) {
if (selectedRows.includes(id)) {
selectedRows = selectedRows.filter((r) => r !== id);
} else {
selectedRows = [...selectedRows, id];
}
}
function toggleAllSelection(checked: boolean) {
if (checked) {
selectedRows = filteredFdaList.map((row) => row.id);
} else {
selectedRows = [];
}
}
async function handleDeleteSelected() {
if (selectedRows.length === 0) return;
if (!confirm(`¿Está seguro de eliminar ${selectedRows.length} código(s) FDA?`)) return;
for (const id of selectedRows) {
await deleteFdaCode(id);
}
}
function handleEditSelected() {
if (selectedRows.length !== 1) return;
const id = selectedRows[0];
handleEditClick(id);
}
function clearSelection() {
selectedRows = [];
}
// Obtener badge de estado de almacenaje
function getStorageBadge(status: string) {
const styles: Record<string, string> = {
ACTIVO: 'bg-green-100 text-green-700 border-green-200',
INACTIVO: 'bg-gray-100 text-gray-700 border-gray-200',
PENDIENTE: 'bg-yellow-100 text-yellow-700 border-yellow-200'
};
return styles[status?.toUpperCase()] || 'bg-blue-100 text-blue-700 border-blue-200';
}
</script>
<div class="min-h-screen bg-gradient-to-b from-background to-muted/20 pb-28">
<!-- Header Principal -->
<div class="sticky top-0 z-20 border-b bg-card/50 backdrop-blur-sm">
<div class="mx-auto max-w-[1600px] px-4 py-6 sm:px-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-primary/10 p-2">
<FileText class="h-6 w-6 text-primary" />
</div>
<h1 class="text-2xl font-bold tracking-tight sm:text-3xl">Códigos F.D.A.</h1>
</div>
<p class="pl-[52px] text-sm text-muted-foreground sm:text-base">
Gestión de códigos FDA para mercancías / FDA goods code management
</p>
</div>
<div class="flex items-center gap-2 sm:gap-3">
<Button
variant="outline"
size="sm"
onclick={loadAllFdaData}
disabled={loading}
class="h-10"
>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
<span class="hidden sm:inline">Actualizar</span>
</Button>
<Button onclick={handleCreateClick} size="sm" class="h-10 shadow-lg shadow-primary/20">
<Plus class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Nuevo Código</span>
<span class="sm:hidden">Nuevo</span>
</Button>
</div>
</div>
</div>
</div>
<!-- Contenido Principal -->
<div class="mx-auto max-w-[1600px] px-4 py-6 sm:px-6">
<Card.Root class="overflow-hidden border-0 shadow-xl shadow-black/5">
<!-- Header de Tabla con Búsqueda -->
<div class="border-b bg-muted/30 p-4 sm:p-6">
<div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-primary/10 p-2">
<Filter class="h-4 w-4 text-primary" />
</div>
<div>
<h3 class="text-lg font-semibold">Registros FDA</h3>
<p class="text-sm text-muted-foreground">
{filteredFdaList.length} de {fdaList.length} registros
</p>
</div>
</div>
<div class="relative w-full sm:w-80">
<Search
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="text"
placeholder="Buscar por clave, código, descripción..."
bind:value={searchQuery}
class="h-10 bg-background pl-10"
/>
</div>
</div>
<!-- Barra de selección -->
{#if selectedRows.length > 0}
<div
class="animate-in fade-in slide-in-from-top-2 mt-4 flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 p-3"
>
<div class="flex items-center gap-3">
<CheckSquare class="h-5 w-5 text-primary" />
<span class="text-sm font-medium">
{selectedRows.length}
{selectedRows.length === 1 ? 'registro seleccionado' : 'registros seleccionados'}
</span>
</div>
<Button variant="ghost" size="sm" onclick={clearSelection} class="h-8">
Limpiar selección
</Button>
</div>
{/if}
</div>
<!-- Tabla -->
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row class="bg-muted/50 hover:bg-muted/50">
<Table.Head class="w-12 text-center">
<Checkbox
checked={filteredFdaList.length > 0 &&
selectedRows.length === filteredFdaList.length}
indeterminate={selectedRows.length > 0 &&
selectedRows.length < filteredFdaList.length}
onCheckedChange={toggleAllSelection}
aria-label="Seleccionar todos"
/>
</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Clave FDA</Table.Head>
<Table.Head class="font-semibold">Descripción</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Código FDA</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Fabricante</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">País</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Estado</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={7} class="py-16 text-center">
<div class="flex flex-col items-center gap-4">
<div class="rounded-full bg-primary/10 p-4">
<RefreshCw class="h-8 w-8 animate-spin text-primary" />
</div>
<div>
<p class="text-lg font-medium">Cargando registros...</p>
<p class="text-sm text-muted-foreground">Por favor espere</p>
</div>
</div>
</Table.Cell>
</Table.Row>
{:else if filteredFdaList.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="py-16 text-center">
{#if searchQuery}
<div class="flex flex-col items-center gap-4">
<div class="rounded-full bg-muted p-4">
<Search class="h-8 w-8 text-muted-foreground" />
</div>
<div>
<p class="text-lg font-medium">No se encontraron resultados</p>
<p class="text-sm text-muted-foreground">Intenta con otra búsqueda</p>
</div>
<Button variant="outline" size="sm" onclick={() => (searchQuery = '')}>
Limpiar búsqueda
</Button>
</div>
{:else}
<div class="flex flex-col items-center gap-4">
<div class="rounded-full bg-muted p-4">
<Box class="h-8 w-8 text-muted-foreground" />
</div>
<div>
<p class="text-lg font-medium">No hay registros FDA</p>
<p class="text-sm text-muted-foreground">
No se encontraron registros para esta compañía
</p>
</div>
</div>
{/if}
</Table.Cell>
</Table.Row>
{:else}
{#each filteredFdaList as fda, i (fda.id)}
<Table.Row
class={cn(
'cursor-pointer transition-all duration-150',
selectedRows.includes(fda.id) && 'bg-primary/5 hover:bg-primary/10',
hoveredRow === fda.id && !selectedRows.includes(fda.id) && 'bg-muted/30',
'hover:shadow-sm'
)}
onmouseenter={() => (hoveredRow = fda.id)}
onmouseleave={() => (hoveredRow = null)}
onclick={() => toggleRowSelection(fda.id)}
>
<Table.Cell class="text-center" onclick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedRows.includes(fda.id)}
onCheckedChange={() => toggleRowSelection(fda.id)}
aria-label="Seleccionar fila"
/>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<span
class="rounded bg-primary/10 px-2 py-1 font-mono text-sm font-bold text-primary"
>
{fda.fda_key}
</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="max-w-[300px]">
<p class="truncate font-medium" title={fda.description}>
{fda.description || '-'}
</p>
{#if fda.requirements}
<p
class="mt-0.5 truncate text-xs text-muted-foreground"
title={fda.requirements}
>
{fda.requirements}
</p>
{/if}
</div>
</Table.Cell>
<Table.Cell>
<span class="font-mono text-sm">
{fda.fda_code || '-'}
</span>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Building2 class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-sm">{fda.manufacturer_number || '-'}</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Globe class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-sm">{fda.country_of_production || '-'}</span>
</div>
</Table.Cell>
<Table.Cell>
{#if fda.storage_status}
<Badge
variant="outline"
class={cn('text-xs font-medium', getStorageBadge(fda.storage_status))}
>
{fda.storage_status}
</Badge>
{:else}
<span class="text-sm text-muted-foreground">-</span>
{/if}
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Footer de Tabla -->
{#if filteredFdaList.length > 0}
<div class="flex items-center justify-between border-t bg-muted/30 px-4 py-3">
<span class="text-sm text-muted-foreground">
Mostrando {filteredFdaList.length} registros
</span>
{#if searchQuery}
<Badge variant="secondary" class="gap-2">
Filtro: "{searchQuery}"
<button onclick={() => (searchQuery = '')} class="hover:text-destructive"> × </button>
</Badge>
{/if}
</div>
{/if}
</Card.Root>
</div>
<!-- Footer Fijo con Acciones -->
<div
class="fixed right-0 bottom-0 left-0 z-30 border-t bg-background/95 shadow-[0_-4px_20px_rgba(0,0,0,0.1)] backdrop-blur supports-[backdrop-filter]:bg-background/90"
>
<div class="mx-auto max-w-[1600px] px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<!-- Info de selección -->
<div class="flex items-center gap-4">
{#if selectedRows.length > 0}
<div class="flex items-center gap-3 rounded-full bg-primary/10 px-4 py-2">
<CheckSquare class="h-4 w-4 text-primary" />
<span class="text-sm font-semibold text-primary">
{selectedRows.length} seleccionado(s)
</span>
</div>
{:else}
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle class="h-4 w-4" />
<span>Selecciona registros para editar o eliminar</span>
</div>
{/if}
</div>
<!-- Botones de acción -->
<div class="flex items-center gap-2">
<Button size="default" onclick={handleCreateClick} class="shadow-lg shadow-primary/20">
<Plus class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Insertar</span>
<span class="sm:hidden">Nuevo</span>
</Button>
<Button
variant="outline"
size="default"
disabled={selectedRows.length !== 1}
onclick={handleEditSelected}
class={selectedRows.length === 1 ? 'border-primary/50' : ''}
>
<Pencil class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Editar</span>
</Button>
<Button
variant="outline"
size="default"
disabled={selectedRows.length === 0}
onclick={handleDeleteSelected}
class={selectedRows.length > 0
? 'border-destructive/50 text-destructive hover:bg-destructive/10'
: ''}
>
<Trash2 class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Borrar</span>
</Button>
</div>
</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,10 @@
// Tipo extendido que combina A76Class y FAClass
interface FixedAssetClassExtended extends A76Class {
fa_class_id?: number;
import_tariff_code?: string | null;
import_tariff_type?: string | null;
export_tariff_code?: string | null;
export_tariff_type?: string | null;
depreciation_rate?: number | null;
fda_code?: string | null;
eccn_code?: string | null;
@@ -578,6 +582,10 @@
cleanData.depreciation_rate != null
? Number(cleanData.depreciation_rate)
: null,
import_tariff_code: cleanData.import_tariff_code || null,
import_tariff_type: cleanData.import_tariff_type || null,
export_tariff_code: cleanData.export_tariff_code || null,
export_tariff_type: cleanData.export_tariff_type || null,
fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null,
eccn_code: cleanData.eccn_code || null
},

0
scripts/backend-entrypoint.sh Normal file → Executable file
View File

0
scripts/frontend-entrypoint.sh Normal file → Executable file
View File

0
scripts/health-check.sh Normal file → Executable file
View File

0
scripts/init_first_time.sh Normal file → Executable file
View File

0
scripts/keycloak-entrypoint.sh Normal file → Executable file
View File

0
scripts/postgres-app-entrypoint.sh Normal file → Executable file
View File

0
scripts/postgres-keycloak-entrypoint.sh Normal file → Executable file
View File

0
start.sh Normal file → Executable file
View File