Se normalizo la tabla de partes para anexo 76 y 24, ademas se parametrizo el formulario

This commit is contained in:
2026-01-08 15:39:40 -06:00
parent cddd64d730
commit 9c2715e8f0
12 changed files with 1244 additions and 961 deletions

View File

@@ -0,0 +1,48 @@
"""
Modelo ORM para datos específicos de Activos Fijos (Q-Partes) - Anexo 24
"""
from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
Integer,
PrimaryKeyConstraint,
String,
ForeignKeyConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
class FaPart(Base, TenantScopedMixin, TimestampMixin):
"""
Tabla fa_partes: Extensión de Anexo 24 para Activos Fijos.
"""
__tablename__ = "fa_partes"
__table_args__ = (
PrimaryKeyConstraint("id", name="fa_partes_pkey"),
ForeignKeyConstraint(
["id"], ["a76.parts.id"], name="fk_fa_partes_master"
),
{"schema": "a24"},
)
# El ID hereda el valor de la tabla parts
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
# --- CAMPOS ESPECÍFICOS FISCALES (Q-PARTES) ---
origin_country: Mapped[Optional[str]] = mapped_column(String(3)) # PAIS
sector: Mapped[Optional[str]] = mapped_column(String(8)) # SECTOR
fraction_type: Mapped[Optional[str]] = mapped_column(String(7)) # TIPOFRACCION
# --- RELACIÓN ---
# Usamos string "Part" para evitar que truene al inicializar los mappers
master_info: Mapped["Part"] = relationship("Part", back_populates="fa_data")
def __repr__(self) -> str:
return f"<FaPart(id={self.id}, sector='{self.sector}')>"

View File

@@ -0,0 +1,108 @@
"""
Modelo ORM para datos específicos de Inventario y Manufactura (S-Partes) - Anexo 24
"""
from typing import TYPE_CHECKING, Optional
from decimal import Decimal
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
Integer,
Numeric,
PrimaryKeyConstraint,
String,
Boolean,
ForeignKeyConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
class InvPart(Base, TenantScopedMixin, TimestampMixin):
"""
Tabla inv_partes: Extensión de Anexo 24 para Inventarios (SPartes).
"""
__tablename__ = "inv_partes"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_partes_pkey"),
ForeignKeyConstraint(
["id"], ["a76.parts.id"], name="fk_inv_partes_master"
),
{"schema": "a24"},
)
# Relación 1:1 - El ID es el mismo de la tabla maestra
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
# --- 1. ATRIBUTOS PRINCIPALES DE INVENTARIO ---
part_type: Mapped[Optional[str]] = mapped_column(String(10)) # TIPOPARTE
material_type: Mapped[Optional[str]] = mapped_column(String(10)) # TIPOMAT
reference_number: Mapped[Optional[str]] = mapped_column(String(70)) # NUMPARTEREF
flex_reference_number: Mapped[Optional[str]] = mapped_column(String(120)) # NUMPARTEREFFLEX
# Conversiones
equivalent_uom: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDEQUIV
conversion_factor: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # FACTORCONV
stock_uom: Mapped[Optional[str]] = mapped_column(String(5)) # UMEXISTENCIA
alternate_uom: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDALTERNA
conversion_uom: Mapped[Optional[str]] = mapped_column(String(9)) # UMCONVERSION
# Valor Agregado
added_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORAGREGADO
added_value_type: Mapped[Optional[str]] = mapped_column(String(2)) # TIPOVA
assigned_client: Mapped[Optional[str]] = mapped_column(String(50)) # CLIENTEASIGNADO
supplier_code: Mapped[Optional[str]] = mapped_column(String(8)) # PROVEEDOR
is_textile: Mapped[Optional[str]] = mapped_column(String(2)) # ESTEXTIL
# --- 2. MANUFACTURA Y PELIGROSIDAD ---
bom_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBOM / VERSIONBILL
is_repair: Mapped[Optional[str]] = mapped_column(String(3)) # ESREPARACION
is_hazardous: Mapped[Optional[str]] = mapped_column(String(1)) # ESMATPELIGROSO
emergency_number: Mapped[Optional[str]] = mapped_column(String(30)) # NUMEMERGENCIA
danger_class: Mapped[Optional[str]] = mapped_column(String(4)) # CLASEDEPELIGRO
packaging_group: Mapped[Optional[str]] = mapped_column(String(3)) # GRUPOEMBALAJE
# Dimensiones
width: Mapped[Optional[str]] = mapped_column(String(50)) # ANCHURA
thickness: Mapped[Optional[str]] = mapped_column(String(50)) # ESPESOR
specification: Mapped[Optional[str]] = mapped_column(String(50)) # SPEC
# --- 3. COSTOS DETALLADOS Y ADUANA US ---
total_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTOTAL
direct_labor: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # TRABAJODIREC
general_expenses: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # GASTOGRALES
total_expenses: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # TOTALGASTOS
depreciation: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # DEPRECIACION
tooling: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # TOOLING
material_consumed: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MATCONSUMED
profit: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # GANANCIA
# Fracciones Internacionales
us_fraction_alt: Mapped[Optional[str]] = mapped_column(String(13)) # FRACEUA
ca_fraction: Mapped[Optional[str]] = mapped_column(String(13)) # FRACCANADA
ad_valorem_us: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2)) # ADVALOREMAME
# Nafta / USMCA
nafta_result: Mapped[Optional[str]] = mapped_column(String(19)) # RESULTADOCALCULONAFTA
nafta_percentage: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2)) # PORCENTAJECALCULONAFTA
# Impuestos Específicos (Derechos de Trámite Admon)
dta: Mapped[Optional[str]] = mapped_column(String(19)) # DTA
dtb: Mapped[Optional[str]] = mapped_column(String(19)) # DTB
dtg: Mapped[Optional[str]] = mapped_column(String(19)) # DTG
# --- RELACIÓN ---
# Usamos string "Part" para evitar problemas de carga
master_info: Mapped["Part"] = relationship("Part", back_populates="inv_data")
def __repr__(self) -> str:
return f"<InvPart(id={self.id}, part_type='{self.part_type}')>"

View File

@@ -0,0 +1,10 @@
from fastapi import APIRouter
from .fa.fa_parts.models import FaPart as fa_model
from .inv.inv_parts.models import InvPart as inv_model
router = APIRouter(prefix="/a24", tags=["Anexo 24"])
@router.get("/check")
def check():
return {"status": "Anexo 24 module is operational."}

View File

@@ -1,107 +1,137 @@
from datetime import datetime
from decimal import Decimal
from typing import List, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ConfigDict
# --- DTO DE CREACIÓN ---
class PartCreateDTO(BaseModel):
# --- SUB-DTO: DATOS ADUANALES (FaData) ---
class FaDataDTO(BaseModel):
origin_country: Optional[str] = None
sector: Optional[str] = None
fraction_type: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
# --- SUB-DTO: DATOS DE INVENTARIO Y COSTEO (InvData) ---
class InvDataDTO(BaseModel):
part_type: Optional[str] = None
material_type: Optional[str] = None
reference_number: Optional[str] = None
flex_reference_number: Optional[str] = None
equivalent_uom: Optional[str] = None
conversion_factor: Optional[Decimal] = None
stock_uom: Optional[str] = None
alternate_uom: Optional[str] = None
conversion_uom: Optional[str] = None
added_value: Optional[Decimal] = None
added_value_type: Optional[str] = None
assigned_client: Optional[str] = None
supplier_code: Optional[str] = None
is_textile: Optional[str] = None
bom_version: Optional[int] = None
is_repair: Optional[str] = None
is_hazardous: Optional[str] = None
emergency_number: Optional[str] = None
danger_class: Optional[str] = None
packaging_group: Optional[str] = None
width: Optional[str] = None
thickness: Optional[str] = None
specification: Optional[str] = None
# Desglose de Costos (Anexo 24 - Valor Agregado)
total_value: Optional[Decimal] = None
direct_labor: Optional[Decimal] = None
general_expenses: Optional[Decimal] = None
total_expenses: Optional[Decimal] = None
depreciation: Optional[Decimal] = None
tooling: Optional[Decimal] = None
material_consumed: Optional[Decimal] = None
profit: Optional[Decimal] = None
# Fracciones adicionales y TLCAN/T-MEC
us_fraction_alt: Optional[str] = None
ca_fraction: Optional[str] = None
ad_valorem_us: Optional[Decimal] = None
nafta_result: Optional[str] = None
nafta_percentage: Optional[Decimal] = None
dta: Optional[str] = None
dtb: Optional[str] = None
dtg: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class PartBase(BaseModel):
client_id: int
part_number: str = Field(..., max_length=50)
part_number: str = Field(..., max_length=70)
commercial_part_number: Optional[str] = None
# Campos Generales
description_spanish: Optional[str] = None
description_english: Optional[str] = None
part_class: Optional[str] = None
material_type: Optional[str] = None
unit_of_measure: Optional[str] = "PZ"
commercial_part_number: Optional[str] = None
country_of_origin: Optional[str] = "MEX"
# Costos y Pesos
unit_cost: Optional[Decimal] = Decimal("0.0")
currency_key: Optional[str] = "USD"
unit_weight: Optional[Decimal] = Decimal("0.0")
weight_type: Optional[str] = "KG"
# --- LOS QUE FALTABAN Y AHORA SE GUARDARÁN ---
added_value: Optional[Decimal] = None
part_photo: Optional[str] = None
alternate_unit_measure: Optional[str] = None
license_code: Optional[str] = None
export_code: Optional[str] = None
exclusion_symbol: Optional[str] = None
# Regulatorios
fraction: Optional[str] = None
us_fraction: Optional[str] = None
supplier: Optional[str] = None
fda_key: Optional[str] = None
fcc_key: Optional[str] = None
eccn: Optional[str] = None
# Estatus
is_active: Optional[bool] = True
# --- DTO DE ACTUALIZACIÓN ---
class PartUpdateDTO(BaseModel):
description_spanish: Optional[str] = None
description_english: Optional[str] = None
part_class: Optional[str] = None
material_type: Optional[str] = None
unit_of_measure: Optional[str] = None
commercial_part_number: Optional[str] = None
country_of_origin: Optional[str] = None
unit_cost: Optional[Decimal] = None
currency_key: Optional[str] = None
currency_type: Optional[str] = None
unit_weight: Optional[Decimal] = None
weight_type: Optional[str] = None
added_value: Optional[Decimal] = None
part_photo: Optional[str] = None
alternate_unit_measure: Optional[str] = None
license_code: Optional[str] = None
export_code: Optional[str] = None
exclusion_symbol: Optional[str] = None
fraction: Optional[str] = None
us_fraction: Optional[str] = None
supplier: Optional[str] = None
# Regulatorios
fda_key: Optional[str] = None
fcc_key: Optional[str] = None
license_code: Optional[str] = None
eccn: Optional[str] = None
is_active: Optional[bool] = None
export_code: Optional[str] = None
exclusion_symbol: Optional[str] = None
is_active: bool = True
part_photo: Optional[str] = None
# Anidados
fa_data: Optional[FaDataDTO] = None
inv_data: Optional[InvDataDTO] = None
class PartResponseDTO(PartCreateDTO):
# --- CREACIÓN ---
class PartCreateDTO(PartBase):
# Opcional para que lo tome de la URL si no viene en el body
company_id: Optional[int] = None
# --- ACTUALIZACIÓN ---
class PartUpdateDTO(PartBase):
client_id: Optional[int] = None
part_number: Optional[str] = None
# Todo opcional para PATCH
pass
# --- RESPUESTA ---
class PartResponseDTO(PartBase):
id: int
tenant_id: int
company_id: int
creation_date: Optional[int] = None
modification_date_iso: Optional[datetime] = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
# --- BÁSICO (Para listados ligeros) ---
class PartBasicDTO(BaseModel):
id: int
client_id: int
part_number: str
description_spanish: Optional[str] = None
is_active: Optional[bool] = True
fraction: Optional[str] = None
is_active: bool
model_config = ConfigDict(from_attributes=True)
class Config:
from_attributes = True
class PartListDTO(BaseModel):
parts: List[PartBasicDTO]
# --- LISTADO PAGINADO ---
class PartListResponseDTO(BaseModel):
items: List[PartResponseDTO]
total: int
page: int
size: int
page_size: int
pages: int
class PartSearchDTO(BaseModel):
client_id: Optional[int] = None
part_number: Optional[str] = None
description: Optional[str] = None
fraction: Optional[str] = None
supplier: Optional[str] = None
enabled_only: bool = False

