diff --git a/backend/alembic/versions/b2c3d4e5f6a7_drop_client_id_from_parts.py b/backend/alembic/versions/b2c3d4e5f6a7_drop_client_id_from_parts.py new file mode 100644 index 00000000..172e2e19 --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6a7_drop_client_id_from_parts.py @@ -0,0 +1,28 @@ +"""drop client_id column from parts + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-04-28 11:50:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "b2c3d4e5f6a7" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("parts", "client_id", schema="a76") + + +def downgrade() -> None: + op.add_column( + "parts", + sa.Column("client_id", sa.Integer(), nullable=True), + schema="a76", + ) diff --git a/backend/alembic/versions/c3d4e5f6a7b8_add_action_to_doda_alta_log.py b/backend/alembic/versions/c3d4e5f6a7b8_add_action_to_doda_alta_log.py new file mode 100644 index 00000000..e28c1542 --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_add_action_to_doda_alta_log.py @@ -0,0 +1,29 @@ +"""add action column to doda_alta_log + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-04-28 13:20:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c3d4e5f6a7b8" +down_revision = "b2c3d4e5f6a7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "doda_alta_log", + sa.Column("action", sa.String(length=20), nullable=True), + schema="a76", + ) + + +def downgrade() -> None: + op.drop_column("doda_alta_log", "action", schema="a76") diff --git a/backend/api/v1/modules/a76/expediente_archivos/routes.py b/backend/api/v1/modules/a76/expediente_archivos/routes.py index c553cfee..5e753b8e 100644 --- a/backend/api/v1/modules/a76/expediente_archivos/routes.py +++ b/backend/api/v1/modules/a76/expediente_archivos/routes.py @@ -60,11 +60,17 @@ def list_expediente_archivos( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), search: str = Query(None), + status: str = Query(None), + rfc_consulta: str = Query(None), + e_document: str = Query(None), 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 ExpedienteArchivoService.list(db, company_id, tenant_id, page, page_size, search) + return ExpedienteArchivoService.list( + db, company_id, tenant_id, page, page_size, + search=search, status=status, rfc_consulta=rfc_consulta, e_document=e_document + ) @router.get("/{record_id}", response_model=ExpedienteArchivoResponseDTO) diff --git a/backend/api/v1/modules/a76/expediente_archivos/service.py b/backend/api/v1/modules/a76/expediente_archivos/service.py index a2ff0587..7af953cc 100644 --- a/backend/api/v1/modules/a76/expediente_archivos/service.py +++ b/backend/api/v1/modules/a76/expediente_archivos/service.py @@ -75,6 +75,9 @@ class ExpedienteArchivoService: page: int = 1, page_size: int = 50, search: Optional[str] = None, + status: Optional[str] = None, + rfc_consulta: Optional[str] = None, + e_document: Optional[str] = None, ) -> ExpedienteArchivoListResponse: query = ( db.query(ExpedienteArchivo) @@ -84,11 +87,22 @@ class ExpedienteArchivoService: ExpedienteArchivo.deleted_at.is_(None), ) ) + if status: + query = query.filter(ExpedienteArchivo.status == status) + if rfc_consulta: + query = query.filter(ExpedienteArchivo.rfc_consulta.ilike(f"%{rfc_consulta}%")) + if e_document: + query = query.filter(ExpedienteArchivo.e_document.ilike(f"%{e_document}%")) if search: like = f"%{search}%" query = query.filter( - ExpedienteArchivo.e_document.ilike(like) - | ExpedienteArchivo.tipo_documento.ilike(like) + or_( + ExpedienteArchivo.e_document.ilike(like), + ExpedienteArchivo.tipo_documento.ilike(like), + ExpedienteArchivo.rfc_consulta.ilike(like), + ExpedienteArchivo.num_operacion.ilike(like), + ExpedienteArchivo.nombre_archivo.ilike(like), + ) ) total = query.count() items = query.order_by(ExpedienteArchivo.id.desc()).offset((page - 1) * page_size).limit(page_size).all() diff --git a/backend/api/v1/modules/a76/general_catalogs/concepts/routes.py b/backend/api/v1/modules/a76/general_catalogs/concepts/routes.py index f66664e4..dda46e4b 100644 --- a/backend/api/v1/modules/a76/general_catalogs/concepts/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/concepts/routes.py @@ -11,6 +11,7 @@ router = TenantCRUDRoutes( tags=["a76.general_catalogs.concepts"], resource_name="Concept", enable_list=True, + enable_filters=True, list_permissions=["cat_concepts.view"], get_permissions=["cat_concepts.view"], create_permissions=["cat_concepts.create"], diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py index 72ef6aab..3ad314d6 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field class DodaAltaLogCreateDTO(BaseModel): doda_id: Optional[int] = None variant: Optional[str] = Field(None, max_length=10) + action: Optional[str] = Field(None, max_length=20) responsible: Optional[str] = Field(None, max_length=20) patent: Optional[str] = Field(None, max_length=10) dispatch_customs: Optional[str] = Field(None, max_length=10) @@ -29,6 +30,7 @@ class DodaAltaLogResponseDTO(BaseModel): id: int doda_id: Optional[int] = None variant: Optional[str] = None + action: Optional[str] = None responsible: Optional[str] = None patent: Optional[str] = None dispatch_customs: Optional[str] = None diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py index 581c5f55..085213ed 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py @@ -23,6 +23,7 @@ class DodaAltaLog(Base, TenantScopedMixin, TimestampMixin): # Tipo de alta (doda / pita) variant: Mapped[str | None] = mapped_column(String(10), nullable=True) + action: Mapped[str | None] = mapped_column(String(20), nullable=True) # Datos copiados del DODA al momento del envío (para historial) responsible: Mapped[str | None] = mapped_column(String(20), nullable=True) diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py index f4b83615..ef325ab8 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py @@ -115,6 +115,7 @@ class DodaAltaLogService: tenant_id: int, variant: str, ext_result: dict, + action: str = "alta", ) -> DodaAltaLog: """ Crea un registro de log a partir de la respuesta del servicio externo de alta. @@ -127,6 +128,7 @@ class DodaAltaLogService: dto = DodaAltaLogCreateDTO( doda_id=doda.id, variant=variant, + action=action, responsible=doda.responsible, patent=doda.patent, dispatch_customs=doda.dispatch_customs, diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py index ffca5a57..2a539bdb 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import json import logging from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -16,6 +17,7 @@ from core.storage_s3 import get_object_bytes, object_exists from api.v1.modules.a76.customs_brokers import models as cb_models from .models import Doda, DodaContainer, DodaAmericanPedimento, DodaPedimento +from .alta_log_models import DodaAltaLog from .payload_normalizer import ( normalize_aduana_despacho, normalize_aduana_seccion, @@ -487,6 +489,40 @@ class DodaAltaService: # Build full payload # ------------------------------------------------------------------ + def _latest_alta_log( + self, + doda_id: int, + tenant_id: int, + company_id: int, + variant: str, + ) -> Optional[DodaAltaLog]: + return ( + self.db.query(DodaAltaLog) + .filter( + DodaAltaLog.doda_id == doda_id, + DodaAltaLog.tenant_id == tenant_id, + DodaAltaLog.company_id == company_id, + DodaAltaLog.variant == variant, + DodaAltaLog.deleted_at.is_(None), + ) + .order_by(DodaAltaLog.id.desc()) + .first() + ) + + @staticmethod + def _extract_numero_transaccion(log_record: DodaAltaLog) -> str: + raw_json = (log_record.result_json or "").strip() + if raw_json: + try: + parsed = json.loads(raw_json) + for key in ("numero_transaccion", "transaction_number"): + value = parsed.get(key) + if value: + return str(value).strip() + except Exception: + logger.warning("No se pudo parsear result_json de DodaAltaLog id=%s", log_record.id) + return "" + def build_alta_payload( self, doda_id: int, @@ -549,3 +585,59 @@ class DodaAltaService: } return payload + + def build_consulta_payload( + self, + doda_id: int, + tenant_id: int, + company_id: int, + variant: str = "doda", + user_email: str = "", + ) -> Dict[str, Any]: + payload = self.build_alta_payload( + doda_id=doda_id, + tenant_id=tenant_id, + company_id=company_id, + variant=variant, + user_email=user_email, + ) + latest_log = self._latest_alta_log(doda_id, tenant_id, company_id, variant) + if not latest_log: + raise ValueError( + "No existe un alta DODA previa para construir la consulta (falta task/log)." + ) + numero_transaccion = self._extract_numero_transaccion(latest_log) + if not numero_transaccion: + raise ValueError( + "No se encontro numero_transaccion en el ultimo resultado de alta DODA." + ) + payload["numero_transaccion"] = numero_transaccion + return payload + + def build_eliminar_payload( + self, + doda_id: int, + tenant_id: int, + company_id: int, + variant: str = "doda", + user_email: str = "", + ) -> Dict[str, Any]: + payload = self.build_alta_payload( + doda_id=doda_id, + tenant_id=tenant_id, + company_id=company_id, + variant=variant, + user_email=user_email, + ) + latest_log = self._latest_alta_log(doda_id, tenant_id, company_id, variant) + if not latest_log: + raise ValueError( + "No existe un alta DODA previa para construir la eliminacion." + ) + numero_integracion = (latest_log.integration_number or "").strip() + if not numero_integracion: + raise ValueError( + "No se encontro numero_integracion en el historial de alta DODA." + ) + payload["numero_integracion"] = numero_integracion + return payload diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py index a972bb66..85970890 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py @@ -17,6 +17,10 @@ class DodaExternalService: Endpoints: POST {base_url}/api/v1/doda/alta GET {base_url}/api/v1/doda/alta-status/{task_id} + POST {base_url}/api/v1/doda/consulta + GET {base_url}/api/v1/doda/consulta-status/{task_id} + POST {base_url}/api/v1/doda/eliminar + GET {base_url}/api/v1/doda/eliminar-status/{task_id} Usa COVE_API_URL como URL base (la misma variable que COVE y Expediente). """ @@ -61,3 +65,43 @@ class DodaExternalService: response = client.get(url) response.raise_for_status() return response.json() + + def post_consulta(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Envia consulta DODA y retorna {task_id, status, message}.""" + url = f"{self.base_url.rstrip('/')}/api/v1/doda/consulta" + with httpx.Client( + timeout=httpx.Timeout(60.0, connect=10.0), verify=self.verify_ssl + ) as client: + response = client.post(url, json=payload) + response.raise_for_status() + return response.json() + + def get_consulta_status(self, task_id: str) -> Dict[str, Any]: + """Consulta el estado de una tarea de consulta DODA.""" + url = f"{self.base_url.rstrip('/')}/api/v1/doda/consulta-status/{task_id}" + with httpx.Client( + timeout=httpx.Timeout(30.0, connect=10.0), verify=self.verify_ssl + ) as client: + response = client.get(url) + response.raise_for_status() + return response.json() + + def post_eliminar(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Envia eliminacion DODA y retorna {task_id, status, message}.""" + url = f"{self.base_url.rstrip('/')}/api/v1/doda/eliminar" + with httpx.Client( + timeout=httpx.Timeout(60.0, connect=10.0), verify=self.verify_ssl + ) as client: + response = client.post(url, json=payload) + response.raise_for_status() + return response.json() + + def get_eliminar_status(self, task_id: str) -> Dict[str, Any]: + """Consulta el estado de una tarea de eliminacion DODA.""" + url = f"{self.base_url.rstrip('/')}/api/v1/doda/eliminar-status/{task_id}" + with httpx.Client( + timeout=httpx.Timeout(30.0, connect=10.0), verify=self.verify_ssl + ) as client: + response = client.get(url) + response.raise_for_status() + return response.json() diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py index 7ed58c89..05b18223 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py @@ -60,6 +60,47 @@ from core.security import get_current_user, validate_access_to_resource logger = logging.getLogger(__name__) + +def _coalesce_external_result_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + result = payload.get("result") + if isinstance(result, dict): + return result + return payload + + +def _apply_consulta_success_to_doda( + doda: Doda, + payload: Dict[str, Any], +) -> None: + source = _coalesce_external_result_payload(payload) + mapping = { + "integration_number": "integration_number", + "numero_integracion": "integration_number", + "transaction_number": "transaction_number", + "numero_transaccion": "transaction_number", + "sat_digital_seal": "sat_digital_seal", + "sello_digital_sat": "sat_digital_seal", + "sat_certificate": "sat_certificate", + "certificado_sat": "sat_certificate", + "serial_number": "serial_number", + "numero_serie": "serial_number", + "electronic_signature": "electronic_signature", + "firma_electronica": "electronic_signature", + "original_chain": "original_chain", + "cadena_original": "original_chain", + "sat_original_chain": "sat_original_chain", + "cadena_original_sat": "sat_original_chain", + "linq_sat_qr": "linq_sat_qr", + "link_sat_qr": "linq_sat_qr", + "xml_doda_sent_path": "xml_doda_sent_path", + "xml_doda_response_path": "xml_doda_response_path", + "status": "status", + } + for src_key, dst_attr in mapping.items(): + value = source.get(src_key) + if value is not None and value != "": + setattr(doda, dst_attr, str(value)) + # Router independiente para rutas literales (deben registrarse antes que /{id}) router = APIRouter(prefix="/doda", tags=["doda"]) @@ -82,7 +123,7 @@ async def export_doda_list( """ Listado al estilo legacy: filtra `doda_date` (YYYYMMDD) entre inicio y fin. """ - tenant_id = int(validate_access_to_resource(db, company_id, current_user)) + tenant_id = int(validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"])) try: d0, d1, fmt, mode = parse_export_params( date_from, date_to, file_format, date_mode @@ -126,7 +167,7 @@ async def export_doda_pedimentos( Reporte por DODA seleccionado: columnas alineadas al listado de pedimentos (PATENTE, DOCUMENTO, COVE, etc.). Si no hay líneas, se devuelve el archivo solo con encabezados. """ - tenant_id = int(validate_access_to_resource(db, company_id, current_user)) + tenant_id = int(validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"])) try: fmt = parse_pedimento_export_format(file_format) except ValueError as e: @@ -168,6 +209,11 @@ _crud_router = TenantCRUDRoutes( id_name="doda_id", enable_list=True, enable_filters=True, + list_permissions=["cat_doda.view"], + get_permissions=["cat_doda.view"], + create_permissions=["cat_doda.create"], + update_permissions=["cat_doda.edit"], + delete_permissions=["cat_doda.delete"], ).router router.include_router(_crud_router) @@ -183,7 +229,7 @@ async def get_doda_detail( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) doda = DodaService.get_by_id(db, doda_id, tenant_id, company_id) if not doda: raise HTTPException( @@ -633,6 +679,7 @@ async def post_doda_alta( tenant_id=int(tenant_id), variant=variant, ext_result=result, + action="alta", ) except Exception: logger.exception("Error persistiendo DodaAltaLog para doda_id=%s", doda_id) @@ -668,6 +715,269 @@ async def get_doda_alta_status( ) from exc +@router.post( + "/{doda_id}/consulta", + summary="Enviar Consulta DODA al servicio externo (asíncrono)", + tags=["doda-alta"], +) +async def post_doda_consulta( + doda_id: int, + company_id: int = Query(..., description="Company ID"), + variant: str = Query("doda", description="Tipo de alta: 'doda' o 'pita'"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +) -> Dict[str, Any]: + tenant_id = validate_access_to_resource(db, company_id, current_user) + user_email = ( + current_user.get("email") + or current_user.get("preferred_username") + or "" + ) + service = DodaAltaService(db) + try: + payload = service.build_consulta_payload( + doda_id=doda_id, + tenant_id=int(tenant_id), + company_id=company_id, + variant=variant, + user_email=user_email, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + ext_result: Dict[str, Any] + try: + ext = DodaExternalService() + ext_result = ext.post_consulta(payload) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except Exception as exc: + logger.exception("Error al enviar consulta DODA al servicio externo: doda_id=%s", doda_id) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Error al contactar el servicio DODA externo (consulta): {exc}", + ) from exc + + doda_record = DodaService.get_by_id(db, doda_id, int(tenant_id), company_id) + if doda_record: + try: + DodaAltaLogService.create_from_alta_result( + db=db, + doda=doda_record, + company_id=company_id, + tenant_id=int(tenant_id), + variant=variant, + ext_result=ext_result, + action="consulta", + ) + except Exception: + logger.exception("Error persistiendo DodaAltaLog(consulta) para doda_id=%s", doda_id) + return ext_result + + +@router.get( + "/consulta-status/{task_id}", + summary="Consultar estado de tarea de Consulta DODA", + tags=["doda-alta"], +) +async def get_doda_consulta_status( + task_id: str, + current_user: dict = Depends(get_current_user), +) -> Any: + try: + ext = DodaExternalService() + return ext.get_consulta_status(task_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except Exception as exc: + logger.exception("Error consultando consulta-status DODA task_id=%s", task_id) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Error al consultar el estado de la consulta DODA: {exc}", + ) from exc + + +@router.post( + "/{doda_id}/consulta-apply/{task_id}", + summary="Aplicar resultado exitoso de consulta DODA al registro local", + tags=["doda-alta"], +) +async def post_doda_consulta_apply( + doda_id: int, + task_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +) -> Dict[str, Any]: + tenant_id = int(validate_access_to_resource(db, company_id, current_user)) + doda_record = DodaService.get_by_id(db, doda_id, tenant_id, company_id) + if not doda_record: + raise HTTPException(status_code=404, detail="DODA no encontrado.") + + try: + ext = DodaExternalService() + status_payload = ext.get_consulta_status(task_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except Exception as exc: + logger.exception("Error al consultar consulta-status para aplicar: task_id=%s", task_id) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Error al consultar el estado de la consulta DODA: {exc}", + ) from exc + + task_state = str(status_payload.get("state") or status_payload.get("status") or "").upper() + if task_state != "SUCCESS": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="La tarea de consulta aún no está en estado SUCCESS.", + ) + + try: + _apply_consulta_success_to_doda(doda_record, status_payload) + if not (doda_record.status or "").strip(): + doda_record.status = "VALIDADO" + db.add(doda_record) + db.commit() + db.refresh(doda_record) + except Exception as exc: + db.rollback() + logger.exception("Error aplicando consulta-status al DODA id=%s", doda_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"No se pudo aplicar el resultado de consulta al DODA: {exc}", + ) from exc + + return { + "message": "Resultado de consulta aplicado correctamente.", + "doda_id": doda_id, + "task_id": task_id, + "state": task_state, + } + + +@router.post( + "/{doda_id}/eliminar", + summary="Enviar Eliminación DODA al servicio externo (asíncrono)", + tags=["doda-alta"], +) +async def post_doda_eliminar( + doda_id: int, + company_id: int = Query(..., description="Company ID"), + variant: str = Query("doda", description="Tipo de alta: 'doda' o 'pita'"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +) -> Dict[str, Any]: + tenant_id = validate_access_to_resource(db, company_id, current_user) + user_email = ( + current_user.get("email") + or current_user.get("preferred_username") + or "" + ) + service = DodaAltaService(db) + try: + payload = service.build_eliminar_payload( + doda_id=doda_id, + tenant_id=int(tenant_id), + company_id=company_id, + variant=variant, + user_email=user_email, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + ext_result: Dict[str, Any] + try: + ext = DodaExternalService() + ext_result = ext.post_eliminar(payload) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except Exception as exc: + logger.exception("Error al enviar eliminación DODA al servicio externo: doda_id=%s", doda_id) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Error al contactar el servicio DODA externo (eliminación): {exc}", + ) from exc + + doda_record = DodaService.get_by_id(db, doda_id, int(tenant_id), company_id) + if doda_record: + try: + DodaAltaLogService.create_from_alta_result( + db=db, + doda=doda_record, + company_id=company_id, + tenant_id=int(tenant_id), + variant=variant, + ext_result=ext_result, + action="eliminar", + ) + except Exception: + logger.exception("Error persistiendo DodaAltaLog(eliminar) para doda_id=%s", doda_id) + + try: + doda_record.integration_number = None + doda_record.transaction_number = None + doda_record.status = "PENDIENTE" + doda_record.sat_digital_seal = None + doda_record.sat_certificate = None + doda_record.serial_number = None + doda_record.electronic_signature = None + doda_record.original_chain = None + doda_record.sat_original_chain = None + doda_record.linq_sat_qr = None + doda_record.xml_doda_sent_path = None + doda_record.xml_doda_response_path = None + db.add(doda_record) + db.commit() + except Exception: + db.rollback() + logger.exception("Error desprocesando DODA local tras eliminación id=%s", doda_id) + return ext_result + + +@router.get( + "/eliminar-status/{task_id}", + summary="Consultar estado de tarea de Eliminación DODA", + tags=["doda-alta"], +) +async def get_doda_eliminar_status( + task_id: str, + current_user: dict = Depends(get_current_user), +) -> Any: + try: + ext = DodaExternalService() + return ext.get_eliminar_status(task_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except Exception as exc: + logger.exception("Error consultando eliminar-status DODA task_id=%s", task_id) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Error al consultar el estado de la eliminación DODA: {exc}", + ) from exc + + # ============ DODA ALTA LOG CRUD ============ diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/service.py b/backend/api/v1/modules/a76/general_catalogs/doda/service.py index ee60e5e5..33172fd7 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/service.py @@ -37,6 +37,19 @@ logger = logging.getLogger(__name__) class DodaService: """Servicio para gestión de DODA""" + @staticmethod + def _ensure_editable_doda(doda: Optional[Doda]) -> None: + if not doda: + return + if (doda.integration_number or "").strip(): + raise HTTPException( + status_code=422, + detail=( + "El DODA ya fue generado (tiene número de integración). " + "Elimínelo primero para poder editarlo." + ), + ) + @staticmethod def _invalidate_report_after_mutation( db: Session, @@ -132,6 +145,7 @@ class DodaService: db_doda = DodaService.get_by_id(db, id, tenant_id, company_id) if not db_doda: return None + DodaService._ensure_editable_doda(db_doda) for key, value in doda_data.model_dump(exclude_unset=True).items(): setattr(db_doda, key, value) @@ -195,6 +209,7 @@ class DodaService: doda = db.query(Doda).filter(Doda.id == doda_id).first() if not doda: return None + DodaService._ensure_editable_doda(doda) cv = (container_data.container_value or "").strip() if not cv: @@ -259,6 +274,8 @@ class DodaService: ) if not db_container: return None + doda = db.get(Doda, doda_id) + DodaService._ensure_editable_doda(doda) for key, value in container_data.model_dump(exclude_unset=True).items(): setattr(db_container, key, value) @@ -308,6 +325,8 @@ class DodaService: ) if not db_container: raise HTTPException(status_code=404, detail="Contenedor no encontrado.") + doda = db.get(Doda, doda_id) + DodaService._ensure_editable_doda(doda) has_seals = bool(db_container.seals_detail) if not has_seals and db_container.seals: @@ -376,6 +395,7 @@ class DodaService: doda = db.query(Doda).filter(Doda.id == doda_id).first() if not doda: raise HTTPException(status_code=404, detail="DODA no encontrado.") + DodaService._ensure_editable_doda(doda) container = ( db.query(DodaContainer) @@ -387,6 +407,8 @@ class DodaService: ) if not container: raise HTTPException(status_code=404, detail="Contenedor no encontrado.") + doda = db.get(Doda, doda_id) + DodaService._ensure_editable_doda(doda) raw_value = (seal_data.seal_value or "").strip() if not raw_value: @@ -504,6 +526,7 @@ class DodaService: doda = db.query(Doda).filter(Doda.id == doda_id).first() if not doda: return None + DodaService._ensure_editable_doda(doda) tipo = (pedimento_data.american_pedimento_type or "").strip() valor = (pedimento_data.american_pedimento_value or "").strip() @@ -595,8 +618,9 @@ class DodaService: raise HTTPException( status_code=404, detail="Pedimento americano no encontrado." ) + doda = db.get(Doda, doda_id) + DodaService._ensure_editable_doda(doda) try: - doda = db.get(Doda, doda_id) db.delete(db_pedimento) db.commit() if doda: @@ -625,6 +649,7 @@ class DodaService: doda = db.query(Doda).filter(Doda.id == doda_id).first() if not doda: return None + DodaService._ensure_editable_doda(doda) max_line = ( db.query(DodaPedimento) @@ -681,8 +706,9 @@ class DodaService: ) if not db_pedimento: raise HTTPException(status_code=404, detail="Pedimento no encontrado.") + doda = db.get(Doda, doda_id) + DodaService._ensure_editable_doda(doda) try: - doda = db.get(Doda, doda_id) db.delete(db_pedimento) db.commit() if doda: diff --git a/backend/api/v1/modules/a76/general_catalogs/electronic_notices/routes.py b/backend/api/v1/modules/a76/general_catalogs/electronic_notices/routes.py index 576f4cf0..16c7629d 100644 --- a/backend/api/v1/modules/a76/general_catalogs/electronic_notices/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/electronic_notices/routes.py @@ -28,6 +28,11 @@ router = TenantCRUDRoutes( resource_name="Electronic Notice", enable_list=True, enable_filters=True, + list_permissions=["cat_notices.view"], + get_permissions=["cat_notices.view"], + create_permissions=["cat_notices.create"], + update_permissions=["cat_notices.edit"], + delete_permissions=["cat_notices.delete"], ).router @@ -43,7 +48,7 @@ async def get_notices_by_pedimento( current_user: dict = Depends(get_current_user), ): """Get all electronic notices for a specific pedimento""" - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_notices.view"]) notices = ElectronicNoticeService.get_by_pedimento( db, pedimento, tenant_id, company_id) return [ElectronicNoticeResponseDTO.model_validate(notice) for notice in notices] @@ -61,7 +66,7 @@ async def get_notices_by_status( current_user: dict = Depends(get_current_user), ): """Get all electronic notices with a specific status""" - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_notices.view"]) notices = ElectronicNoticeService.get_by_status( db, status, tenant_id, company_id) return [ElectronicNoticeResponseDTO.model_validate(notice) for notice in notices] diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py index a54f8cf7..a98f78eb 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py @@ -9,6 +9,7 @@ from sqlalchemy.exc import IntegrityError from fastapi import HTTPException import zlib import logging +import re from .models import TariffFraction from .dto import TariffFractionCreateDTO, TariffFractionUpdateDTO @@ -22,7 +23,42 @@ logger = logging.getLogger(__name__) class TariffFractionMapper: """Helper to map Sitar responses to Local domain objects""" - + + @staticmethod + def _digits_only(value: Optional[str]) -> str: + return re.sub(r"\D", "", (value or "").strip()) + + @staticmethod + def _format_mx_fraction(code: str) -> str: + if code.isdigit() and len(code) == 8: + return f"{code[:2]}.{code[2:4]}.{code[4:6]}.{code[6:]}" + if code.isdigit() and len(code) == 6: + return f"{code[:2]}.{code[2:4]}.{code[4:]}" + return code + + @staticmethod + def _format_usa_fraction(code: str) -> str: + if code.isdigit() and len(code) == 10: + return f"{code[:4]}.{code[4:6]}.{code[6:8]}.{code[8:]}" + if code.isdigit() and len(code) == 8: + return f"{code[:4]}.{code[4:6]}.{code[6:]}" + return code + + @staticmethod + def _normalized_pair(raw_code: Optional[str], raw_fraction: Optional[str], formatter) -> Tuple[str, str]: + """Return (code_without_separators, formatted_fraction).""" + code = TariffFractionMapper._digits_only(raw_code) + fraction = (raw_fraction or "").strip() + if not code: + code = TariffFractionMapper._digits_only(fraction) + if not fraction: + fraction = formatter(code) + elif "." not in fraction and "-" not in fraction: + fraction = formatter(TariffFractionMapper._digits_only(fraction)) + if not fraction: + fraction = formatter(code) + return code, fraction + @staticmethod def to_domain(fraccion: FraccionesResponse) -> TariffFraction: # Generate ID: Use SYSID if available, else composite hash of code + nico @@ -33,22 +69,9 @@ class TariffFractionMapper: unique_str = f"{fraccion.FRACCION}-{fraccion.NICO}" fake_id = zlib.crc32(unique_str.encode('utf-8')) - # UX Enhauncement: Sitar API returns empty strings for some fields. - # We fill them with fallbacks so the frontend table isn't 90% empty. - code_val = fraccion.FRACCION - - # Formatting Logic: if FRACCIONPUNTO is empty, try to format code_val - formatted_fraction = code_val - if fraccion.FRACCIONPUNTO: - formatted_fraction = fraccion.FRACCIONPUNTO - elif code_val and code_val.isdigit() and len(code_val) == 8: - # Standard 8 digit format: XX.XX.XX.XX - formatted_fraction = f"{code_val[:2]}.{code_val[2:4]}.{code_val[4:6]}.{code_val[6:]}" - elif code_val and code_val.isdigit() and len(code_val) == 6: - # 6 digit (subheading): XX.XX.XX - formatted_fraction = f"{code_val[:2]}.{code_val[2:4]}.{code_val[4:]}" - - fraction_val = formatted_fraction + code_val, fraction_val = TariffFractionMapper._normalized_pair( + fraccion.FRACCION, fraccion.FRACCIONPUNTO, TariffFractionMapper._format_mx_fraction + ) description_val = fraccion.DESCRIPCION if fraccion.DESCRIPCION else "(Sin descripción)" tf = TariffFraction( @@ -73,10 +96,15 @@ class TariffFractionMapper: @staticmethod def to_domain_usa(item: FraccionesUSAResponse) -> TariffFraction: """Map US Fraction to Domain""" + code_val, fraction_val = TariffFractionMapper._normalized_pair( + item.FRACCION_SIN_PUNTO, + item.FRACCION_CON_PUNTO or item.FRACCION_MOSTRAR, + TariffFractionMapper._format_usa_fraction, + ) return TariffFraction( id=item.CONSECUTIVO, - code=item.FRACCION_SIN_PUNTO or "", - fraction=item.FRACCION_CON_PUNTO or "", + code=code_val, + fraction=fraction_val, description=item.DESCRIPCION or "(Sin descripción)", nico=None, # Not applicable umt=item.UNIDADCANTIDAD, @@ -183,28 +211,27 @@ class TariffFractionService: # Map filters sitar_fraccion = None sitar_nico = None + sitar_description = None - # Default level logic - level_filter = 5 # Default legacy + # Legacy parity: base query is always Nivel = 5 unless caller explicitly requests another level. + level_filter = 5 if filters and filters.get("level") is not None: level_filter = filters["level"] - - # Allow disabling level filter explicitly + # UI compatibility: level -1 means "sin filtro de nivel". if level_filter == -1: level_filter = None if filters: if filters.get("search"): - term = filters["search"] - # Heuristic: if search starts with digit (after removing dots), treat as code/fraccion/nico - # This covers "0101", "01.01", "020691A" + term = str(filters["search"]).strip() + # Legacy-like behavior: + # - Numeric search targets fracción first. + # - Text search targets descripción. clean_term = term.replace(".", "") - if clean_term and clean_term[0].isdigit(): + if clean_term.isdigit(): sitar_fraccion = clean_term else: - # Attempt description search via API first - logger.info(f"Search term '{term}' identified as text. Attempting API description search.") - pass + sitar_description = term if filters.get("code"): sitar_fraccion = filters["code"] @@ -212,14 +239,8 @@ class TariffFractionService: sitar_fraccion = filters["fraction"] if filters.get("nico"): sitar_nico = filters["nico"] - - # Determine description filter - sitar_description = None - # Only use description if we didn't use it as code above - if filters and filters.get("search"): - clean_term = filters["search"].replace(".", "") - if not (clean_term and clean_term[0].isdigit()): - sitar_description = filters["search"] + if sitar_fraccion is not None: + sitar_fraccion = str(sitar_fraccion).replace(".", "").strip() # Note: Sitar search might not return total count. # We fetch page items. Pagination might be tricky if Sitar doesn't return total. @@ -239,6 +260,8 @@ class TariffFractionService: # Map items items = [TariffFractionMapper.to_domain(item) for item in sitar_items] + # Legacy browse behavior: keep table in ascending fracción order. + items = sorted(items, key=lambda row: ((row.code or ""), (row.nico or ""))) # Estimate total (Sitar service doesn't return total currently) # If we got full limit, assume there are more. @@ -289,8 +312,6 @@ class TariffFractionService: query = query.filter(TariffFraction.umt.ilike(f"%{filters['umt']}%")) total = query.count() - # Add deterministic sort order - query = query.order_by(TariffFraction.fraction) items = query.offset(skip).limit(limit).all() return items, total diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/dto.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/dto.py index c316dc08..b30d2d59 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/dto.py @@ -6,6 +6,7 @@ from datetime import datetime from typing import Optional, Any from pydantic import BaseModel, Field, ConfigDict, model_validator +import re class USTariffFractionCreateDTO(BaseModel): @@ -62,10 +63,20 @@ class USTariffFractionResponseDTO(BaseModel): 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("-", "") + fraction_raw = "" + if isinstance(data, dict): + fraction_raw = str(data.get("fraction") or "") + else: + fraction_raw = str(getattr(data, "fraction", "") or "") + + code = re.sub(r"[.\s-]", "", code_str) + fraction = fraction_raw.strip() or code_str + if "." not in fraction and "-" not in fraction: + only_digits = re.sub(r"[.\s-]", "", fraction) + if len(only_digits) == 10: + fraction = f"{only_digits[:4]}.{only_digits[4:6]}.{only_digits[6:8]}.{only_digits[8:]}" + elif len(only_digits) == 8: + fraction = f"{only_digits[:4]}.{only_digits[4:6]}.{only_digits[6:]}" if isinstance(data, dict): data["code"] = code diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py index e07bf0fd..a1cee9f5 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py @@ -28,6 +28,7 @@ def _sitar_row_to_us_response_payload(item: FraccionesUSAResponse) -> dict: return { "id": item.CONSECUTIVO, "code": canon, + "fraction": item.FRACCION_CON_PUNTO or item.FRACCION_MOSTRAR or canon, "prefix": item.FRACCION_SIN_PUNTO, "type_code": str(item.NIVEL) if item.NIVEL is not None else None, "ad_valorem": american_fraction_ad_valorem_from_row(item), diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py index 661bf01b..e5f8cfbf 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py @@ -128,7 +128,7 @@ class USTariffFractionService: ) total = query.count() - items = query.order_by(USTariffFraction.code).offset(skip).limit(limit).all() + items = query.offset(skip).limit(limit).all() return items, total diff --git a/backend/api/v1/modules/a76/general_catalogs/identifiers/routes.py b/backend/api/v1/modules/a76/general_catalogs/identifiers/routes.py index 16ff8736..ed326704 100644 --- a/backend/api/v1/modules/a76/general_catalogs/identifiers/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/identifiers/routes.py @@ -19,6 +19,7 @@ identifier_crud = TenantCRUDRoutes( tags=["Identifiers"], resource_name="Identifier", enable_list=True, + enable_filters=True, list_permissions=["cat_identifiers.view"], get_permissions=["cat_identifiers.view"], create_permissions=["cat_identifiers.create"], diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 3960adf8..2d110de1 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -1,7 +1,7 @@ import traceback from typing import Optional, List, Tuple from sqlalchemy.orm import Session -from sqlalchemy import func +from sqlalchemy import and_, func, or_ from core.exceptions import ErrorCollector, DuplicateResourceException from core.context import get_user_context from .common.mappers import clean_dict @@ -328,7 +328,6 @@ class InvoiceService: # Filtro por permisos granulares (allowed_types) if "allowed_types" in filters: - from sqlalchemy import or_, and_ allowed = filters["allowed_types"] if allowed is None: # Acceso global (admin o view_all) - no filtramos por tipos @@ -337,7 +336,6 @@ class InvoiceService: # Seguridad: Si el usuario NO tiene permisos para ningún tipo específico query = query.filter(models.InvoiceHeader.id == -1) else: - from sqlalchemy import func conditions = [] for op, inv in allowed: # Aseguramos comparación insensible a mayúsculas para mayor robustez con la DB diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py index 6c4cf830..1589e61c 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py @@ -325,7 +325,6 @@ def insert_valid_rows(self, job_id: str): new_part = Part( tenant_id=tenant_id, company_id=company_id, - client_id=company_id, **data, ) session.add(new_part) diff --git a/backend/api/v1/modules/a76/parts/dto.py b/backend/api/v1/modules/a76/parts/dto.py index a6815a01..9320e585 100644 --- a/backend/api/v1/modules/a76/parts/dto.py +++ b/backend/api/v1/modules/a76/parts/dto.py @@ -145,7 +145,6 @@ class InvDataDTO(BaseModel): class PartBase(BaseModel): - client_id: Optional[int] = None part_number: str = Field(..., max_length=70) commercial_part_number: Optional[str] = None @@ -186,7 +185,6 @@ class PartCreateDTO(PartBase): # --- ACTUALIZACIÓN --- class PartUpdateDTO(PartBase): - client_id: Optional[int] = None part_number: Optional[str] = None # Todo opcional para PATCH pass diff --git a/backend/api/v1/modules/a76/parts/models.py b/backend/api/v1/modules/a76/parts/models.py index 27ba357f..0f1e8c03 100644 --- a/backend/api/v1/modules/a76/parts/models.py +++ b/backend/api/v1/modules/a76/parts/models.py @@ -68,7 +68,6 @@ class Part(Base, TenantScopedMixin, TimestampMixin): ) id: Mapped[int] = mapped_column(Integer, primary_key=True) - client_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) part_number: Mapped[str] = mapped_column(String(70)) commercial_part_number: Mapped[Optional[str]] = mapped_column(String(70)) diff --git a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/routes.py b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/routes.py index 3f2107d6..03f5efd7 100644 --- a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/routes.py +++ b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/routes.py @@ -16,7 +16,6 @@ router = APIRouter(prefix="/code-pedimento-regimens") def list_code_pedimento_regimens( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=1000, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), code: str = Query(None, description="Filter by code"), regime: str = Query(None, description="Filter by regime"), type: str = Query(None, description="Filter by type"), @@ -24,8 +23,6 @@ def list_code_pedimento_regimens( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_regimens.view"]) skip = (page - 1) * page_size query = db.query(CodePedimentoRegimen) diff --git a/backend/api/v1/modules/public/reference_data/containers/routes.py b/backend/api/v1/modules/public/reference_data/containers/routes.py index 6f0bbe4b..7182f848 100644 --- a/backend/api/v1/modules/public/reference_data/containers/routes.py +++ b/backend/api/v1/modules/public/reference_data/containers/routes.py @@ -1,7 +1,7 @@ from typing import Any, Dict from core.database import get_core_db -from core.security import get_current_user, validate_access_to_resource +from core.security import get_current_user, has_role from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import or_ @@ -16,14 +16,10 @@ router = APIRouter(prefix="/containers") async def list_containers( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - # 🛡️ Permiso de Lectura (Listado) - validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_containers.view"]) - skip = (page - 1) * page_size query = db.query(Container) @@ -48,13 +44,9 @@ async def list_containers( @router.get("/{key}", response_model=ContainerDTO) async def get_container( key: str, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - # 🛡️ Permiso de Lectura (Individual) - validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_containers.view"]) - obj = db.query(Container).filter(Container.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -64,13 +56,9 @@ async def get_container( @router.post("/", response_model=ContainerDTO, status_code=201) async def create_container( data: ContainerDTO, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - # 🛡️ Permiso de Creación - validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_containers.create"]) - obj = Container(**data.dict()) db.add(obj) db.commit() @@ -82,13 +70,9 @@ async def create_container( async def update_container( key: str, data: ContainerDTO, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - # 🛡️ Permiso de Edición - validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_containers.edit"]) - obj = db.query(Container).filter(Container.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -102,16 +86,12 @@ async def update_container( @router.delete("/{key}", status_code=204) async def delete_container( key: str, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - # 🛡️ Permiso de Borrado - validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_containers.delete"]) - obj = db.query(Container).filter(Container.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") db.delete(obj) db.commit() - return None \ No newline at end of file + return None diff --git a/backend/api/v1/modules/public/reference_data/countries/routes.py b/backend/api/v1/modules/public/reference_data/countries/routes.py index 90aea08b..45d76eea 100644 --- a/backend/api/v1/modules/public/reference_data/countries/routes.py +++ b/backend/api/v1/modules/public/reference_data/countries/routes.py @@ -1,7 +1,7 @@ from typing import Any, Dict from core.database import get_core_db -from core.security import get_current_user +from core.security import get_current_user, has_role from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session @@ -15,14 +15,10 @@ router = APIRouter(prefix="/countries") async def list_countries( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_countries.view", "cat_countries.view"], require_all=False) - + """Endpoint público para obtener lista de países - no requiere autenticación""" skip = (page - 1) * page_size query = db.query(Country) @@ -53,8 +49,6 @@ async def get_country( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_countries", "view") obj = db.query(Country).filter(Country.m3_key == m3_key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -65,10 +59,8 @@ async def get_country( async def create_country( data: CountryDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_countries", "create") obj = Country(**data.dict()) db.add(obj) db.commit() @@ -81,10 +73,8 @@ async def update_country( m3_key: str, data: CountryDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_countries", "edit") obj = db.query(Country).filter(Country.m3_key == m3_key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -99,10 +89,8 @@ async def update_country( async def delete_country( m3_key: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_countries", "delete") obj = db.query(Country).filter(Country.m3_key == m3_key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") diff --git a/backend/api/v1/modules/public/reference_data/currency_types/routes.py b/backend/api/v1/modules/public/reference_data/currency_types/routes.py index fab4e557..6d860ba3 100644 --- a/backend/api/v1/modules/public/reference_data/currency_types/routes.py +++ b/backend/api/v1/modules/public/reference_data/currency_types/routes.py @@ -1,7 +1,7 @@ from typing import Any, Dict from core.database import get_core_db -from core.security import get_current_user +from core.security import get_current_user, has_role from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import or_ @@ -16,13 +16,10 @@ router = APIRouter(prefix="/currency-types") async def list_currency_types( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_currency_types.view", "cat_currency.view"], require_all=False) skip = (page - 1) * page_size query = db.query(CurrencyType) @@ -51,8 +48,6 @@ async def get_currency_type( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_currency_types", "view") obj = db.query(CurrencyType).filter(CurrencyType.code == code).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -63,10 +58,8 @@ async def get_currency_type( async def create_currency_type( data: CurrencyTypeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_currency_types", "create") obj = CurrencyType(**data.dict()) db.add(obj) db.commit() @@ -79,10 +72,8 @@ async def update_currency_type( code: str, data: CurrencyTypeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_currency_types", "edit") obj = db.query(CurrencyType).filter(CurrencyType.code == code).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -97,10 +88,8 @@ async def update_currency_type( async def delete_currency_type( code: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_currency_types", "delete") obj = db.query(CurrencyType).filter(CurrencyType.code == code).first() if not obj: raise HTTPException(status_code=404, detail="Not found") diff --git a/backend/api/v1/modules/public/reference_data/customs_sections/routes.py b/backend/api/v1/modules/public/reference_data/customs_sections/routes.py index 6a5b51df..767462d3 100644 --- a/backend/api/v1/modules/public/reference_data/customs_sections/routes.py +++ b/backend/api/v1/modules/public/reference_data/customs_sections/routes.py @@ -16,13 +16,10 @@ router = APIRouter(prefix="/customs-sections") def list_customs_sections( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_customs_sections.view"]) skip = (page - 1) * page_size query = db.query(CustomsSection) @@ -47,12 +44,9 @@ def list_customs_sections( @router.get("/{customs_code}", response_model=CustomsSectionDTO) def get_customs_section( customs_code: str, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_customs_sections.view"]) obj = ( db.query(CustomsSection) .filter(CustomsSection.customs_code == customs_code) @@ -66,12 +60,9 @@ def get_customs_section( @router.post("/", response_model=CustomsSectionDTO, status_code=201) def create_customs_section( data: CustomsSectionDTO, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_customs_sections.edit"]) obj = CustomsSection(**data.dict()) db.add(obj) db.commit() @@ -83,12 +74,9 @@ def create_customs_section( def update_customs_section( customs_code: str, data: CustomsSectionDTO, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_customs_sections.edit"]) obj = ( db.query(CustomsSection) .filter(CustomsSection.customs_code == customs_code) @@ -106,12 +94,9 @@ def update_customs_section( @router.delete("/{customs_code}", status_code=204) def delete_customs_section( customs_code: str, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_customs_sections.edit"]) obj = ( db.query(CustomsSection) .filter(CustomsSection.customs_code == customs_code) diff --git a/backend/api/v1/modules/public/reference_data/customs_warehouses/routes.py b/backend/api/v1/modules/public/reference_data/customs_warehouses/routes.py index b32a4815..03947cd0 100644 --- a/backend/api/v1/modules/public/reference_data/customs_warehouses/routes.py +++ b/backend/api/v1/modules/public/reference_data/customs_warehouses/routes.py @@ -16,13 +16,10 @@ router = APIRouter(prefix="/customs-warehouses") def list_customs_warehouses( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_customs_warehouses.view", "cat_warehouses.view"], require_all=False) skip = (page - 1) * page_size query = db.query(CustomsWarehouse) diff --git a/backend/api/v1/modules/public/reference_data/identifiers/routes.py b/backend/api/v1/modules/public/reference_data/identifiers/routes.py index 9f28495f..b2263881 100644 --- a/backend/api/v1/modules/public/reference_data/identifiers/routes.py +++ b/backend/api/v1/modules/public/reference_data/identifiers/routes.py @@ -1,7 +1,7 @@ from typing import Any, Dict from core.database import get_core_db -from core.security import get_current_user +from core.security import get_current_user, has_role from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import or_ from sqlalchemy.orm import Session @@ -22,8 +22,6 @@ async def list_identifiers( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_identifiers", "view") skip = (page - 1) * page_size query = db.query(IdentifierCatalog) @@ -67,8 +65,6 @@ async def get_identifier( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_identifiers", "view") obj = db.query(IdentifierCatalog).filter(IdentifierCatalog.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -79,10 +75,8 @@ async def get_identifier( async def create_identifier( data: IdentifierDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_identifiers", "create") # Check if already exists existing = db.query(IdentifierCatalog).filter(IdentifierCatalog.key == data.key).first() if existing: @@ -100,10 +94,8 @@ async def update_identifier( key: str, data: IdentifierDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_identifiers", "edit") obj = db.query(IdentifierCatalog).filter(IdentifierCatalog.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -118,10 +110,8 @@ async def update_identifier( async def delete_identifier( key: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(current_user, "cat_identifiers", "delete") obj = db.query(IdentifierCatalog).filter(IdentifierCatalog.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") diff --git a/backend/api/v1/modules/public/reference_data/incoterms/routes.py b/backend/api/v1/modules/public/reference_data/incoterms/routes.py index 7fac2546..6661e334 100644 --- a/backend/api/v1/modules/public/reference_data/incoterms/routes.py +++ b/backend/api/v1/modules/public/reference_data/incoterms/routes.py @@ -16,15 +16,12 @@ router = APIRouter(prefix="/incoterms") async def list_incoterms( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), code: str = Query(None, description="Filtrar por clave"), description: str = Query(None, description="Filtrar por descripción"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_incoterms.view", "cat_incoterms.view"], require_all=False) skip = (page - 1) * page_size query = db.query(Incoterm) diff --git a/backend/api/v1/modules/public/reference_data/invoice_types/routes.py b/backend/api/v1/modules/public/reference_data/invoice_types/routes.py index 638c2917..c2b34297 100644 --- a/backend/api/v1/modules/public/reference_data/invoice_types/routes.py +++ b/backend/api/v1/modules/public/reference_data/invoice_types/routes.py @@ -16,15 +16,11 @@ router = APIRouter(prefix="/invoice-types") def list_invoice_types( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), - company_id: int = Query(..., description="ID de la empresa"), type: Optional[str] = Query(None, description="Filter by type"), operation: Optional[str] = Query(None, description="Filter by operation type (imp, exp, both)"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_invoice_types.view", "cat_inv_types.view"], require_all=False) query = db.query(InvoiceType) if search: diff --git a/backend/api/v1/modules/public/reference_data/material_types/routes.py b/backend/api/v1/modules/public/reference_data/material_types/routes.py index 49101606..b3982a93 100644 --- a/backend/api/v1/modules/public/reference_data/material_types/routes.py +++ b/backend/api/v1/modules/public/reference_data/material_types/routes.py @@ -16,14 +16,11 @@ router = APIRouter(prefix="/material-types") async def list_material_types( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=1000, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), type: str = Query(None, description="Filtrar por tipo (ACTIVO FIJO, MATERIALES, PRODUCTOS)"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_material_types.view", "cat_material_types.view"], require_all=False) skip = (page - 1) * page_size query = db.query(MaterialType) diff --git a/backend/api/v1/modules/public/reference_data/payment_methods/routes.py b/backend/api/v1/modules/public/reference_data/payment_methods/routes.py index cf0d3b34..3e728f4b 100644 --- a/backend/api/v1/modules/public/reference_data/payment_methods/routes.py +++ b/backend/api/v1/modules/public/reference_data/payment_methods/routes.py @@ -16,13 +16,10 @@ router = APIRouter(prefix="/payment-methods") def list_payment_methods( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["pedimentos_payments.view"]) skip = (page - 1) * page_size query = db.query(PaymentMethod) @@ -47,12 +44,9 @@ def list_payment_methods( @router.get("/{key}", response_model=PaymentMethodDTO) def get_payment_method( key: str, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["pedimentos_payments.view"]) obj = db.query(PaymentMethod).filter(PaymentMethod.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") diff --git a/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py b/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py index b25cdc48..0d7aa39e 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py @@ -16,13 +16,10 @@ router = APIRouter(prefix="/pedimento-codes") def list_pedimento_codes( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=1000, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_codes.view"]) skip = (page - 1) * page_size query = db.query(PedimentoCode) @@ -47,12 +44,9 @@ def list_pedimento_codes( @router.get("/{code}", response_model=PedimentoCodeDTO) def get_pedimento_code( code: str, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_codes .view"]) obj = db.query(PedimentoCode).filter(PedimentoCode.code == code).first() if not obj: raise HTTPException(status_code=404, detail="Not found") diff --git a/backend/api/v1/modules/public/reference_data/pedimento_regimens/routes.py b/backend/api/v1/modules/public/reference_data/pedimento_regimens/routes.py index 6cfbae6f..678fa194 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_regimens/routes.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_regimens/routes.py @@ -1,7 +1,7 @@ from typing import Any, Dict from core.database import get_core_db -from core.security import get_current_user +from core.security import get_current_user, has_role from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import or_ @@ -16,13 +16,10 @@ router = APIRouter(prefix="/pedimento-regimens") def list_pedimento_regimens( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_regimens.view"]) skip = (page - 1) * page_size query = db.query(RegimenPedimento) @@ -47,12 +44,9 @@ def list_pedimento_regimens( @router.get("/{key}", response_model=RegimenPedimentoDTO) def get_pedimento_regimen( key: str, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_regimens.view"]) obj = db.query(RegimenPedimento).filter(RegimenPedimento.code == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -62,13 +56,9 @@ def get_pedimento_regimen( @router.post("/", response_model=RegimenPedimentoDTO, status_code=201) def create_pedimento_regimen( data: RegimenPedimentoDTO, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource - # Mutation for reference data usually restricted to admin role or specific perm - validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_regimens.edit"]) obj = RegimenPedimento(**data.model_dump()) db.add(obj) db.commit() @@ -80,12 +70,9 @@ def create_pedimento_regimen( def update_pedimento_regimen( key: str, data: RegimenPedimentoDTO, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_regimens.edit"]) obj = db.query(RegimenPedimento).filter(RegimenPedimento.code == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -99,12 +86,9 @@ def update_pedimento_regimen( @router.delete("/{key}", status_code=204) def delete_pedimento_regimen( key: str, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(has_role("admin")), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_regimens.edit"]) obj = db.query(RegimenPedimento).filter(RegimenPedimento.code == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") diff --git a/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/routes.py b/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/routes.py index 63154da8..e3a0db27 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/routes.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/routes.py @@ -14,15 +14,11 @@ router = APIRouter(prefix="/pedimento-transport-catalog") @router.get("/", response_model=Dict[str, Any]) async def list_pedimento_transport_catalog( - company_id: int = Query(..., description="ID de la empresa"), page: int = Query(1, ge=1, description="Numero de pagina"), page_size: int = Query(100, ge=1, le=200, description="Tamano de pagina"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["pedimentos_anexo22.view"]) skip = (page - 1) * page_size query = db.query(PedimentoTransportCatalog) @@ -47,14 +43,7 @@ async def list_pedimento_transport_catalog( @router.get("/{code}", response_model=PedimentoTransportCatalogDTO) -async def get_pedimento_transport_catalog( - code: str, - company_id: int = Query(..., description="ID de la empresa"), - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["pedimentos_anexo22.view"]) +async def get_pedimento_transport_catalog(code: str, db: Session = Depends(get_core_db)): obj = ( db.query(PedimentoTransportCatalog) .filter(PedimentoTransportCatalog.code == code) @@ -68,12 +57,9 @@ async def get_pedimento_transport_catalog( @router.post("/", response_model=PedimentoTransportCatalogDTO, status_code=201) async def create_pedimento_transport_catalog( data: PedimentoTransportCatalogDTO, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + user=Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["pedimentos_anexo22.view"]) obj = PedimentoTransportCatalog(**data.model_dump()) db.add(obj) db.commit() @@ -85,12 +71,9 @@ async def create_pedimento_transport_catalog( async def update_pedimento_transport_catalog( code: str, data: PedimentoTransportCatalogDTO, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + user=Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["pedimentos_anexo22.view"]) obj = ( db.query(PedimentoTransportCatalog) .filter(PedimentoTransportCatalog.code == code) @@ -108,12 +91,9 @@ async def update_pedimento_transport_catalog( @router.delete("/{code}", status_code=204) async def delete_pedimento_transport_catalog( code: str, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + user=Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["pedimentos_anexo22.view"]) obj = ( db.query(PedimentoTransportCatalog) .filter(PedimentoTransportCatalog.code == code) diff --git a/backend/api/v1/modules/public/reference_data/states/routes.py b/backend/api/v1/modules/public/reference_data/states/routes.py index f88f614d..52d09f53 100644 --- a/backend/api/v1/modules/public/reference_data/states/routes.py +++ b/backend/api/v1/modules/public/reference_data/states/routes.py @@ -16,13 +16,10 @@ router = APIRouter(prefix="/states") async def list_states( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_states.view"]) skip = (page - 1) * page_size query = db.query(State) diff --git a/backend/api/v1/modules/public/reference_data/transport_modes/routes.py b/backend/api/v1/modules/public/reference_data/transport_modes/routes.py index 3a134db7..cfe4405d 100644 --- a/backend/api/v1/modules/public/reference_data/transport_modes/routes.py +++ b/backend/api/v1/modules/public/reference_data/transport_modes/routes.py @@ -1,7 +1,7 @@ from typing import Any, Dict from core.database import get_core_db -from core.security import get_current_user, validate_access_to_resource +from core.security import get_current_user from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import or_ @@ -16,14 +16,9 @@ router = APIRouter(prefix="/transport-modes") async def list_transport_modes( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), ): - # 🛡️ Permiso de Lectura (Listado) - validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_transport_modes.view"]) - skip = (page - 1) * page_size query = db.query(TransportMode) @@ -46,15 +41,7 @@ async def list_transport_modes( @router.get("/{key}", response_model=TransportModeDTO) -async def get_transport_mode( - key: str, - company_id: int = Query(..., description="ID de la empresa"), - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - # 🛡️ Permiso de Lectura (Individual) - validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_transport_modes.view"]) - +async def get_transport_mode(key: str, db: Session = Depends(get_core_db)): obj = db.query(TransportMode).filter(TransportMode.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -64,13 +51,9 @@ async def get_transport_mode( @router.post("/", response_model=TransportModeDTO, status_code=201) async def create_transport_mode( data: TransportModeDTO, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + user=Depends(get_current_user), ): - # 🛡️ Permiso de Creación - validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_transport_modes.create"]) - obj = TransportMode(**data.dict()) db.add(obj) db.commit() @@ -82,13 +65,9 @@ async def create_transport_mode( async def update_transport_mode( key: str, data: TransportModeDTO, - company_id: int = Query(..., description="ID de la empresa"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + user=Depends(get_current_user), ): - # 🛡️ Permiso de Edición - validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_transport_modes.edit"]) - obj = db.query(TransportMode).filter(TransportMode.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -101,17 +80,11 @@ async def update_transport_mode( @router.delete("/{key}", status_code=204) async def delete_transport_mode( - key: str, - company_id: int = Query(..., description="ID de la empresa"), - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + key: str, db: Session = Depends(get_core_db), user=Depends(get_current_user) ): - # 🛡️ Permiso de Borrado - validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_transport_modes.delete"]) - obj = db.query(TransportMode).filter(TransportMode.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") db.delete(obj) db.commit() - return None \ No newline at end of file + return None diff --git a/backend/api/v1/modules/public/reference_data/transport_types/routes.py b/backend/api/v1/modules/public/reference_data/transport_types/routes.py index d7736b53..563d5e0b 100644 --- a/backend/api/v1/modules/public/reference_data/transport_types/routes.py +++ b/backend/api/v1/modules/public/reference_data/transport_types/routes.py @@ -16,13 +16,9 @@ router = APIRouter(prefix="/transport-types") def list_transport_types( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_transport_types.view"]) skip = (page - 1) * page_size query = db.query(TransportType) diff --git a/backend/api/v1/modules/public/reference_data/valuation_methods/routes.py b/backend/api/v1/modules/public/reference_data/valuation_methods/routes.py index a0c67c7a..e0f7c65d 100644 --- a/backend/api/v1/modules/public/reference_data/valuation_methods/routes.py +++ b/backend/api/v1/modules/public/reference_data/valuation_methods/routes.py @@ -16,13 +16,10 @@ router = APIRouter(prefix="/valuation-methods") async def list_valuation_methods( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - company_id: int = Query(..., description="ID de la empresa"), search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - from core.security import validate_access_to_resource - validate_access_to_resource(db, company_id, current_user, ["ref_valuation_methods.view", "cat_valuation.view"], require_all=False) skip = (page - 1) * page_size query = db.query(ValuationMethod) diff --git a/backend/tests/unit/general_catalogs/doda/test_doda_alta_payloads.py b/backend/tests/unit/general_catalogs/doda/test_doda_alta_payloads.py new file mode 100644 index 00000000..4d18d0e0 --- /dev/null +++ b/backend/tests/unit/general_catalogs/doda/test_doda_alta_payloads.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from api.v1.modules.a76.general_catalogs.doda.alta_service import DodaAltaService + + +def test_build_consulta_payload_appends_numero_transaccion(monkeypatch): + service = DodaAltaService(db=None) + monkeypatch.setattr( + service, + "build_alta_payload", + lambda **kwargs: {"base": "payload"}, + ) + monkeypatch.setattr( + service, + "_latest_alta_log", + lambda *args, **kwargs: SimpleNamespace( + id=9, + result_json=json.dumps({"numero_transaccion": "TX-001"}), + integration_number="INT-001", + ), + ) + + payload = service.build_consulta_payload( + doda_id=1, tenant_id=1, company_id=1, variant="doda", user_email="u@test.com" + ) + assert payload["base"] == "payload" + assert payload["numero_transaccion"] == "TX-001" + + +def test_build_consulta_payload_fails_when_numero_transaccion_missing(monkeypatch): + service = DodaAltaService(db=None) + monkeypatch.setattr(service, "build_alta_payload", lambda **kwargs: {}) + monkeypatch.setattr( + service, + "_latest_alta_log", + lambda *args, **kwargs: SimpleNamespace( + id=10, result_json=json.dumps({"other": "value"}), integration_number="INT-001" + ), + ) + + with pytest.raises(ValueError, match="numero_transaccion"): + service.build_consulta_payload( + doda_id=1, tenant_id=1, company_id=1, variant="doda", user_email="" + ) + + +def test_build_eliminar_payload_appends_numero_integracion(monkeypatch): + service = DodaAltaService(db=None) + monkeypatch.setattr(service, "build_alta_payload", lambda **kwargs: {"base": "payload"}) + monkeypatch.setattr( + service, + "_latest_alta_log", + lambda *args, **kwargs: SimpleNamespace( + id=11, + result_json="{}", + integration_number="INT-900", + ), + ) + + payload = service.build_eliminar_payload( + doda_id=2, tenant_id=1, company_id=1, variant="doda", user_email="user@test.com" + ) + assert payload["base"] == "payload" + assert payload["numero_integracion"] == "INT-900" diff --git a/backend/tests/unit/general_catalogs/doda/test_doda_external_service.py b/backend/tests/unit/general_catalogs/doda/test_doda_external_service.py new file mode 100644 index 00000000..8b5367f3 --- /dev/null +++ b/backend/tests/unit/general_catalogs/doda/test_doda_external_service.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Any, Dict + +from api.v1.modules.a76.general_catalogs.doda.external_service import DodaExternalService + + +class _FakeResponse: + def __init__(self, payload: Dict[str, Any]): + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> Dict[str, Any]: + return self._payload + + +class _FakeClient: + calls = [] + + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def post(self, url: str, json: Dict[str, Any]): + _FakeClient.calls.append(("POST", url, json)) + return _FakeResponse({"task_id": "t-1", "status": "queued", "message": "ok"}) + + def get(self, url: str): + _FakeClient.calls.append(("GET", url, None)) + return _FakeResponse({"task_id": "t-1", "status": "done", "message": "ok"}) + + +def test_external_service_supports_consulta_and_eliminar(monkeypatch): + from api.v1.modules.a76.general_catalogs.doda import external_service as module + + _FakeClient.calls = [] + monkeypatch.setattr(module, "httpx", module.httpx) + monkeypatch.setattr(module.httpx, "Client", _FakeClient) + + service = DodaExternalService() + service.base_url = "http://example.test" + + payload = {"foo": "bar"} + consulta = service.post_consulta(payload) + consulta_status = service.get_consulta_status("abc123") + eliminar = service.post_eliminar(payload) + eliminar_status = service.get_eliminar_status("abc123") + + assert consulta["task_id"] == "t-1" + assert consulta_status["status"] == "done" + assert eliminar["status"] == "queued" + assert eliminar_status["task_id"] == "t-1" + assert _FakeClient.calls == [ + ("POST", "http://example.test/api/v1/doda/consulta", payload), + ("GET", "http://example.test/api/v1/doda/consulta-status/abc123", None), + ("POST", "http://example.test/api/v1/doda/eliminar", payload), + ("GET", "http://example.test/api/v1/doda/eliminar-status/abc123", None), + ] diff --git a/backend/tests/unit/general_catalogs/fractions/test_tariff_fraction_mapper.py b/backend/tests/unit/general_catalogs/fractions/test_tariff_fraction_mapper.py new file mode 100644 index 00000000..06e61c8c --- /dev/null +++ b/backend/tests/unit/general_catalogs/fractions/test_tariff_fraction_mapper.py @@ -0,0 +1,57 @@ +from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.service import ( + TariffFractionMapper, +) +from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.dto import ( + USTariffFractionResponseDTO, +) +from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse + + +def test_to_domain_usa_keeps_separate_code_and_fraction(): + row = FraccionesUSAResponse( + CONSECUTIVO=10, + FRACCION_SIN_PUNTO="1234567890", + FRACCION_CON_PUNTO="1234.56.78.90", + DESCRIPCION="Test", + UNIDADCANTIDAD="KG", + TARIFA1="5%", + TARIFA2="0%", + ) + + mapped = TariffFractionMapper.to_domain_usa(row) + + assert mapped.code == "1234567890" + assert mapped.fraction == "1234.56.78.90" + + +def test_to_domain_usa_formats_fraction_when_only_code_available(): + row = FraccionesUSAResponse( + CONSECUTIVO=11, + FRACCION_SIN_PUNTO="9876543210", + FRACCION_CON_PUNTO=None, + FRACCION_MOSTRAR=None, + DESCRIPCION="Fallback", + UNIDADCANTIDAD="PZA", + TARIFA1="7.5%", + TARIFA2="0%", + ) + + mapped = TariffFractionMapper.to_domain_usa(row) + + assert mapped.code == "9876543210" + assert mapped.fraction == "9876.54.32.10" + + +def test_us_response_dto_preserves_fraction_when_provided(): + dto = USTariffFractionResponseDTO.model_validate( + { + "id": 1, + "code": "1111.22.33.44", + "fraction": "1111.22.33.44", + "description": "DTO test", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + } + ) + assert dto.code == "1111223344" + assert dto.fraction == "1111.22.33.44" diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 5414a941..295799e9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -260,7 +260,29 @@ "transporters": "Carriers", "drivers": "Drivers", "trailers": "Trailers", - "vehicles": "Vehicles" + "vehicles": "Vehicles", + "vehicle_transport_types": { + "ar": "Armored Truck", + "au": "Automobiles", + "bt": "Box Truck", + "bu": "Bus", + "bv": "Beverage Truck (Refer or not)", + "by": "Bicycle", + "co": "Construction Vehicle (general)", + "ev": "Emergency Vehicle (general)", + "fe": "Ferry", + "fm": "Farm Tractor", + "gb": "Garbage Truck", + "mc": "Motorcycle", + "oc": "Other", + "pm": "Pick-up Truck w/camper", + "pn": "Panel Truck", + "pu": "Pickup Truck", + "pv": "Passenger", + "rv": "Recreation Vehicle (RV)", + "tr": "Semi Tracker", + "tv": "Van" + } }, "reports": { "title": "Reports", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index d17d7d5f..0c310e9a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -259,7 +259,29 @@ "transporters": "Transportistas", "drivers": "Conductores", "trailers": "Trailers", - "vehicles": "Vehículos" + "vehicles": "Vehículos", + "vehicle_transport_types": { + "ar": "Camión Blindado", + "au": "Automóviles", + "bt": "Camión de Caja", + "bu": "Autobús", + "bv": "Camión de Bebidas", + "by": "Bicicleta", + "co": "Vehículo de Construcción", + "ev": "Vehículo de Emergencia", + "fe": "Ferry", + "fm": "Tractor Agrícola", + "gb": "Camión de Basura", + "mc": "Motocicleta", + "oc": "Otro", + "pm": "Camioneta con cabina", + "pn": "Camión Panel", + "pu": "Camioneta (Pick-up)", + "pv": "Pasajero", + "rv": "Vehículo Recreativo (RV)", + "tr": "Tractocamión", + "tv": "Van" + } }, "reports": { "title": "Reportes", diff --git a/frontend/src/lib/api/dashboard/a76/expediente-archivos.ts b/frontend/src/lib/api/dashboard/a76/expediente-archivos.ts index b706366b..02d8ec82 100644 --- a/frontend/src/lib/api/dashboard/a76/expediente-archivos.ts +++ b/frontend/src/lib/api/dashboard/a76/expediente-archivos.ts @@ -106,7 +106,14 @@ class ExpedienteArchivosApi { async list( companyId: string | number, - params?: Record + params?: { + page?: number; + page_size?: number; + search?: string; + status?: string; + rfc_consulta?: string; + e_document?: string; + } ): Promise> { const queryParams = new URLSearchParams({ company_id: companyId.toString() }); if (params) { diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/concepts.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/concepts.ts index b943aaee..1c4ee05b 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/concepts.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/concepts.ts @@ -50,11 +50,22 @@ export async function getConcepts( companyId: number, filters: Record = {}, ): Promise> { + const cleanFilters = Object.fromEntries( + Object.entries(filters).filter(([, value]) => { + if (value === undefined || value === null) return false; + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed !== '' && trimmed.toLowerCase() !== 'undefined'; + } + return true; + }) + ); + const params = new URLSearchParams({ page: page.toString(), page_size: pageSize.toString(), company_id: companyId.toString(), - ...filters + ...cleanFilters }); return await api.get(`/v1/a76/concepts/?${params.toString()}`); diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts index 7e381174..770510dc 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts @@ -478,6 +478,56 @@ export async function getDodaAltaStatus( return api.get(`/v1/a76/doda/alta-status/${taskId}`); } +export async function postDodaConsulta( + dodaId: number, + companyId: number, + variant: 'doda' | 'pita' = 'doda' +): Promise> { + const params = new URLSearchParams({ + company_id: companyId.toString(), + variant, + }); + return api.post(`/v1/a76/doda/${dodaId}/consulta?${params}`, {}); +} + +export async function getDodaConsultaStatus( + taskId: string +): Promise> { + return api.get(`/v1/a76/doda/consulta-status/${taskId}`); +} + +export async function postDodaConsultaApply( + dodaId: number, + taskId: string, + companyId: number +): Promise>> { + const params = new URLSearchParams({ + company_id: companyId.toString(), + }); + return api.post>( + `/v1/a76/doda/${dodaId}/consulta-apply/${taskId}?${params}`, + {} + ); +} + +export async function postDodaEliminar( + dodaId: number, + companyId: number, + variant: 'doda' | 'pita' = 'doda' +): Promise> { + const params = new URLSearchParams({ + company_id: companyId.toString(), + variant, + }); + return api.post(`/v1/a76/doda/${dodaId}/eliminar?${params}`, {}); +} + +export async function getDodaEliminarStatus( + taskId: string +): Promise> { + return api.get(`/v1/a76/doda/eliminar-status/${taskId}`); +} + export async function getDodaElegibilidad( dodaId: number, companyId: number, diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/identifiers.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/identifiers.ts index 2b7ccf3e..2f5ee917 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/identifiers.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/identifiers.ts @@ -68,11 +68,22 @@ export async function getIdentifiers( companyId: number, filters: Record = {} ): Promise> { + const cleanFilters = Object.fromEntries( + Object.entries(filters).filter(([, value]) => { + if (value === undefined || value === null) return false; + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed !== '' && trimmed.toLowerCase() !== 'undefined'; + } + return true; + }) + ); + const queryParams = new URLSearchParams({ page: page.toString(), page_size: pageSize.toString(), company_id: companyId.toString(), - ...filters + ...cleanFilters }); return await api.get(`/v1/a76/identifiers/?${queryParams.toString()}`); diff --git a/frontend/src/lib/api/dashboard/a76/parts.ts b/frontend/src/lib/api/dashboard/a76/parts.ts index a0ef2c4f..5c4ab8a6 100644 --- a/frontend/src/lib/api/dashboard/a76/parts.ts +++ b/frontend/src/lib/api/dashboard/a76/parts.ts @@ -92,7 +92,6 @@ export interface Part { id: number; tenant_id: number; company_id: number; - client_id: number; // Identificación part_number: string; diff --git a/frontend/src/lib/api/dashboard/a76/sitar.ts b/frontend/src/lib/api/dashboard/a76/sitar.ts index 829b1b42..9692ab5a 100644 --- a/frontend/src/lib/api/dashboard/a76/sitar.ts +++ b/frontend/src/lib/api/dashboard/a76/sitar.ts @@ -39,16 +39,81 @@ export interface SitarALADI { } export async function getSitarTLCS(filters: { fraccion: string; nico?: string }): Promise> { - const queryParams = new URLSearchParams(filters); - return await api.get(`/v1/sitar/tlcs/?${queryParams.toString()}`); + const queryParams = new URLSearchParams( + Object.entries(filters).reduce( + (acc, [key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== '') { + acc[key] = String(value); + } + return acc; + }, + {} as Record + ) + ); + return await api.get(`/v1/sitar/tlcs/?${queryParams.toString()}`); } export async function getSitarPROSEC(filters: { fraccion: string; nico?: string }): Promise> { - const queryParams = new URLSearchParams(filters); - return await api.get(`/v1/sitar/prosec/?${queryParams.toString()}`); + const queryParams = new URLSearchParams( + Object.entries(filters).reduce( + (acc, [key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== '') { + acc[key] = String(value); + } + return acc; + }, + {} as Record + ) + ); + return await api.get(`/v1/sitar/prosec/?${queryParams.toString()}`); } export async function getSitarALADI(filters: { fraccion: string; nico?: string }): Promise> { - const queryParams = new URLSearchParams(filters); - return await api.get(`/v1/sitar/aladi2/?${queryParams.toString()}`); + const queryParams = new URLSearchParams( + Object.entries(filters).reduce( + (acc, [key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== '') { + acc[key] = String(value); + } + return acc; + }, + {} as Record + ) + ); + return await api.get(`/v1/sitar/aladi2/?${queryParams.toString()}`); +} + +export type SitarGenericRecord = Record; + +export type SitarDatasetEndpoint = + | 'reit' + | 'requisito-previo' + | 'informacion-general' + | 'regulaciones' + | 'fundamentos-tlc' + | 'cuotas2' + | 'cupos' + | 'noms' + | 'precios-estimados' + | 'ieps' + | 'rcg2' + | 'vehiculos-marcas' + | 'vehiculos-modelos'; + +export async function getSitarDataset( + endpoint: SitarDatasetEndpoint, + filters: { fraccion: string; nico?: string; [key: string]: string | undefined } +): Promise> { + const queryParams = new URLSearchParams( + Object.entries(filters).reduce( + (acc, [key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== '') { + acc[key] = String(value); + } + return acc; + }, + {} as Record + ) + ); + return await api.get(`/v1/sitar/${endpoint}/?${queryParams.toString()}`); } diff --git a/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte b/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte index 6580d455..a7498cd4 100644 --- a/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte +++ b/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte @@ -7,6 +7,7 @@ getDodaAltaStatus, type DodaAltaStatusResponse } from '$lib/api/dashboard/a76/general_catalogs/doda'; + import type { ApiResponse } from '$lib/api'; import { m } from '$lib/i18n/messages'; let { @@ -14,6 +15,9 @@ taskId, dodaId, variant = 'doda', + title = m['sidebar.doda_alta.progress_title'](), + description = 'Task ID', + getStatus = getDodaAltaStatus, onComplete, onCancel }: { @@ -21,6 +25,9 @@ taskId: string; dodaId?: number; variant?: 'doda' | 'pita'; + title?: string; + description?: string; + getStatus?: (taskId: string) => Promise>; onComplete?: (result: DodaAltaStatusResponse) => void; onCancel?: () => void; } = $props(); @@ -84,7 +91,7 @@ if (!taskId || !pollingActive || pollInFlight) return; pollInFlight = true; try { - const res = await getDodaAltaStatus(taskId); + const res = await getStatus(taskId); if (res.error) { consecutivePollErrors += 1; @@ -151,8 +158,8 @@ - {m['sidebar.doda_alta.progress_title']()} - Alta {variantLabel} — Task ID: {taskId} + {title} + {description} {variantLabel} — Task ID: {taskId}
diff --git a/frontend/src/lib/components/dashboard/digitalizacion/columns.ts b/frontend/src/lib/components/dashboard/digitalizacion/columns.ts index f31b4a46..b7e2b43d 100644 --- a/frontend/src/lib/components/dashboard/digitalizacion/columns.ts +++ b/frontend/src/lib/components/dashboard/digitalizacion/columns.ts @@ -107,6 +107,16 @@ export function createColumns( header: 'Tipo Documento', cell: ({ row }) => row.original.tipo_documento || '-' }, + { + accessorKey: 'rfc_consulta', + header: 'RFC Consulta', + cell: ({ row }) => row.original.rfc_consulta || '-' + }, + { + accessorKey: 'nombre_archivo', + header: 'Archivo', + cell: ({ row }) => row.original.nombre_archivo || '-' + }, { accessorKey: 'e_document', header: 'E-Document', diff --git a/frontend/src/lib/components/dashboard/digitalizacion/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/digitalizacion/create-edit-dialog.svelte index 26451d7e..188ca3d5 100644 --- a/frontend/src/lib/components/dashboard/digitalizacion/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/digitalizacion/create-edit-dialog.svelte @@ -320,6 +320,18 @@ />
+
+ + (formData.rfc_consulta = (e.target as HTMLInputElement).value.toUpperCase())} + placeholder="RFC para consulta" + maxlength={13} + disabled={loading} + /> +
+
(null); - // Cargar datos al abrir + function resetForm() { + formData = { + code: '', + description: '', + description_en: '', + detailed_description: '', + priority: '', + priority_ame: '', + first_total: '', + type: '', + is_printed: false, + section: '', + classification: '' + }; + } + + // Cargar/limpiar datos al abrir $effect(() => { + if (!open) return; + if (item) { formData = { code: item.code || '', @@ -62,21 +80,10 @@ classification: item.classification || '' }; } else { - // Limpiar formulario - formData = { - code: '', - description: '', - description_en: '', - detailed_description: '', - priority: '', - priority_ame: '', - first_total: '', - type: '', - is_printed: false, - section: '', - classification: '' - }; + resetForm(); } + + error = null; }); async function handleSubmit() { diff --git a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte index dc78cf10..49305d14 100644 --- a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte @@ -1041,8 +1041,8 @@ - - + + @@ -1054,8 +1054,8 @@ class="cursor-pointer border-b transition-colors hover:bg-gray-100 dark:hover:bg-gray-700" onclick={() => selectUSFraction(fraction)} > - + diff --git a/frontend/src/lib/components/dashboard/goods/fractions/SitarFractionTabs.svelte b/frontend/src/lib/components/dashboard/goods/fractions/SitarFractionTabs.svelte new file mode 100644 index 00000000..ef9b8327 --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/fractions/SitarFractionTabs.svelte @@ -0,0 +1,434 @@ + + +
+
+
+

+ Información Arancelaria (Solo Consulta) +

+
+
+
CódigoPrefijoClaveFracción Ad valorem Costo Fijo Descripción{fraction.fraction || fraction.code} {fraction.code || '—'}{fraction.fraction || '—'} {fraction.adv_impo ?? '—'} {fraction.adv_expo ?? '—'} {fraction.description || ''}
+ + + + + + + + + + + + + + + + + + +
FracciónUMTUMAdvalorem ImpoAdvalorem Expo
+ {headerFraction || '-'} + {selectedFraction?.umt || '-'}{selectedFraction?.um_code || '-'}{selectedFraction?.adv_impo || '-'}{selectedFraction?.adv_expo || '-'}
+
+ + + {#if !hasSelection} +
+ {catalog === 'mex' + ? 'Selecciona una fracción de la tabla para ver su detalle SITAR (Descripción, TLCS, PROSEC, ALADI).' + : 'Selecciona una fracción de la tabla para ver su descripción.'} +
+ {:else if catalog !== 'mex'} +
+
+

+ Descripción de la Fracción +

+
+
+ {selectedFraction?.description || 'No hay descripción disponible para esta fracción.'} +
+
+ {:else} +
+ (activeTab = v as TabKey)} class="h-full w-full flex flex-col"> + + Descripción + TLCS + PROSEC + ALADI + IMMEX + ACUERDOS + + + {#if activeError} +
{activeError}
+ {/if} + + +
+

+ Descripción de la Fracción +

+
+ {selectedFraction?.description || 'No hay descripción disponible para esta fracción.'} +
+
+
+ + +
+
+

+ Información TLCS +

+
+
+ + + + + + + + + + + {#each sitarTLCSData as item} + + + + + + + {:else} + + {/each} + +
PaísTasaD.O.FNotas
{item.PAIS}{item.TASATXT}{item.DOF || '-'}{item.NOTA || '-'}
No hay información de TLCS disponible para esta fracción.
+
+
+
+ + +
+
+

+ Programa PROSEC +

+
+
+ + + + + + + + + + + {#each sitarPROSECData as item} + + + + + + + {:else} + + {/each} + +
ArtículoSectorTasa TxtD.O.F
{item.PRODUCTO}{item.SECTOR}{item.TASA}{item.DOF || '-'}
No hay información de PROSEC disponible para esta fracción.
+
+
+
+ + +
+
+

+ Acuerdo ALADI +

+
+
+ + + + + + + + + + + {#each sitarALADIData as item} + + + + + + + {:else} + + {/each} + +
AcuerdoPaísTasaD.O.F
{item.ACUERDO}{item.PAIS}{item.TASATXT}{item.DOF || '-'}
No hay información de ALADI disponible para esta fracción.
+
+
+
+ + +
+
+

+ IMMEX / REIT +

+
+
+ + + + + + + + + + + + {#each sitarIMMEXData as item} + + + + + + + + {:else} + + + + {/each} + +
ArtículoFundamentoAcuerdoPermisoD.O.F
{item.ARTICULO || ''}{item.FUNDAMENTO || ''}{item.ACUERDO || ''}{item.PERMISO || '-'}{item.DOF || '-'}
+ No hay información de IMMEX disponible para esta fracción. +
+
+
+
+ + +
+
+

+ Acuerdos / Requisitos previos +

+
+
+ + + + + + + + + + + {#each sitarAcuerdosData as item} + + + + + + + {:else} + + + + {/each} + +
DescripciónPermisoD.O.FVigencia
{item.DESCRIPCION || ''}{item.PERMISO || '-'}{item.DOF || '-'}{item.VIGENCIA || '-'}
+ No hay información de ACUERDOS disponible para esta fracción. +
+
+
+
+
+
+ {/if} + diff --git a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte index d4577e32..cfb49d74 100644 --- a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte +++ b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte @@ -20,22 +20,29 @@ import { untrack } from 'svelte'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import TariffFractionFormDialog from './TariffFractionFormDialog.svelte'; + import SitarFractionTabs from './SitarFractionTabs.svelte'; import { toast } from 'svelte-sonner'; import { currentUser, userHasPermission } from '$lib/auth'; import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; + import { + getTariffFractionDisplayFraction, + getTariffFractionDisplayKey + } from '$lib/utils/tariff-fraction-display'; let { title = 'Fracciones Arancelarias', catalog = 'mex', // 'mex' or 'usa' levelFilter = null, // null or number readOnly = false, - basePerm: customBasePerm = null + basePerm: customBasePerm = null, + showSitarTabsOnSelect = false }: { title?: string; catalog?: string; levelFilter?: number | null; readOnly?: boolean; basePerm?: string | null; + showSitarTabsOnSelect?: boolean; } = $props(); let fractions = $state([]); @@ -74,6 +81,7 @@ let isFormDialogOpen = $state(false); let selectedFraction = $state(null); + let selectedDetailFraction = $state(null); let isManageMode = $state(false); // If true, opens form in edit mode // Delete confirmation @@ -110,6 +118,11 @@ } else { fractions = [...fractions, ...newItems]; } + if (selectedDetailFraction) { + selectedDetailFraction = + [...fractions, ...newItems].find((item) => item.id === selectedDetailFraction?.id) || + selectedDetailFraction; + } totalFractions = payload.total || 0; // Safer end-of-data detection @@ -171,6 +184,11 @@ isFormDialogOpen = true; } + function selectFractionDetail(fraction: TariffFraction) { + if (!showSitarTabsOnSelect) return; + selectedDetailFraction = fraction; + } + function confirmDelete(fraction: TariffFraction) { fractionToDelete = fraction; showDeleteConfirm = true; @@ -244,7 +262,8 @@ {/if} - +
+
@@ -289,9 +308,12 @@ {:else} {#each fractions as fraction (fraction.id)} - - {fraction.um_code || fraction.code} - {fraction.fraction} + selectFractionDetail(fraction)} + > + {getTariffFractionDisplayKey(fraction)} + {getTariffFractionDisplayFraction(fraction)} {fraction.description} @@ -346,6 +368,12 @@
+ {#if showSitarTabsOnSelect} +
+ +
+ {/if} +
Mostrando {fractions.length} de {totalFractions} registros
{/if} diff --git a/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte b/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte index b0461aa5..e23f5e9a 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte @@ -13,6 +13,7 @@ buildMexTariffDigitsFromCatalogRow, formatMexTariffDigitsForDisplay } from '$lib/utils/mexican-tariff-fraction'; + import { getTariffFractionDisplayKey } from '$lib/utils/tariff-fraction-display'; import { m } from '$lib/i18n/messages'; let { @@ -139,7 +140,7 @@ open = false; }} > - {fraction.um_code} + {getTariffFractionDisplayKey(fraction)} {formatMexTariffDigitsForDisplay( buildMexTariffDigitsFromCatalogRow(fraction) diff --git a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte index 1cf8e8f8..206ab402 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte @@ -9,6 +9,7 @@ getTariffFractions, type TariffFraction } from "$lib/api/dashboard/a76/general_catalogs/tariff-fractions"; + import { getTariffFractionDisplayKey } from '$lib/utils/tariff-fraction-display'; import { companyStore } from "$lib/stores/company.svelte"; import { m } from '$lib/i18n/messages'; @@ -155,7 +156,7 @@
- {item.fraction || item.code} + {getTariffFractionDisplayKey(item)}
diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index ec05e492..5cb464ad 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -26,7 +26,6 @@ Settings, Image as ImageIcon, FolderSearch, - UserCheck, CheckCircle2, XCircle, Tag, @@ -53,7 +52,6 @@ // 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'; import { countriesApi } from '$lib/api/dashboard/reference_data/countries'; @@ -187,8 +185,6 @@ let showNonDischargeDialog = $state(false); // Descripciones Visuales - let selectedClientName = $state(''); - let selectedClientStatus = $state(true); let selectedClassDesc = $state(''); let selectedCurrencyName = $state(''); let selectedCountryName = $state(''); @@ -406,7 +402,6 @@ // Estado Formulario let formData = $state({ id: 0, - client_id: 0, part_number: '', assigned_client: '', description_spanish: '', @@ -525,7 +520,6 @@ const d = response.data; formData = { id: d.id, - client_id: d.client_id, part_number: d.part_number, description_spanish: d.description_spanish || '', description_english: d.description_english || '', @@ -648,7 +642,6 @@ if (d.currency_key === 'MXN') formData.currency_type = 'NA'; else formData.currency_type = 'EX'; - if (d.client_id) await fetchClientName(d.client_id, companyId); if (d.part_class) await fetchClassDesc(d.part_class, companyId); if (d.inv_data?.material_type) await fetchMaterialName(d.inv_data.material_type); await fetchSectorDesc(formData.sector); @@ -674,19 +667,6 @@ }); // --- 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.error('Error visual cliente', e); - } - } - async function fetchClassDesc(code: string, companyId: number) { try { const res = await classesApi.list({ company_id: companyId, class_code: code }); @@ -745,20 +725,6 @@ if (modalContext === 'non_discharge') { tempNonDischargeItem.client_id = client.id; tempNonDischargeItem.name = client.name; - } else { - formData.client_id = client.id; - selectedClientName = client.name; - selectedClientStatus = client.is_active ?? true; - - // Cambio reactivo: Si estamos en SCAI y seleccionamos un cliente, - // asumimos que el usuario quiere convertirlo a SCAF (Activo Fijo). - if (formType === 'inv') { - formType = 'fa'; - toast.info('Cambio de sistema detectado', { - description: - 'Se ha cambiado automáticamente a SCAF (Activo Fijo) al seleccionar un cliente.' - }); - } } modalContext = 'main'; } @@ -937,10 +903,6 @@ error = 'No hay una compañía activa seleccionada'; return; } - if (!formData.client_id && formType !== 'inv') { - error = 'Debe seleccionar un Cliente'; - return; - } if (!formData.unit_of_measure) { error = 'Debe seleccionar una Unidad de Medida'; return; @@ -973,9 +935,6 @@ commonData.scrap_export_fraction = normalizeMexTariffDigitsStored( commonData.scrap_export_fraction ); - if (commonData.client_id === 0) { - commonData.client_id = null; - } if (!commonData.currency_key || commonData.currency_key === 'USD') { commonData.currency_key = null; } @@ -1155,39 +1114,6 @@ {isEdit ? 'Editar' : 'Nueva'} -
- - - -
-
@@ -1219,7 +1145,7 @@ class="animate-in fade-in space-y-8 pt-6 duration-300" >
-
+
@@ -1235,36 +1161,6 @@ />
-
- -
-
- - (showClientModal = true)} - onkeydown={(event) => - openOnEnterOrSpace(event, () => (showClientModal = true))} - tabindex="0" - class="cursor-pointer pl-9 transition-colors hover:bg-muted/50" - placeholder="Seleccione un cliente..." - /> -
- -
-
@@ -1699,7 +1595,7 @@ >
-
+
-
- -
-
- - (showClientModal = true)} - onkeydown={(event) => - openOnEnterOrSpace(event, () => (showClientModal = true))} - tabindex="0" - class="cursor-pointer pl-9 transition-colors hover:bg-muted/50" - placeholder="Asignar cliente..." - /> -
- -
-

- * Al asignar un cliente, se cambiará automáticamente a modo SCAF. -

-
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/class-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/class-dialog.svelte index 5ae270bb..b3a75637 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/class-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/class-dialog.svelte @@ -134,22 +134,6 @@ - {#if classesLoadError} - - {/if} -
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte index f15141ff..a33a6a3d 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte @@ -132,22 +132,6 @@ - {#if partsLoadError} - - {/if} -
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte index 1cf364f5..82e4aee7 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte @@ -173,22 +173,6 @@
Series - {#if seriesErrorMessage} - - {/if} -
- -

- Tipo de fecha para TC: FECHA {getEffectiveDateLabel().toUpperCase()} - {#if formData.pedimento_type === 'consolidated' && getEffectiveExchangeDate() > getCurrentLocalDate()} - - (Opcional por fecha futura en consolidado) - - {/if} -

+
+ +

+ FECHA {getEffectiveDateLabel().toUpperCase()} +

+
+ {#if formData.pedimento_type === 'consolidated' && getEffectiveExchangeDate() > getCurrentLocalDate()} +

+ (Opcional por fecha futura en consolidado) +

+ {/if}
diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/columns.ts b/frontend/src/lib/components/dashboard/transportation/vehicles/columns.ts index 2c9caa6f..cdda5571 100644 --- a/frontend/src/lib/components/dashboard/transportation/vehicles/columns.ts +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/columns.ts @@ -5,6 +5,7 @@ import type { Vehicle } from '$lib/api/dashboard/a76/vehicles'; import type { ColumnDef } from '@tanstack/table-core'; import { renderComponent } from '$lib/components/ui/data-table'; import DataTableActions from './data-table-actions.svelte'; +import { tv, tvTransportType } from '$lib/i18n/vehicles'; export function createColumns( onSuccess: () => void, @@ -13,49 +14,50 @@ export function createColumns( return [ { accessorKey: 'vehicle_key', - header: 'Clave', + header: tv('col_key'), cell: ({ row }) => { return row.original.vehicle_key; } }, { accessorKey: 'brand', - header: 'Marca', + header: tv('col_brand'), cell: ({ row }) => { return row.original.brand || '-'; } }, { accessorKey: 'year', - header: 'Año', + header: tv('col_year'), cell: ({ row }) => { return row.original.year || '-'; } }, { accessorKey: 'plate_number', - header: 'Placas', + header: tv('col_plate'), cell: ({ row }) => { return row.original.plate_number || '-'; } }, { accessorKey: 'transporter_key', - header: 'Transportista', + header: tv('col_transporter'), cell: ({ row }) => { return row.original.transporter_key || '-'; } }, { accessorKey: 'transport_type', - header: 'Tipo Transporte', + header: tv('col_transport_type'), cell: ({ row }) => { - return row.original.transport_type || '-'; + if (!row.original.transport_type) return '-'; + return `${row.original.transport_type} - ${tvTransportType(row.original.transport_type, row.original.transport_type)}`; } }, { id: 'actions', - header: 'Acciones', + header: tv('col_actions'), cell: ({ row }) => { return renderComponent(DataTableActions, { item: row.original, diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte index 8bbc2f07..b3b57717 100644 --- a/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte @@ -11,6 +11,7 @@ import { countriesApi, type Country } from '$lib/api/dashboard/reference_data/countries'; import { statesApi, type State } from '$lib/api/dashboard/reference_data/states'; import { companyStore } from '$lib/stores/company.svelte'; + import { tv, tvTransportType } from '$lib/i18n/vehicles'; import { browser } from '$app/environment'; let { @@ -25,7 +26,7 @@ // Determinar si es modo edición o creación const isEdit = $derived(!!item); - const title = $derived(isEdit ? 'Editar Vehículo' : 'Nuevo Vehículo'); + const title = $derived(isEdit ? tv('dialog_edit_title') : tv('dialog_new_title')); // Estado del formulario let formData = $state({ @@ -66,35 +67,16 @@ let refsLoading = $state(false); const ENTITY_OPTS = [ - { value: '', label: '— Sin código —' }, + { value: '', label: tv('no_code') }, { value: 'C', label: 'C' }, { value: 'I', label: 'I' }, { value: 'A', label: 'A' }, { value: 'B', label: 'B' } ]; - const TRANSPORT_TYPE_TRANSLATIONS: Record = { - AR: 'Camión Blindado', - AU: 'Automóviles', - BT: 'Camión de Caja', - BU: 'Autobús', - BV: 'Camión de Bebidas', - BY: 'Bicicleta', - CO: 'Vehículo de Construcción', - EV: 'Vehículo de Emergencia', - FE: 'Ferry', - FM: 'Tractor Agrícola', - GB: 'Camión de Basura', - MC: 'Motocicleta', - OC: 'Otro', - PM: 'Camioneta con cabina', - PN: 'Camión Panel', - PU: 'Camioneta (Pick-up)', - PV: 'Pasajero', - RV: 'Vehículo Recreativo (RV)', - TR: 'Tractocamión', - TV: 'Van' - }; + function getTransportTypeText(transportCode?: string, description?: string): string { + return tvTransportType(transportCode, description); + } const countryM3 = $derived( countries.find((c) => c.ame_key === formData.country)?.m3_key ?? null @@ -110,7 +92,7 @@ try { const [tr, tt, cc, ss] = await Promise.all([ transportersApi.list(cid, { page: 1, page_size: 100 }), - transportTypesApi.list(1, 100), + transportTypesApi.list(cid, 1, 100), countriesApi.list(1, 100), statesApi.list(1, 100) ]); @@ -181,12 +163,12 @@ try { const companyId = companyStore.activeCompany?.id; if (!companyId) { - throw new Error('No hay una compañía seleccionada'); + throw new Error(tv('no_company_selected')); } // Validación básica if (!formData.vehicle_key.trim()) { - throw new Error('La clave del vehículo es requerida'); + throw new Error(tv('vehicle_key_required')); } let response; @@ -210,7 +192,7 @@ onSuccess(); } } catch (e) { - error = e instanceof Error ? e.message : 'Error al guardar el vehículo'; + error = e instanceof Error ? e.message : tv('save_error'); } finally { loading = false; } @@ -238,8 +220,8 @@ {title} {isEdit - ? 'Modifica los datos del vehículo' - : 'Completa los datos para crear un nuevo vehículo de transporte'} + ? tv('dialog_edit_description') + : tv('dialog_new_description')} @@ -258,21 +240,21 @@ - Información General - Seguro y Detalles + {tv('tab_general')} + {tv('tab_details')}

- Identificación del Vehículo + {tv('section_vehicle_identification')}

{tv('label_vehicle_key')} *
- - + +
- - + +
- - + +
- - + +
@@ -308,22 +290,22 @@

- Datos de Transporte + {tv('section_transport_data')}

- + {refsLoading ? '...' : formData.transporter_key ? `${formData.transporter_key} — ${transporters.find((t) => t.transporter_key === formData.transporter_key)?.name ?? ''}` - : '— Seleccionar transportista —'} + : tv('select_transporter')} - — Vacío — + {tv('empty')} {#each transporters as t} {t.transporter_key} — {t.name || t.short_name || ''} @@ -334,30 +316,30 @@
- +
- + {refsLoading ? '...' : formData.transport_type - ? `${formData.transport_type} — ${TRANSPORT_TYPE_TRANSLATIONS[formData.transport_type] || transportTypes.find((x) => x.transport_code === formData.transport_type)?.description || ''}` - : '— Opcional —'} + ? `${formData.transport_type} — ${getTransportTypeText(formData.transport_type, transportTypes.find((x) => (x.transport_code || '').trim().toUpperCase() === (formData.transport_type || '').trim().toUpperCase())?.description)}` + : tv('optional')} - — Vacío — + {tv('empty')} {#each transportTypes as x} - {x.transport_code} — {TRANSPORT_TYPE_TRANSLATIONS[x.transport_code] || x.description} + {x.transport_code} — {getTransportTypeText(x.transport_code, x.description)} {/each} @@ -365,11 +347,11 @@
- + {ENTITY_OPTS.find((o) => o.value === (formData.entity_code || ''))?.label ?? - '— Sin código —'} + tv('no_code')} {#each ENTITY_OPTS as o} @@ -377,12 +359,12 @@ {/each} -

Obligatorio al crear (reglas CSV)

+

{tv('entity_required_hint')}

- - + +
@@ -392,31 +374,31 @@

- Seguro y Otros + {tv('section_insurance')}

- +
- +
- +
- +
@@ -435,22 +417,22 @@

- Ubicación y Detalles + {tv('section_location')}

- + {refsLoading ? '...' : formData.country ? `${formData.country} — ${countries.find((c) => c.ame_key === formData.country)?.description_es ?? ''}` - : '— Opcional —'} + : tv('optional')} - — Vacío — + {tv('empty')} {#each countries as c} {c.ame_key} — {c.description_es} @@ -460,16 +442,16 @@
- + {refsLoading ? '...' : formData.state || - (formData.country ? '— Selecciona —' : '— Primero el país —')} + (formData.country ? tv('select_state') : tv('select_country_first'))} - — Vacío — + {tv('empty')} {#each statesFiltered as s} {s.description} @@ -480,21 +462,21 @@
- - + +
- - + +
- +
@@ -502,12 +484,12 @@
- +
@@ -516,10 +498,10 @@ diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/data-table-actions.svelte b/frontend/src/lib/components/dashboard/transportation/vehicles/data-table-actions.svelte index 255014ce..678d30e7 100644 --- a/frontend/src/lib/components/dashboard/transportation/vehicles/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/data-table-actions.svelte @@ -5,6 +5,7 @@ import { companyStore } from '$lib/stores/company.svelte'; import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte'; import CreateEditDialog from './create-edit-dialog.svelte'; + import { tv } from '$lib/i18n/vehicles'; let { item, @@ -26,14 +27,14 @@ async function handleDelete() { if ( !confirm( - `¿Estás seguro de eliminar el vehículo "${item.vehicle_key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.` + tv('delete_confirm', { key: item.vehicle_key }) ) ) { return; } if (!companyStore.activeCompany) { - alert('❌ Error: No hay una compañía seleccionada'); + alert(tv('no_company_error')); return; } @@ -45,25 +46,25 @@ if (response.error) { if (response.status === 401) { - error = 'Sesión expirada. Recargando página...'; + error = tv('session_expired'); setTimeout(() => { window.location.reload(); }, 1500); } else { error = response.error; - alert(`❌ Error al eliminar:\n\n${response.error}`); + alert(`${tv('delete_error_title')}\n\n${response.error}`); } return; } // Éxito - alert(`✅ Vehículo "${item.vehicle_key}" eliminado correctamente`); + alert(tv('delete_success_item', { key: item.vehicle_key })); if (onSuccess) { onSuccess(); } } catch (e) { - error = e instanceof Error ? e.message : 'Error al eliminar'; - alert(`❌ Error: ${error}`); + error = e instanceof Error ? e.message : tv('delete_generic_error'); + alert(tv('delete_error_alert', { error })); console.error('Error deleting:', e); } finally { loading = false; @@ -88,18 +89,18 @@ {#snippet child({ props })} {/snippet} - Acciones + {tv('menu_actions')} {#if canEdit} - Editar + {tv('edit')} {/if} {#if canDelete} @@ -110,7 +111,7 @@ {:else} {/if} - Eliminar + {tv('delete')} {/if} diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 6cadef43..42ab79dc 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -291,10 +291,6 @@ export function getSidebarData(): SidebarData { title: m["sidebar.general_catalogs.customs_warehouses"](), url: "/dashboard/reference_data/customs_warehouses", }, - { - title: m["sidebar.general_catalogs.doda"](), - url: "/dashboard/general_catalogs/doda", - }, { title: m["sidebar.general_catalogs.prevalidators"](), url: "/dashboard/general_catalogs/prevalidators", @@ -514,6 +510,21 @@ export function getSidebarData(): SidebarData { icon: BadgeCheck, items: [], }, + { + title: m["sidebar.despacho.title"](), + url: "#", + icon: GalleryVerticalEnd, // Using a suitable icon + items: [ + { + title: m["sidebar.despacho.doda"](), + url: "/dashboard/despacho/doda", + }, + { + title: m["sidebar.despacho.digitalizacion"](), + url: "/dashboard/despacho/digitalizacion", + }, + ], + }, { title: m["sidebar.reports.title"](), url: "#", @@ -533,12 +544,6 @@ export function getSidebarData(): SidebarData { }, ], }, - { - title: m["sidebar.digitalizacion.title"](), - url: "/dashboard/digitalizacion", - icon: FolderArchive, - items: [], - }, { title: m["sidebar.reference_data.configuracion"](), url: "#", diff --git a/frontend/src/lib/components/sidebar/nav-projects.svelte b/frontend/src/lib/components/sidebar/nav-projects.svelte index 15f67eb9..ced6fb82 100644 --- a/frontend/src/lib/components/sidebar/nav-projects.svelte +++ b/frontend/src/lib/components/sidebar/nav-projects.svelte @@ -86,7 +86,7 @@ More - + (open = v)}> - + diff --git a/frontend/src/lib/components/ui/error-panel-notice.svelte b/frontend/src/lib/components/ui/error-panel-notice.svelte index 1022d86e..e69de29b 100644 --- a/frontend/src/lib/components/ui/error-panel-notice.svelte +++ b/frontend/src/lib/components/ui/error-panel-notice.svelte @@ -1,203 +0,0 @@ - - -{#if open} -
- -
- -

{title}

- - - - -
- - {#if expanded && rows.length > 0} -
- - - - - - - {#if dismissibleRows && onDismissRow} - - {/if} - - - - {#each rows as row, i (i)} - - - - - {#if dismissibleRows && onDismissRow} - - {/if} - - {/each} - -
- {labels.columnType} - - {labels.columnField} - - {labels.columnMessage} -
- - - {row.field || labels.emptyField} - - {row.message} - - -
-
- {/if} -
-{/if} diff --git a/frontend/src/lib/i18n/vehicles.ts b/frontend/src/lib/i18n/vehicles.ts new file mode 100644 index 00000000..16a2efc7 --- /dev/null +++ b/frontend/src/lib/i18n/vehicles.ts @@ -0,0 +1,266 @@ +import { getLocale } from '$lib/paraglide/runtime'; + +type VehiclesLocale = 'es' | 'en'; + +const MESSAGES = { + es: { + shortcuts_scope: 'Vehículos (transporte)', + shortcuts_save: 'Guardar', + page_title: 'Vehículos (Transporte)', + page_subtitle: 'Gestión del catálogo de camiones y vehículos de transporte', + new_vehicle: 'Nuevo Vehículo', + vehicle_list: 'Listado de Vehículos', + search_key: 'Clave', + search_plate: 'Placas', + loading_vehicles: 'Cargando vehículos...', + showing_records: 'Mostrando {shown} de {total} registros', + edit: 'Editar', + delete: 'Eliminar', + delete_confirm: '¿Estás seguro de eliminar el vehículo "{key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.', + delete_error_title: '❌ Error al eliminar:', + delete_success: '✅ Vehículo eliminado correctamente', + col_key: 'Clave', + col_brand: 'Marca', + col_year: 'Año', + col_plate: 'Placas', + col_transporter: 'Transportista', + col_transport_type: 'Tipo Transporte', + col_actions: 'Acciones', + menu_open: 'Abrir menú', + menu_actions: 'Acciones', + session_expired: 'Sesión expirada. Recargando página...', + no_company_error: '❌ Error: No hay una compañía seleccionada', + delete_generic_error: 'Error al eliminar', + delete_error_alert: '❌ Error: {error}', + delete_success_item: '✅ Vehículo "{key}" eliminado correctamente', + dialog_edit_title: 'Editar Vehículo', + dialog_new_title: 'Nuevo Vehículo', + dialog_edit_description: 'Modifica los datos del vehículo', + dialog_new_description: 'Completa los datos para crear un nuevo vehículo de transporte', + tab_general: 'Información General', + tab_details: 'Seguro y Detalles', + section_vehicle_identification: 'Identificación del Vehículo', + label_vehicle_key: 'Clave del Vehículo', + placeholder_vehicle_key: 'Ej: VH001', + label_brand: 'Marca', + placeholder_brand: 'Ej: Kenworth', + label_year: 'Año', + placeholder_year: 'YYYY', + label_plate_number: 'Placas', + placeholder_plate_number: 'Placas actuales', + label_series: 'Serie / VIN', + placeholder_series: 'Número de serie', + section_transport_data: 'Datos de Transporte', + label_transporter: 'Transportista', + select_transporter: '— Seleccionar transportista —', + empty: '— Vacío —', + label_transport_identifier: 'ID Transporte', + placeholder_transport_identifier: 'Identificador único', + label_transport_type: 'Tipo de Transporte', + optional: '— Opcional —', + label_entity_code: 'Código de Entidad', + no_code: '— Sin código —', + entity_required_hint: 'Obligatorio al crear (reglas CSV)', + label_sct_permission: 'Permiso SCT', + placeholder_sct_permission: 'Número de permiso', + section_insurance: 'Seguro y Otros', + label_insurance_company: 'Aseguradora', + placeholder_insurance_company: 'Nombre de la compañía', + label_insurance_number: 'Póliza', + placeholder_insurance_number: 'Número de póliza', + label_insurance_amount: 'Monto', + label_dot_number: 'Número DOT', + label_country: 'País (AME)', + label_state: 'Estado', + select_state: '— Selecciona —', + select_country_first: '— Primero el país —', + label_city: 'Ciudad', + placeholder_city: 'Ciudad/Localidad', + label_color: 'Color', + placeholder_color: 'Color del vehículo', + label_container_type: 'Tipo Contenedor', + placeholder_container_type: 'Ej: 40G', + section_location: 'Ubicación y Detalles', + label_additional_description: 'Descripción Adicional', + placeholder_additional_description: 'Notas adicionales sobre el vehículo...', + cancel: 'Cancelar', + saving: 'Guardando...', + update: 'Actualizar', + create: 'Crear', + no_company_selected: 'No hay una compañía seleccionada', + vehicle_key_required: 'La clave del vehículo es requerida', + save_error: 'Error al guardar el vehículo', + transport_types: { + AR: 'Camión Blindado', + AU: 'Automóviles', + BT: 'Camión de Caja', + BU: 'Autobús', + BV: 'Camión de Bebidas', + BY: 'Bicicleta', + CO: 'Vehículo de Construcción', + EV: 'Vehículo de Emergencia', + FE: 'Ferry', + FM: 'Tractor Agrícola', + GB: 'Camión de Basura', + MC: 'Motocicleta', + OC: 'Otro', + PM: 'Camioneta con cabina', + PN: 'Camión Panel', + PU: 'Camioneta (Pick-up)', + PV: 'Pasajero', + RV: 'Vehículo Recreativo (RV)', + TR: 'Tractocamión', + TV: 'Van' + } + }, + en: { + shortcuts_scope: 'Vehicles (transport)', + shortcuts_save: 'Save', + page_title: 'Vehicles (Transport)', + page_subtitle: 'Transport trucks and vehicles catalog management', + new_vehicle: 'New Vehicle', + vehicle_list: 'Vehicle List', + search_key: 'Key', + search_plate: 'Plates', + loading_vehicles: 'Loading vehicles...', + showing_records: 'Showing {shown} of {total} records', + edit: 'Edit', + delete: 'Delete', + delete_confirm: 'Are you sure you want to delete vehicle "{key}"?\n\nNote: It cannot be deleted if it has related records.', + delete_error_title: '❌ Error deleting:', + delete_success: '✅ Vehicle deleted successfully', + col_key: 'Key', + col_brand: 'Brand', + col_year: 'Year', + col_plate: 'Plates', + col_transporter: 'Transporter', + col_transport_type: 'Transport Type', + col_actions: 'Actions', + menu_open: 'Open menu', + menu_actions: 'Actions', + session_expired: 'Session expired. Reloading page...', + no_company_error: '❌ Error: No company selected', + delete_generic_error: 'Error deleting', + delete_error_alert: '❌ Error: {error}', + delete_success_item: '✅ Vehicle "{key}" deleted successfully', + dialog_edit_title: 'Edit Vehicle', + dialog_new_title: 'New Vehicle', + dialog_edit_description: 'Modify vehicle data', + dialog_new_description: 'Complete the data to create a new transport vehicle', + tab_general: 'General Information', + tab_details: 'Insurance and Details', + section_vehicle_identification: 'Vehicle Identification', + label_vehicle_key: 'Vehicle Key', + placeholder_vehicle_key: 'Eg: VH001', + label_brand: 'Brand', + placeholder_brand: 'Eg: Kenworth', + label_year: 'Year', + placeholder_year: 'YYYY', + label_plate_number: 'Plates', + placeholder_plate_number: 'Current plates', + label_series: 'Series / VIN', + placeholder_series: 'Serial number', + section_transport_data: 'Transport Data', + label_transporter: 'Transporter', + select_transporter: '— Select transporter —', + empty: '— Empty —', + label_transport_identifier: 'Transport ID', + placeholder_transport_identifier: 'Unique identifier', + label_transport_type: 'Transport Type', + optional: '— Optional —', + label_entity_code: 'Entity Code', + no_code: '— No code —', + entity_required_hint: 'Required on create (CSV rules)', + label_sct_permission: 'SCT Permit', + placeholder_sct_permission: 'Permit number', + section_insurance: 'Insurance and Others', + label_insurance_company: 'Insurance Company', + placeholder_insurance_company: 'Company name', + label_insurance_number: 'Policy', + placeholder_insurance_number: 'Policy number', + label_insurance_amount: 'Amount', + label_dot_number: 'DOT Number', + label_country: 'Country (AME)', + label_state: 'State', + select_state: '— Select —', + select_country_first: '— Select country first —', + label_city: 'City', + placeholder_city: 'City/Location', + label_color: 'Color', + placeholder_color: 'Vehicle color', + label_container_type: 'Container Type', + placeholder_container_type: 'Eg: 40G', + section_location: 'Location and Details', + label_additional_description: 'Additional Description', + placeholder_additional_description: 'Additional notes about the vehicle...', + cancel: 'Cancel', + saving: 'Saving...', + update: 'Update', + create: 'Create', + no_company_selected: 'No company selected', + vehicle_key_required: 'Vehicle key is required', + save_error: 'Error saving vehicle', + transport_types: { + AR: 'Armored Truck', + AU: 'Automobiles', + BT: 'Box Truck', + BU: 'Bus', + BV: 'Beverage Truck (Refer or not)', + BY: 'Bicycle', + CO: 'Construction Vehicle (general)', + EV: 'Emergency Vehicle (general)', + FE: 'Ferry', + FM: 'Farm Tractor', + GB: 'Garbage Truck', + MC: 'Motorcycle', + OC: 'Other', + PM: 'Pick-up Truck w/camper', + PN: 'Panel Truck', + PU: 'Pickup Truck', + PV: 'Passenger', + RV: 'Recreation Vehicle (RV)', + TR: 'Semi Tracker', + TV: 'Van' + } + } +} as const; + +export type VehiclesMessageKey = Exclude; + +function activeLocale(): VehiclesLocale { + if (typeof window !== 'undefined') { + const htmlLang = document.documentElement?.lang?.toLowerCase() || ''; + if (htmlLang.startsWith('es')) return 'es'; + if (htmlLang.startsWith('en')) return 'en'; + + const storedLocaleCandidates = [ + window.localStorage.getItem('locale'), + window.localStorage.getItem('lang'), + window.localStorage.getItem('language') + ] + .filter(Boolean) + .map((value) => String(value).toLowerCase()); + + if (storedLocaleCandidates.some((value) => value.startsWith('es'))) return 'es'; + if (storedLocaleCandidates.some((value) => value.startsWith('en'))) return 'en'; + } + + return getLocale().startsWith('es') ? 'es' : 'en'; +} + +export function tv(key: VehiclesMessageKey, vars?: Record): string { + const locale = activeLocale(); + let text = MESSAGES[locale][key] as string; + if (!vars) return text; + for (const [name, value] of Object.entries(vars)) { + text = text.replaceAll(`{${name}}`, String(value)); + } + return text; +} + +export function tvTransportType(code?: string, fallback?: string): string { + const normalizedCode = (code || '').trim().toUpperCase(); + if (!normalizedCode) return fallback || ''; + const locale = activeLocale(); + return MESSAGES[locale].transport_types[normalizedCode as keyof (typeof MESSAGES)['es']['transport_types']] || fallback || ''; +} diff --git a/frontend/src/lib/utils/tariff-fraction-display.test.ts b/frontend/src/lib/utils/tariff-fraction-display.test.ts new file mode 100644 index 00000000..26f0d7bc --- /dev/null +++ b/frontend/src/lib/utils/tariff-fraction-display.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + getTariffFractionDisplayFraction, + getTariffFractionDisplayKey +} from './tariff-fraction-display'; +import type { TariffFraction } from '$lib/api/dashboard/a76/general_catalogs/tariff-fractions'; + +function buildFraction(partial: Partial): TariffFraction { + return { + id: 1, + code: '', + fraction: '', + description: null, + nico: null, + umt: null, + adv_impo: null, + adv_expo: null, + updated_at: null, + dof: null, + aplica_ieps: null, + um_code: null, + ...partial + }; +} + +describe('tariff-fraction-display', () => { + it('uses technical code for key column', () => { + const row = buildFraction({ + code: '01012101', + fraction: '0101.21.01', + um_code: '06' + }); + expect(getTariffFractionDisplayKey(row)).toBe('01012101'); + }); + + it('uses formatted fraction for fraction column', () => { + const row = buildFraction({ + code: '1234567890', + fraction: '1234.56.78.90' + }); + expect(getTariffFractionDisplayFraction(row)).toBe('1234.56.78.90'); + }); +}); diff --git a/frontend/src/lib/utils/tariff-fraction-display.ts b/frontend/src/lib/utils/tariff-fraction-display.ts new file mode 100644 index 00000000..bd2f73c5 --- /dev/null +++ b/frontend/src/lib/utils/tariff-fraction-display.ts @@ -0,0 +1,9 @@ +import type { TariffFraction } from '$lib/api/dashboard/a76/general_catalogs/tariff-fractions'; + +export function getTariffFractionDisplayKey(fraction: TariffFraction): string { + return fraction.code || '-'; +} + +export function getTariffFractionDisplayFraction(fraction: TariffFraction): string { + return fraction.fraction || '-'; +} diff --git a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte index 433149e2..8384d1b7 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte @@ -16,10 +16,6 @@ import { toast } from 'svelte-sonner'; import { companyStore } from '$lib/stores/company.svelte'; import type { ApiError } from '$lib/utils/error-handler'; - import { authStore, userHasPermission } from '$lib/auth'; - import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; - import DataTable from '$lib/components/dashboard/clients_and_providers/data-table.svelte'; - import { createColumns } from '$lib/components/dashboard/clients_and_providers/columns'; // Los datos iniciales vienen del servidor let { data }: { data: any } = $props(); @@ -33,38 +29,29 @@ let currentPage = $state(data.page || 1); let pageSize = $state(50); let totalItems = $state(data.total || 0); - let hasMore = $derived(items.length < totalItems); // Filter state let searchName = $state(''); let searchRfc = $state(''); let searchType = $state($page.url.searchParams.get('type') || 'both'); - let filterDebounce: ReturnType | null = null; // Estado para el diálogo de crear let showCreateDialog = $state(false); let error = $state(data.error || null); - // Permisos - const canView = $derived(userHasPermission($authStore.user, 'partners_mgmt.view')); - const canCreate = $derived(userHasPermission($authStore.user, 'partners_mgmt.create')); - const canEdit = $derived(userHasPermission($authStore.user, 'partners_mgmt.edit')); - const canDelete = $derived(userHasPermission($authStore.user, 'partners_mgmt.delete')); - // --- Lifecycle --- onMount(() => { if (browser) { - const getCookie = (name: string): string | null => { + // Sincronizar token de cookies a localStorage si es necesario + const getCookie = (name: string) => { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop()?.split(';').shift() || null; return null; }; - const cookieToken = getCookie('access_token'); const localToken = localStorage.getItem('access_token'); - if (cookieToken && cookieToken !== localToken) localStorage.setItem('access_token', cookieToken); @@ -77,7 +64,7 @@ // --- Actions --- - async function loadItems(pageToLoad = 1, append = false) { + async function loadItems(pageToLoad = 1) { const companyId = companyStore.activeCompany?.id; if (!companyId) return; @@ -85,10 +72,13 @@ try { const filters: any = {}; if (searchType !== 'both') filters.type = searchType; - const trimmedName = searchName.trim(); - const trimmedRfc = searchRfc.trim(); - if (trimmedName) filters.name = trimmedName; - if (trimmedRfc) filters.rfc = trimmedRfc; + // Note: The API technically supports name/rfc fitlering if backend implements it. + // Assuming backend supports 'name' and 'rfc' query params based on standard patterns, + // or we filter client side if the list is small. + // Given pagination, we should try sending them. If backend ignores them, we might need client filtering. + // Ideally backend should handle this. I will assume backend filters for now or add query params. + if (searchName) filters.name = searchName; + if (searchRfc) filters.rfc = searchRfc; const response = await clientsProvidersApi.list(companyId, pageToLoad, pageSize, filters); @@ -103,11 +93,7 @@ } if (response.data) { - if (append) { - items = [...items, ...response.data.items]; - } else { - items = response.data.items; - } + items = response.data.items; totalItems = response.data.total; currentPage = response.data.page; } @@ -119,13 +105,13 @@ } } - async function loadMore() { - if (isLoading || !hasMore) return; - await loadItems(currentPage + 1, true); - } - function handleTypeChange(value: string) { searchType = value; + loadItems(1); + } + + function handleSearch() { + loadItems(1); } function selectItem(item: ClientProvider) { @@ -142,10 +128,6 @@ if (selectedItem) goto(`/dashboard/clients_and_providers/edit/${selectedItem.id}`); } - function handleSearch() { - loadItems(1); - } - import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosListaSocios } from '$lib/config/shortcuts/dashboard/clients_and_providers/list'; @@ -173,218 +155,331 @@ recargar: () => loadItems(1) }) ); - - $effect(() => { - const companyId = companyStore.activeCompany?.id; - if (!browser || !companyId) return; - - searchName; - searchRfc; - searchType; - pageSize; - - if (filterDebounce) clearTimeout(filterDebounce); - filterDebounce = setTimeout(() => { - selectedItem = null; - loadItems(1); - }, 350); - - return () => { - if (filterDebounce) clearTimeout(filterDebounce); - }; - }); -
- {#if !canView} - window.location.reload()} - /> - {:else} -
-
-

Socio Comercial

-

Administración de clientes y proveedores

-
-
- - {#if canCreate} - - {/if} - {#if canEdit} - - {/if} - {#if canDelete} - - {/if} -
-
+
+ +
+

CLIENTES Y PROVEEDORES

+

+ Gestiona el catálogo de clientes y proveedores de tu empresa +

+
- {#if error} -
- {typeof error === 'string' ? error : error.detail} -
- {/if} + + {#if error} + + + Error + + {typeof error === 'string' ? error : error.detail} + + + + {/if} -
- -
- -
-
-
-

Filtros

- Busque por nombre, RFC/TAX-ID o tipo -
-
-
- - e.key === 'Enter' && handleSearch()} - /> -
-
- - e.key === 'Enter' && handleSearch()} - /> -
-
- - - - {searchType === 'both' - ? 'Todos' - : searchType === 'client' - ? 'Clientes' - : 'Proveedores'} - - - Todos - Clientes - Proveedores - - -
-
- -
-
+
+ +
+ +
+
+
+

Filtros

+ Busque por nombre, RFC/TAX-ID o tipo
-
- - -
-
-

Listado

-
- - {totalItems} registros - -
- -
- loadItems(1))} - loading={isLoading} - {hasMore} - {loadMore} - onRowClick={(row) => selectItem(row as ClientProvider)} - selectedId={selectedItem?.id} - /> -
- -
-
-

- Detalles del Registro -

-

- {selectedItem?.name || '---'} -

-
- {taxIdOrRfcLabel(selectedItem)}: - {selectedItem?.rfc || ''} + +
+
+

Listado

+
+ + {totalItems} registros + +
-
- {#if selectedItem} -
-
- -

{selectedItem.client_or_provider}

-
- - {#if selectedItem.address} -
- -
-

{selectedItem.address.streets || ''} {selectedItem.address.exterior_number || ''}

-

{selectedItem.address.neighborhood || ''}

-

{selectedItem.address.city || ''}, {selectedItem.address.state || ''}

-

{selectedItem.address.postal_code || ''}, {selectedItem.address.country || ''}

-
-
+
+ + + + + + + + + + + + {#if isLoading} + + {:else if items.length === 0} + + {:else} + {#each items as item (item.id)} + selectItem(item)} + > + + + + + + + {/each} {/if} - - {:else} -
- -

Selecciona un registro

-
- {/if} + +
#RFC / TAX-IDNombreTipoEstatus
Cargando...
No se encontraron registros
{item.id}{item.rfc}{item.name} + {#if item.client_or_provider === 'client'} + Cliente + {:else if item.client_or_provider === 'provider'} + Proveedor + {:else} + Ambos + {/if} + + + {item.is_active ? 'Activo' : 'Inactivo'} + +
+
+ +
+ + + Página {currentPage} de {Math.ceil(totalItems / pageSize)} + +
- {/if} + + +
+
+

+ Detalles del Registro +

+

+ {selectedItem?.name || '---'} +

+
+ {taxIdOrRfcLabel(selectedItem)}: + {selectedItem?.rfc || ''} +
+
+ +
+ {#if selectedItem} +
+
+ +

{selectedItem.client_or_provider}

+
+ + {#if selectedItem.address} +
+ +
+

+ {selectedItem.address.streets || ''} + {selectedItem.address.exterior_number || ''} + {selectedItem.address.interior_number + ? 'Int ' + selectedItem.address.interior_number + : ''} +

+

{selectedItem.address.neighborhood || ''}

+

{selectedItem.address.city || ''}, {selectedItem.address.state || ''}

+

+ {selectedItem.address.postal_code || ''}, {selectedItem.address.country || ''} +

+
+
+ +
+ + {#if selectedItem.address.email} +
+ + {selectedItem.address.email} +
+ {/if} + {#if selectedItem.address.phone} +
+ + {selectedItem.address.phone} +
+ {/if} +
+ {:else} +
+

Sin dirección registrada

+
+ {/if} + + {#if selectedItem.programs} +
+ +
+
+ Programa + {selectedItem.programs.program || '-'} +
+
+ Número + {selectedItem.programs.program_number || '-'} +
+
+
+ {/if} +
+ {:else} +
+ +

Selecciona un registro

+
+ {/if} +
+
+
+
+ + +
+
+
+ + + +
+
diff --git a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte index 961424b1..94d020b3 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte @@ -35,7 +35,6 @@ Briefcase } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; - import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; // Componentes Compartidos (Modales) import CountrySelectorDialog from '$lib/components/dashboard/goods/modales/country-selector-dialog.svelte'; @@ -49,9 +48,6 @@ // API & Stores import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers'; import { companyStore } from '$lib/stores/company.svelte'; - import { authStore, userHasPermission } from '$lib/auth'; - import { browser } from '$app/environment'; - // --- CONFIGURACIÓN --- let id = $derived($page.params.id); @@ -133,20 +129,6 @@ else if (!id || id === 'new') formData = getEmptyForm(); }); - // Permisos - const requiredPermission = $derived(isEditing ? 'partners_mgmt.edit' : 'partners_mgmt.create'); - const canAccess = $derived(userHasPermission($authStore.user, requiredPermission)); - - onMount(() => { - if (browser) { - const handleCompanyChange = () => { - if (isEditing) loadData(Number(id)); - }; - window.addEventListener('companyChanged', handleCompanyChange); - return () => window.removeEventListener('companyChanged', handleCompanyChange); - } - }); - async function loadData(clientId: number) { if (!companyStore.activeCompany?.id) return; loading = true; @@ -316,521 +298,530 @@ ); -
- {#if !canAccess} - window.location.reload()} - onBack={() => goto('/dashboard/clients_and_providers')} - /> - {:else} -
- -
-
-
- -

- {isEditing ? `Socio Comercial #${id}` : 'Nuevo Socio Comercial'} -

- {#if isEditing} - - {formData.is_active ? 'Activo' : 'Inactivo'} - - {:else} - Nuevo - {/if} -
-

- {isEditing - ? 'Edita la información del cliente o proveedor' - : 'Registra un nuevo cliente o proveedor en el sistema'} -

-
-
- - - - -
- -
{ - e.preventDefault(); - handleSubmit(); - }} - > - - - - - - Información General - Datos principales de identificación y clasificación. - - -
-
- - -
-
- - (formData.client_or_provider = v)} - > - - {typeLabels[formData.client_or_provider] || 'Selecciona un tipo'} - - - Cliente - Proveedor - Ambos - - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - (formData.type_nat_foreign = v)} - > - - {formData.type_nat_foreign === 'N' - ? 'Nacional' - : formData.type_nat_foreign === 'E' - ? 'Extranjero' - : 'Seleccione'} - - - Nacional - Extranjero - - -
-
- -
-
- - -
-
- - -
-
-
-
-
- - - - - - Dirección y Contacto - Ubicación fiscal y datos de contacto. - - -
-
- - -
-
-
- - -
-
- - -
-
-
- -
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
- -
- - -
-
-
- -
- - -
-
-
- - - -
-
- - -
-
- - -
-
-
-
-
- - - - - - Programas y Certificaciones - Información sobre IMMEX, PROSEC y otras certificaciones. - - - -
-
- - Programas de Fomento -
- -
-
- - (formData.program = v)} - disabled={loading} - > - - {formData.program || 'Selecciona un programa'} - - - {#each scaiiPrograms as prog} - - {prog.label} - - {/each} - - -
-
- - -
-
- - -
-
- -
- - -
-
-
-
- - -
-
- - Identificación Industrial -
- -
-
- - -
-
-
- - -
-
- - Certificaciones y Seguridad -
- -
-
- - -
-
- -
- -

- Indica si cuenta con certificación de empresa -

-
-
-
-
-
-
-
- - - - - - Configuración - Ajustes de estado y atributos especiales. - - -
-
- -
- -

- Habilitar o deshabilitar este socio comercial -

-
-
-
-
-
-
-
-
+
+ +
+
+
+ +

+ {isEditing ? `Socio Comercial #${id}` : 'Nuevo Socio Comercial'} +

+ {#if isEditing} + + {formData.is_active ? 'Activo' : 'Inactivo'} + + {:else} + Nuevo + {/if}
+

+ {isEditing + ? 'Edita la información del cliente o proveedor' + : 'Registra un nuevo cliente o proveedor en el sistema'} +

- {/if} +
+ + + + +
+ +
{ + e.preventDefault(); + handleSubmit(); + }} + > + + + + + + Información General + Datos principales de identificación y clasificación. + + +
+
+ + +
+
+ + (formData.client_or_provider = v)} + > + + {typeLabels[formData.client_or_provider] || 'Selecciona un tipo'} + + + Cliente + Proveedor + Ambos + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + (formData.type_nat_foreign = v)} + > + + {formData.type_nat_foreign === 'N' + ? 'Nacional' + : formData.type_nat_foreign === 'E' + ? 'Extranjero' + : 'Seleccione'} + + + Nacional + Extranjero + + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+
+ + + + + + Dirección y Contacto + Ubicación fiscal y datos de contacto. + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + + +
+
+ + +
+
+ + +
+
+
+
+
+ + + + + + Programas y Certificaciones + Información sobre IMMEX, PROSEC y otras certificaciones. + + + +
+
+ + Programas de Fomento +
+ +
+
+ + (formData.program = v)} + disabled={loading} + > + + {formData.program || 'Selecciona un programa'} + + + {#each scaiiPrograms as prog} + + {prog.label} + + {/each} + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+
+ + Identificación Industrial +
+ +
+
+ + +
+
+
+ + +
+
+ + Certificaciones y Seguridad +
+ +
+
+ + +
+
+ +
+ +

+ Indica si cuenta con certificación de empresa +

+
+
+
+
+
+
+
+ + + + + + Configuración + Ajustes de estado y atributos especiales. + + +
+
+ +
+ +

+ Habilitar o deshabilitar este socio comercial +

+
+
+
+
+
+
+
+
+
-{#if canAccess} -
-
- - -
- - - General - - - Dirección - - - Programas - - - Config - - -
-
- - -
- - +
+
+ + +
+ + + General + + + Dirección + + + Programas + + + Config + +
+
+ + +
+ + {#if !isEditing} + + {/if} +
-{/if} +
- - (formData.country = c.code_3)} /> - (formData.state = s.code)} /> - (formData.prosec = sc.code)} /> + + (formData.country = country.m3_key)} +/> + + { + formData.state = state.description; + if (state.m3_key && !formData.country) { + formData.country = state.m3_key; + } + }} +/> + + (formData.prosec = sector.key)} +/> diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte index 0e5cc04d..876a166b 100644 --- a/frontend/src/routes/dashboard/csv-upload/+page.svelte +++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte @@ -1139,6 +1139,9 @@

Importación Masiva de Datos (CSV)

+

+ Click izquierdo: cargar archivo CSV. Click derecho: descargar estructura (plantilla). +

diff --git a/frontend/src/routes/dashboard/customs_brokers/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/+page.svelte index 7eacd2b6..a3516841 100644 --- a/frontend/src/routes/dashboard/customs_brokers/+page.svelte +++ b/frontend/src/routes/dashboard/customs_brokers/+page.svelte @@ -13,15 +13,13 @@ import { page } from '$app/stores'; import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosListaAgentes } from '$lib/config/shortcuts/dashboard/customs_brokers/list'; - import { authStore, userHasPermission } from '$lib/auth'; - import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; import { customsSectionsApi, type CustomsSection } from '$lib/api/dashboard/reference_data/customs_sections'; import SectionsDataTable from '$lib/components/dashboard/reference_data/customs_sections/data-table.svelte'; - import { createColumns as createSectionColumns } from '$lib/components/dashboard/reference_data/customs_sections/columns.js'; + import { createColumns as createSectionColumns } from '$lib/components/dashboard/reference_data/customs_sections/columns'; import * as Card from '$lib/components/ui/card'; // Specialized Broker Components @@ -70,12 +68,6 @@ let sectionsPage = $state(1); let hasMoreSections = $derived(sections.length < totalSections); - // Permisos - const canView = $derived(userHasPermission($authStore.user, 'customs_brokers.view')); - const canCreate = $derived(userHasPermission($authStore.user, 'customs_brokers.create')); - const canEdit = $derived(userHasPermission($authStore.user, 'customs_brokers.edit')); - const canDelete = $derived(userHasPermission($authStore.user, 'customs_brokers.delete')); - // --- Lifecycle --- onMount(() => { if (browser) { @@ -123,6 +115,10 @@ function selectItem(item: CustomsBroker) { selectedItem = item; } + function handleRowDoubleClick(item: CustomsBroker) { + selectedItem = item; + goto(`/dashboard/customs_brokers/edit/${item.broker_key}`); + } function handleEdit() { if (selectedItem) goto(`/dashboard/customs_brokers/edit/${selectedItem.broker_key}`); } @@ -196,266 +192,257 @@
- {#if !canView} - window.location.reload()} - /> - {:else} -
-
-

Gestión Aduanal

-

Administración de Agentes y Secciones Aduanales

-
-
- {#if canCreate} - - {/if} - {#if canEdit} - - {/if} - {#if canDelete} - - {/if} -
+
+
+

Gestión Aduanal

+

Administración de Agentes y Secciones Aduanales

+
- - - - Agentes Aduanales - - - Secciones Aduanales - - - - + + -
-
-
-
-

Filtros

- Busque por nombre o patente -
-
-
- - -
-
- - -
-
-
-
-
+ Agentes Aduanales + + + Secciones Aduanales + + -
-
-

Listado

-
- - {filteredItems.length} registros - - -
+ +
+
+
+
+

Filtros

+ Busque por nombre o patente
- -
- -
- {#if totalItems > pageSize} -
- - - Página {currentPage} de {Math.ceil(totalItems / pageSize)} - - +
+
+ +
- {/if} +
+ + +
+
+
-
-
-

- Detalles del Agente -

-

- {selectedItem?.name || '---'} -

-
- Patente: {selectedItem?.broker_key || ''} +
+
+

Listado

+
+ + {filteredItems.length} registros + +
- - {#if selectedItem} - - {/if}
-
- {#if selectedItem} -
+
+ +
+ {#if totalItems > pageSize} +
+ + + Página {currentPage} de {Math.ceil(totalItems / pageSize)} + + +
+ {/if} +
+
+ +
+
+

+ Detalles del Agente +

+

+ {selectedItem?.name || '---'} +

+
+ Patente: {selectedItem?.broker_key || ''} +
+
+ +
+ {#if selectedItem} +
+
+ +

{selectedItem.license || '-'}

+
+ + {#if selectedItem.tax_id}
-

{selectedItem.license || '-'}

+

{selectedItem.tax_id}

+ {/if} - {#if selectedItem.tax_id} -
- -

{selectedItem.tax_id}

+
+ +
+

{selectedItem.address || ''}

+

+ {[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')} +

+

+ {[selectedItem.postal_code, selectedItem.country].filter(Boolean).join(', ')} +

+
+
+ +
+ + {#if selectedItem.email} +
+ + {selectedItem.email}
{/if} - -
- -
-

{selectedItem.address || ''}

-

- {[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')} -

-

- {[selectedItem.postal_code, selectedItem.country].filter(Boolean).join(', ')} -

+ {#if selectedItem.phone} +
+ + {selectedItem.phone}
-
- -
- - {#if selectedItem.email} -
- - {selectedItem.email} -
- {/if} - {#if selectedItem.phone} -
- - {selectedItem.phone} -
- {/if} - {#if selectedItem.contact} -
- Contacto: - {selectedItem.contact} -
- {/if} -
+ {/if} + {#if selectedItem.contact} +
+ Contacto: + {selectedItem.contact} +
+ {/if}
- {:else} -
- -

Selecciona un agente

-
- {/if} -
+
+ {:else} +
+ +

Selecciona un agente

+
+ {/if}
- +
+ - - - - - - - - + + + + + + + + -
- {/if} +
+
+
+
+
+ {#if activeTab === 'brokers'} + + + + {:else} + + {/if} +
+
diff --git a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte index 6174b0f1..f08dcc61 100644 --- a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte @@ -1,8 +1,5 @@ -
- {#if !canAccess} - window.location.reload()} - onBack={() => goto('/dashboard/customs_brokers')} - /> - {:else} - -
- -
-
-
- -

- {isEdit ? `Agente ${formData.broker_key}` : 'Nuevo Agente Aduanal'} -

- - {isEdit ? 'Edición' : 'Nuevo'} - -
-

- {isEdit - ? 'Modifica la información del agente aduanal' - : 'Registra un nuevo agente aduanal en el sistema'} -

-
-
- - + +
+ +
+
+
+ +

+ {isEdit ? `Agente ${formData.broker_key}` : 'Nuevo Agente Aduanal'} +

+ + {isEdit ? 'Edición' : 'Nuevo'} +
+

+ {isEdit + ? 'Modifica la información del agente aduanal' + : 'Registra un nuevo agente aduanal en el sistema'} +

+
+
- -
-
{ - e.preventDefault(); - handleSave(); - }} - > - - - - - Información General - Identificación oficial del agente y patente. - - -
-
- - (formData.type = v)} - disabled={loading} - > - - {formData.type === 'MEX' - ? 'Agente Aduanal Mexicano' - : formData.type === 'USA' - ? 'Agente Aduanal Americano (Broker)' - : 'Selecciona un tipo...'} - - - Agente Aduanal Mexicano - Agente Aduanal Americano (Broker) - - -
-
+ -
-
- - { - const val = e.currentTarget.value.toUpperCase(); - if (val.length > 5) { - brokerKeyError = true; - formData.broker_key = val.slice(0, 5); - e.currentTarget.value = formData.broker_key; - - clearTimeout(brokerKeyTimeout); - brokerKeyTimeout = setTimeout(() => { - brokerKeyError = false; - }, 3000); - } else { - brokerKeyError = false; - formData.broker_key = val; - } - }} - placeholder="Ej. 550" - maxlength={6} - class={`h-10 ${brokerKeyError ? 'border-red-500 focus-visible:ring-red-500' : ''}`} - disabled={isEdit || loading} - /> - {#if brokerKeyError} -

- La clave no debe superar los 5 caracteres -

- {/if} -
-
- - { - const val = e.currentTarget.value.replace(/\D/g, ''); - if (val.length > 4) { - licenseError = true; - formData.license = val.slice(0, 4); - e.currentTarget.value = formData.license; - - clearTimeout(licenseTimeout); - licenseTimeout = setTimeout(() => { - licenseError = false; - }, 3000); - } else { - licenseError = false; - formData.license = val; - } - }} - placeholder="Ej. 3421" - maxlength={5} - class={`h-10 ${licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}`} - disabled={loading} - /> - {#if licenseError} -

- La patente no debe superar los 4 dígitos -

- {/if} -
-
- - - -
- - -
- -
-
- - { - formData.tax_id = e.currentTarget.value - .toUpperCase() - .replace(/[^A-Z0-9&Ñ]/g, '') - .slice(0, 13); - e.currentTarget.value = formData.tax_id; - }} - placeholder="RFC de la empresa" - disabled={loading} - class="h-10" - /> -
-
- - { - formData.personal_id = e.currentTarget.value - .toUpperCase() - .replace(/[^A-Z0-9]/g, '') - .slice(0, 18); - e.currentTarget.value = formData.personal_id; - }} - placeholder="CURP si aplica" - disabled={loading} - class="h-10" - /> -
-
-
-
-
- - - - - - Información de Contacto - Datos para comunicación con el agente. - - -
-
- - { - formData.contact = e.currentTarget.value.replace( - /[^a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]/g, - '' - ); - e.currentTarget.value = formData.contact; - }} - placeholder="Nombre del contacto" - disabled={loading} - class="h-10" - /> -
-
- - -
-
- - - -
-
- - { - formData.phone = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, ''); - e.currentTarget.value = formData.phone; - }} - placeholder="656-000-0000" - disabled={loading} - class="h-10" - /> -
-
- - { - formData.fax = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, ''); - e.currentTarget.value = formData.fax; - }} - disabled={loading} - class="h-10" - /> -
-
- - -
-
-
-
-
- - - - - - Domicilio Fiscal - Ubicación registrada del agente aduanal. - - -
- - -
-
-
- - { - formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); - }} - /> -
-
- - -
-
-
-
- -
- (showStateDialog = true)} - onkeydown={(event) => - openOnEnterOrSpace(event, () => (showStateDialog = true))} - /> - -
-
-
- -
- (showCountryDialog = true)} - onkeydown={(event) => - openOnEnterOrSpace(event, () => (showCountryDialog = true))} - /> - -
-
-
-
-
- - -
- - - - - Ventanilla Única / Web Services - Certificados y credenciales para integración con DODA/PITA. +
+ { + e.preventDefault(); + handleSave(); + }} + > + + + + + Información General + Identificación oficial del agente y patente. + + +
+
+ + (formData.type = v)} + disabled={loading} > - - -
-
- - -
-
- - -
-
- - - -
-
- -
- (pendingVuFiles.certificate = file)} - disabled={loading} - /> - {#if vuData.certificate_path} - - {getFileDisplayName(vuData.certificate_path)} - - {/if} -
-
-
- -
- (pendingVuFiles.key = file)} - disabled={loading} - /> - {#if vuData.key_path} - - {getFileDisplayName(vuData.key_path)} - - {/if} -
-
-
- -
-
- - -
-
- - -
-
-
- - - - - - - DODA / PITA - Credenciales exclusivas para el servicio DODA. - - -
-
- - -
-
- - -
+ + {formData.type === 'MEX' + ? 'Agente Aduanal Mexicano' + : formData.type === 'USA' + ? 'Agente Aduanal Americano (Broker)' + : 'Selecciona un tipo...'} + + + Agente Aduanal Mexicano + Agente Aduanal Americano (Broker) + +
- -
-
- -
- (pendingVuFiles.dodaCertificate = file)} - disabled={loading} - /> - {#if vuData.doda_certificate_path} - - {getFileDisplayName(vuData.doda_certificate_path)} - - {/if} -
-
-
- -
- (pendingVuFiles.dodaKey = file)} - disabled={loading} - /> - {#if vuData.doda_key_path} - - {getFileDisplayName(vuData.doda_key_path)} - - {/if} -
-
-
-
-
-
+
- - - - ANAM / Otros Servicios - Configuraciones de rutas y archivos locales. - - -
-
- - -
-
- - -
+
+
+ + { + const val = e.currentTarget.value.toUpperCase(); + if (val.length > 5) { + brokerKeyError = true; + formData.broker_key = val.slice(0, 5); + e.currentTarget.value = formData.broker_key; + + clearTimeout(brokerKeyTimeout); + brokerKeyTimeout = setTimeout(() => { + brokerKeyError = false; + }, 3000); + } else { + brokerKeyError = false; + formData.broker_key = val; + } + }} + placeholder="Ej. 550" + maxlength={6} + class={`h-10 ${brokerKeyError ? 'border-red-500 focus-visible:ring-red-500' : ''}`} + disabled={isEdit || loading} + /> + {#if brokerKeyError} +

+ La clave no debe superar los 5 caracteres +

+ {/if}
- + { + const val = e.currentTarget.value.replace(/\D/g, ''); + if (val.length > 4) { + licenseError = true; + formData.license = val.slice(0, 4); + e.currentTarget.value = formData.license; + + clearTimeout(licenseTimeout); + licenseTimeout = setTimeout(() => { + licenseError = false; + }, 3000); + } else { + licenseError = false; + formData.license = val; + } + }} + placeholder="Ej. 3421" + maxlength={5} + class={`h-10 ${licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}`} + disabled={loading} + /> + {#if licenseError} +

+ La patente no debe superar los 4 dígitos +

+ {/if} +
+
+ + + +
+ + +
+ +
+
+ + { + formData.tax_id = e.currentTarget.value + .toUpperCase() + .replace(/[^A-Z0-9&Ñ]/g, '') + .slice(0, 13); + e.currentTarget.value = formData.tax_id; + }} + placeholder="RFC de la empresa" disabled={loading} class="h-10" />
- - - - -
- - {/if} -
+
+ + { + formData.personal_id = e.currentTarget.value + .toUpperCase() + .replace(/[^A-Z0-9]/g, '') + .slice(0, 18); + e.currentTarget.value = formData.personal_id; + }} + placeholder="CURP si aplica" + disabled={loading} + class="h-10" + /> +
+
+
+
+
- -{#if canAccess} + + + + + Información de Contacto + Datos para comunicación con el agente. + + +
+
+ + { + formData.contact = e.currentTarget.value.replace( + /[^a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]/g, + '' + ); + e.currentTarget.value = formData.contact; + }} + placeholder="Nombre del contacto" + disabled={loading} + class="h-10" + /> +
+
+ + +
+
+ + + +
+
+ + { + formData.phone = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, ''); + e.currentTarget.value = formData.phone; + }} + placeholder="656-000-0000" + disabled={loading} + class="h-10" + /> +
+
+ + { + formData.fax = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, ''); + e.currentTarget.value = formData.fax; + }} + disabled={loading} + class="h-10" + /> +
+
+ + +
+
+
+
+
+ + + + + + Domicilio Fiscal + Ubicación registrada del agente aduanal. + + +
+ + +
+
+
+ + { + formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); + }} + /> +
+
+ + +
+
+
+
+ +
+ (showStateDialog = true)} + onkeydown={(event) => + openOnEnterOrSpace(event, () => (showStateDialog = true))} + /> + +
+
+
+ +
+ (showCountryDialog = true)} + onkeydown={(event) => + openOnEnterOrSpace(event, () => (showCountryDialog = true))} + /> + +
+
+
+
+
+ + +
+ + + + + Ventanilla Única / Web Services + Certificados y credenciales para integración con DODA/PITA. + + +
+
+ + { + vuData.certificate_path = file.name; + pendingVuFiles.certificate = file; + toast.success(`Archivo ${file.name} seleccionado`); + }} + /> +
+
+ + { + vuData.key_path = file.name; + pendingVuFiles.key = file; + toast.success(`Archivo ${file.name} seleccionado`); + }} + /> +
+
+ +
+
+ + +
+
+ + + + {vuData.vu_figure_type || 'Seleccionar tipo de figura'} + + + AGENTE ADUANAL + APODERADO ADUANAL + MANDATARIO + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + { + vuData.xml_files_path = file.name; + pendingVuFiles.cove = file; + toast.success(`Archivo ${file.name} seleccionado`); + }} + /> +
+
+ + + +
+

+ Configuración Adicional +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + + + + + DODA-PITA + Configuración de servicios DODA / PITA. + + +
+
+ + +
+
+ + +
+
+ +
+
+ + { + vuData.doda_certificate_path = file.name; + pendingVuFiles.dodaCertificate = file; + toast.success(`Archivo ${file.name} seleccionado`); + }} + /> +
+
+ + { + vuData.doda_key_path = file.name; + pendingVuFiles.dodaKey = file; + toast.success(`Archivo ${file.name} seleccionado`); + }} + /> +
+
+ +
+
+ + +
+
+ + { + vuData.doda_xml_files_path = file.name; + pendingVuFiles.dodaCove = file; + toast.success(`Archivo ${file.name} seleccionado`); + }} + /> +
+
+
+
+
+ + + + ANAM + Configuración de acceso para ANAM. + + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+ +
- - -
- - - General - - - Contacto - - - Dirección - - - VU - - - DODA - - - ANAM - - -
-
+ +
+ + General + Contacto + Domicilio + VU + DODA + ANAM + +
- - +
-{/if} + diff --git a/frontend/src/routes/dashboard/digitalizacion/+page.svelte b/frontend/src/routes/dashboard/despacho/digitalizacion/+page.svelte similarity index 87% rename from frontend/src/routes/dashboard/digitalizacion/+page.svelte rename to frontend/src/routes/dashboard/despacho/digitalizacion/+page.svelte index f54b8830..28245bc8 100644 --- a/frontend/src/routes/dashboard/digitalizacion/+page.svelte +++ b/frontend/src/routes/dashboard/despacho/digitalizacion/+page.svelte @@ -7,7 +7,9 @@ import * as Card from '$lib/components/ui/card'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; - import { Plus, RefreshCw, FileCheck2, Download, FolderArchive, Pencil, Trash2 } from 'lucide-svelte'; + import { Label } from '$lib/components/ui/label'; + import * as Select from '$lib/components/ui/select'; + import { Plus, RefreshCw, FileCheck2, Download, FolderArchive, Pencil, Trash2, Filter } from 'lucide-svelte'; import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte'; import CreateEditDialog from '$lib/components/dashboard/digitalizacion/create-edit-dialog.svelte'; @@ -31,6 +33,10 @@ let hasMore = $derived(data.length < totalItems); let search = $state($page.url.searchParams.get('search') || ''); + let searchEDocument = $state(''); + let searchRFC = $state(''); + let searchStatus = $state('all'); + let showFilters = $state(false); let searchTimeout: ReturnType; // Selección de filas @@ -63,7 +69,10 @@ const canOpenAcuse = $derived(selectedItem?.status === 'success' && !!selectedIt const res = await expedienteArchivosApi.list(companyStore.activeCompany.id, { page: 1, page_size: pageSize, - search: search || undefined + search: search || undefined, + e_document: searchEDocument || undefined, + rfc_consulta: searchRFC || undefined, + status: searchStatus === 'all' ? undefined : searchStatus }); if (res.data) { data = res.data.items; @@ -85,7 +94,10 @@ const canOpenAcuse = $derived(selectedItem?.status === 'success' && !!selectedIt const res = await expedienteArchivosApi.list(companyStore.activeCompany.id, { page: currentPage + 1, page_size: pageSize, - search: search || undefined + search: search || undefined, + e_document: searchEDocument || undefined, + rfc_consulta: searchRFC || undefined, + status: searchStatus === 'all' ? undefined : searchStatus }); if (res.data?.items) { data = [...data, ...res.data.items]; @@ -393,6 +405,15 @@ async function handleDownloadArtifact(
{m['sidebar.digitalizacion.table_title']()}
+
+ + {#if showFilters} +
+ +
+ + +
+ + +
+ + +
+ + +
+ + { + searchStatus = v || 'all'; + loadData(); + }} + > + + + + + Todos + Pendiente + Procesando + Completado + Fallido + + +
+ + +
+ +
+
+ {/if} {#if loading && data.length === 0} diff --git a/frontend/src/routes/dashboard/despacho/doda/+page.server.ts b/frontend/src/routes/dashboard/despacho/doda/+page.server.ts index 96caa12d..8d3a8b86 100644 --- a/frontend/src/routes/dashboard/despacho/doda/+page.server.ts +++ b/frontend/src/routes/dashboard/despacho/doda/+page.server.ts @@ -2,67 +2,80 @@ import type { PageServerLoad } from './$types'; import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { - const parentData = await parent(); - const { accessToken } = getAuthTokens(cookies); + const parentData = await parent(); + const { accessToken } = getAuthTokens(cookies); - if (!accessToken) { - return { - error: 'No authenticated', - dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } - }; - } + const pUser = parentData.user as + | { preferred_username?: string; name?: string; email?: string } + | undefined + | null; + const defaultLastUser = + (pUser?.preferred_username?.trim() || pUser?.name?.trim() || pUser?.email?.trim() || '') || ''; - try { - const page = Number(url.searchParams.get('page')) || 1; - const pageSize = Number(url.searchParams.get('pageSize')) || 50; + if (!accessToken) { + return { + error: 'No authenticated', + dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 }, + defaultLastUser + }; + } - const cookieCompanyId = cookies.get('active_company_id'); - const companyId = cookieCompanyId - ? parseInt(cookieCompanyId) - : parentData.companies?.[0]?.id; + try { + const page = Number(url.searchParams.get('page')) || 1; + const pageSize = Number(url.searchParams.get('pageSize')) || 50; + + // Obtener company_id de la cookie o usar el primero disponible + const cookieCompanyId = cookies.get('active_company_id'); + const companyId = cookieCompanyId + ? parseInt(cookieCompanyId) + : parentData.companies?.[0]?.id; - if (!companyId) { - return { - error: 'No company selected', - dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } - }; - } + if (!companyId) { + return { + error: 'No company selected', + dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 }, + defaultLastUser + }; + } - const filters: Record = {}; - const integrationNumber = url.searchParams.get('integration_number'); - const patent = url.searchParams.get('patent'); - const status = url.searchParams.get('status'); - const operationType = url.searchParams.get('operation_type'); + const filters: Record = {}; + const integrationNumber = url.searchParams.get('integration_number'); - if (integrationNumber) filters.integration_number = integrationNumber; - if (patent) filters.patent = patent; - if (status) filters.status = status; - if (operationType) filters.operation_type = operationType; + if (integrationNumber) filters.integration_number = integrationNumber; - const queryParams = new URLSearchParams({ - page: page.toString(), - page_size: pageSize.toString(), - company_id: companyId.toString(), - ...filters - }); + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + const response = await authenticatedFetch(`v1/a76/doda?${queryParams.toString()}`, { method: 'GET' }, cookies, fetch); - const response = await authenticatedFetch( - `v1/a76/doda?${queryParams.toString()}`, - { method: 'GET' }, - cookies, - fetch - ); + if (!response.ok) { + let errorMsg = 'Failed to load'; + try { + const errorData = await response.json(); + errorMsg = errorData.detail || errorData.message || errorMsg; + } catch (e) { + // Ignore json parsing error + } + return { + error: errorMsg, + status: response.status, + dodas: { items: [], total: 0, page, page_size: pageSize, pages: 0 }, + defaultLastUser + }; + } - if (!response.ok) { - return { - error: 'Failed to load', - dodas: { items: [], total: 0, page, page_size: pageSize, pages: 0 } - }; - } - - return { dodas: await response.json() }; - } catch (error) { - console.error('Error loading DODAs:', error); - return { error: 'Error loading', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } }; - } + const dodasData = await response.json(); + return { dodas: dodasData, status: 200, defaultLastUser }; + } catch (error: any) { + console.error('Error loading DODAs:', error); + return { + error: error.message || 'Error loading', + status: error.status || 500, + dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 }, + defaultLastUser + }; + } }; diff --git a/frontend/src/routes/dashboard/despacho/doda/+page.svelte b/frontend/src/routes/dashboard/despacho/doda/+page.svelte index b5b9126e..4edc440c 100644 --- a/frontend/src/routes/dashboard/despacho/doda/+page.svelte +++ b/frontend/src/routes/dashboard/despacho/doda/+page.svelte @@ -35,7 +35,14 @@ deleteDoda, exportDodaPedimentosDetail, postDodaAlta, + postDodaConsulta, + postDodaConsultaApply, + postDodaEliminar, + getDodaAltaStatus, getDodaElegibilidad, + getDodaConsultaStatus, + getDodaEliminarStatus, + type DodaAltaStatusResponse, type Doda } from '$lib/api/dashboard/a76/general_catalogs/doda'; @@ -71,9 +78,13 @@ let exportDialogOpen = $state(false); let currentTaskId = $state(''); let currentVariant = $state<'doda' | 'pita'>('doda'); + let progressMode = $state<'alta' | 'consulta' | 'eliminar'>('alta'); let altaLoading = $state(false); + let consultaLoading = $state(false); + let eliminarExternoLoading = $state(false); let deleteLoading = $state(false); let pedimentosExportLoading = $state(false); + const hasIntegration = $derived(!!(selectedDoda?.integration_number || '').trim()); $effect(() => { if (data.dodas) { @@ -223,11 +234,94 @@ } } - function onAltaComplete() { - progressDialogOpen = false; - reloadDodas(); - toast.success(m['sidebar.doda_alta.progress_success']()); + async function handleConsultar() { + if (!selectedDoda || !companyStore.activeCompany || consultaLoading) return; + if (!hasIntegration) { + toast.error('El DODA aún no está generado para consultar.'); + return; + } + consultaLoading = true; + try { + const resp = await postDodaConsulta(selectedDoda.id, companyStore.activeCompany.id, altaVariant); + if (resp.error || !resp.data?.task_id) { + toast.error(resp.error || 'Error al enviar consulta DODA'); + return; + } + progressMode = 'consulta'; + currentTaskId = resp.data.task_id; + currentVariant = altaVariant; + progressDialogOpen = true; + } finally { + consultaLoading = false; + } } + + async function handleEliminarExterno() { + if (!selectedDoda || !companyStore.activeCompany || eliminarExternoLoading) return; + if (!hasIntegration) { + toast.error('El DODA aún no está generado para eliminar externamente.'); + return; + } + if (!confirm('¿Deseas eliminar este DODA en el servicio externo para volver a editarlo?')) return; + eliminarExternoLoading = true; + try { + const resp = await postDodaEliminar(selectedDoda.id, companyStore.activeCompany.id, altaVariant); + if (resp.error || !resp.data?.task_id) { + toast.error(resp.error || 'Error al enviar eliminación DODA'); + return; + } + progressMode = 'eliminar'; + currentTaskId = resp.data.task_id; + currentVariant = altaVariant; + progressDialogOpen = true; + } finally { + eliminarExternoLoading = false; + } + } + + async function onProgressComplete(_result: DodaAltaStatusResponse) { + progressDialogOpen = false; + if (progressMode === 'consulta' && selectedDoda && companyStore.activeCompany) { + const applyResp = await postDodaConsultaApply( + selectedDoda.id, + currentTaskId, + companyStore.activeCompany.id + ); + if (applyResp.error) { + toast.error(`Consulta completada, pero no se pudo aplicar al DODA: ${applyResp.error}`); + } + } + await reloadDodas(); + if (progressMode === 'eliminar') { + toast.success('Eliminación DODA completada. El registro quedó editable nuevamente.'); + } else if (progressMode === 'consulta') { + toast.success('Consulta DODA completada y aplicada al registro.'); + } else { + toast.success(m['sidebar.doda_alta.progress_success']()); + } + } + + const progressTitle = $derived( + progressMode === 'consulta' + ? 'Consulta DODA' + : progressMode === 'eliminar' + ? 'Eliminación DODA' + : m['sidebar.doda_alta.progress_title']() + ); + const progressDescription = $derived( + progressMode === 'consulta' + ? 'Consulta' + : progressMode === 'eliminar' + ? 'Eliminación' + : 'Alta' + ); + const progressStatusGetter = $derived( + progressMode === 'consulta' + ? getDodaConsultaStatus + : progressMode === 'eliminar' + ? getDodaEliminarStatus + : getDodaAltaStatus + );
@@ -343,11 +437,38 @@ variant="outline" size="sm" onclick={handleEdit} - disabled={selectedDodaIds.length !== 1} + disabled={selectedDodaIds.length !== 1 || hasIntegration} > {m['sidebar.doda_alta.action_edit']()} + + - {/if} +
- {#if isError} - - {:else} - - - - Filtros - - Busca manifiestos por número o descripción - - -
-
- - -
-
- - -
+ + + + + Filtros + + Busca manifiestos por número o descripción + + +
+
+ +
- - +
+ + +
+
+
+
- + {#if error} + -
-
- Listado de Manifiestos - - Mostrando {allItems.length} de {totalItems} registros - -
- -
+ Error + {error}
- - -
{/if} + + + + +
+
+ Listado de Manifiestos + + Mostrando {allItems.length} de {totalItems} registros + +
+ +
+
+ + + +
-{#if !isError} -
-
-
-
- {#if selectedItem} - Seleccionado: {selectedItem.manifest_number} - {/if} -
-
- - - {#if canEdit} - - {/if} - - {#if canDelete} - - {/if} -
+ {/if} +
+
+ + +
+
- - -{/if} + + + diff --git a/frontend/src/routes/dashboard/general_catalogs/concepts/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/concepts/+page.svelte index f514e93d..fbb8734d 100644 --- a/frontend/src/routes/dashboard/general_catalogs/concepts/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/concepts/+page.svelte @@ -1,238 +1,285 @@ -
-
-
-

Conceptos

-

Catálogo de Conceptos

-
-
- -{#if !isError && canCreate} - -{/if} -
-
+
+
+
+

Conceptos

+

Catálogo de Conceptos

+
+
+ + {#if !isError && canCreate} + + {/if} +
+
-{#if isError} - -{:else} - - -
-Listado -
- - -
-
-
- -
- (selectedIds = ids)} -onRowClick={(row) => selectedIds = selectedIds.includes(row.id) ? [] : [row.id]} -onRowDoubleClick={(row) => { if(canEdit) { editingItem = row; dialogOpen = true; } }} -/> -
-
-
+ {#if isError} + + {:else} + + +
+ Listado +
+ + +
+
+
+ +
+ (selectedIds = ids)} + onRowClick={(row) => (selectedIds = selectedIds.includes(row.id) ? [] : [row.id])} + onRowDoubleClick={(row) => { + if (canEdit) { + editingItem = row; + dialogOpen = true; + } + }} + /> +
+
+
-
-Mostrando {allItems.length} de {totalItems} registros -
+
+ Mostrando {allItems.length} de {totalItems} registros +
+
+
+
+ {#if canEdit} + + {/if} + {#if canDelete} + + {/if} +
+
+
+ {/if} -
-
-
-{#if canEdit} - -{/if} -{#if canDelete} - -{/if} +
-
-
-{/if} - - -
\ No newline at end of file diff --git a/frontend/src/routes/dashboard/general_catalogs/doda/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/doda/edit/[[id]]/+page.svelte new file mode 100644 index 00000000..3b0d13bc --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/doda/edit/[[id]]/+page.svelte @@ -0,0 +1,751 @@ + + +
+ +
+
+
+ +

{title}

+
+

Catálogos Generales / Doda

+
+
+ + + + + +
+ +
+
+ +
+ (showBrokerSelector = true)} + onkeydown={(event) => + openOnEnterOrSpace(event, () => (showBrokerSelector = true))} + tabindex="0" + readonly + /> + +
+
+
+ +
+ (showAduanaSelector = true)} + onkeydown={(event) => + openOnEnterOrSpace(event, () => (showAduanaSelector = true))} + tabindex="0" + readonly + /> + +
+
+
+ +
+ (showSectionSelector = true)} + onkeydown={(event) => + openOnEnterOrSpace(event, () => (showSectionSelector = true))} + tabindex="0" + readonly + /> + +
+
+
+ + (formData.operation_type = v)} + > + + {formData.operation_type === 'E' + ? 'E' + : formData.operation_type === 'I' + ? 'I' + : '...'} + + + I - Importación + E - Exportación + + +
+
+ + {#if error} +
+ ⚠️ + {error} +
+ {/if} + + + +
+ +
+
+ +
+ (showTransporterSelector = true)} + onkeydown={(event) => + openOnEnterOrSpace(event, () => (showTransporterSelector = true))} + tabindex="0" + readonly + /> + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ (showBrokerSelector = true)} + onkeydown={(event) => + openOnEnterOrSpace(event, () => (showBrokerSelector = true))} + tabindex="0" + readonly + /> + +
+
+
+ + (formData.status = v)} + > + + {formData.status || 'Seleccionar...'} + + + PENDIENTE + GENERADO + ELIMINADO + + +
+
+ + +
+
+ +
+ + +
+
+ + (formData.customs_clearance = parseInt(v))} + class="flex gap-6" + > +
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+

Sellos y Firmas

+

+ Validación electrónica ante el SAT +

+
+ +
+
+ +