View File

@@ -1,7 +1,6 @@
"""
Modelos ORM para gestión de partes/componentes
Modelos ORM para gestión de partes/componentes - Anexo 76 (Master Data)
"""
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING, Optional
@@ -9,34 +8,23 @@ from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKeyConstraint,
Integer,
Numeric,
PrimaryKeyConstraint,
String,
UniqueConstraint,
Boolean,
ForeignKeyConstraint, Integer, Numeric, PrimaryKeyConstraint,
String, UniqueConstraint, Boolean, DateTime
)
# Importante usar relationship y Mapped
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a24.fa.fa_parts.models import FaPart
from api.v1.modules.a24.inv.inv_parts.models import InvPart
class Part(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla GPartes - Información de partes en los sistemas SCAII (N), SCAF (S) Y WINSAAI (W)
"""
__tablename__ = "parts"
__table_args__ = (
PrimaryKeyConstraint("id", name="parts_pkey"),
ForeignKeyConstraint(
["country_of_origin"], ["public.countries.m3_key"], name="fk_parts_country"
),
ForeignKeyConstraint(
["currency_key"], ["public.currency_types.code"], name="fk_parts_currency"
),
@@ -45,6 +33,12 @@ class Part(Base, TenantScopedMixin, TimestampMixin):
["a76.units_of_measure.code", "a76.units_of_measure.tenant_id",
"a76.units_of_measure.company_id"],
),
# Puente hacia la tabla de clases
ForeignKeyConstraint(
["part_class", "tenant_id", "company_id"],
["a76.classes.class_code", "a76.classes.tenant_id", "a76.classes.company_id"],
name="fk_parts_class"
),
UniqueConstraint(
"tenant_id", "company_id", "part_number", name="client_part_ukey"
),
@@ -52,81 +46,58 @@ class Part(Base, TenantScopedMixin, TimestampMixin):
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Unique constraint compuesta
client_id: Mapped[int] = mapped_column(Integer)
part_number: Mapped[str] = mapped_column(String(50))
part_number: Mapped[str] = mapped_column(String(70))
commercial_part_number: Mapped[Optional[str]] = mapped_column(String(70))
# Basic information
fraction: Mapped[Optional[str]] = mapped_column(String(10))
description_spanish: Mapped[Optional[str]] = mapped_column(String(500))
description_english: Mapped[Optional[str]] = mapped_column(String(500))
part_class: Mapped[Optional[str]] = mapped_column(String(8))
material_type: Mapped[Optional[str]] = mapped_column(String(10))
unit_of_measure: Mapped[Optional[str]] = mapped_column(
String(5)
)
commercial_part_number: Mapped[Optional[str]] = mapped_column(String(70))
country_of_origin: Mapped[Optional[str]] = mapped_column(String(3))
# Pricing and currency
unit_of_measure: Mapped[Optional[str]] = mapped_column(String(5))
unit_cost: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
currency_type: Mapped[Optional[str]] = mapped_column(String(2))
currency_key: Mapped[Optional[str]] = mapped_column(String(3))
# Weight information
unit_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
weight_type: Mapped[Optional[str]] = mapped_column(String(6))
# Classification and regulatory
us_fraction: Mapped[Optional[str]] = mapped_column(
String(16)) # FRACCIONAME
fraction: Mapped[Optional[str]] = mapped_column(String(10))
us_fraction: Mapped[Optional[str]] = mapped_column(String(16))
fda_key: Mapped[Optional[str]] = mapped_column(String(20))
fcc_key: Mapped[Optional[str]] = mapped_column(String(30))
license_code: Mapped[Optional[str]] = mapped_column(String(3))
eccn: Mapped[Optional[str]] = mapped_column(
String(20)
) # Export Control Classification Number
eccn: Mapped[Optional[str]] = mapped_column(String(20))
export_code: Mapped[Optional[str]] = mapped_column(String(2))
exclusion_symbol: Mapped[Optional[str]] = mapped_column(
String(19)) # SIMBOLOEXCLIC
exclusion_symbol: Mapped[Optional[str]] = mapped_column(String(19))
# Additional information
supplier: Mapped[Optional[str]] = mapped_column(String(14))
alternate_unit_measure: Mapped[Optional[str]] = mapped_column(String(14))
added_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
# Status and dates
is_active: Mapped[Optional[bool]] = mapped_column(Boolean)
creation_date: Mapped[Optional[int]
] = mapped_column() # FECHACREACIONPARTE
modification_date: Mapped[Optional[int]] = mapped_column() # FECHAMODIFICA
modification_date_iso: Mapped[Optional[datetime]] = (
mapped_column()
) # FECHAMODIFICA_ISO
# Media
is_active: Mapped[Optional[bool]] = mapped_column(Boolean, default=True)
part_photo: Mapped[Optional[str]] = mapped_column(String(255))
creation_date: Mapped[Optional[int]] = mapped_column()
modification_date: Mapped[Optional[int]] = mapped_column()
modification_date_iso: Mapped[Optional[datetime]] = mapped_column(DateTime)
# Relationships
country: Mapped[Optional["Country"]] = relationship(
foreign_keys=[country_of_origin]
)
# --- RELACIONES CORREGIDAS ---
currency: Mapped[Optional["CurrencyType"]] = relationship(
foreign_keys=[currency_key]
foreign_keys="[Part.currency_key]"
)
unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
foreign_keys=[unit_of_measure]
foreign_keys="[Part.unit_of_measure, Part.tenant_id, Part.company_id]"
)
part_class_info: Mapped[Optional["Class"]] = relationship(
"Class",
back_populates="parts",
foreign_keys="[Part.part_class, Part.tenant_id, Part.company_id]"
)
# Relationship with Class through composite foreign key
# Note: This requires both client_id and part_class to match client_id and class_code in Class
part_class_info: Mapped[Optional["Class"]] = relationship(
primaryjoin="and_(Part.client_id == Class.client_id, Part.part_class == Class.class_code)",
foreign_keys="[Part.client_id, Part.part_class]",
viewonly=True,
back_populates="parts",
# Extensiones Anexo 24
fa_data: Mapped[Optional["FaPart"]] = relationship(
"FaPart", back_populates="master_info", uselist=False, cascade="all, delete-orphan"
)
inv_data: Mapped[Optional["InvPart"]] = relationship(
"InvPart", back_populates="master_info", uselist=False, cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Part(client_id={self.client_id}, part_number='{self.part_number}', description='{self.description_spanish}')>"
return f"<Part(id={self.id}, part_number='{self.part_number}')>"

View File

@@ -1,13 +1,13 @@
"""
Endpoints API para gestión de partes (SCAII)
"""
# ESTA ES LA LÍNEA QUE FALTA:
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import PartCreateDTO, PartResponseDTO, PartUpdateDTO
from .service import PartService
# Ahora ya no dará error aquí
router = TenantCRUDRoutes(
service=PartService,
create_schema=PartCreateDTO,

View File

@@ -1,18 +1,26 @@
import logging
from typing import List, Optional, Any, Dict
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from sqlalchemy import and_, or_
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
# Importamos el modelo PRINCIPAL
from .models import Part
from .dto import PartCreateDTO, PartUpdateDTO, PartSearchDTO
# Importamos TODOS los DTOs necesarios
from .dto import (
PartCreateDTO,
PartUpdateDTO,
PartResponseDTO,
PartBasicDTO # ¡Importante tener este!
)
logger = logging.getLogger(__name__)
class PartService:
"""
Servicio de Partes compatible con TenantCRUDRoutes
"""
"""Servicio para gestión de Partes (Anexo 76 + Anexo 24)"""
@staticmethod
def get_all(
@@ -21,39 +29,32 @@ class PartService:
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None, # Agregamos este argumento explícito
filters: Optional[Dict[str, Any]] = None,
) -> tuple[List[Part], int]:
"""Obtener todas las partes con paginación y filtros"""
try:
query = db.query(Part).filter(
Part.tenant_id == tenant_id,
Part.company_id == company_id
)
if filters:
if filters.get("part_number"):
query = query.filter(Part.part_number.ilike(f"%{filters['part_number']}%"))
if filters.get("description"):
pattern = f"%{filters['description']}%"
query = query.filter(or_(
Part.description_spanish.ilike(pattern),
Part.description_english.ilike(pattern)
))
query = db.query(Part).filter(
Part.tenant_id == tenant_id,
Part.company_id == company_id
)
if filters.get("client_id"):
query = query.filter(Part.client_id == filters["client_id"])
if filters:
if filters.get("q"):
search = f"%{filters['q']}%"
query = query.filter(
or_(
Part.part_number.ilike(search),
Part.description_spanish.ilike(search),
Part.commercial_part_number.ilike(search)
)
)
# Otros filtros...
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
except Exception as e:
logger.error(f"Error en get_all partes: {e}")
raise HTTPException(status_code=500, detail="Error al listar partes")
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(db: Session, part_id: int, tenant_id: int, company_id: int) -> Optional[Part]:
"""Obtener una parte por su ID numérico (Reemplaza a get_part)"""
return db.query(Part).filter(
Part.id == part_id,
Part.tenant_id == tenant_id,
@@ -62,67 +63,117 @@ class PartService:
@staticmethod
def create(db: Session, part_data: PartCreateDTO, tenant_id: int, company_id: int) -> Part:
"""Crear parte (Reemplaza a create_part)"""
# 1. Preparar datos
data = part_data.model_dump()
# Separar datos anidados
fa_dict = data.pop('fa_data', None)
inv_dict = data.pop('inv_data', None)
# Inyectar IDs de contexto (Seguridad Multi-tenant)
data['company_id'] = company_id
data['tenant_id'] = tenant_id
# 2. Verificar duplicados (Usando la UniqueConstraint del modelo)
existing = db.query(Part).filter(
Part.tenant_id == tenant_id,
Part.company_id == company_id,
Part.part_number == data['part_number']
).first()
if existing:
raise HTTPException(
status_code=400,
detail=f"El número de parte '{data['part_number']}' ya existe."
)
# 3. Crear objeto Part
db_part = Part(**data)
# 4. Crear relaciones (Anexo 24)
# Importamos aquí para evitar ciclos, usando las rutas de tu modelo
if fa_dict:
from api.v1.modules.a24.fa.fa_parts.models import FaPart
# Importante: Pasar tenant/company también al hijo
db_part.fa_data = FaPart(**fa_dict, tenant_id=tenant_id, company_id=company_id)
if inv_dict:
from api.v1.modules.a24.inv.inv_parts.models import InvPart
db_part.inv_data = InvPart(**inv_dict, tenant_id=tenant_id, company_id=company_id)
try:
data = part_data.model_dump()
data['company_id'] = company_id
data['tenant_id'] = tenant_id
db_part = Part(**data)
db.add(db_part)
db.commit()
db.refresh(db_part)
return db_part
except IntegrityError as e:
db.rollback()
err_msg = str(e.orig)
logger.error(f"Error DB creando parte: {err_msg}")
if "foreign key" in err_msg:
if "unit_of_measure" in err_msg:
raise HTTPException(400, "La Unidad de Medida no existe en el catálogo.")
if "currency_key" in err_msg:
raise HTTPException(400, "La Moneda no existe en el catálogo.")
if "part_class" in err_msg:
raise HTTPException(400, "La Clase no existe para este cliente.")
# El error genérico si falla Company/Client
raise HTTPException(400, "Error de referencia: Verifique Cliente, Compañía o Catálogos.")
raise HTTPException(400, "Error al guardar la parte.")
@staticmethod
def update(db: Session, part_id: int, tenant_id: int, part_data: PartUpdateDTO, company_id: int) -> Optional[Part]:
db_part = PartService.get_by_id(db, part_id, tenant_id, company_id)
if not db_part:
return None
data = part_data.model_dump(exclude_unset=True)
fa_dict = data.pop('fa_data', None)
inv_dict = data.pop('inv_data', None)
# Actualizar campos directos
for key, value in data.items():
setattr(db_part, key, value)
# Actualizar FA Data
if fa_dict is not None:
if db_part.fa_data:
for k, v in fa_dict.items():
setattr(db_part.fa_data, k, v)
else:
from api.v1.modules.a24.fa.fa_parts.models import FaPart
db_part.fa_data = FaPart(**fa_dict, tenant_id=tenant_id, company_id=company_id)
# Actualizar INV Data
if inv_dict is not None:
if db_part.inv_data:
for k, v in inv_dict.items():
setattr(db_part.inv_data, k, v)
else:
from api.v1.modules.a24.inv.inv_parts.models import InvPart
db_part.inv_data = InvPart(**inv_dict, tenant_id=tenant_id, company_id=company_id)
try:
db.commit()
db.refresh(db_part)
return db_part
except IntegrityError as e:
db.rollback()
msg = str(e.orig)
if "client_part_ukey" in msg:
raise HTTPException(status_code=400, detail="El número de parte ya existe para este cliente.")
raise HTTPException(status_code=400, detail=f"Error de integridad: {msg}")
@staticmethod
def update(
db: Session,
part_id: int,
tenant_id: int,
part_data: PartUpdateDTO,
company_id: int
) -> Optional[Part]:
"""Actualizar parte por ID (Reemplaza a update_part)"""
db_part = PartService.get_by_id(db, part_id, tenant_id, company_id)
if not db_part:
return None
update_data = part_data.model_dump(exclude_unset=True)
# Evitar que se intente actualizar el ID o las llaves de seguridad
for key in ["id", "tenant_id", "company_id"]:
update_data.pop(key, None)
for key, value in update_data.items():
setattr(db_part, key, value)
try:
db.commit()
db.refresh(db_part)
return db_part
except Exception as e:
db.rollback()
logger.error(f"Error actualizando parte {part_id}: {e}")
raise HTTPException(status_code=500, detail="Error al actualizar parte")
raise HTTPException(400, f"Error actualizando: {str(e.orig)}")
@staticmethod
def delete(db: Session, part_id: int, tenant_id: int, company_id: int) -> bool:
"""Eliminar parte"""
db_part = PartService.get_by_id(db, part_id, tenant_id, company_id)
if not db_part:
return False
if not db_part: return False
try:
db.delete(db_part)
db.commit()
return True
except Exception as e:
except Exception:
db.rollback()
logger.error(f"Error eliminando parte {part_id}: {e}")
raise HTTPException(status_code=500, detail="Error al eliminar parte")
raise HTTPException(500, "Error eliminando parte")

View File

@@ -43,6 +43,7 @@ from .transportation.transporters.routes import router as transporters_router
from .transportation.vehicles.routes import router as vehicles_router
from api.v1.modules.public.reference_data.material_types.routes import router as material_types_router
# Router principal
router = APIRouter()
@@ -96,6 +97,7 @@ router.include_router(doda_router, prefix="/a76")
router.include_router(prevalidators_router, prefix="/a76")
router.include_router(electronic_notices_router, prefix="/a76")
# Registrar router de tipos de material públicos
router.include_router(
material_types_router,

View File

@@ -9,6 +9,7 @@ from fastapi import APIRouter
from .modules.core.router import router as core_router
from .modules.a76.router import router as a76_router
from .modules.public.router import router as public_router
from .modules.a24.router import router as a24_router
# Router principal
router = APIRouter()
@@ -17,6 +18,8 @@ router = APIRouter()
router.include_router(core_router)
router.include_router(a76_router)
router.include_router(public_router)
# nuevas rutas de partes de anexo 24
router.include_router(a24_router)
# Health check

View File

@@ -1,9 +1,57 @@
import { api } from '$lib/api';
import type { ApiResponse } from '$lib/api';
export interface FaData {
origin_country?: string | null;
sector?: string | null;
fraction_type?: string | null;
}
export interface InvData {
part_type?: string | null;
material_type?: string | null;
reference_number?: string | null;
flex_reference_number?: string | null;
equivalent_uom?: string | null;
conversion_factor?: number | null;
stock_uom?: string | null;
alternate_uom?: string | null;
conversion_uom?: string | null;
added_value?: number | null;
added_value_type?: string | null;
assigned_client?: string | null;
supplier_code?: string | null;
is_textile?: string | null;
bom_version?: number | null;
is_repair?: string | null;
is_hazardous?: string | null;
emergency_number?: string | null;
danger_class?: string | null;
packaging_group?: string | null;
width?: string | null;
thickness?: string | null;
specification?: string | null;
total_value?: number | null;
direct_labor?: number | null;
general_expenses?: number | null;
total_expenses?: number | null;
depreciation?: number | null;
tooling?: number | null;
material_consumed?: number | null;
profit?: number | null;
us_fraction_alt?: string | null;
ca_fraction?: string | null;
ad_valorem_us?: number | null;
nafta_result?: string | null;
nafta_percentage?: number | null;
dta?: string | null;
dtb?: string | null;
dtg?: string | null;
}
export interface Part {
id: number;
// Llaves foráneas y IDs
tenant_id: number;
company_id: number;
client_id: number;
@@ -11,99 +59,76 @@ export interface Part {
// Identificación
part_number: string;
commercial_part_number: string | null;
part_class: string | null;
material_type?: string | null;
// Descripciones
// Descripciones y Clase
description_spanish: string | null;
description_english: string | null;
part_class: string | null;
unit_of_measure: string | null;
// Físico y Origen
unit_of_measure: string;
alternate_unit_measure: string | null;
country_of_origin: string;
// Costos y Pesos
unit_cost: number | null;
currency_key: string | null;
currency_type: string | null;
unit_weight: number | null;
weight_type: string | null;
part_photo: string | null;
// Clasificación Arancelaria
// Regulatorio
fraction: string | null;
us_fraction: string | null;
// Costos y Valores
unit_cost: number | null;
currency_key: string | null; // currency_type en DB a veces es redundante, usamos key
added_value: number | null;
// Regulatorio y Proveedores
supplier: string | null;
fda_key: string | null;
fcc_key: string | null;
eccn: string | null;
license_code: string | null;
eccn: string | null;
export_code: string | null;
exclusion_symbol: string | null;
// Estado
// Estado y Media
is_active: boolean;
created_at?: string;
updated_at?: string;
part_photo: string | null;
created_at: string;
updated_at: string;
fa_data?: FaData | null;
inv_data?: InvData | null;
}
export interface PartCreate {
company_id: number;
client_id: number;
part_number: string;
// Opcionales
description_spanish?: string | null;
description_english?: string | null;
commercial_part_number?: string | null;
part_class?: string | null;
material_type?: string | null;
unit_of_measure: string;
alternate_unit_measure?: string | null;
country_of_origin?: string;
unit_weight?: number | null;
weight_type?: string | null;
fraction?: string | null;
us_fraction?: string | null;
unit_cost?: number | null;
currency_key?: string | null;
added_value?: number | null;
supplier?: string | null;
fda_key?: string | null;
fcc_key?: string | null;
eccn?: string | null;
license_code?: string | null;
export_code?: string | null;
exclusion_symbol?: string | null;
part_photo?: string | null;
is_active?: boolean;
export interface PartCreate extends Omit<Part, 'id' | 'tenant_id' | 'created_at' | 'updated_at'> {
}
export interface PartUpdate extends Partial<PartCreate> {}
export interface PartListResponse {
items: Part[];
total: number;
page: number;
page_size: number;
pages: number;
}
export const partsApi = {
list: (params: { company_id: number; page?: number; page_size?: number; q?: string }) => {
list: (params: {
company_id: number;
page?: number;
page_size?: number;
q?: string
}) => {
const { company_id, page = 1, page_size = 50, q = '' } = params;
const skip = (page - 1) * page_size;
return api.get<PartListResponse>(
`/v1/a76/parts/?company_id=${company_id}&skip=${skip}&limit=${page_size}&description=${q}`
);
const query = new URLSearchParams({
company_id: company_id.toString(),
skip: skip.toString(),
limit: page_size.toString(),
description: q
});
return api.get<PartListResponse>(`/v1/a76/parts/?${query.toString()}`);
},
get: (id: number, company_id: number) => {
@@ -118,6 +143,7 @@ export const partsApi = {
return api.put<Part>(`/v1/a76/parts/${id}?company_id=${company_id}`, data);
},
delete: (id: number, company_id: number) => {
return api.delete<void>(`/v1/a76/parts/${id}?company_id=${company_id}`);
}

View File

@@ -0,0 +1,711 @@
<script lang="ts">
import { goto } from '$app/navigation';
// UI Components
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import * as Tabs from '$lib/components/ui/tabs';
import * as Card from '$lib/components/ui/card';
import * as Select from "$lib/components/ui/select";
import { Switch } from "$lib/components/ui/switch";
// Iconos
import {
ArrowLeft, LoaderCircle, Save, Package, DollarSign,
FileText, Settings, Image as ImageIcon, FolderSearch,
UserCheck, CheckCircle2, XCircle, Tag, Layers, Scale, Info, Briefcase, ShieldCheck
} from 'lucide-svelte';
// Stores & APIs
import { companyStore } from '$lib/stores/company.svelte';
import { partsApi } from '$lib/api/dashboard/a76/parts';
import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers';
import { classesApi } from '$lib/api/dashboard/a76/classes';
import { materialTypesApi } from '$lib/api/dashboard/a76/material-types';
// Modales
import ClientSelectorDialog from '$lib/components/dashboard/goods/modales/client-selector-dialog.svelte';
import ClassSelectorDialog from '$lib/components/dashboard/goods/parts/class-selector-dialog.svelte';
import MaterialTypeSelectorDialog from '$lib/components/dashboard/goods/modales/material-type-selector-dialog.svelte';
import UnitMeasureSelectorDialog from '$lib/components/dashboard/goods/modales/unit-measure-dialog.svelte';
// --- PROPS ---
let { partId = null, formType = 'inv' }: { partId?: number | null, formType?: 'inv' | 'fa' } = $props();
// --- LÓGICA DE ESTADO ---
let isEdit = $derived(!!partId);
let title = $derived(isEdit ? "Editar Parte" : "Nueva Parte");
let loading = $state(false);
let error = $state<string | null>(null);
// Estado Modales
let showClientModal = $state(false);
let showClassModal = $state(false);
let showMaterialModal = $state(false);
let showUOMModal = $state(false);
let showAltUOMModal = $state(false);
// Descripciones Visuales
let selectedClientName = $state("");
let selectedClientStatus = $state(true);
let selectedClassDesc = $state("");
let selectedMaterialDesc = $state("");
// Estado Formulario
let formData = $state({
client_id: 0,
part_number: '',
description_spanish: '',
description_english: '',
part_class: '',
material_type: '',
unit_of_measure: 'PZ',
unit_weight: 0,
weight_type: 'KG',
unit_cost: 0,
currency_key: 'USD',
added_value: 0,
value_added_type: 'USD',
us_fraction: '',
supplier: '',
fcc_key: '',
part_photo: '',
fda_key: '',
commercial_part_number: '',
alternate_unit_measure: '',
fraction: '',
eccn: '',
license_code: '',
export_code: '',
exclusion_symbol: '',
is_active: true,
// --- CAMPOS ESPECIFICOS DE FA (Activos Fijos) ---
origin_country: 'MEX',
sector: '',
fraction_type: ''
});
// --- CARGA DE DATOS ---
$effect(() => {
if (companyStore.activeCompany?.id && partId) {
loadPartData(partId, companyStore.activeCompany.id);
}
});
async function loadPartData(id: number, companyId: number) {
loading = true;
try {
const response = await partsApi.get(id, companyId);
if (response.data) {
const d = response.data;
formData = {
client_id: d.client_id,
part_number: d.part_number,
description_spanish: d.description_spanish || '',
description_english: d.description_english || '',
part_class: d.part_class || '',
material_type: (d as any).material_type || '',
origin_country: d.origin_country || 'MEX',
unit_of_measure: d.unit_of_measure || 'PZ',
fraction: d.fraction || '',
us_fraction: d.us_fraction || '',
unit_weight: Number(d.unit_weight) || 0,
weight_type: d.weight_type || 'KG',
supplier: d.supplier || '',
fda_key: d.fda_key || '',
fcc_key: d.fcc_key || '',
eccn: d.eccn || '',
license_code: d.license_code || '',
export_code: d.export_code || '',
exclusion_symbol: d.exclusion_symbol || '',
unit_cost: Number(d.unit_cost) || 0,
currency_key: d.currency_key || 'USD',
added_value: Number(d.added_value) || 0,
value_added_type: 'USD',
commercial_part_number: d.commercial_part_number || '',
alternate_unit_measure: d.alternate_unit_measure || '',
part_photo: d.part_photo || '',
is_active: d.is_active ?? true,
// Cargar datos FA si existen
sector: d.fa_data?.sector || '',
fraction_type: d.fa_data?.fraction_type || ''
};
if (d.client_id) await fetchClientName(d.client_id, companyId);
if (d.part_class) await fetchClassDesc(d.part_class, companyId);
if ((d as any).material_type) await fetchMaterialName((d as any).material_type);
}
} catch (e) { console.error(e); } finally { loading = false; }
}
// --- HELPERS VISUALES ---
async function fetchClientName(clientId: number, companyId: number) {
try {
const res = await clientsProvidersApi.get(clientId, companyId);
const clientData = (res as any).data || res;
if (clientData) {
selectedClientName = clientData.name;
selectedClientStatus = clientData.is_active ?? true;
}
} catch (e) { console.log("Error visual cliente", e); }
}
async function fetchClassDesc(code: string, companyId: number) {
try {
const res = await classesApi.list({ company_id: companyId, class_code: code });
const data = (res as any).data || res;
const list = data.items || data.classes || [];
if (list.length > 0) {
const found = list.find((i: any) => i.class_code === code) || list[0];
selectedClassDesc = found.description_es || found.description_en || "";
}
} catch (e) { console.log("Error visual clase", e); }
}
async function fetchMaterialName(key: string) {
try {
const res = await materialTypesApi.list(1, 100);
const data = (res as any).data || res;
const list = data.items || [];
const found = list.find((m: any) => m.key === key);
if (found) selectedMaterialDesc = found.description;
} catch (e) { console.log("Error visual material", e); }
}
// --- HANDLERS ---
function handleClientSelect(client: any) { formData.client_id = client.id; selectedClientName = client.name; selectedClientStatus = client.is_active ?? true; }
function handleClassSelect(item: any) { formData.part_class = item.class_code; selectedClassDesc = item.description_es || item.description_en || ""; }
function handleMaterialSelect(item: any) { formData.material_type = item.key; selectedMaterialDesc = item.description; }
function handleUOMSelect(item: any) { formData.unit_of_measure = item.code; }
function handleAltUOMSelect(item: any) { formData.alternate_unit_measure = item.code; }
// --- SUBMIT ---
async function handleSubmit() {
error = null;
const activeCompanyId = companyStore.activeCompany?.id;
if (!activeCompanyId) { error = 'No hay una compañía activa seleccionada'; return; }
if (!formData.client_id) { error = 'Debe seleccionar un Cliente'; return; }
if (!formData.part_number.trim()) { error = 'Número de Parte requerido'; return; }
loading = true;
try {
// Construimos el payload.
// Si es FA, inyectamos el objeto fa_data anidado.
let commonData: any = { ...formData };
if (formType === 'fa') {
commonData.fa_data = {
sector: formData.sector,
fraction_type: formData.fraction_type,
origin_country: formData.origin_country
};
}
if (isEdit && partId) {
await partsApi.update(partId, commonData, activeCompanyId);
} else {
await partsApi.create({ ...commonData, company_id: activeCompanyId }, activeCompanyId);
}
goto('/dashboard/goods/parts');
} catch (e: any) { error = e.message || 'Error al guardar'; } finally { loading = false; }
}
</script>
<div class="w-full mx-auto max-w-6xl py-6 px-4 space-y-6 pb-48">
<div class="flex items-center gap-4">
<Button variant="outline" size="icon" href="/dashboard/goods/parts">
<ArrowLeft class="h-4 w-4" />
</Button>
<div>
<h1 class="text-2xl font-bold tracking-tight">{title}</h1>
<p class="text-muted-foreground">
{formType === 'fa' ? 'Gestión de Activos Fijos (Q-Partes)' : 'Gestión detallada de números de parte (S-Partes).'}
</p>
</div>
</div>
{#if error}
<div class="p-4 rounded-md bg-destructive/10 text-destructive border border-destructive/20 text-sm font-medium">
⚠️ {error}
</div>
{/if}
{#if formType === 'fa'}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-6">
<Card.Root>
<Card.Content class="p-6">
<Tabs.Root value="general" class="w-full">
<div class="min-h-[500px]">
<Tabs.Content value="general" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="fa_pn" class="text-base font-semibold required">Número de Parte</Label>
<Input id="fa_pn" bind:value={formData.part_number} disabled={isEdit} maxlength={50} class="text-lg font-mono" placeholder="Ej: MAQ-001"/>
</div>
<div class="grid gap-2">
<Label for="fa_client" class="required">Cliente Asignado</Label>
<div class="flex gap-2">
<Input id="fa_client" bind:value={formData.client_id} readonly onclick={() => showClientModal = true} class="cursor-pointer font-mono" placeholder="Seleccione..."/>
<Button variant="outline" size="icon" type="button" onclick={() => showClientModal = true}><FolderSearch class="h-4 w-4" /></Button>
</div>
{#if selectedClientName}<div class="text-xs font-semibold text-primary">{selectedClientName}</div>{/if}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="fa_desc_es">Descripción Español</Label>
<Textarea id="fa_desc_es" bind:value={formData.description_spanish} rows={2}/>
</div>
<div class="grid gap-2">
<Label for="fa_desc_en">Descripción Inglés</Label>
<Textarea id="fa_desc_en" bind:value={formData.description_english} rows={2}/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 border rounded-lg bg-slate-50 dark:bg-slate-900/30">
<div class="grid gap-2">
<Label for="fa_class" class="required">Clase</Label>
<div class="flex gap-2">
<Input id="fa_class" bind:value={formData.part_class} readonly onclick={() => showClassModal = true} class="cursor-pointer" placeholder="Seleccione..."/>
<Button variant="outline" size="icon" type="button" onclick={() => showClassModal = true}><FolderSearch class="h-4 w-4" /></Button>
</div>
{#if selectedClassDesc}<div class="text-xs text-muted-foreground">{selectedClassDesc}</div>{/if}
</div>
<div class="grid gap-2">
<Label for="fa_uom" class="required">Unidad Medida</Label>
<div class="flex gap-2">
<Input id="fa_uom" bind:value={formData.unit_of_measure} readonly onclick={() => showUOMModal = true} class="cursor-pointer" placeholder="PZ"/>
<Button variant="outline" size="icon" type="button" onclick={() => showUOMModal = true}><FolderSearch class="h-4 w-4" /></Button>
</div>
</div>
<div class="grid gap-2">
<Label for="fa_origin">País Origen (ISO)</Label>
<Input id="fa_origin" bind:value={formData.origin_country} maxlength={3} placeholder="MEX"/>
</div>
</div>
<div class="p-4 border rounded-lg bg-green-50/50 dark:bg-green-900/10 space-y-4">
<h3 class="font-medium text-sm text-green-800 dark:text-green-300 flex items-center gap-2"><DollarSign class="h-4 w-4"/> Costos y Pesos</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid gap-2">
<Label for="fa_cost">Costo Unitario</Label>
<div class="relative">
<span class="absolute left-3 top-2.5 text-muted-foreground">$</span>
<Input type="number" step="0.0001" id="fa_cost" bind:value={formData.unit_cost} class="pl-7" />
</div>
</div>
<div class="grid gap-2">
<Label for="fa_currency">Moneda</Label>
<Select.Root type="single" bind:value={formData.currency_key}>
<Select.Trigger id="fa_currency">{formData.currency_key}</Select.Trigger>
<Select.Content>
<Select.Item value="USD">Extranjera (USD)</Select.Item>
<Select.Item value="MXP">Nacional (MXP)</Select.Item>
<Select.Item value="EUR">Euros (EUR)</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="fa_weight">Peso Unitario</Label>
<div class="flex gap-2">
<Input type="number" step="0.0001" id="fa_weight" bind:value={formData.unit_weight} placeholder="0.0000" />
<Select.Root type="single" bind:value={formData.weight_type}>
<Select.Trigger class="w-[80px]">{formData.weight_type}</Select.Trigger>
<Select.Content>
<Select.Item value="KG">KG</Select.Item>
<Select.Item value="LB">LB</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
</div>
</div>
<div class="space-y-4 p-4 border rounded-lg">
<h3 class="font-medium text-sm text-muted-foreground flex items-center gap-2"><Briefcase class="h-4 w-4"/> Datos Aduaneros</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="fa_fraction">Fracción UMT (MX)</Label>
<Input id="fa_fraction" bind:value={formData.fraction} maxlength={10} />
</div>
<div class="grid gap-2">
<Label for="fa_sector">Sector</Label>
<Input id="fa_sector" bind:value={formData.sector} placeholder="Ej: Automotriz"/>
</div>
<div class="grid gap-2">
<Label for="fa_fraction_type">Tipo Tarifa</Label>
<Input id="fa_fraction_type" bind:value={formData.fraction_type} />
</div>
<div class="grid gap-2">
<Label for="fa_us_fraction">Fracción Americana</Label>
<Input id="fa_us_fraction" bind:value={formData.us_fraction} maxlength={10} />
</div>
</div>
</div>
<div class="flex items-center gap-3 pt-2">
<Switch id="fa_is_active" bind:checked={formData.is_active} />
<Label for="fa_is_active">Activo en sistema</Label>
</div>
</Tabs.Content>
<Tabs.Content value="cont1" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="p-6 border rounded-lg space-y-4">
<div class="grid gap-4 max-w-lg mx-auto">
<Label for="fa_photo" class="text-left flex items-center gap-2">
<ImageIcon class="h-4 w-4"/> URL Imagen de la parte
</Label>
<Input id="fa_photo" bind:value={formData.part_photo} maxlength={255} placeholder="https://ejemplo.com/imagen.jpg" />
</div>
</div>
</Tabs.Content>
<Tabs.Content value="cont2" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4 p-4 border rounded-lg">
<h3 class="font-medium text-sm text-muted-foreground flex items-center gap-2"><ShieldCheck class="h-4 w-4"/> Control de Exportación</h3>
<div class="grid gap-2">
<Label for="fa_lic">License Code</Label>
<Input id="fa_lic" bind:value={formData.license_code} maxlength={3} />
</div>
<div class="grid gap-2">
<Label for="fa_exp">Export Code</Label>
<Input id="fa_exp" bind:value={formData.export_code} maxlength={2} />
</div>
<div class="grid gap-2">
<Label for="fa_eccn">ECCN</Label>
<Input id="fa_eccn" bind:value={formData.eccn} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="fa_exc">Símbolo Excepción Licencia</Label>
<Input id="fa_exc" bind:value={formData.exclusion_symbol} maxlength={19} />
</div>
</div>
<div class="space-y-4 p-4 border rounded-lg">
<h3 class="font-medium text-sm text-muted-foreground flex items-center gap-2"><Info class="h-4 w-4"/> Regulaciones Adicionales</h3>
<div class="grid gap-2">
<Label for="fa_fda">Clave FDA</Label>
<Input id="fa_fda" bind:value={formData.fda_key} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="fa_fcc">Clave FCC</Label>
<Input id="fa_fcc" bind:value={formData.fcc_key} maxlength={30} />
</div>
</div>
</div>
</Tabs.Content>
</div>
<Tabs.List class="grid grid-cols-3 fixed bottom-24 left-1/2 -translate-x-1/2 w-[95%] max-w-xl z-40 shadow-2xl bg-background border p-1 rounded-xl">
<Tabs.Trigger value="general" class="flex gap-2 items-center justify-center">Generales</Tabs.Trigger>
<Tabs.Trigger value="cont1" class="flex gap-2 items-center justify-center">Continuación 1</Tabs.Trigger>
<Tabs.Trigger value="cont2" class="flex gap-2 items-center justify-center">Continuación 2</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
</Card.Content>
</Card.Root>
<div class="fixed bottom-0 right-0 left-0 md:left-64 p-4 border-t bg-background/95 backdrop-blur z-50">
<div class="max-w-6xl mx-auto flex justify-end gap-4">
<Button variant="ghost" href="/dashboard/goods/parts" disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading} class="min-w-[140px] bg-amber-600 hover:bg-amber-700">
{#if loading}<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />{:else}<Save class="mr-2 h-4 w-4" />{/if}
{isEdit ? 'Actualizar Activo' : 'Guardar Activo'}
</Button>
</div>
</div>
</form>
{:else}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-6">
<Card.Root>
<Card.Content class="p-6">
<Tabs.Root value="general" class="w-full">
<div class="min-h-[500px]">
<Tabs.Content value="general" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="grid gap-2">
<Label for="part_number" class="text-base font-semibold required">Número de Parte</Label>
<Input id="part_number" bind:value={formData.part_number} disabled={isEdit} maxlength={50} class="text-lg font-mono" placeholder="Ej: 123-ABC-456"/>
</div>
<div class="space-y-3 p-4 border rounded-lg bg-slate-50 dark:bg-slate-900/30">
<h3 class="font-medium text-sm text-muted-foreground flex items-center gap-2">
<FileText class="h-4 w-4"/> Descripción
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="desc_es">Español</Label>
<Textarea id="desc_es" bind:value={formData.description_spanish} maxlength={500} rows={3}/>
</div>
<div class="grid gap-2">
<Label for="desc_en">Inglés</Label>
<Textarea id="desc_en" bind:value={formData.description_english} maxlength={500} rows={3}/>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="part_class_client" class="required">Clase (Anexo 24 - Cliente)</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<div class="absolute left-3 top-2.5 text-muted-foreground">
<Package class="h-4 w-4" />
</div>
<Input id="part_class_client" bind:value={formData.part_class} maxlength={8} placeholder="Seleccione Clase..." class="pl-9 font-mono cursor-pointer" readonly onclick={() => showClassModal = true}/>
</div>
<Button variant="outline" size="icon" type="button" onclick={() => showClassModal = true}><FolderSearch class="h-4 w-4" /></Button>
</div>
{#if selectedClassDesc}<div class="text-xs text-primary font-medium px-1 flex items-center gap-1"><Tag class="h-3 w-3" />{selectedClassDesc}</div>{/if}
</div>
<div class="grid gap-2">
<Label for="part_class_material">Tipo Material (Catálogo General)</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<div class="absolute left-3 top-2.5 text-muted-foreground">
<Layers class="h-4 w-4" />
</div>
<Input id="part_class_material" bind:value={formData.material_type} maxlength={8} placeholder="Seleccione Material..." class="pl-9 font-mono cursor-pointer" readonly onclick={() => showMaterialModal = true}/>
</div>
<Button variant="outline" size="icon" type="button" onclick={() => showMaterialModal = true}><FolderSearch class="h-4 w-4" /></Button>
</div>
{#if selectedMaterialDesc}<div class="text-xs text-blue-600 font-medium px-1 flex items-center gap-1"><Layers class="h-3 w-3" />{selectedMaterialDesc}</div>{/if}
<p class="text-[10px] text-muted-foreground">Opcional: Clasificación adicional por tipo de material.</p>
</div>
<div class="grid gap-2">
<Label for="country">País Origen (ISO)</Label>
<Input id="country" bind:value={formData.origin_country} maxlength={3} placeholder="MEX"/>
</div>
<div class="grid gap-2">
<Label for="uom" class="required">Unidad de Medida (TIGIE)</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<div class="absolute left-3 top-2.5 text-muted-foreground">
<Scale class="h-4 w-4" />
</div>
<Input id="uom" bind:value={formData.unit_of_measure} placeholder="Seleccione..." class="pl-9 font-mono cursor-pointer" readonly onclick={() => showUOMModal = true}/>
</div>
<Button variant="outline" size="icon" type="button" onclick={() => showUOMModal = true}><FolderSearch class="h-4 w-4" /></Button>
</div>
</div>
</div>
<div class="p-4 border rounded-lg bg-green-50/50 dark:bg-green-900/10 space-y-4">
<h3 class="font-medium text-sm text-green-800 dark:text-green-300 flex items-center gap-2"><DollarSign class="h-4 w-4"/> Costos, valores y peso unitario</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid gap-2">
<Label for="currency">Tipos de Moneda</Label>
<Select.Root type="single" bind:value={formData.currency_key}>
<Select.Trigger id="currency">{formData.currency_key}</Select.Trigger>
<Select.Content>
<Select.Item value="USD">Dólares (USD)</Select.Item>
<Select.Item value="MXP">Pesos (MXP)</Select.Item>
<Select.Item value="EUR">Euros (EUR)</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="unit_cost">Costo Unitario</Label>
<div class="relative">
<span class="absolute left-3 top-2.5 text-muted-foreground">$</span>
<Input type="number" step="0.0001" id="unit_cost" bind:value={formData.unit_cost} class="pl-7" />
</div>
</div>
<div class="grid gap-2">
<Label for="unit_weight">Peso Unitario</Label>
<div class="flex gap-2">
<Input type="number" step="0.0001" id="unit_weight" bind:value={formData.unit_weight} placeholder="0.0000" />
<Select.Root type="single" bind:value={formData.weight_type}>
<Select.Trigger class="w-[100px]">{formData.weight_type}</Select.Trigger>
<Select.Content>
<Select.Item value="KG">KG</Select.Item>
<Select.Item value="LB">LB</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
</div>
</div>
<div class="space-y-3 p-4 border rounded-lg">
<Label class="font-semibold">Tipo Valor Agregado</Label>
<div class="flex flex-wrap gap-6">
<div class="flex items-center space-x-2">
<input type="radio" id="va_ext" name="va_type" value="USD" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
<Label for="va_ext" class="font-normal cursor-pointer">Extranjera (Dls)</Label>
</div>
<div class="flex items-center space-x-2">
<input type="radio" id="va_nac" name="va_type" value="MXP" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
<Label for="va_nac" class="font-normal cursor-pointer">Nacional (Pesos)</Label>
</div>
<div class="flex items-center space-x-2">
<input type="radio" id="va_pct" name="va_type" value="PERCENT" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
<Label for="va_pct" class="font-normal cursor-pointer">Porcentaje</Label>
</div>
</div>
<div class="grid gap-2 mt-2">
<div class="relative max-w-xs">
{#if formData.value_added_type === 'PERCENT'}
<span class="absolute right-3 top-2.5 text-muted-foreground">%</span>
{:else}
<span class="absolute left-3 top-2.5 text-muted-foreground">$</span>
{/if}
<Input type="number" step="0.0001" id="added_value" bind:value={formData.added_value} class={formData.value_added_type === 'PERCENT' ? 'pr-7' : 'pl-7'} placeholder="0.00"/>
</div>
</div>
</div>
<div class="grid gap-2 pt-4 border-t">
<Label for="frac_us">Fracción Americana (HTS)</Label>
<Input id="frac_us" bind:value={formData.us_fraction} maxlength={16} placeholder="Ej: 8501.10.00" />
</div>
</Tabs.Content>
<Tabs.Content value="opciones" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="space-y-4 p-4 border rounded-lg bg-card">
<h3 class="font-medium text-sm text-muted-foreground">Configuración General</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label for="fcc">Clave FCC</Label>
<Input id="fcc" bind:value={formData.fcc_key} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="supplier">Proveedor (Textil)</Label>
<Input id="supplier" bind:value={formData.supplier} maxlength={14} />
</div>
</div>
<div class="grid gap-2">
<Label for="photo" class="flex items-center gap-2"><ImageIcon class="h-4 w-4"/> URL Imagen de la parte</Label>
<Input id="photo" bind:value={formData.part_photo} maxlength={255} placeholder="https://..." />
</div>
</div>
</Tabs.Content>
<Tabs.Content value="opcionales2" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="p-4 border rounded-lg space-y-4">
<h3 class="font-medium text-sm text-muted-foreground">FDA</h3>
<div class="grid gap-2">
<Label for="fda">Clave FDA</Label>
<Input id="fda" bind:value={formData.fda_key} maxlength={20} />
</div>
</div>
</Tabs.Content>
<Tabs.Content value="otros" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="grid gap-2">
<Label for="client_id" class="required">Cliente Asignado</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<UserCheck class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input type="number" id="client_id" bind:value={formData.client_id} class="pl-9 font-mono" placeholder="Seleccione un cliente..." readonly onclick={() => showClientModal = true}/>
</div>
<Button variant="outline" class="shrink-0" type="button" onclick={() => showClientModal = true}><FolderSearch class="h-4 w-4 mr-2" /> Buscar</Button>
</div>
{#if selectedClientName}
<div class="flex items-center gap-2 mt-1 px-3 py-2 bg-slate-50 dark:bg-slate-900/50 border rounded-md text-sm">
<span class="font-semibold text-primary">{selectedClientName}</span>
{#if selectedClientStatus}<span class="text-green-600 flex items-center gap-1 text-xs font-medium"><CheckCircle2 class="h-3 w-3"/> Activo</span>{:else}<span class="text-red-600 flex items-center gap-1 text-xs font-medium"><XCircle class="h-3 w-3"/> Inactivo</span>{/if}
</div>
{/if}
</div>
<div class="grid gap-2">
<Label for="comm_pn">Número de Parte Comercial</Label>
<Input id="comm_pn" bind:value={formData.commercial_part_number} maxlength={70} />
</div>
<div class="p-4 border rounded-lg space-y-4">
<Label for="alt_um">UM Conversión (Alterna)</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<div class="absolute left-3 top-2.5 text-muted-foreground"><Scale class="h-4 w-4" /></div>
<Input id="alt_um" bind:value={formData.alternate_unit_measure} placeholder="Seleccione..." class="pl-9 font-mono cursor-pointer" readonly onclick={() => showAltUOMModal = true}/>
</div>
<Button variant="outline" size="icon" type="button" onclick={() => showAltUOMModal = true}><FolderSearch class="h-4 w-4" /></Button>
</div>
</div>
<div class="p-4 border rounded-lg space-y-4 bg-slate-50 dark:bg-slate-900/30">
<h3 class="font-medium text-sm text-muted-foreground">Datos Regulatorios</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="fraction_mx" class="required">Fracción Arancelaria (MX)</Label>
<Input id="fraction_mx" bind:value={formData.fraction} maxlength={10} />
</div>
<div class="grid gap-2">
<Label for="eccn">ECCN</Label>
<Input id="eccn" bind:value={formData.eccn} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="lic_code">License Code</Label>
<Input id="lic_code" bind:value={formData.license_code} maxlength={3} />
</div>
<div class="grid gap-2">
<Label for="exp_code">Export Code</Label>
<Input id="exp_code" bind:value={formData.export_code} maxlength={2} />
</div>
<div class="grid gap-2">
<Label for="exc_sym">Símbolo de Exclusión</Label>
<Input id="exc_sym" bind:value={formData.exclusion_symbol} maxlength={19} />
</div>
</div>
</div>
<div class="flex items-center gap-3 p-4 border rounded-lg bg-card">
<Switch id="is_active" bind:checked={formData.is_active} disabled={loading} />
<Label for="is_active">Parte Activa en Sistema</Label>
</div>
</Tabs.Content>
</div>
<Tabs.List class="grid grid-cols-4 fixed bottom-24 left-1/2 -translate-x-1/2 w-[95%] max-w-2xl z-40 shadow-2xl bg-background border p-1 rounded-xl">
<Tabs.Trigger value="general" class="flex gap-2 items-center justify-center"><Package class="h-4 w-4 hidden sm:block" /> General</Tabs.Trigger>
<Tabs.Trigger value="opciones" class="flex gap-2 items-center justify-center"><Settings class="h-4 w-4 hidden sm:block" /> Opciones</Tabs.Trigger>
<Tabs.Trigger value="opcionales2" class="flex gap-2 items-center justify-center"><FileText class="h-4 w-4 hidden sm:block" /> Opcionales 2</Tabs.Trigger>
<Tabs.Trigger value="otros" class="flex gap-2 items-center justify-center"><Settings class="h-4 w-4 hidden sm:block" /> Otros</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
</Card.Content>
</Card.Root>
<div class="fixed bottom-0 right-0 left-0 md:left-64 p-4 border-t bg-background/95 backdrop-blur z-50">
<div class="max-w-6xl mx-auto flex justify-end gap-4">
<Button variant="ghost" href="/dashboard/goods/parts" disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading} class="min-w-[140px]">
{#if loading}<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />{:else}<Save class="mr-2 h-4 w-4" />{/if}
{isEdit ? 'Actualizar' : 'Guardar'}
</Button>
</div>
</div>
</form>
{/if}
</div>
<ClientSelectorDialog bind:open={showClientModal} onSelect={handleClientSelect} />
<ClassSelectorDialog bind:open={showClassModal} onSelect={handleClassSelect} />
<MaterialTypeSelectorDialog bind:open={showMaterialModal} onSelect={handleMaterialSelect} />
<UnitMeasureSelectorDialog bind:open={showUOMModal} onSelect={handleUOMSelect} />
<UnitMeasureSelectorDialog bind:open={showAltUOMModal} onSelect={handleAltUOMSelect} />
<style>
:global(.required::after) {
content: " *";
color: hsl(var(--destructive));
}
</style>

View File

@@ -1,686 +1,9 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
// UI Components
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import * as Tabs from '$lib/components/ui/tabs';
import * as Card from '$lib/components/ui/card';
import * as Select from "$lib/components/ui/select";
import { Switch } from "$lib/components/ui/switch";
// Iconos
import {
ArrowLeft, LoaderCircle, Save, Package, DollarSign,
FileText, Settings, Image as ImageIcon, Search,
UserCheck, CheckCircle2, XCircle, Tag, Layers, Scale
} from 'lucide-svelte';
import PartForm from '$lib/components/dashboard/goods/parts/partForm.svelte';
// Stores & APIs
import { companyStore } from '$lib/stores/company.svelte';
import { partsApi, type PartCreate } from '$lib/api/dashboard/a76/parts';
import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers';
import { classesApi } from '$lib/api/dashboard/a76/classes';
import { materialTypesApi } from '$lib/api/dashboard/a76/material-types';
// Modales
import ClientSelectorDialog from '$lib/components/dashboard/goods/modales/client-selector-dialog.svelte';
import ClassSelectorDialog from '$lib/components/dashboard/goods/parts/class-selector-dialog.svelte';
import MaterialTypeSelectorDialog from '$lib/components/dashboard/goods/modales/material-type-selector-dialog.svelte';
import UnitMeasureSelectorDialog from '$lib/components/dashboard/goods/modales/unit-measure-dialog.svelte';
// --- 1. IDENTIFICACIÓN ---
let id = $derived($page.params.id === 'new' ? null : Number($page.params.id));
let isEdit = $derived(!!id);
let title = $derived(isEdit ? "Editar Parte" : "Nueva Parte");
// --- 2. ESTADOS ---
let loading = $state(false);
let error = $state<string | null>(null);
// Estado Modales
let showClientModal = $state(false);
let showClassModal = $state(false);
let showMaterialModal = $state(false);
let showUOMModal = $state(false);
let showAltUOMModal = $state(false);
// Descripciones Visuales
let selectedClientName = $state("");
let selectedClientStatus = $state(true);
let selectedClassDesc = $state("");
let selectedMaterialDesc = $state("");
// Estado Formulario
let formData = $state({
client_id: 0,
part_number: '',
// General
description_spanish: '',
description_english: '',
part_class: '',
material_type: '',
country_of_origin: 'MEX',
unit_of_measure: 'PZ', // U.M. TIGIE
// Costos y Pesos
unit_weight: 0,
weight_type: 'KG',
unit_cost: 0,
currency_key: 'USD',
added_value: 0,
value_added_type: 'USD',
us_fraction: '',
// Opciones
supplier: '',
fcc_key: '',
part_photo: '',
// Opcionales 2
fda_key: '',
// Otros (Comerciales)
commercial_part_number: '',
alternate_unit_measure: '', // U.M. Comercial
// Regulatorios
fraction: '',
eccn: '',
license_code: '',
export_code: '',
exclusion_symbol: '',
is_active: true
});
// --- 3. CARGA INICIAL ---
onMount(async () => {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
if (id) {
await loadPartData(id, companyId);
}
});
async function loadPartData(partId: number, companyId: number) {
loading = true;
try {
const response = await partsApi.get(partId, companyId);
if (response.error) {
error = response.error;
return;
}
if (response.data) {
const d = response.data;
// Mapeo de datos
formData = {
client_id: d.client_id,
part_number: d.part_number,
description_spanish: d.description_spanish || '',
description_english: d.description_english || '',
part_class: d.part_class || '',
material_type: d.material_type || '', // Corregido
country_of_origin: d.country_of_origin || 'MEX',
unit_of_measure: d.unit_of_measure || 'PZ',
fraction: d.fraction || '',
us_fraction: d.us_fraction || '',
unit_weight: Number(d.unit_weight) || 0,
weight_type: d.weight_type || 'KG',
supplier: d.supplier || '',
fda_key: d.fda_key || '',
fcc_key: d.fcc_key || '',
eccn: d.eccn || '',
license_code: d.license_code || '',
export_code: d.export_code || '',
exclusion_symbol: d.exclusion_symbol || '',
unit_cost: Number(d.unit_cost) || 0,
currency_key: d.currency_key || 'USD',
added_value: Number(d.added_value) || 0,
value_added_type: 'USD',
commercial_part_number: d.commercial_part_number || '',
alternate_unit_measure: d.alternate_unit_measure || '',
part_photo: d.part_photo || '',
is_active: d.is_active ?? true
};
// Cargar datos visuales
if (d.client_id) await fetchClientName(d.client_id, companyId);
if (d.part_class) await fetchClassDesc(d.part_class, companyId);
if (d.material_type) await fetchMaterialName(d.material_type);
}
} catch (e) {
error = "Error al cargar la parte";
console.error(e);
} finally {
loading = false;
}
}
// --- HELPERS VISUALES ---
async function fetchClientName(clientId: number, companyId: number) {
try {
const res = await clientsProvidersApi.get(clientId, companyId);
const clientData = (res as any).data || res;
if (clientData) {
selectedClientName = clientData.name;
selectedClientStatus = clientData.is_active ?? true;
}
} catch (e) { console.log("Error visual cliente", e); }
}
async function fetchClassDesc(code: string, companyId: number) {
try {
const res = await classesApi.list({ company_id: companyId, class_code: code });
const data = (res as any).data || res;
const list = data.items || data.classes || [];
if (list.length > 0) {
const found = list.find((i: any) => i.class_code === code) || list[0];
selectedClassDesc = found.description_es || found.description_en || "";
}
} catch (e) { console.log("Error visual clase", e); }
}
async function fetchMaterialName(key: string) {
try {
const res = await materialTypesApi.list(1, 100);
const data = (res as any).data || res;
const list = data.items || [];
const found = list.find((m: any) => m.key === key);
if (found) selectedMaterialDesc = found.description;
} catch (e) { console.log("Error visual material", e); }
}
// --- HANDLERS ---
function handleClientSelect(client: any) {
formData.client_id = client.id;
selectedClientName = client.name;
selectedClientStatus = client.is_active ?? true;
}
function handleClassSelect(item: any) {
formData.part_class = item.class_code;
selectedClassDesc = item.description_es || item.description_en || "";
}
function handleMaterialSelect(item: any) {
formData.material_type = item.key;
selectedMaterialDesc = item.description;
}
function handleUOMSelect(item: any) {
formData.unit_of_measure = item.code;
}
function handleAltUOMSelect(item: any) {
formData.alternate_unit_measure = item.code;
}
// --- SUBMIT ---
async function handleSubmit() {
error = null;
const activeCompanyId = companyStore.activeCompany?.id;
if (!activeCompanyId) { error = 'No hay una compañía activa seleccionada'; return; }
if (!formData.client_id) { error = 'Debe seleccionar un Cliente (Pestaña Otros)'; return; }
if (!formData.part_number.trim()) { error = 'Número de Parte requerido'; return; }
loading = true;
try {
const commonData = {
description_spanish: formData.description_spanish || null,
description_english: formData.description_english || null,
part_class: formData.part_class || null,
material_type: formData.material_type || null, // Corregido
country_of_origin: formData.country_of_origin || 'MEX',
unit_of_measure: formData.unit_of_measure,
fraction: formData.fraction || null,
us_fraction: formData.us_fraction || null,
unit_weight: Number(formData.unit_weight) || 0,
weight_type: formData.weight_type || 'KG',
supplier: formData.supplier || null,
fda_key: formData.fda_key || null,
fcc_key: formData.fcc_key || null,
eccn: formData.eccn || null,
license_code: formData.license_code || null,
export_code: formData.export_code || null,
exclusion_symbol: formData.exclusion_symbol || null,
alternate_unit_measure: formData.alternate_unit_measure || null,
part_photo: formData.part_photo || null,
added_value: Number(formData.added_value) || 0,
unit_cost: Number(formData.unit_cost) || 0,
currency_key: formData.currency_key || 'USD',
commercial_part_number: formData.commercial_part_number || null,
is_active: formData.is_active
};
if (isEdit && id) {
const response = await partsApi.update(id, commonData, activeCompanyId);
if (response.error) throw new Error(response.error);
} else {
const createData: PartCreate = {
...commonData,
company_id: activeCompanyId,
client_id: Number(formData.client_id),
part_number: formData.part_number
};
const response = await partsApi.create(createData, activeCompanyId);
if (response.error) throw new Error(response.error);
}
goto('/dashboard/goods/parts');
} catch (e: any) {
console.error("Error en el guardado:", e);
error = e.message || 'Error inesperado al guardar';
} finally {
loading = false;
}
}
let type = 'fa';
</script>
<div class="w-full mx-auto max-w-6xl py-6 px-4 space-y-6 pb-48">
<div class="flex items-center gap-4">
<Button variant="outline" size="icon" href="/dashboard/goods/parts">
<ArrowLeft class="h-4 w-4" />
</Button>
<div>
<h1 class="text-2xl font-bold tracking-tight">{title}</h1>
<p class="text-muted-foreground">Gestión detallada de números de parte.</p>
</div>
</div>
{#if error}
<div class="p-4 rounded-md bg-destructive/10 text-destructive border border-destructive/20 text-sm font-medium">
⚠️ {error}
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-6">
<Card.Root>
<Card.Content class="p-6">
<Tabs.Root value="general" class="w-full">
<div class="min-h-[500px]">
<Tabs.Content value="general" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="grid gap-2">
<Label for="part_number" class="text-base font-semibold required">Número de Parte</Label>
<Input id="part_number" bind:value={formData.part_number} disabled={isEdit} maxlength={50} class="text-lg font-mono" placeholder="Ej: 123-ABC-456"/>
</div>
<div class="space-y-3 p-4 border rounded-lg bg-slate-50 dark:bg-slate-900/30">
<h3 class="font-medium text-sm text-muted-foreground flex items-center gap-2">
<FileText class="h-4 w-4"/> Descripción
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="desc_es">Español</Label>
<Textarea id="desc_es" bind:value={formData.description_spanish} maxlength={500} rows={3}/>
</div>
<div class="grid gap-2">
<Label for="desc_en">Inglés</Label>
<Textarea id="desc_en" bind:value={formData.description_english} maxlength={500} rows={3}/>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="part_class_client" class="required">Clase (Anexo 24 - Cliente)</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<div class="absolute left-3 top-2.5 text-muted-foreground">
<Package class="h-4 w-4" />
</div>
<Input
id="part_class_client"
bind:value={formData.part_class}
maxlength={8}
placeholder="Seleccione Clase..."
class="pl-9 font-mono cursor-pointer"
readonly
onclick={() => showClassModal = true}
/>
</div>
<Button variant="outline" size="icon" type="button" onclick={() => showClassModal = true}>
<Search class="h-4 w-4" />
</Button>
</div>
{#if selectedClassDesc}
<div class="text-xs text-primary font-medium px-1 animate-in fade-in flex items-center gap-1">
<Tag class="h-3 w-3" />
{selectedClassDesc}
</div>
{/if}
</div>
<div class="grid gap-2">
<Label for="part_class_material">Tipo Material (Catálogo General)</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<div class="absolute left-3 top-2.5 text-muted-foreground">
<Layers class="h-4 w-4" />
</div>
<Input
id="part_class_material"
bind:value={formData.material_type}
maxlength={8}
placeholder="Seleccione Material..."
class="pl-9 font-mono cursor-pointer"
readonly
onclick={() => showMaterialModal = true}
/>
</div>
<Button variant="outline" size="icon" type="button" onclick={() => showMaterialModal = true}>
<Search class="h-4 w-4" />
</Button>
</div>
{#if selectedMaterialDesc}
<div class="text-xs text-blue-600 font-medium px-1 animate-in fade-in flex items-center gap-1">
<Layers class="h-3 w-3" />
{selectedMaterialDesc}
</div>
{/if}
<p class="text-[10px] text-muted-foreground">Opcional: Clasificación adicional por tipo de material.</p>
</div>
<!-- <div class="grid gap-2">
<Label for="country">País Origen (ISO)</Label>
<Input id="country" bind:value={formData.country_of_origin} maxlength={3} placeholder="MEX" class="font-mono"/>
</div> -->
<div class="grid gap-2">
<Label for="uom" class="required">Unidad de Medida (TIGIE)</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<div class="absolute left-3 top-2.5 text-muted-foreground">
<Scale class="h-4 w-4" />
</div>
<Input
id="uom"
bind:value={formData.unit_of_measure}
placeholder="Seleccione..."
class="pl-9 font-mono cursor-pointer"
readonly
onclick={() => showUOMModal = true}
/>
</div>
<Button variant="outline" size="icon" type="button" onclick={() => showUOMModal = true}>
<Search class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div class="p-4 border rounded-lg bg-green-50/50 dark:bg-green-900/10 space-y-4">
<h3 class="font-medium text-sm text-green-800 dark:text-green-300 flex items-center gap-2">
<DollarSign class="h-4 w-4"/> Costos, valores y peso unitario
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid gap-2">
<Label for="currency">Tipos de Moneda</Label>
<Select.Root type="single" bind:value={formData.currency_key}>
<Select.Trigger id="currency">{formData.currency_key}</Select.Trigger>
<Select.Content>
<Select.Item value="USD">Dólares (USD)</Select.Item>
<Select.Item value="MXP">Pesos (MXP)</Select.Item>
<Select.Item value="EUR">Euros (EUR)</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="unit_cost">Costo Unitario</Label>
<div class="relative">
<span class="absolute left-3 top-2.5 text-muted-foreground">$</span>
<Input type="number" step="0.0001" id="unit_cost" bind:value={formData.unit_cost} class="pl-7" />
</div>
</div>
<div class="grid gap-2">
<Label for="unit_weight">Peso Unitario</Label>
<div class="flex gap-2">
<Input type="number" step="0.0001" id="unit_weight" bind:value={formData.unit_weight} placeholder="0.0000" />
<Select.Root type="single" bind:value={formData.weight_type}>
<Select.Trigger class="w-[100px]">{formData.weight_type}</Select.Trigger>
<Select.Content>
<Select.Item value="KG">KG</Select.Item>
<Select.Item value="LB">LB</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
</div>
</div>
<div class="space-y-3 p-4 border rounded-lg">
<Label class="font-semibold">Tipo Valor Agregado</Label>
<div class="flex flex-wrap gap-6">
<div class="flex items-center space-x-2">
<input type="radio" id="va_ext" name="va_type" value="USD" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
<Label for="va_ext" class="font-normal cursor-pointer">Extranjera (Dls)</Label>
</div>
<div class="flex items-center space-x-2">
<input type="radio" id="va_nac" name="va_type" value="MXP" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
<Label for="va_nac" class="font-normal cursor-pointer">Nacional (Pesos)</Label>
</div>
<div class="flex items-center space-x-2">
<input type="radio" id="va_pct" name="va_type" value="PERCENT" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
<Label for="va_pct" class="font-normal cursor-pointer">Porcentaje</Label>
</div>
</div>
<div class="grid gap-2 mt-2">
<div class="relative max-w-xs">
{#if formData.value_added_type === 'PERCENT'}
<span class="absolute right-3 top-2.5 text-muted-foreground">%</span>
{:else}
<span class="absolute left-3 top-2.5 text-muted-foreground">$</span>
{/if}
<Input type="number" step="0.0001" id="added_value" bind:value={formData.added_value} class={formData.value_added_type === 'PERCENT' ? 'pr-7' : 'pl-7'} placeholder="0.00"/>
</div>
</div>
</div>
<div class="grid gap-2 pt-4 border-t">
<Label for="frac_us">Fracción Americana (HTS)</Label>
<Input id="frac_us" bind:value={formData.us_fraction} maxlength={16} placeholder="Ej: 8501.10.00" />
</div>
</Tabs.Content>
<Tabs.Content value="opciones" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="space-y-4 p-4 border rounded-lg bg-card">
<h3 class="font-medium text-sm text-muted-foreground">Configuración General</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label for="fcc">Clave FCC</Label>
<Input id="fcc" bind:value={formData.fcc_key} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="supplier">Proveedor (Textil)</Label>
<Input id="supplier" bind:value={formData.supplier} maxlength={14} />
</div>
</div>
<div class="grid gap-2">
<Label for="photo" class="flex items-center gap-2"><ImageIcon class="h-4 w-4"/> URL Imagen de la parte</Label>
<Input id="photo" bind:value={formData.part_photo} maxlength={255} placeholder="https://..." />
</div>
</div>
</Tabs.Content>
<Tabs.Content value="opcionales2" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="p-4 border rounded-lg space-y-4">
<h3 class="font-medium text-sm text-muted-foreground">FDA</h3>
<div class="grid gap-2">
<Label for="fda">Clave FDA</Label>
<Input id="fda" bind:value={formData.fda_key} maxlength={20} />
</div>
</div>
</Tabs.Content>
<Tabs.Content value="otros" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="grid gap-2">
<Label for="client_id" class="required">Cliente Asignado</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<UserCheck class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="number"
id="client_id"
bind:value={formData.client_id}
class="pl-9 font-mono"
placeholder="Seleccione un cliente..."
readonly
onclick={() => showClientModal = true}
/>
</div>
<Button variant="outline" class="shrink-0" type="button" onclick={() => showClientModal = true}>
<Search class="h-4 w-4 mr-2" /> Buscar
</Button>
</div>
{#if selectedClientName}
<div class="flex items-center gap-2 mt-1 px-3 py-2 bg-slate-50 dark:bg-slate-900/50 border rounded-md text-sm">
<span class="font-semibold text-primary">{selectedClientName}</span>
{#if selectedClientStatus}
<span class="text-green-600 flex items-center gap-1 text-xs font-medium"><CheckCircle2 class="h-3 w-3"/> Activo</span>
{:else}
<span class="text-red-600 flex items-center gap-1 text-xs font-medium"><XCircle class="h-3 w-3"/> Inactivo</span>
{/if}
</div>
{/if}
</div>
<div class="p-4 border rounded-lg bg-slate-50 dark:bg-slate-900/30 space-y-4">
<h3 class="font-medium text-sm text-muted-foreground flex items-center gap-2">
<Package class="h-4 w-4"/> Datos Comerciales (Factura)
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="comm_pn">Número de Parte Comercial</Label>
<Input id="comm_pn" bind:value={formData.commercial_part_number} maxlength={70} placeholder="Código en factura" />
</div>
<div class="grid gap-2">
<Label for="uom" class="required">Unidad de medida alternativa</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<div class="absolute left-3 top-2.5 text-muted-foreground">
<Scale class="h-4 w-4" />
</div>
<Input
id="uom"
bind:value={formData.alternate_unit_measure}
placeholder="Seleccione..."
class="pl-9 font-mono cursor-pointer"
readonly
onclick={() => showAltUOMModal = true}
/>
</div>
<Button variant="outline" size="icon" type="button" onclick={() => showAltUOMModal = true}>
<Search class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
<div class="p-4 border rounded-lg space-y-4">
<h3 class="font-medium text-sm text-muted-foreground">Datos Regulatorios</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="fraction_mx" class="required">Fracción Arancelaria (MX)</Label>
<Input id="fraction_mx" bind:value={formData.fraction} maxlength={10} />
</div>
<div class="grid gap-2">
<Label for="eccn">ECCN</Label>
<Input id="eccn" bind:value={formData.eccn} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="lic_code">License Code</Label>
<Input id="lic_code" bind:value={formData.license_code} maxlength={3} />
</div>
<div class="grid gap-2">
<Label for="exp_code">Export Code</Label>
<Input id="exp_code" bind:value={formData.export_code} maxlength={2} />
</div>
<div class="grid gap-2">
<Label for="exc_sym">Símbolo de Exclusión</Label>
<Input id="exc_sym" bind:value={formData.exclusion_symbol} maxlength={19} />
</div>
</div>
</div>
<div class="flex items-center gap-3 p-4 border rounded-lg bg-card">
<Switch id="is_active" bind:checked={formData.is_active} disabled={loading} />
<Label for="is_active">Parte Activa en Sistema</Label>
</div>
</Tabs.Content>
</div>
<Tabs.List class="grid grid-cols-4 fixed bottom-24 left-1/2 -translate-x-1/2 w-[95%] max-w-2xl z-40 shadow-2xl bg-background border p-1 rounded-xl">
<Tabs.Trigger value="general" class="flex gap-2 items-center justify-center"><Package class="h-4 w-4 hidden sm:block" /> General</Tabs.Trigger>
<Tabs.Trigger value="opciones" class="flex gap-2 items-center justify-center"><Settings class="h-4 w-4 hidden sm:block" /> Opciones</Tabs.Trigger>
<Tabs.Trigger value="opcionales2" class="flex gap-2 items-center justify-center"><FileText class="h-4 w-4 hidden sm:block" /> Opcionales 2</Tabs.Trigger>
<Tabs.Trigger value="otros" class="flex gap-2 items-center justify-center"><Settings class="h-4 w-4 hidden sm:block" /> Otros</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
</Card.Content>
</Card.Root>
<div class="fixed bottom-0 right-0 left-0 md:left-64 p-4 border-t bg-background/95 backdrop-blur z-50">
<div class="max-w-6xl mx-auto flex justify-end gap-4">
<Button variant="ghost" href="/dashboard/goods/parts" disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading} class="min-w-[140px]">
{#if loading}<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />{:else}<Save class="mr-2 h-4 w-4" />{/if}
{isEdit ? 'Actualizar' : 'Guardar'}
</Button>
</div>
</div>
</form>
</div>
<ClientSelectorDialog
bind:open={showClientModal}
onSelect={handleClientSelect}
/>
<ClassSelectorDialog
bind:open={showClassModal}
onSelect={handleClassSelect}
/>
<MaterialTypeSelectorDialog
bind:open={showMaterialModal}
onSelect={handleMaterialSelect}
/>
<UnitMeasureSelectorDialog
bind:open={showUOMModal}
onSelect={handleUOMSelect}
/>
<UnitMeasureSelectorDialog
bind:open={showAltUOMModal}
onSelect={handleAltUOMSelect}
/>
<style>
:global(.required::after) {
content: " *";
color: hsl(var(--destructive));
}
</style>
<PartForm partId={id} formType={type} />