feature/api-doda
This commit is contained in:
@@ -28,8 +28,15 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
# License Service
|
||||
LICENSE_CHECK_ENABLED=True
|
||||
|
||||
# Factura COVE / VUCEM / API Ventanilla Única
|
||||
# Llave y IV AES-256-CBC para cifrar la clave FIEL (misma que usa VU y DODA).
|
||||
COVE_FIEL_HASH_KEY=
|
||||
COVE_FIEL_HASH_IV=
|
||||
# URL del API de digitalización de expediente (ExpedienteExternalService / CoveExternalService)
|
||||
COVE_API_URL=https://api.vu.aduanasoft.com
|
||||
# URL del API externo DODA/PITA (alta, status). Misma red que VU.
|
||||
DODA_API_BASE_URL=http://192.168.1.66:8008
|
||||
DODA_API_VERIFY_SSL=False
|
||||
|
||||
# Synchronization (Hub & Spoke)
|
||||
SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""add annex30_duration_days to company_certification
|
||||
|
||||
Revision ID: 7c8d9e0f1a2b
|
||||
Revises: c8d9e0f1a2b3
|
||||
Revises: d1a2b3c4e5f6
|
||||
Create Date: 2026-04-24 16:50:00.000000
|
||||
|
||||
"""
|
||||
@@ -12,7 +12,7 @@ import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7c8d9e0f1a2b'
|
||||
down_revision = 'c8d9e0f1a2b3'
|
||||
down_revision = 'd1a2b3c4e5f6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
117
backend/alembic/versions/a1b2c3d4e5f6_create_doda_alta_log.py
Normal file
117
backend/alembic/versions/a1b2c3d4e5f6_create_doda_alta_log.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""create doda_alta_log table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 7c8d9e0f1a2b
|
||||
Create Date: 2026-04-26 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a1b2c3d4e5f6"
|
||||
down_revision = "7c8d9e0f1a2b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"doda_alta_log",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("doda_id", sa.Integer(), nullable=True),
|
||||
sa.Column("variant", sa.String(length=10), nullable=True),
|
||||
sa.Column("responsible", sa.String(length=20), nullable=True),
|
||||
sa.Column("patent", sa.String(length=10), nullable=True),
|
||||
sa.Column("dispatch_customs", sa.String(length=10), nullable=True),
|
||||
sa.Column("operation_type", sa.String(length=5), nullable=True),
|
||||
sa.Column("integration_number", sa.String(length=50), nullable=True),
|
||||
sa.Column("task_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=30), nullable=True),
|
||||
sa.Column("message", sa.String(length=2000), nullable=True),
|
||||
sa.Column("result_json", sa.Text(), nullable=True),
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["company_id"], ["a76.company.id"]),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.PrimaryKeyConstraint("id", name="doda_alta_log_pkey"),
|
||||
schema="a76",
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_a76_doda_alta_log_company_id"),
|
||||
"doda_alta_log",
|
||||
["company_id"],
|
||||
unique=False,
|
||||
schema="a76",
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_a76_doda_alta_log_tenant_id"),
|
||||
"doda_alta_log",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
schema="a76",
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_a76_doda_alta_log_doda_id"),
|
||||
"doda_alta_log",
|
||||
["doda_id"],
|
||||
unique=False,
|
||||
schema="a76",
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_a76_doda_alta_log_task_id"),
|
||||
"doda_alta_log",
|
||||
["task_id"],
|
||||
unique=False,
|
||||
schema="a76",
|
||||
)
|
||||
|
||||
# Reporte PDF almacenado (S3) + invalidación por huella de contenido
|
||||
op.add_column(
|
||||
"doda",
|
||||
sa.Column("doda_report_pdf_path", sa.String(length=1000), nullable=True),
|
||||
schema="a76",
|
||||
)
|
||||
op.add_column(
|
||||
"doda",
|
||||
sa.Column("doda_report_pdf_generated_at", sa.DateTime(), nullable=True),
|
||||
schema="a76",
|
||||
)
|
||||
op.add_column(
|
||||
"doda",
|
||||
sa.Column("doda_report_source_fingerprint", sa.String(length=64), nullable=True),
|
||||
schema="a76",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("doda", "doda_report_source_fingerprint", schema="a76")
|
||||
op.drop_column("doda", "doda_report_pdf_generated_at", schema="a76")
|
||||
op.drop_column("doda", "doda_report_pdf_path", schema="a76")
|
||||
|
||||
op.drop_index(
|
||||
op.f("ix_a76_doda_alta_log_task_id"),
|
||||
table_name="doda_alta_log",
|
||||
schema="a76",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_a76_doda_alta_log_doda_id"),
|
||||
table_name="doda_alta_log",
|
||||
schema="a76",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_a76_doda_alta_log_tenant_id"),
|
||||
table_name="doda_alta_log",
|
||||
schema="a76",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_a76_doda_alta_log_company_id"),
|
||||
table_name="doda_alta_log",
|
||||
schema="a76",
|
||||
)
|
||||
op.drop_table("doda_alta_log", schema="a76")
|
||||
@@ -27,25 +27,25 @@ class ConfiguracionVU(BaseModel):
|
||||
class PersonaCove(BaseModel):
|
||||
tipo_identificador: str = Field(..., max_length=10)
|
||||
identificacion: str = Field(..., max_length=30)
|
||||
apellido_paterno: Optional[str] = Field(None, max_length=80)
|
||||
apellido_materno: Optional[str] = Field(None, max_length=80)
|
||||
nombre: Optional[str] = Field(None, max_length=80)
|
||||
calle: Optional[str] = Field(None, max_length=120)
|
||||
numero_exterior: Optional[str] = Field(None, max_length=20)
|
||||
numero_interior: Optional[str] = Field(None, max_length=20)
|
||||
colonia: Optional[str] = Field(None, max_length=120)
|
||||
localidad: Optional[str] = Field(None, max_length=120)
|
||||
municipio: Optional[str] = Field(None, max_length=120)
|
||||
entidad_federativa: Optional[str] = Field(None, max_length=120)
|
||||
apellido_paterno: str = Field(default="", max_length=80)
|
||||
apellido_materno: str = Field(default="", max_length=80)
|
||||
nombre: str = Field(default="", max_length=80)
|
||||
calle: str = Field(default="", max_length=120)
|
||||
numero_exterior: str = Field(default="", max_length=20)
|
||||
numero_interior: str = Field(default="", max_length=20)
|
||||
colonia: str = Field(default="", max_length=120)
|
||||
localidad: str = Field(default="", max_length=120)
|
||||
municipio: str = Field(default="", max_length=120)
|
||||
entidad_federativa: str = Field(default="", max_length=120)
|
||||
pais: str = Field(..., max_length=3, description="País en formato ISO o catálogo VU")
|
||||
codigo_postal: Optional[str] = Field(None, max_length=15)
|
||||
codigo_postal: str = Field(default="", max_length=15)
|
||||
|
||||
|
||||
class DescripcionEspecifica(BaseModel):
|
||||
marca: Optional[str] = Field(None, max_length=80)
|
||||
modelo: Optional[str] = Field(None, max_length=80)
|
||||
submodelo: Optional[str] = Field(None, max_length=80)
|
||||
numero_serie: Optional[str] = Field(None, max_length=80)
|
||||
marca: str = Field(default="", max_length=80)
|
||||
modelo: str = Field(default="", max_length=80)
|
||||
submodelo: str = Field(default="", max_length=80)
|
||||
numero_serie: str = Field(default="", max_length=80)
|
||||
|
||||
|
||||
class MercanciaCove(BaseModel):
|
||||
@@ -55,7 +55,7 @@ class MercanciaCove(BaseModel):
|
||||
cantidad: Decimal = Field(..., gt=0)
|
||||
valor_unitario: Decimal = Field(..., ge=0)
|
||||
valor_total: Decimal = Field(..., ge=0)
|
||||
valor_dolares: Optional[Decimal] = Field(None, ge=0)
|
||||
valor_dolares: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
descripcion_especifica: List[DescripcionEspecifica] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
@@ -295,30 +295,28 @@ class FacturaCoveDomainService:
|
||||
identificacion=identificacion,
|
||||
apellido_paterno="",
|
||||
apellido_materno="",
|
||||
nombre=(cp.name or cp.short_name or "").strip() or None,
|
||||
calle=(addr.streets or "").strip() if addr and addr.streets else None,
|
||||
nombre=(cp.name or cp.short_name or "").strip(),
|
||||
calle=(addr.streets or "").strip() if addr and addr.streets else "",
|
||||
numero_exterior=(addr.exterior_number or "").strip()
|
||||
if addr and addr.exterior_number
|
||||
else None,
|
||||
# El API de COVE exige texto (no null) para numero_interior y municipio.
|
||||
# Si no hay valor, enviamos cadena vacía.
|
||||
else "",
|
||||
numero_interior=(addr.interior_number or "").strip()
|
||||
if addr
|
||||
else "",
|
||||
colonia=(addr.neighborhood or "").strip()
|
||||
if addr and addr.neighborhood
|
||||
else None,
|
||||
localidad=(addr.city or "").strip() if addr and addr.city else None,
|
||||
else "",
|
||||
localidad=(addr.city or "").strip() if addr and addr.city else "",
|
||||
municipio=(addr.municipality or "").strip()
|
||||
if addr
|
||||
else "",
|
||||
entidad_federativa=(addr.state or "").strip()
|
||||
if addr and addr.state
|
||||
else None,
|
||||
else "",
|
||||
pais=country_code,
|
||||
codigo_postal=(addr.postal_code or "").strip()
|
||||
if addr and addr.postal_code
|
||||
else None,
|
||||
else "",
|
||||
)
|
||||
|
||||
def _company_to_persona(self, company: Company) -> PersonaCove:
|
||||
@@ -343,28 +341,28 @@ class FacturaCoveDomainService:
|
||||
identificacion=(company.rfc or "").strip().upper(),
|
||||
apellido_paterno="",
|
||||
apellido_materno="",
|
||||
nombre=(company.name or "").strip() or None,
|
||||
calle=(addr.street or "").strip() if addr and addr.street else None,
|
||||
nombre=(company.name or "").strip(),
|
||||
calle=(addr.street or "").strip() if addr and addr.street else "",
|
||||
numero_exterior=(addr.exterior_number or "").strip()
|
||||
if addr and addr.exterior_number
|
||||
else None,
|
||||
else "",
|
||||
numero_interior=(addr.interior_number or "").strip()
|
||||
if addr
|
||||
else "",
|
||||
colonia=(addr.neighborhood or "").strip()
|
||||
if addr and addr.neighborhood
|
||||
else None,
|
||||
localidad=(addr.city or "").strip() if addr and addr.city else None,
|
||||
else "",
|
||||
localidad=(addr.city or "").strip() if addr and addr.city else "",
|
||||
municipio=(addr.municipality or "").strip()
|
||||
if addr
|
||||
else "",
|
||||
entidad_federativa=(addr.state or "").strip()
|
||||
if addr and addr.state
|
||||
else None,
|
||||
else "",
|
||||
pais=country_code,
|
||||
codigo_postal=(addr.postal_code or "").strip()
|
||||
if addr and addr.postal_code
|
||||
else None,
|
||||
else "",
|
||||
)
|
||||
|
||||
def _build_personas(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DodaAltaLogCreateDTO(BaseModel):
|
||||
doda_id: Optional[int] = None
|
||||
variant: Optional[str] = Field(None, max_length=10)
|
||||
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)
|
||||
operation_type: Optional[str] = Field(None, max_length=5)
|
||||
integration_number: Optional[str] = Field(None, max_length=50)
|
||||
task_id: Optional[str] = Field(None, max_length=255)
|
||||
status: Optional[str] = Field(None, max_length=30)
|
||||
message: Optional[str] = Field(None, max_length=2000)
|
||||
result_json: Optional[str] = None
|
||||
|
||||
|
||||
class DodaAltaLogUpdateDTO(BaseModel):
|
||||
status: Optional[str] = Field(None, max_length=30)
|
||||
message: Optional[str] = Field(None, max_length=2000)
|
||||
result_json: Optional[str] = None
|
||||
|
||||
|
||||
class DodaAltaLogResponseDTO(BaseModel):
|
||||
id: int
|
||||
doda_id: Optional[int] = None
|
||||
variant: Optional[str] = None
|
||||
responsible: Optional[str] = None
|
||||
patent: Optional[str] = None
|
||||
dispatch_customs: Optional[str] = None
|
||||
operation_type: Optional[str] = None
|
||||
integration_number: Optional[str] = None
|
||||
task_id: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
result_json: Optional[str] = None
|
||||
company_id: int
|
||||
tenant_id: int
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DodaAltaLogListResponse(BaseModel):
|
||||
items: List[DodaAltaLogResponseDTO]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class DodaAltaLog(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Registro histórico de envíos de alta DODA/PITA al servicio externo.
|
||||
Cada fila corresponde a un intento de alta para un DODA específico.
|
||||
"""
|
||||
|
||||
__tablename__ = "doda_alta_log"
|
||||
__table_args__ = ({"schema": "a76"},)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Referencia al DODA origen
|
||||
doda_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
# Tipo de alta (doda / pita)
|
||||
variant: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
|
||||
# Datos copiados del DODA al momento del envío (para historial)
|
||||
responsible: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
patent: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
dispatch_customs: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
operation_type: Mapped[str | None] = mapped_column(String(5), nullable=True)
|
||||
integration_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# Respuesta del servicio externo
|
||||
task_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
status: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
message: Mapped[str | None] = mapped_column(String(2000), nullable=True)
|
||||
result_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .alta_log_dto import (
|
||||
DodaAltaLogCreateDTO,
|
||||
DodaAltaLogListResponse,
|
||||
DodaAltaLogResponseDTO,
|
||||
DodaAltaLogUpdateDTO,
|
||||
)
|
||||
from .alta_log_models import DodaAltaLog
|
||||
from .models import Doda
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DodaAltaLogService:
|
||||
|
||||
@staticmethod
|
||||
def list(
|
||||
db: Session,
|
||||
company_id: int,
|
||||
tenant_id: int,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
doda_id: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
) -> DodaAltaLogListResponse:
|
||||
query = (
|
||||
db.query(DodaAltaLog)
|
||||
.filter(
|
||||
DodaAltaLog.company_id == company_id,
|
||||
DodaAltaLog.tenant_id == tenant_id,
|
||||
DodaAltaLog.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if doda_id:
|
||||
query = query.filter(DodaAltaLog.doda_id == doda_id)
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
query = query.filter(
|
||||
DodaAltaLog.task_id.ilike(like)
|
||||
| DodaAltaLog.integration_number.ilike(like)
|
||||
| DodaAltaLog.patent.ilike(like)
|
||||
| DodaAltaLog.status.ilike(like)
|
||||
)
|
||||
total = query.count()
|
||||
items = (
|
||||
query.order_by(DodaAltaLog.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return DodaAltaLogListResponse(
|
||||
items=[DodaAltaLogResponseDTO.model_validate(r) for r in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get(
|
||||
db: Session, record_id: int, company_id: int, tenant_id: int
|
||||
) -> Optional[DodaAltaLog]:
|
||||
return (
|
||||
db.query(DodaAltaLog)
|
||||
.filter(
|
||||
DodaAltaLog.id == record_id,
|
||||
DodaAltaLog.company_id == company_id,
|
||||
DodaAltaLog.tenant_id == tenant_id,
|
||||
DodaAltaLog.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
db: Session, dto: DodaAltaLogCreateDTO, company_id: int, tenant_id: int
|
||||
) -> DodaAltaLog:
|
||||
record = DodaAltaLog(
|
||||
company_id=company_id,
|
||||
tenant_id=tenant_id,
|
||||
**dto.model_dump(exclude_none=False),
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
def update(
|
||||
db: Session, record: DodaAltaLog, dto: DodaAltaLogUpdateDTO
|
||||
) -> DodaAltaLog:
|
||||
for field, value in dto.model_dump(exclude_unset=True).items():
|
||||
setattr(record, field, value)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, record: DodaAltaLog) -> None:
|
||||
from datetime import datetime
|
||||
record.deleted_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def create_from_alta_result(
|
||||
db: Session,
|
||||
doda: Doda,
|
||||
company_id: int,
|
||||
tenant_id: int,
|
||||
variant: str,
|
||||
ext_result: dict,
|
||||
) -> DodaAltaLog:
|
||||
"""
|
||||
Crea un registro de log a partir de la respuesta del servicio externo de alta.
|
||||
Llamado automáticamente al completar `POST /{doda_id}/alta`.
|
||||
"""
|
||||
task_id = ext_result.get("task_id") or ext_result.get("id") or ""
|
||||
status = ext_result.get("status") or "pending"
|
||||
message = ext_result.get("message") or ""
|
||||
|
||||
dto = DodaAltaLogCreateDTO(
|
||||
doda_id=doda.id,
|
||||
variant=variant,
|
||||
responsible=doda.responsible,
|
||||
patent=doda.patent,
|
||||
dispatch_customs=doda.dispatch_customs,
|
||||
operation_type=doda.operation_type,
|
||||
integration_number=doda.integration_number,
|
||||
task_id=task_id,
|
||||
status=status,
|
||||
message=message,
|
||||
result_json=json.dumps(ext_result),
|
||||
)
|
||||
return DodaAltaLogService.create(db, dto, company_id, tenant_id)
|
||||
551
backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py
Normal file
551
backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py
Normal file
@@ -0,0 +1,551 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cryptography.hazmat.primitives import padding as crypto_padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
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 .payload_normalizer import (
|
||||
normalize_aduana_despacho,
|
||||
normalize_aduana_seccion,
|
||||
normalize_caat,
|
||||
normalize_doda_pedimento_row,
|
||||
normalize_fast_id,
|
||||
normalize_id_transporte,
|
||||
normalize_numero_gafete,
|
||||
normalize_patente,
|
||||
normalize_tipo_operacion,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response schemas (inline para no añadir dependencias externas)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ElegibilidadReason:
|
||||
field: str
|
||||
message: str
|
||||
solution: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ElegibilidadResponse:
|
||||
can_alta: bool
|
||||
reasons: List[ElegibilidadReason] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FIEL encryption (mismo esquema AES-256-CBC que COVE / expediente_archivos)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _encrypt_fiel(raw_fiel: str) -> str:
|
||||
normalized = (raw_fiel or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
key_bytes = (settings.COVE_FIEL_HASH_KEY or "").encode("utf-8")
|
||||
iv_bytes = (settings.COVE_FIEL_HASH_IV or "").encode("utf-8")
|
||||
if not key_bytes or not iv_bytes:
|
||||
return normalized
|
||||
key32 = key_bytes[:32].ljust(32, b"\0")
|
||||
iv16 = iv_bytes[:16].ljust(16, b"\0")
|
||||
padder = crypto_padding.PKCS7(algorithms.AES.block_size).padder()
|
||||
padded = padder.update(normalized.encode("utf-8")) + padder.finalize()
|
||||
cipher = Cipher(algorithms.AES(key32), modes.CBC(iv16))
|
||||
enc = cipher.encryptor()
|
||||
encrypted = enc.update(padded) + enc.finalize()
|
||||
return base64.b64encode(encrypted).decode("ascii")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DodaAltaService:
|
||||
"""
|
||||
Servicio de dominio para construir el payload de alta DODA y verificar
|
||||
elegibilidad antes de enviarlo al servicio externo.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Broker resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_broker(
|
||||
self,
|
||||
responsible_key: Optional[str],
|
||||
company_id: int,
|
||||
tenant_id: int,
|
||||
) -> Optional[cb_models.CustomsBroker]:
|
||||
"""
|
||||
Resuelve el agente aduanal a partir de Doda.responsible (ClaveAA del legacy).
|
||||
Prioridad: broker_key exacto → license exacto.
|
||||
"""
|
||||
normalized = (responsible_key or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
brokers = (
|
||||
self.db.query(cb_models.CustomsBroker)
|
||||
.filter(
|
||||
or_(
|
||||
cb_models.CustomsBroker.broker_key == normalized,
|
||||
cb_models.CustomsBroker.license == normalized,
|
||||
),
|
||||
cb_models.CustomsBroker.company_id == company_id,
|
||||
cb_models.CustomsBroker.tenant_id == tenant_id,
|
||||
cb_models.CustomsBroker.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(cb_models.CustomsBroker.id.desc())
|
||||
.all()
|
||||
)
|
||||
if not brokers:
|
||||
return None
|
||||
|
||||
exact = next(
|
||||
(b for b in brokers if (b.broker_key or "").strip() == normalized),
|
||||
None,
|
||||
)
|
||||
return exact or brokers[0]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# configuracion_vu DODA
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_configuracion_vu_doda(
|
||||
self,
|
||||
broker: cb_models.CustomsBroker,
|
||||
errors: List[ElegibilidadReason],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Construye configuracion_vu usando los campos DODA del CustomsBrokerVU:
|
||||
doda_certificate_path, doda_key_path, doda_fiel_access_key.
|
||||
"""
|
||||
vu = broker.vu if broker else None
|
||||
|
||||
if not vu:
|
||||
errors.append(ElegibilidadReason(
|
||||
field="vu",
|
||||
message="El agente aduanal no tiene configuración VU.",
|
||||
solution="Configura la sección VU/DODA del agente aduanal.",
|
||||
))
|
||||
return None
|
||||
|
||||
doda_cert_path = (getattr(vu, "doda_certificate_path", None) or "").strip()
|
||||
doda_key_path = (getattr(vu, "doda_key_path", None) or "").strip()
|
||||
doda_fiel = (getattr(vu, "doda_fiel_access_key", None) or "").strip()
|
||||
|
||||
if not doda_cert_path or not doda_key_path:
|
||||
errors.append(ElegibilidadReason(
|
||||
field="vu.doda_certificate_path",
|
||||
message="Faltan rutas de certificado o llave DODA en la configuración VU del agente.",
|
||||
solution="Sube el .cer y .key DODA en la pestaña DODA del agente aduanal.",
|
||||
))
|
||||
return None
|
||||
|
||||
if not doda_fiel:
|
||||
errors.append(ElegibilidadReason(
|
||||
field="vu.doda_fiel_access_key",
|
||||
message="La clave FIEL DODA no está configurada en la VU del agente.",
|
||||
solution="Captura la clave FIEL DODA en la configuración VU del agente aduanal.",
|
||||
))
|
||||
return None
|
||||
|
||||
cer_b64: Optional[str] = None
|
||||
key_b64: Optional[str] = None
|
||||
try:
|
||||
if not object_exists(doda_cert_path):
|
||||
errors.append(ElegibilidadReason(
|
||||
field="vu.doda_certificate_path",
|
||||
message="El certificado DODA no existe en el almacenamiento.",
|
||||
solution="Vuelve a subir el .cer DODA en la configuración VU del agente.",
|
||||
))
|
||||
else:
|
||||
cer_b64 = base64.b64encode(get_object_bytes(doda_cert_path)).decode("ascii")
|
||||
|
||||
if not object_exists(doda_key_path):
|
||||
errors.append(ElegibilidadReason(
|
||||
field="vu.doda_key_path",
|
||||
message="La llave DODA no existe en el almacenamiento.",
|
||||
solution="Vuelve a subir el .key DODA en la configuración VU del agente.",
|
||||
))
|
||||
else:
|
||||
key_b64 = base64.b64encode(get_object_bytes(doda_key_path)).decode("ascii")
|
||||
except Exception:
|
||||
logger.exception("Error leyendo certificados DODA desde S3")
|
||||
errors.append(ElegibilidadReason(
|
||||
field="vu",
|
||||
message="Error leyendo certificados DODA desde el almacenamiento.",
|
||||
solution="Verifica la configuración de S3/MinIO y las rutas de los certificados.",
|
||||
))
|
||||
return None
|
||||
|
||||
if errors:
|
||||
return None
|
||||
|
||||
rfc_ciec = (
|
||||
(getattr(vu, "query_tax_id", None) or "").strip()
|
||||
or (getattr(broker, "tax_id", None) or "").strip()
|
||||
)
|
||||
|
||||
clave_fiel = _encrypt_fiel(doda_fiel)
|
||||
|
||||
return {
|
||||
"rfc_ciec": rfc_ciec,
|
||||
"archivo_cer_base64": cer_b64 or "",
|
||||
"archivo_key_base64": key_b64 or "",
|
||||
"clave_fiel": clave_fiel,
|
||||
}
|
||||
|
||||
def _attach_user_email(
|
||||
self, configuracion_vu: Dict[str, Any], user_email: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Agrega el email del usuario autenticado al bloque configuracion_vu."""
|
||||
configuracion_vu["email"] = (user_email or "").strip()
|
||||
return configuracion_vu
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Payload builders for child collections
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_contenedores(
|
||||
self, containers: List[DodaContainer]
|
||||
) -> List[Dict[str, Any]]:
|
||||
result = []
|
||||
for c in containers:
|
||||
candados = []
|
||||
for seal in (c.seals_detail or []):
|
||||
if seal.seal_value:
|
||||
candados.append({"candado": seal.seal_value})
|
||||
# Fallback: si no hay filas en seals_detail pero hay string legado en seals
|
||||
if not candados and c.seals:
|
||||
for raw in c.seals.split(","):
|
||||
val = raw.strip()
|
||||
if val:
|
||||
candados.append({"candado": val})
|
||||
val = (c.container_value or "").strip()
|
||||
result.append({
|
||||
"valor_contenedor": val,
|
||||
"candados": candados,
|
||||
})
|
||||
return result
|
||||
|
||||
def _build_pedimentos_americanos(
|
||||
self, american_pedimentos: List[DodaAmericanPedimento]
|
||||
) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"tipo_pedimento_americano": p.american_pedimento_type or "",
|
||||
"valor_pedimento_americano": p.american_pedimento_value or "",
|
||||
}
|
||||
for p in american_pedimentos
|
||||
]
|
||||
|
||||
def _build_pedimentos(
|
||||
self, pedimentos_detail: List[DodaPedimento]
|
||||
) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for p in pedimentos_detail:
|
||||
row = normalize_doda_pedimento_row(
|
||||
document=p.document,
|
||||
authorization_patent=p.authorization_patent,
|
||||
shipment=p.shipment,
|
||||
cove=p.cove,
|
||||
umc=p.umc,
|
||||
dta_niu=p.dta_niu,
|
||||
pedimento_type=p.pedimento_type,
|
||||
effective=p.effective_amount_usd,
|
||||
diff=p.difference_amount_usd,
|
||||
)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Elegibilidad (validaciones del legacy Clarion activas)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def check_elegibilidad(
|
||||
self,
|
||||
doda_id: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
variant: str = "doda",
|
||||
user_email: Optional[str] = None,
|
||||
) -> ElegibilidadResponse:
|
||||
"""
|
||||
Verifica si el DODA cumple los requisitos para enviar el alta.
|
||||
Porta las validaciones activas del código Clarion legacy.
|
||||
"""
|
||||
reasons: List[ElegibilidadReason] = []
|
||||
|
||||
# --- Email del usuario autenticado ---
|
||||
if not (user_email or "").strip():
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="user_email",
|
||||
message="El usuario no tiene correo electrónico registrado.",
|
||||
solution="Configura un correo electrónico en tu perfil de Keycloak.",
|
||||
))
|
||||
|
||||
doda = self.db.query(Doda).filter(
|
||||
Doda.id == doda_id,
|
||||
Doda.tenant_id == tenant_id,
|
||||
Doda.company_id == company_id,
|
||||
).first()
|
||||
|
||||
if not doda:
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="doda_id",
|
||||
message=f"DODA {doda_id} no encontrado.",
|
||||
))
|
||||
return ElegibilidadResponse(can_alta=False, reasons=reasons)
|
||||
|
||||
# --- Campos obligatorios (ramas activas del legacy) ---
|
||||
if not (doda.responsible or "").strip():
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="responsible",
|
||||
message="El campo Responsable se encuentra vacío.",
|
||||
solution="Captura la clave del agente aduanal responsable.",
|
||||
))
|
||||
|
||||
if not (doda.dispatch_customs or "").strip():
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="dispatch_customs",
|
||||
message="El campo Aduana de Despacho se encuentra vacío.",
|
||||
solution="Captura la clave de la aduana de despacho.",
|
||||
))
|
||||
|
||||
if not (doda.customs_sections or "").strip():
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="customs_sections",
|
||||
message="El campo Aduana Sección (E/S) se encuentra vacío.",
|
||||
solution="Captura la sección aduanera.",
|
||||
))
|
||||
|
||||
if not (doda.operation_type or "").strip():
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="operation_type",
|
||||
message="El campo Tipo de Operación se encuentra vacío.",
|
||||
solution="Selecciona el tipo de operación.",
|
||||
))
|
||||
|
||||
if not (doda.caat or "").strip():
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="caat",
|
||||
message="El campo CAAT se encuentra vacío.",
|
||||
solution="Captura el código CAAT del transportista.",
|
||||
))
|
||||
|
||||
if not (doda.transport_identification or "").strip():
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="transport_identification",
|
||||
message="El campo Identificación de Transporte se encuentra vacío.",
|
||||
solution="Captura el número de identificación del transporte.",
|
||||
))
|
||||
|
||||
if not (doda.patent or "").strip():
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="patent",
|
||||
message="El campo Patente se encuentra vacío.",
|
||||
solution="La patente se llena automáticamente al seleccionar el responsable.",
|
||||
))
|
||||
|
||||
# --- Gafete único: obligatorio si variant=doda ---
|
||||
if variant.lower() == "doda":
|
||||
if not (doda.unique_badge_number or "").strip():
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="unique_badge_number",
|
||||
message="El campo Número de Gafete Único es obligatorio para el Alta DODA.",
|
||||
solution="Captura el número de gafete único del conductor.",
|
||||
))
|
||||
|
||||
# --- Contenedores máximo 4 (legacy: IF SQL2:C2 = 4 THEN MESSAGE) ---
|
||||
containers = doda.containers or []
|
||||
if len(containers) > 4:
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="containers",
|
||||
message=f"El DODA tiene {len(containers)} contenedores. El máximo permitido es 4.",
|
||||
solution="Elimina los contenedores sobrantes antes de enviar el alta.",
|
||||
))
|
||||
|
||||
# --- Precintos: máximo 8 en todo el DODA (legacy gDoda_Contenedores_Candados) ---
|
||||
seal_count = 0
|
||||
for c in containers:
|
||||
details = getattr(c, "seals_detail", None) or []
|
||||
if details:
|
||||
seal_count += len(details)
|
||||
else:
|
||||
legacy = (getattr(c, "seals", None) or "").strip()
|
||||
if legacy:
|
||||
seal_count += len([s for s in legacy.split(",") if s.strip()])
|
||||
if seal_count > 8:
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="containers",
|
||||
message=f"El DODA tiene {seal_count} precintos. El máximo permitido es 8.",
|
||||
solution="Elimina precintos hasta quedar en 8 o menos.",
|
||||
))
|
||||
|
||||
# --- Pedimentos americanos: tipo obligatorio y rango según operación (legacy, salvo PITA) ---
|
||||
clearance = getattr(doda, "customs_clearance", None)
|
||||
if clearance != 1:
|
||||
op = (doda.operation_type or "").strip().upper()
|
||||
if op in ("I", "1"):
|
||||
allowed_tipo = {"1", "2", "3", "4", "5"}
|
||||
elif op in ("E", "2"):
|
||||
allowed_tipo = {"6", "7", "8"}
|
||||
else:
|
||||
allowed_tipo = set()
|
||||
for idx, ap in enumerate(doda.american_pedimentos or [], 1):
|
||||
tipo = (ap.american_pedimento_type or "").strip()
|
||||
if not tipo:
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="american_pedimentos",
|
||||
message=f"Pedimento americano (línea {idx}): el tipo es obligatorio para este tipo de despacho.",
|
||||
solution="Captura el tipo de pedimento americano (1–5 importación, 6–8 exportación).",
|
||||
))
|
||||
elif allowed_tipo and tipo not in allowed_tipo:
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="american_pedimentos",
|
||||
message=(
|
||||
f"Pedimento americano (línea {idx}): el tipo '{tipo}' no corresponde al tipo de operación."
|
||||
),
|
||||
solution="Corrige el tipo según importación (1–5) o exportación (6–8).",
|
||||
))
|
||||
|
||||
# --- Patente vs. patente del agente aduanal (legacy: DODA:Patente <> AgeAdu:Patente) ---
|
||||
broker = self._resolve_broker(doda.responsible, company_id, tenant_id)
|
||||
|
||||
if (doda.responsible or "").strip() and not broker:
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="responsible",
|
||||
message=f"La clave de Responsable '{doda.responsible}' no existe en el catálogo de agentes aduanales.",
|
||||
solution="Selecciona un agente aduanal válido del catálogo.",
|
||||
))
|
||||
elif broker and (doda.patent or "").strip():
|
||||
broker_patent = (broker.license or "").strip()
|
||||
doda_patent = (doda.patent or "").strip()
|
||||
if broker_patent and doda_patent and doda_patent != broker_patent:
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="patent",
|
||||
message=(
|
||||
f"La patente declarada en el DODA '{doda_patent}' es distinta "
|
||||
f"a la patente del responsable '{broker_patent}'."
|
||||
),
|
||||
solution="Verifica o actualiza la patente del DODA para que coincida con la del agente.",
|
||||
))
|
||||
|
||||
# --- Certificados DODA en VU del agente (equivalente a RutaArchivosXMLDODA) ---
|
||||
if broker:
|
||||
vu = broker.vu
|
||||
if not vu:
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="vu",
|
||||
message="El agente aduanal no tiene configuración VU.",
|
||||
solution="Configura la sección VU/DODA del agente aduanal.",
|
||||
))
|
||||
else:
|
||||
if not (getattr(vu, "doda_certificate_path", None) or "").strip():
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="vu.doda_certificate_path",
|
||||
message="No hay certificado DODA (.cer) configurado en la VU del agente.",
|
||||
solution="Sube el certificado .cer DODA en la pestaña DODA del agente aduanal.",
|
||||
))
|
||||
if not (getattr(vu, "doda_key_path", None) or "").strip():
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="vu.doda_key_path",
|
||||
message="No hay llave DODA (.key) configurada en la VU del agente.",
|
||||
solution="Sube la llave .key DODA en la pestaña DODA del agente aduanal.",
|
||||
))
|
||||
if not (getattr(vu, "doda_fiel_access_key", None) or "").strip():
|
||||
reasons.append(ElegibilidadReason(
|
||||
field="vu.doda_fiel_access_key",
|
||||
message="La clave FIEL DODA no está configurada en la VU del agente.",
|
||||
solution="Captura la clave FIEL DODA en la configuración VU del agente.",
|
||||
))
|
||||
|
||||
return ElegibilidadResponse(
|
||||
can_alta=len(reasons) == 0,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build full payload
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_alta_payload(
|
||||
self,
|
||||
doda_id: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
variant: str = "doda",
|
||||
user_email: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Construye el payload completo para POST /api/v1/doda/alta.
|
||||
Lanza ValueError si hay problemas de configuración críticos.
|
||||
"""
|
||||
doda = self.db.query(Doda).filter(
|
||||
Doda.id == doda_id,
|
||||
Doda.tenant_id == tenant_id,
|
||||
Doda.company_id == company_id,
|
||||
).first()
|
||||
|
||||
if not doda:
|
||||
raise ValueError(f"DODA {doda_id} no encontrado.")
|
||||
|
||||
broker = self._resolve_broker(doda.responsible, company_id, tenant_id)
|
||||
if not broker:
|
||||
raise ValueError(
|
||||
f"No se encontró el agente aduanal con clave '{doda.responsible}'."
|
||||
)
|
||||
|
||||
errors: List[ElegibilidadReason] = []
|
||||
configuracion_vu = self._build_configuracion_vu_doda(broker, errors)
|
||||
if errors or not configuracion_vu:
|
||||
msgs = "; ".join(r.message for r in errors)
|
||||
raise ValueError(f"Error en configuración VU DODA: {msgs}")
|
||||
|
||||
self._attach_user_email(configuracion_vu, user_email)
|
||||
|
||||
containers = doda.containers or []
|
||||
american_pedimentos = doda.american_pedimentos or []
|
||||
pedimentos_detail = doda.pedimentos_detail or []
|
||||
|
||||
# El API externo espera "1" para DODA y "2" para PITA (ver DODARequest.despacho_aduanero)
|
||||
despacho_aduanero = "1" if variant.lower() == "doda" else "2"
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"configuracion_vu": configuracion_vu,
|
||||
"despacho_aduanero": despacho_aduanero,
|
||||
"numero_gafete_unico": normalize_numero_gafete(
|
||||
doda.unique_badge_number
|
||||
),
|
||||
"aduana_despacho": normalize_aduana_despacho(doda.dispatch_customs),
|
||||
"aduana_seccion": normalize_aduana_seccion(doda.customs_sections),
|
||||
"patente": normalize_patente(doda.patent),
|
||||
"caat": normalize_caat(doda.caat),
|
||||
"id_transporte": normalize_id_transporte(doda.transport_identification),
|
||||
"fast_id": normalize_fast_id(doda.fast_id),
|
||||
"tipo_operacion": normalize_tipo_operacion(doda.operation_type),
|
||||
"contenedores": self._build_contenedores(containers),
|
||||
"pedimentos_americanos": self._build_pedimentos_americanos(american_pedimentos),
|
||||
"cfdi_carta_porte": {"cfdi_carta_porte": ""},
|
||||
"pedimentos": self._build_pedimentos(pedimentos_detail),
|
||||
}
|
||||
|
||||
return payload
|
||||
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import AliasChoices, BaseModel, Field
|
||||
|
||||
|
||||
# ============ DODA CONTAINER SEAL DTOS ============
|
||||
@@ -24,7 +24,9 @@ class DodaContainerSealResponseDTO(BaseModel):
|
||||
"""DTO para responder con datos de un candado"""
|
||||
|
||||
id: int
|
||||
doda_sys_id: int
|
||||
doda_sys_id: int = Field(
|
||||
validation_alias=AliasChoices("doda_sys_id", "doda_id")
|
||||
)
|
||||
seal_line: int
|
||||
seal_value: Optional[str] = None
|
||||
|
||||
@@ -62,7 +64,9 @@ class DodaContainerResponseDTO(BaseModel):
|
||||
"""DTO para responder con datos de un contenedor"""
|
||||
|
||||
id: int
|
||||
doda_sys_id: int
|
||||
doda_sys_id: int = Field(
|
||||
validation_alias=AliasChoices("doda_sys_id", "doda_id")
|
||||
)
|
||||
container_line: int
|
||||
container_value: Optional[str] = None
|
||||
seals: Optional[str] = None
|
||||
@@ -105,7 +109,9 @@ class DodaAmericanPedimentoResponseDTO(BaseModel):
|
||||
"""DTO para responder con datos de un pedimento americano"""
|
||||
|
||||
id: int
|
||||
doda_sys_id: int
|
||||
doda_sys_id: int = Field(
|
||||
validation_alias=AliasChoices("doda_sys_id", "doda_id")
|
||||
)
|
||||
american_pedimento_line: int
|
||||
american_pedimento_type: Optional[str] = None
|
||||
american_pedimento_value: Optional[str] = None
|
||||
@@ -183,7 +189,9 @@ class DodaPedimentoResponseDTO(BaseModel):
|
||||
"""DTO para responder con datos de un pedimento DODA"""
|
||||
|
||||
id: int
|
||||
doda_sys_id: int
|
||||
doda_sys_id: int = Field(
|
||||
validation_alias=AliasChoices("doda_sys_id", "doda_id")
|
||||
)
|
||||
pedimento_line: int
|
||||
authorization_patent: Optional[str] = None
|
||||
document: Optional[str] = None
|
||||
@@ -369,6 +377,9 @@ class DodaResponseDTO(BaseModel):
|
||||
sat_digital_seal: Optional[str] = None
|
||||
xml_doda_sent_path: Optional[str] = None
|
||||
xml_doda_response_path: Optional[str] = None
|
||||
doda_report_pdf_path: Optional[str] = None
|
||||
doda_report_pdf_generated_at: Optional[datetime] = None
|
||||
doda_report_source_fingerprint: Optional[str] = None
|
||||
sat_original_chain: Optional[str] = None
|
||||
customs_clearance: Optional[int] = None
|
||||
unique_badge_number: Optional[str] = None
|
||||
@@ -412,6 +423,9 @@ class DodaDetailResponseDTO(BaseModel):
|
||||
sat_digital_seal: Optional[str] = None
|
||||
xml_doda_sent_path: Optional[str] = None
|
||||
xml_doda_response_path: Optional[str] = None
|
||||
doda_report_pdf_path: Optional[str] = None
|
||||
doda_report_pdf_generated_at: Optional[datetime] = None
|
||||
doda_report_source_fingerprint: Optional[str] = None
|
||||
sat_original_chain: Optional[str] = None
|
||||
customs_clearance: Optional[int] = None
|
||||
unique_badge_number: Optional[str] = None
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Exportación de listado DODA a CSV / TSV (xls) / pipe, alineada al reporte legacy GDoda.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import Doda
|
||||
|
||||
# Encabezados (orden legacy Clarion)
|
||||
EXPORT_HEADERS: List[str] = [
|
||||
"SYSID",
|
||||
"NUM INTEGRACIÓN",
|
||||
"FECHA",
|
||||
"HORA",
|
||||
"ADUANA",
|
||||
"ADUANA ES",
|
||||
"PATENTE",
|
||||
"PEDIMENTOS",
|
||||
"CAAT",
|
||||
"IDEN. TRANSPORTE",
|
||||
"FAST_ID",
|
||||
"TIPO OPERACIÓN",
|
||||
"RESPONSABLE",
|
||||
"TRANSPORTISTA",
|
||||
"REMESAS",
|
||||
"TIPO PEDIMENTO",
|
||||
"CADENA ORIGINIAL",
|
||||
"NUMERO SERIE",
|
||||
"FIRMA ELECTRÓNICA",
|
||||
"NO TRANSACCIÓN",
|
||||
"ESTATUS",
|
||||
"LINQSQTQR",
|
||||
"SAT_CERTIFICADO",
|
||||
"SELLO DIGITAL",
|
||||
"PATH XML ENVÍO",
|
||||
"PATH XML RESPUESTA",
|
||||
"SAT_CADENA ORIGINAL",
|
||||
"DESPACHO ADUANERO",
|
||||
"GAFETE ÚNICO",
|
||||
"USUARIO",
|
||||
]
|
||||
|
||||
|
||||
class DodaExportFormat(str, Enum):
|
||||
csv = "csv"
|
||||
xls = "xls"
|
||||
txt = "txt"
|
||||
|
||||
|
||||
def _parse_iso_date(s: str) -> date:
|
||||
s = (s or "").strip()
|
||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%d-%m-%Y"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(f"Fecha inválida: {s!r} (use YYYY-MM-DD)")
|
||||
|
||||
|
||||
def _date_to_yyyymmdd(d: date) -> int:
|
||||
return d.year * 10000 + d.month * 100 + d.day
|
||||
|
||||
|
||||
def _format_doda_date_formatted(doda_date: Optional[int]) -> str:
|
||||
if doda_date is None:
|
||||
return ""
|
||||
s = str(doda_date)
|
||||
if len(s) == 8 and s.isdigit():
|
||||
y, m, d = s[:4], s[4:6], s[6:8]
|
||||
return f"{d}/{m}/{y}"
|
||||
return s
|
||||
|
||||
|
||||
def _format_doda_time(doda_time: Optional[int]) -> str:
|
||||
if doda_time is None:
|
||||
return ""
|
||||
t = int(doda_time)
|
||||
s = str(t)
|
||||
if len(s) <= 2:
|
||||
return s
|
||||
if len(s) == 4:
|
||||
return f"{s[:2]}:{s[2:4]}"
|
||||
if len(s) == 6:
|
||||
return f"{s[:2]}:{s[2:4]}:{s[4:6]}"
|
||||
if len(s) > 6:
|
||||
return s[:2] + ":" + s[2:4] + ":" + s[4:6]
|
||||
return s
|
||||
|
||||
|
||||
def _as_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return "1" if value else "0"
|
||||
s = str(value)
|
||||
s = s.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
|
||||
return s
|
||||
|
||||
|
||||
def doda_row_values(
|
||||
row: Doda, *, date_mode: str
|
||||
) -> List[str]:
|
||||
"""date_mode: 'raw' | 'formatted' (legacy FechaJul branch)."""
|
||||
if date_mode == "raw":
|
||||
fecha = _as_text(row.doda_date)
|
||||
hora = _as_text(row.doda_time)
|
||||
else:
|
||||
fecha = _format_doda_date_formatted(row.doda_date)
|
||||
hora = _format_doda_time(row.doda_time)
|
||||
|
||||
return [
|
||||
_as_text(row.id),
|
||||
_as_text(row.integration_number),
|
||||
fecha,
|
||||
hora,
|
||||
_as_text(row.dispatch_customs),
|
||||
_as_text(row.customs_sections),
|
||||
_as_text(row.patent),
|
||||
_as_text(row.pedimentos),
|
||||
_as_text(row.caat),
|
||||
_as_text(row.transport_identification),
|
||||
_as_text(row.fast_id),
|
||||
_as_text(row.operation_type),
|
||||
_as_text(row.responsible),
|
||||
_as_text(row.carrier),
|
||||
_as_text(row.shipments),
|
||||
_as_text(row.pedimento_type),
|
||||
_as_text(row.original_chain),
|
||||
_as_text(row.serial_number),
|
||||
_as_text(row.electronic_signature),
|
||||
_as_text(row.transaction_number),
|
||||
_as_text(row.status),
|
||||
_as_text(row.linq_sat_qr),
|
||||
_as_text(row.sat_certificate),
|
||||
_as_text(row.sat_digital_seal),
|
||||
_as_text(row.xml_doda_sent_path),
|
||||
_as_text(row.xml_doda_response_path),
|
||||
_as_text(row.sat_original_chain),
|
||||
_as_text(row.customs_clearance),
|
||||
_as_text(row.unique_badge_number),
|
||||
_as_text(row.last_user),
|
||||
]
|
||||
|
||||
|
||||
def _delimiter_for_format(fmt: DodaExportFormat) -> str:
|
||||
if fmt == DodaExportFormat.csv:
|
||||
return ","
|
||||
if fmt == DodaExportFormat.xls:
|
||||
return "\t"
|
||||
if fmt == DodaExportFormat.txt:
|
||||
return "|"
|
||||
return ","
|
||||
|
||||
|
||||
def _content_type_and_filename(fmt: DodaExportFormat) -> tuple[str, str]:
|
||||
if fmt == DodaExportFormat.csv:
|
||||
return "text/csv; charset=utf-8", "doda_export.csv"
|
||||
if fmt == DodaExportFormat.xls:
|
||||
return "application/vnd.ms-excel; charset=utf-8", "doda_export.xls"
|
||||
return "text/plain; charset=utf-8", "doda_export.txt"
|
||||
|
||||
|
||||
def list_dodas_in_date_range(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
date_start: int,
|
||||
date_end: int,
|
||||
) -> List[Doda]:
|
||||
return (
|
||||
db.query(Doda)
|
||||
.filter(
|
||||
and_(
|
||||
Doda.tenant_id == tenant_id,
|
||||
Doda.company_id == company_id,
|
||||
Doda.doda_date.isnot(None),
|
||||
Doda.doda_date >= date_start,
|
||||
Doda.doda_date <= date_end,
|
||||
)
|
||||
)
|
||||
.order_by(Doda.doda_date.asc(), Doda.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def build_export_text(
|
||||
rows: List[Doda],
|
||||
*,
|
||||
export_format: DodaExportFormat,
|
||||
date_mode: str = "formatted",
|
||||
) -> str:
|
||||
delim = _delimiter_for_format(export_format)
|
||||
out = io.StringIO()
|
||||
w = csv.writer(
|
||||
out,
|
||||
delimiter=delim,
|
||||
quoting=csv.QUOTE_MINIMAL,
|
||||
lineterminator="\r\n",
|
||||
)
|
||||
w.writerow(EXPORT_HEADERS)
|
||||
for r in rows:
|
||||
w.writerow(doda_row_values(r, date_mode=date_mode))
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def parse_export_params(
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
format_str: str,
|
||||
date_mode: str,
|
||||
) -> tuple[int, int, DodaExportFormat, str]:
|
||||
d0 = _date_to_yyyymmdd(_parse_iso_date(date_from))
|
||||
d1 = _date_to_yyyymmdd(_parse_iso_date(date_to))
|
||||
if d0 > d1:
|
||||
raise ValueError("date_from no puede ser posterior a date_to")
|
||||
try:
|
||||
fmt = DodaExportFormat(format_str.lower().strip())
|
||||
except ValueError:
|
||||
raise ValueError("format debe ser csv, xls o txt")
|
||||
mode = (date_mode or "formatted").lower().strip()
|
||||
if mode not in ("raw", "formatted"):
|
||||
raise ValueError("date_mode debe ser raw o formatted")
|
||||
return d0, d1, fmt, mode
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DodaExternalService:
|
||||
"""
|
||||
Cliente HTTP para el servicio externo de alta DODA.
|
||||
|
||||
Endpoints:
|
||||
POST {base_url}/api/v1/doda/alta
|
||||
GET {base_url}/api/v1/doda/alta-status/{task_id}
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = (settings.DODA_API_BASE_URL or "").strip()
|
||||
self.verify_ssl = settings.DODA_API_VERIFY_SSL
|
||||
|
||||
def post_alta(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Envía el payload de alta DODA al servicio externo.
|
||||
Retorna {task_id, status, message}.
|
||||
"""
|
||||
if not self.base_url:
|
||||
raise ValueError(
|
||||
"DODA_API_BASE_URL no está configurado. "
|
||||
"Agrega la variable de entorno con la URL del servicio DODA."
|
||||
)
|
||||
|
||||
url = f"{self.base_url.rstrip('/')}/api/v1/doda/alta"
|
||||
|
||||
configuracion_vu = payload.get("configuracion_vu") or {}
|
||||
logger.info(
|
||||
"Enviando alta DODA: rfc_ciec=%s cer_len=%s key_len=%s clave_fiel_len=%s",
|
||||
configuracion_vu.get("rfc_ciec"),
|
||||
len(configuracion_vu.get("archivo_cer_base64") or ""),
|
||||
len(configuracion_vu.get("archivo_key_base64") or ""),
|
||||
len(configuracion_vu.get("clave_fiel") or ""),
|
||||
)
|
||||
|
||||
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_status(self, task_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Consulta el estado de una tarea de alta DODA en el servicio externo.
|
||||
"""
|
||||
if not self.base_url:
|
||||
raise ValueError("DODA_API_BASE_URL no está configurado.")
|
||||
|
||||
url = f"{self.base_url.rstrip('/')}/api/v1/doda/alta-status/{task_id}"
|
||||
logger.debug("Consultando estado tarea DODA: task_id=%s url=%s", task_id, url)
|
||||
|
||||
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()
|
||||
132
backend/api/v1/modules/a76/general_catalogs/doda/fingerprint.py
Normal file
132
backend/api/v1/modules/a76/general_catalogs/doda/fingerprint.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Huella de contenido (fingerprint) para invalidar el PDF de reporte DODA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict
|
||||
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import (
|
||||
Doda,
|
||||
DodaAmericanPedimento,
|
||||
DodaContainer,
|
||||
DodaContainerSeal,
|
||||
DodaPedimento,
|
||||
)
|
||||
|
||||
_DODA_FINGERPRINT_EXCLUDE = frozenset(
|
||||
{
|
||||
"doda_report_pdf_path",
|
||||
"doda_report_pdf_generated_at",
|
||||
"doda_report_source_fingerprint",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> Any:
|
||||
if isinstance(obj, Decimal):
|
||||
return str(obj)
|
||||
if isinstance(obj, (datetime, date)):
|
||||
return obj.isoformat()
|
||||
if isinstance(obj, (bytes, bytearray)):
|
||||
return obj.hex()
|
||||
raise TypeError(f"Type {type(obj)} not serializable")
|
||||
|
||||
|
||||
def _instance_payload(instance: object, *, exclude: frozenset[str]) -> Dict[str, Any]:
|
||||
insp = sa_inspect(instance)
|
||||
d: Dict[str, Any] = {}
|
||||
for col in insp.mapper.column_attrs:
|
||||
name = col.key
|
||||
if name in exclude or name in _DODA_FINGERPRINT_EXCLUDE:
|
||||
continue
|
||||
d[name] = getattr(instance, name)
|
||||
return d
|
||||
|
||||
|
||||
def build_doda_fingerprint(db: Session, doda_id: int) -> str:
|
||||
doda = db.get(Doda, doda_id)
|
||||
if doda is None:
|
||||
raise ValueError("DODA no encontrado")
|
||||
|
||||
doda_block = _instance_payload(doda, exclude=frozenset())
|
||||
|
||||
containers_rows = (
|
||||
db.query(DodaContainer)
|
||||
.filter(DodaContainer.doda_id == doda_id)
|
||||
.order_by(DodaContainer.container_line.asc())
|
||||
.all()
|
||||
)
|
||||
container_blocks: list[Dict[str, Any]] = []
|
||||
for c in containers_rows:
|
||||
c_block = _instance_payload(
|
||||
c,
|
||||
exclude=frozenset(
|
||||
{
|
||||
"id",
|
||||
"doda_id",
|
||||
}
|
||||
),
|
||||
)
|
||||
seals = (
|
||||
db.query(DodaContainerSeal)
|
||||
.filter(DodaContainerSeal.container_id == c.id)
|
||||
.order_by(DodaContainerSeal.seal_line.asc())
|
||||
.all()
|
||||
)
|
||||
c_block["seals"] = [
|
||||
_instance_payload(
|
||||
s,
|
||||
exclude=frozenset({"id", "container_id", "doda_id"}),
|
||||
)
|
||||
for s in seals
|
||||
]
|
||||
container_blocks.append(c_block)
|
||||
|
||||
american = (
|
||||
db.query(DodaAmericanPedimento)
|
||||
.filter(DodaAmericanPedimento.doda_id == doda_id)
|
||||
.order_by(DodaAmericanPedimento.american_pedimento_line.asc())
|
||||
.all()
|
||||
)
|
||||
american_blocks = [
|
||||
_instance_payload(
|
||||
p, exclude=frozenset({"id", "doda_id"})
|
||||
)
|
||||
for p in american
|
||||
]
|
||||
|
||||
pedimentos = (
|
||||
db.query(DodaPedimento)
|
||||
.filter(DodaPedimento.doda_id == doda_id)
|
||||
.order_by(DodaPedimento.pedimento_line.asc())
|
||||
.all()
|
||||
)
|
||||
pedimento_blocks = [
|
||||
_instance_payload(p, exclude=frozenset({"id", "doda_id"})) for p in pedimentos
|
||||
]
|
||||
|
||||
snapshot: Dict[str, Any] = {
|
||||
"doda": doda_block,
|
||||
"containers": container_blocks,
|
||||
"american_pedimentos": american_blocks,
|
||||
"pedimentos_detail": pedimento_blocks,
|
||||
}
|
||||
raw = json.dumps(
|
||||
snapshot,
|
||||
sort_keys=True,
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
default=_json_default,
|
||||
)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
@@ -2,17 +2,18 @@
|
||||
Modelos ORM para gestión de DODA (Documentos de Operación de Aduana)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import (
|
||||
ForeignKeyConstraint,
|
||||
DateTime,
|
||||
Integer,
|
||||
PrimaryKeyConstraint,
|
||||
String,
|
||||
Text,
|
||||
LargeBinary,
|
||||
Numeric,
|
||||
Boolean,
|
||||
)
|
||||
@@ -80,6 +81,11 @@ class Doda(Base, TenantScopedMixin, TimestampMixin):
|
||||
xml_doda_sent_path: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
xml_doda_response_path: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
|
||||
# Reporte PDF (S3) + caché por huella de contenido
|
||||
doda_report_pdf_path: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
doda_report_pdf_generated_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
doda_report_source_fingerprint: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
|
||||
# SAT original chain
|
||||
sat_original_chain: Mapped[Optional[Text]] = mapped_column(Text)
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Normaliza valores de negocio hacia el JSON del servicio DODA externo.
|
||||
Solo cadenas limpias: sin mezclar claves de catálogo (broker_key, vehicle_key) como
|
||||
sustituto de patente, CAAT o id de transporte cuando deban ser otros datos.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# Aduana-Patente-Pedimento (3 segmentos; patente 4, pedimento alfanum limpio a dígitos)
|
||||
_PEDIMENTO_DOC_SPLIT = re.compile(r"^[\s\-–—]*([^\s\-–—]+)[\s\-–—]+([^\s\-–—]+)[\s\-–—]+([^\s\-–—]+)[\s\-–—]*$")
|
||||
|
||||
|
||||
def _digits_only(s: str, max_len: int) -> str:
|
||||
d = re.sub(r"\D", "", s or "")
|
||||
if max_len and len(d) > max_len:
|
||||
return d[-max_len:]
|
||||
return d
|
||||
|
||||
|
||||
def normalize_aduana_despacho(value: Optional[str]) -> str:
|
||||
s = (value or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
return _digits_only(s, 3).zfill(3) if _digits_only(s, 3) else s[:3]
|
||||
|
||||
|
||||
def normalize_aduana_seccion(value: Optional[str]) -> str:
|
||||
s = (value or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
d = _digits_only(s, 3)
|
||||
if d:
|
||||
return d.zfill(3)
|
||||
return s[:3]
|
||||
|
||||
|
||||
def normalize_patente(value: Optional[str]) -> str:
|
||||
s = (re.sub(r"[\s\u00A0]+", " ", (value or "").strip()))
|
||||
d = re.sub(r"\D", "", s)
|
||||
if len(d) >= 4:
|
||||
return d[-4:]
|
||||
return s[:4] if s else ""
|
||||
|
||||
|
||||
def normalize_caat(value: Optional[str]) -> str:
|
||||
return (value or "").strip()[:20]
|
||||
|
||||
|
||||
def normalize_id_transporte(value: Optional[str]) -> str:
|
||||
return (re.sub(r"\s+", " ", (value or "").strip()))[:20]
|
||||
|
||||
|
||||
def normalize_tipo_operacion(value: Optional[str]) -> str:
|
||||
v = (value or "").strip().upper()[:1]
|
||||
return v if v in ("I", "E") else v
|
||||
|
||||
|
||||
def normalize_numero_gafete(value: Optional[str]) -> str:
|
||||
return (value or "").strip()[:250]
|
||||
|
||||
|
||||
def normalize_fast_id(value: Optional[str]) -> str:
|
||||
return (value or "").strip()[:20]
|
||||
|
||||
|
||||
def _parse_document_pedimento(
|
||||
document: Optional[str], authorization_patent: Optional[str]
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Retorna (patente_4, número_pedimento) a partir de documento o patente de autorización.
|
||||
"""
|
||||
doc = (document or "").strip()
|
||||
m = _PEDIMENTO_DOC_SPLIT.match(doc)
|
||||
if m:
|
||||
b, c = m.group(2), m.group(3)
|
||||
pat = re.sub(r"\D", "", b)
|
||||
if len(pat) > 4:
|
||||
pat = pat[-4:]
|
||||
else:
|
||||
pat = pat.zfill(4) if pat else ""
|
||||
if not pat:
|
||||
ap = (authorization_patent or "").strip()
|
||||
pat = re.sub(r"\D", "", ap)[-4:].zfill(4) if ap else ""
|
||||
ped = re.sub(r"[^\d\w]", "", c) or re.sub(r"\D", "", c)
|
||||
return (pat[:4], ped)
|
||||
ped = re.sub(r"[^\d]", "", doc) if doc else ""
|
||||
ap = (authorization_patent or "").strip()
|
||||
pat4 = re.sub(r"\D", "", ap)[:4] if ap else ""
|
||||
if len(pat4) < 4 and ap and ap.isalnum():
|
||||
pat4 = (re.sub(r"\D", "", ap) + "0000")[:4] if re.sub(r"\D", "", ap) else (ap + "0")[:4]
|
||||
return (pat4, ped)
|
||||
|
||||
|
||||
def normalize_doda_pedimento_row(
|
||||
document: Optional[str],
|
||||
authorization_patent: Optional[str],
|
||||
shipment: Optional[str],
|
||||
cove: Optional[str],
|
||||
umc: Optional[str],
|
||||
dta_niu: Optional[str],
|
||||
pedimento_type: Optional[str],
|
||||
effective: Any,
|
||||
diff: Any,
|
||||
) -> Dict[str, Any]:
|
||||
ap_raw = (authorization_patent or "").strip()
|
||||
pat, ped = _parse_document_pedimento(document, ap_raw)
|
||||
if not pat and ap_raw:
|
||||
d = re.sub(r"\D", "", ap_raw)
|
||||
pat = d[-4:].zfill(4) if d else ap_raw[:4]
|
||||
|
||||
rem = (shipment or "").strip()[:11] if (shipment or "").strip() else ""
|
||||
if not rem and ped:
|
||||
rem = ped
|
||||
|
||||
return {
|
||||
"patente": pat,
|
||||
"pedimento": ped,
|
||||
"numero_remesa": rem,
|
||||
"tipo_pedimento": (pedimento_type or "")[:20],
|
||||
"dta_niu": (dta_niu or "")[:20],
|
||||
"importe_efectivo_dolares": float(effective or 0) if effective is not None else 0.0,
|
||||
"importe_diferencia_dolares": float(diff or 0) if diff is not None else 0.0,
|
||||
"campo_12_apendice_17": 0,
|
||||
"cove": (cove or "")[:50],
|
||||
"umc": (umc or "")[:20],
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Caché del PDF de reporte DODA (S3 + columnas en a76.doda) e invalidación.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
from core.s3_keys import doda_report_pdf_key
|
||||
from core import storage_s3
|
||||
|
||||
from .models import Doda
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _delete_stored_s3_key(key: Optional[str]) -> None:
|
||||
if not key or not settings.use_s3_object_storage:
|
||||
return
|
||||
storage_s3.delete_object_if_exists(key)
|
||||
|
||||
|
||||
def clear_doda_report_fields(doda: Doda) -> None:
|
||||
doda.doda_report_pdf_path = None
|
||||
doda.doda_report_pdf_generated_at = None
|
||||
doda.doda_report_source_fingerprint = None
|
||||
|
||||
|
||||
def touch_invalidate_doda_report(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
doda_id: int,
|
||||
doda: Optional[Doda] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Borra el PDF previo en S3 (si aplica) y limpia columnas de caché en el DODA.
|
||||
Llamar tras mutaciones de DODA o de tablas hijas.
|
||||
"""
|
||||
if doda is None:
|
||||
doda = (
|
||||
db.query(Doda)
|
||||
.filter(
|
||||
Doda.id == doda_id,
|
||||
Doda.tenant_id == tenant_id,
|
||||
Doda.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not doda:
|
||||
return
|
||||
|
||||
keys: set[str] = set()
|
||||
if doda.doda_report_pdf_path:
|
||||
keys.add(doda.doda_report_pdf_path)
|
||||
try:
|
||||
keys.add(doda_report_pdf_key(tenant_id, company_id, doda_id))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
for k in keys:
|
||||
_delete_stored_s3_key(k)
|
||||
|
||||
clear_doda_report_fields(doda)
|
||||
try:
|
||||
db.add(doda)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Error invalidating DODA report cache doda_id=%s", doda_id)
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Generación de PDF de reporte DODA (Jinja2 + pdfkit / wkhtmltopdf).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import pdfkit
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .fingerprint import build_doda_fingerprint
|
||||
from .models import (
|
||||
Doda,
|
||||
DodaAmericanPedimento,
|
||||
DodaContainer,
|
||||
DodaContainerSeal,
|
||||
DodaPedimento,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SealView:
|
||||
seal_line: int
|
||||
seal_value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ContainerView:
|
||||
container_line: int
|
||||
container_value: str
|
||||
seals: List[_SealView]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AmericanView:
|
||||
american_pedimento_line: int
|
||||
american_pedimento_type: str
|
||||
american_pedimento_value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PedimentoView:
|
||||
pedimento_line: int
|
||||
authorization_patent: str
|
||||
document: str
|
||||
shipment: str
|
||||
cove: str
|
||||
umc: str
|
||||
pedimento_type: str
|
||||
|
||||
|
||||
def _s(v: Optional[str]) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
return str(v).strip()
|
||||
|
||||
|
||||
class DodaReportPdfService:
|
||||
def __init__(self) -> None:
|
||||
self.template_dir = Path(__file__).parent / "templates"
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.template_dir),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
self._tpl = self.jinja_env.get_template("doda_report.html")
|
||||
|
||||
def _get_wkhtmltopdf_config(self):
|
||||
for path in (
|
||||
shutil.which("wkhtmltopdf"),
|
||||
"/usr/local/bin/wkhtmltopdf",
|
||||
"/usr/bin/wkhtmltopdf",
|
||||
):
|
||||
if path:
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
raise RuntimeError("wkhtmltopdf binary not found.")
|
||||
|
||||
@staticmethod
|
||||
def _doda_header_block(d: Doda) -> dict[str, str]:
|
||||
return {
|
||||
"integration_number": _s(d.integration_number),
|
||||
"patent": _s(d.patent),
|
||||
"dispatch_customs": _s(d.dispatch_customs),
|
||||
"customs_sections": _s(d.customs_sections),
|
||||
"caat": _s(d.caat),
|
||||
"transport_identification": _s(d.transport_identification),
|
||||
"fast_id": _s(d.fast_id),
|
||||
"operation_type": _s(d.operation_type),
|
||||
"status": _s(d.status),
|
||||
"transaction_number": _s(d.transaction_number),
|
||||
}
|
||||
|
||||
def _load_children(self, db: Session, doda_id: int) -> dict[str, Any]:
|
||||
containers = (
|
||||
db.query(DodaContainer)
|
||||
.filter(DodaContainer.doda_id == doda_id)
|
||||
.order_by(DodaContainer.container_line.asc())
|
||||
.all()
|
||||
)
|
||||
cviews: list[_ContainerView] = []
|
||||
for c in containers:
|
||||
seals = (
|
||||
db.query(DodaContainerSeal)
|
||||
.filter(DodaContainerSeal.container_id == c.id)
|
||||
.order_by(DodaContainerSeal.seal_line.asc())
|
||||
.all()
|
||||
)
|
||||
cviews.append(
|
||||
_ContainerView(
|
||||
container_line=c.container_line,
|
||||
container_value=_s(c.container_value),
|
||||
seals=[
|
||||
_SealView(
|
||||
seal_line=s.seal_line, seal_value=_s(s.seal_value)
|
||||
)
|
||||
for s in seals
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
american = (
|
||||
db.query(DodaAmericanPedimento)
|
||||
.filter(DodaAmericanPedimento.doda_id == doda_id)
|
||||
.order_by(DodaAmericanPedimento.american_pedimento_line.asc())
|
||||
.all()
|
||||
)
|
||||
amer_views = [
|
||||
_AmericanView(
|
||||
american_pedimento_line=p.american_pedimento_line,
|
||||
american_pedimento_type=_s(p.american_pedimento_type),
|
||||
american_pedimento_value=_s(p.american_pedimento_value),
|
||||
)
|
||||
for p in american
|
||||
]
|
||||
|
||||
peds = (
|
||||
db.query(DodaPedimento)
|
||||
.filter(DodaPedimento.doda_id == doda_id)
|
||||
.order_by(DodaPedimento.pedimento_line.asc())
|
||||
.all()
|
||||
)
|
||||
ped_views = [
|
||||
_PedimentoView(
|
||||
pedimento_line=p.pedimento_line,
|
||||
authorization_patent=_s(p.authorization_patent),
|
||||
document=_s(p.document),
|
||||
shipment=_s(p.shipment),
|
||||
cove=_s(p.cove),
|
||||
umc=_s(p.umc),
|
||||
pedimento_type=_s(p.pedimento_type),
|
||||
)
|
||||
for p in peds
|
||||
]
|
||||
|
||||
return {
|
||||
"containers": [asdict(x) for x in cviews],
|
||||
"american_pedimentos": [asdict(x) for x in amer_views],
|
||||
"pedimentos_detail": [asdict(x) for x in ped_views],
|
||||
}
|
||||
|
||||
def build_context(self, db: Session, doda: Doda) -> dict[str, Any]:
|
||||
fp = build_doda_fingerprint(db, doda.id)
|
||||
children = self._load_children(db, doda.id)
|
||||
return {
|
||||
"doda": self._doda_header_block(doda),
|
||||
"linq_sat_qr": _s(d.linq_sat_qr),
|
||||
"sat_chain_preview": _s((d.sat_original_chain or d.original_chain) or "")[:2000],
|
||||
"sat_digital_seal_preview": _s(d.sat_digital_seal)[:2000],
|
||||
"fingerprint_sha256": fp,
|
||||
**children,
|
||||
}
|
||||
|
||||
def render_pdf_bytes(self, context: dict[str, Any]) -> bytes:
|
||||
html = self._tpl.render(**context)
|
||||
options = {
|
||||
"page-size": "A4",
|
||||
"encoding": "UTF-8",
|
||||
"margin-top": "12mm",
|
||||
"margin-bottom": "12mm",
|
||||
"margin-left": "10mm",
|
||||
"margin-right": "10mm",
|
||||
}
|
||||
return pdfkit.from_string(
|
||||
html,
|
||||
False,
|
||||
options=options,
|
||||
configuration=self._get_wkhtmltopdf_config(),
|
||||
)
|
||||
|
||||
def build_pdf_for_doda(self, db: Session, doda: Doda) -> bytes:
|
||||
ctx = self.build_context(db, doda)
|
||||
return self.render_pdf_bytes(ctx)
|
||||
|
||||
@staticmethod
|
||||
def debug_json_snapshot(context: dict[str, Any]) -> str:
|
||||
"""Para depuración: snapshot legible (no contiene el sello completo)."""
|
||||
return json.dumps(context, ensure_ascii=True, indent=2)
|
||||
@@ -2,12 +2,19 @@
|
||||
Rutas para gestión de DODA (Documentos de Operación de Aduana)
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core import storage_s3
|
||||
from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.s3_keys import doda_report_pdf_key
|
||||
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
|
||||
from .dto import (
|
||||
DodaCreateDTO,
|
||||
@@ -17,6 +24,8 @@ from .dto import (
|
||||
DodaContainerCreateDTO,
|
||||
DodaContainerResponseDTO,
|
||||
DodaContainerUpdateDTO,
|
||||
DodaContainerSealCreateDTO,
|
||||
DodaContainerSealResponseDTO,
|
||||
DodaAmericanPedimentoCreateDTO,
|
||||
DodaAmericanPedimentoResponseDTO,
|
||||
DodaAmericanPedimentoUpdateDTO,
|
||||
@@ -26,25 +35,95 @@ from .dto import (
|
||||
)
|
||||
from .models import Doda
|
||||
from .service import DodaService
|
||||
from .alta_service import DodaAltaService
|
||||
from .external_service import DodaExternalService
|
||||
from .alta_log_dto import (
|
||||
DodaAltaLogCreateDTO,
|
||||
DodaAltaLogListResponse,
|
||||
DodaAltaLogResponseDTO,
|
||||
DodaAltaLogUpdateDTO,
|
||||
)
|
||||
from .alta_log_service import DodaAltaLogService
|
||||
from .fingerprint import build_doda_fingerprint
|
||||
from .print_cache import touch_invalidate_doda_report
|
||||
from .report_service import DodaReportPdfService
|
||||
from .export_service import (
|
||||
build_export_text,
|
||||
list_dodas_in_date_range,
|
||||
parse_export_params,
|
||||
_content_type_and_filename,
|
||||
)
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
|
||||
# Create CRUD router
|
||||
crud_router = TenantCRUDRoutes(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Router independiente para rutas literales (deben registrarse antes que /{id})
|
||||
router = APIRouter(prefix="/doda", tags=["doda"])
|
||||
|
||||
# ============ RUTAS LITERALES (antes del CRUD /{id}) ============
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="Exportar DODA por rango de fechas (CSV, TSV como XLS, o TXT con |)",
|
||||
)
|
||||
async def export_doda_list(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
date_from: str = Query(..., description="Fecha inicio (YYYY-MM-DD)"),
|
||||
date_to: str = Query(..., description="Fecha fin (YYYY-MM-DD)"),
|
||||
file_format: str = Query("csv", alias="format", description="csv, xls o txt"),
|
||||
date_mode: str = Query("formatted", description="raw o formatted (fechas/horas)"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Listado al estilo legacy: filtra `doda_date` (YYYYMMDD) entre inicio y fin.
|
||||
"""
|
||||
tenant_id = int(validate_access_to_resource(db, company_id, current_user))
|
||||
try:
|
||||
d0, d1, fmt, mode = parse_export_params(
|
||||
date_from, date_to, file_format, date_mode
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
|
||||
|
||||
rows = list_dodas_in_date_range(
|
||||
db, tenant_id=tenant_id, company_id=company_id, date_start=d0, date_end=d1
|
||||
)
|
||||
if not rows:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No existen DODA en el rango de fechas seleccionado.",
|
||||
)
|
||||
|
||||
text = build_export_text(rows, export_format=fmt, date_mode=mode)
|
||||
content_type, default_name = _content_type_and_filename(fmt)
|
||||
data = ("\ufeff" + text).encode("utf-8")
|
||||
return StreamingResponse(
|
||||
io.BytesIO(data),
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{default_name}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Incluir rutas CRUD (contiene GET /{id}, POST /, PUT /{id}, DELETE /{id}).
|
||||
# Se registra DESPUÉS de los endpoints literales para que /export, /alta-logs,
|
||||
# /alta-status no sean capturados por el parámetro /{id}.
|
||||
_crud_router = TenantCRUDRoutes(
|
||||
service=DodaService,
|
||||
create_schema=DodaCreateDTO,
|
||||
update_schema=DodaUpdateDTO,
|
||||
response_schema=DodaResponseDTO,
|
||||
prefix="/doda",
|
||||
prefix="",
|
||||
tags=["doda"],
|
||||
resource_name="DODA",
|
||||
id_name="doda_id",
|
||||
enable_list=True,
|
||||
enable_filters=True,
|
||||
).router
|
||||
|
||||
router = crud_router
|
||||
|
||||
# ============ CUSTOM ENDPOINTS ============
|
||||
router.include_router(_crud_router)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -68,6 +147,86 @@ async def get_doda_detail(
|
||||
return DodaDetailResponseDTO.model_validate(doda)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{doda_id}/print",
|
||||
summary="Imprimir DODA (PDF)",
|
||||
responses={422: {"description": "Validación (p. ej. falta sello digital SAT)."}},
|
||||
)
|
||||
async def print_doda_pdf(
|
||||
doda_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Genera o reutiliza el PDF almacenado en S3 cuando el contenido no ha cambiado
|
||||
(huella SHA-256 de DODA + hijos).
|
||||
"""
|
||||
tenant_id = int(validate_access_to_resource(db, company_id, current_user))
|
||||
doda = DodaService.get_by_id(db, doda_id, tenant_id, company_id)
|
||||
if not doda:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="DODA not found",
|
||||
)
|
||||
|
||||
if not (doda.sat_digital_seal and str(doda.sat_digital_seal).strip()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Falta el sello digital SAT requerido para imprimir el DODA.",
|
||||
)
|
||||
|
||||
content_fp = build_doda_fingerprint(db, doda_id)
|
||||
expected_key = doda_report_pdf_key(tenant_id, company_id, doda_id)
|
||||
|
||||
can_reuse = (
|
||||
doda.doda_report_source_fingerprint == content_fp
|
||||
and doda.doda_report_pdf_path == expected_key
|
||||
and bool(doda.doda_report_pdf_path)
|
||||
)
|
||||
if can_reuse and settings.use_s3_object_storage and storage_s3.object_exists(expected_key):
|
||||
data = storage_s3.get_object_bytes(expected_key)
|
||||
return StreamingResponse(
|
||||
io.BytesIO(data),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="doda_{doda_id}.pdf"'},
|
||||
)
|
||||
|
||||
# Re-generar: eliminar caché previa (S3 + columnas) y volver a guardar
|
||||
if settings.use_s3_object_storage:
|
||||
touch_invalidate_doda_report(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
doda_id=doda_id,
|
||||
doda=None,
|
||||
)
|
||||
|
||||
doda_fresh = DodaService.get_by_id(db, doda_id, tenant_id, company_id)
|
||||
if not doda_fresh:
|
||||
raise HTTPException(status_code=404, detail="DODA not found")
|
||||
|
||||
service = DodaReportPdfService()
|
||||
pdf = service.build_pdf_for_doda(db, doda_fresh)
|
||||
if settings.use_s3_object_storage:
|
||||
storage_s3.put_object_bytes(expected_key, pdf, content_type="application/pdf")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
doda_fresh.doda_report_pdf_path = expected_key if settings.use_s3_object_storage else None
|
||||
doda_fresh.doda_report_pdf_generated_at = now
|
||||
doda_fresh.doda_report_source_fingerprint = content_fp
|
||||
db.add(doda_fresh)
|
||||
db.commit()
|
||||
if settings.use_s3_object_storage:
|
||||
pdf = storage_s3.get_object_bytes(expected_key)
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(pdf),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="doda_{doda_id}.pdf"'},
|
||||
)
|
||||
|
||||
|
||||
# ============ CONTAINERS ENDPOINTS ============
|
||||
@router.get(
|
||||
"/{doda_id}/containers",
|
||||
@@ -127,6 +286,80 @@ async def update_container(
|
||||
return DodaContainerResponseDTO.model_validate(container)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{doda_id}/containers/{container_line}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete container from DODA",
|
||||
)
|
||||
async def delete_container(
|
||||
doda_id: int,
|
||||
container_line: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Elimina un contenedor del DODA.
|
||||
Devuelve 409 si el contenedor tiene precintos (candados) asignados.
|
||||
"""
|
||||
DodaService.delete_container(db, doda_id, container_line)
|
||||
return None
|
||||
|
||||
|
||||
def _seal_to_response(seal) -> DodaContainerSealResponseDTO:
|
||||
"""ORM usa doda_id; DTO expone doda_sys_id."""
|
||||
return DodaContainerSealResponseDTO(
|
||||
id=seal.id,
|
||||
doda_sys_id=seal.doda_id,
|
||||
seal_line=seal.seal_line,
|
||||
seal_value=seal.seal_value,
|
||||
)
|
||||
|
||||
|
||||
# ============ CONTAINER SEALS (PRECINTOS) ============
|
||||
@router.get(
|
||||
"/{doda_id}/containers/{container_line}/seals",
|
||||
response_model=List[DodaContainerSealResponseDTO],
|
||||
summary="Listar precintos de un contenedor",
|
||||
)
|
||||
async def get_container_seals(
|
||||
doda_id: int,
|
||||
container_line: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
seals = DodaService.get_seals_for_container(db, doda_id, container_line)
|
||||
return [_seal_to_response(s) for s in seals]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{doda_id}/containers/{container_line}/seals",
|
||||
response_model=DodaContainerSealResponseDTO,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Agregar precinto a un contenedor",
|
||||
)
|
||||
async def add_container_seal(
|
||||
doda_id: int,
|
||||
container_line: int,
|
||||
seal_data: DodaContainerSealCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
seal = DodaService.add_seal(db, doda_id, container_line, seal_data)
|
||||
return _seal_to_response(seal)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{doda_id}/containers/{container_line}/seals/{seal_line}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Eliminar precinto de un contenedor",
|
||||
)
|
||||
async def delete_container_seal(
|
||||
doda_id: int,
|
||||
container_line: int,
|
||||
seal_line: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
DodaService.delete_seal(db, doda_id, container_line, seal_line)
|
||||
return None
|
||||
|
||||
|
||||
# ============ AMERICAN PEDIMENTOS ENDPOINTS ============
|
||||
@router.get(
|
||||
"/{doda_id}/american-pedimentos",
|
||||
@@ -163,6 +396,22 @@ async def add_american_pedimento(
|
||||
return DodaAmericanPedimentoResponseDTO.model_validate(pedimento)
|
||||
|
||||
|
||||
# ============ AMERICAN PEDIMENTOS (DELETE) ============
|
||||
@router.delete(
|
||||
"/{doda_id}/american-pedimentos/{pedimento_line}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete American pedimento from DODA",
|
||||
)
|
||||
async def delete_american_pedimento(
|
||||
doda_id: int,
|
||||
pedimento_line: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Elimina un pedimento americano del DODA."""
|
||||
DodaService.delete_american_pedimento(db, doda_id, pedimento_line)
|
||||
return None
|
||||
|
||||
|
||||
# ============ PEDIMENTOS ENDPOINTS ============
|
||||
@router.get(
|
||||
"/{doda_id}/pedimentos",
|
||||
@@ -197,3 +446,279 @@ async def add_pedimento(
|
||||
detail="DODA not found",
|
||||
)
|
||||
return DodaPedimentoResponseDTO.model_validate(pedimento)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{doda_id}/pedimentos/{pedimento_line}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete pedimento from DODA",
|
||||
)
|
||||
async def delete_pedimento(
|
||||
doda_id: int,
|
||||
pedimento_line: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Elimina un pedimento del DODA."""
|
||||
DodaService.delete_pedimento(db, doda_id, pedimento_line)
|
||||
return None
|
||||
|
||||
|
||||
# ============ ALTA DODA ENDPOINTS ============
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{doda_id}/alta/elegibilidad",
|
||||
summary="Verificar elegibilidad para Alta DODA",
|
||||
tags=["doda-alta"],
|
||||
)
|
||||
async def get_doda_elegibilidad(
|
||||
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]:
|
||||
"""
|
||||
Verifica si el DODA cumple los requisitos para enviar el alta al servicio externo.
|
||||
Porta las validaciones del sistema legacy (campos requeridos, max 4 contenedores,
|
||||
gafete si DODA, patente vs agente, certificados DODA en VU).
|
||||
"""
|
||||
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)
|
||||
result = service.check_elegibilidad(
|
||||
doda_id=doda_id,
|
||||
tenant_id=int(tenant_id),
|
||||
company_id=company_id,
|
||||
variant=variant,
|
||||
user_email=user_email,
|
||||
)
|
||||
return {
|
||||
"can_alta": result.can_alta,
|
||||
"reasons": [
|
||||
{"field": r.field, "message": r.message, "solution": r.solution}
|
||||
for r in result.reasons
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{doda_id}/alta",
|
||||
summary="Enviar Alta DODA al servicio externo (asíncrono)",
|
||||
tags=["doda-alta"],
|
||||
)
|
||||
async def post_doda_alta(
|
||||
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]:
|
||||
"""
|
||||
Verifica elegibilidad, construye el payload desde los datos del DODA y su VU,
|
||||
y envía el alta al servicio externo. Retorna {task_id, status, message} para polling.
|
||||
"""
|
||||
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)
|
||||
|
||||
elegibilidad = service.check_elegibilidad(
|
||||
doda_id=doda_id,
|
||||
tenant_id=int(tenant_id),
|
||||
company_id=company_id,
|
||||
variant=variant,
|
||||
user_email=user_email,
|
||||
)
|
||||
if not elegibilidad.can_alta:
|
||||
reasons = [
|
||||
{"field": r.field, "message": r.message, "solution": r.solution}
|
||||
for r in elegibilidad.reasons
|
||||
]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"message": "El DODA no cumple los requisitos para el alta.", "reasons": reasons},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = service.build_alta_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
|
||||
|
||||
try:
|
||||
ext = DodaExternalService()
|
||||
result = ext.post_alta(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 alta 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: {exc}",
|
||||
) from exc
|
||||
|
||||
# Persistir el log del alta
|
||||
doda_record = DodaService.get_by_id(db, doda_id, 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=result,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error persistiendo DodaAltaLog para doda_id=%s", doda_id)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/alta-status/{task_id}",
|
||||
summary="Consultar estado de tarea de Alta DODA",
|
||||
tags=["doda-alta"],
|
||||
)
|
||||
async def get_doda_alta_status(
|
||||
task_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Proxy transparente al servicio externo para consultar el estado de una tarea de alta DODA.
|
||||
"""
|
||||
try:
|
||||
ext = DodaExternalService()
|
||||
return ext.get_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 estado DODA task_id=%s", task_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Error al consultar el estado de la tarea DODA: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
# ============ DODA ALTA LOG CRUD ============
|
||||
|
||||
|
||||
@router.get(
|
||||
"/alta-logs",
|
||||
response_model=DodaAltaLogListResponse,
|
||||
summary="Listar registros de alta DODA",
|
||||
tags=["doda-alta"],
|
||||
)
|
||||
async def list_doda_alta_logs(
|
||||
company_id: int = Query(...),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
doda_id: int = Query(None),
|
||||
search: str = Query(None),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
return DodaAltaLogService.list(
|
||||
db, company_id, int(tenant_id), page, page_size, doda_id, search
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/alta-logs/{log_id}",
|
||||
response_model=DodaAltaLogResponseDTO,
|
||||
summary="Obtener registro de alta DODA",
|
||||
tags=["doda-alta"],
|
||||
)
|
||||
async def get_doda_alta_log(
|
||||
log_id: int,
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
record = DodaAltaLogService.get(db, log_id, company_id, int(tenant_id))
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Registro de alta DODA no encontrado.")
|
||||
return DodaAltaLogResponseDTO.model_validate(record)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/alta-logs",
|
||||
response_model=DodaAltaLogResponseDTO,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Crear registro de alta DODA manualmente",
|
||||
tags=["doda-alta"],
|
||||
)
|
||||
async def create_doda_alta_log(
|
||||
dto: DodaAltaLogCreateDTO,
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
record = DodaAltaLogService.create(db, dto, company_id, int(tenant_id))
|
||||
return DodaAltaLogResponseDTO.model_validate(record)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/alta-logs/{log_id}",
|
||||
response_model=DodaAltaLogResponseDTO,
|
||||
summary="Actualizar registro de alta DODA",
|
||||
tags=["doda-alta"],
|
||||
)
|
||||
async def update_doda_alta_log(
|
||||
log_id: int,
|
||||
dto: DodaAltaLogUpdateDTO,
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
record = DodaAltaLogService.get(db, log_id, company_id, int(tenant_id))
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Registro de alta DODA no encontrado.")
|
||||
record = DodaAltaLogService.update(db, record, dto)
|
||||
return DodaAltaLogResponseDTO.model_validate(record)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/alta-logs/{log_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Eliminar registro de alta DODA",
|
||||
tags=["doda-alta"],
|
||||
)
|
||||
async def delete_doda_alta_log(
|
||||
log_id: int,
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
record = DodaAltaLogService.get(db, log_id, company_id, int(tenant_id))
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Registro de alta DODA no encontrado.")
|
||||
DodaAltaLogService.delete(db, record)
|
||||
return None
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -15,6 +16,7 @@ from .dto import (
|
||||
DodaUpdateDTO,
|
||||
DodaContainerCreateDTO,
|
||||
DodaContainerUpdateDTO,
|
||||
DodaContainerSealCreateDTO,
|
||||
DodaAmericanPedimentoCreateDTO,
|
||||
DodaAmericanPedimentoUpdateDTO,
|
||||
DodaPedimentoCreateDTO,
|
||||
@@ -27,6 +29,7 @@ from .models import (
|
||||
DodaAmericanPedimento,
|
||||
DodaPedimento,
|
||||
)
|
||||
from .print_cache import touch_invalidate_doda_report
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,6 +37,19 @@ logger = logging.getLogger(__name__)
|
||||
class DodaService:
|
||||
"""Servicio para gestión de DODA"""
|
||||
|
||||
@staticmethod
|
||||
def _invalidate_report_after_mutation(
|
||||
db: Session,
|
||||
*,
|
||||
doda_id: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
) -> None:
|
||||
"""Limpia PDF de reporte y objeto S3 previo (patrón artefactos)."""
|
||||
touch_invalidate_doda_report(
|
||||
db, tenant_id=tenant_id, company_id=company_id, doda_id=doda_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_all(
|
||||
db: Session,
|
||||
@@ -94,6 +110,9 @@ class DodaService:
|
||||
db.add(db_doda)
|
||||
db.commit()
|
||||
db.refresh(db_doda)
|
||||
DodaService._invalidate_report_after_mutation(
|
||||
db, doda_id=db_doda.id, tenant_id=tenant_id, company_id=company_id
|
||||
)
|
||||
return db_doda
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
@@ -119,6 +138,9 @@ class DodaService:
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_doda)
|
||||
DodaService._invalidate_report_after_mutation(
|
||||
db, doda_id=db_doda.id, tenant_id=tenant_id, company_id=company_id
|
||||
)
|
||||
return db_doda
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
@@ -139,7 +161,16 @@ class DodaService:
|
||||
return False
|
||||
|
||||
try:
|
||||
db.delete(db_doda)
|
||||
tid = int(db_doda.tenant_id)
|
||||
cid = int(db_doda.company_id)
|
||||
did = int(db_doda.id)
|
||||
DodaService._invalidate_report_after_mutation(
|
||||
db, doda_id=did, tenant_id=tid, company_id=cid
|
||||
)
|
||||
to_delete = DodaService.get_by_id(db, id, tenant_id, company_id)
|
||||
if not to_delete:
|
||||
return True
|
||||
db.delete(to_delete)
|
||||
db.commit()
|
||||
return True
|
||||
except IntegrityError as e:
|
||||
@@ -165,6 +196,13 @@ class DodaService:
|
||||
if not doda:
|
||||
return None
|
||||
|
||||
cv = (container_data.container_value or "").strip()
|
||||
if not cv:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="El valor del contenedor no puede estar vacío.",
|
||||
)
|
||||
|
||||
# Get max line number
|
||||
max_line = (
|
||||
db.query(DodaContainer)
|
||||
@@ -172,19 +210,30 @@ class DodaService:
|
||||
.count()
|
||||
)
|
||||
|
||||
dump = container_data.model_dump(exclude_unset=True)
|
||||
dump.pop("seals_detail", None)
|
||||
dump["container_value"] = cv
|
||||
|
||||
db_container = DodaContainer(
|
||||
doda_id=doda_id,
|
||||
container_line=max_line + 1,
|
||||
**{
|
||||
k: v
|
||||
for k, v in container_data.model_dump(exclude_unset=True).items()
|
||||
if k != "seals_detail"
|
||||
},
|
||||
tenant_id=doda.tenant_id,
|
||||
company_id=doda.company_id,
|
||||
**{k: v for k, v in dump.items() if k not in ("doda_id", "container_line")},
|
||||
)
|
||||
db.add(db_container)
|
||||
db.commit()
|
||||
db.refresh(db_container)
|
||||
DodaService._invalidate_report_after_mutation(
|
||||
db,
|
||||
doda_id=doda_id,
|
||||
tenant_id=int(doda.tenant_id),
|
||||
company_id=int(doda.company_id),
|
||||
)
|
||||
return db_container
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error adding container: {str(e)}")
|
||||
@@ -216,6 +265,14 @@ class DodaService:
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_container)
|
||||
doda = db.get(Doda, doda_id)
|
||||
if doda:
|
||||
DodaService._invalidate_report_after_mutation(
|
||||
db,
|
||||
doda_id=doda_id,
|
||||
tenant_id=int(doda.tenant_id),
|
||||
company_id=int(doda.company_id),
|
||||
)
|
||||
return db_container
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@@ -232,6 +289,211 @@ class DodaService:
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_container(
|
||||
db: Session, doda_id: int, container_line: int
|
||||
) -> None:
|
||||
"""
|
||||
Delete a container from a DODA.
|
||||
Raises HTTP 409 if the container has seals assigned (Clarion: validación precintos).
|
||||
Raises HTTP 404 if not found.
|
||||
"""
|
||||
db_container = (
|
||||
db.query(DodaContainer)
|
||||
.filter(
|
||||
DodaContainer.doda_id == doda_id,
|
||||
DodaContainer.container_line == container_line,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not db_container:
|
||||
raise HTTPException(status_code=404, detail="Contenedor no encontrado.")
|
||||
|
||||
has_seals = bool(db_container.seals_detail)
|
||||
if not has_seals and db_container.seals:
|
||||
has_seals = any(s.strip() for s in db_container.seals.split(","))
|
||||
|
||||
if has_seals:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"El contenedor tiene uno o más precintos asignados. "
|
||||
"No se puede borrar el contenedor."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
doda = db.get(Doda, doda_id)
|
||||
tid = int(doda.tenant_id) if doda else 0
|
||||
cid = int(doda.company_id) if doda else 0
|
||||
db.delete(db_container)
|
||||
db.commit()
|
||||
if doda:
|
||||
DodaService._invalidate_report_after_mutation(
|
||||
db, doda_id=doda_id, tenant_id=tid, company_id=cid, doda=None
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error deleting container doda_id={doda_id} line={container_line}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Error al eliminar el contenedor.")
|
||||
|
||||
# ============ CONTAINER SEALS (PRECINTOS) ============
|
||||
MAX_SEALS_PER_DODA = 8
|
||||
|
||||
@staticmethod
|
||||
def get_seals_for_container(
|
||||
db: Session, doda_id: int, container_line: int
|
||||
) -> List[DodaContainerSeal]:
|
||||
"""Precintos de un contenedor (por línea de contenedor dentro del DODA)."""
|
||||
db_container = (
|
||||
db.query(DodaContainer)
|
||||
.filter(
|
||||
DodaContainer.doda_id == doda_id,
|
||||
DodaContainer.container_line == container_line,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not db_container:
|
||||
return []
|
||||
return (
|
||||
db.query(DodaContainerSeal)
|
||||
.filter(DodaContainerSeal.container_id == db_container.id)
|
||||
.order_by(DodaContainerSeal.seal_line.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def add_seal(
|
||||
db: Session,
|
||||
doda_id: int,
|
||||
container_line: int,
|
||||
seal_data: DodaContainerSealCreateDTO,
|
||||
) -> DodaContainerSeal:
|
||||
"""
|
||||
Añade un precinto (candado) a un contenedor.
|
||||
Máximo 8 precintos en total por DODA (legacy Clarion: gDoda_Contenedores_Candados).
|
||||
"""
|
||||
doda = db.query(Doda).filter(Doda.id == doda_id).first()
|
||||
if not doda:
|
||||
raise HTTPException(status_code=404, detail="DODA no encontrado.")
|
||||
|
||||
container = (
|
||||
db.query(DodaContainer)
|
||||
.filter(
|
||||
DodaContainer.doda_id == doda_id,
|
||||
DodaContainer.container_line == container_line,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not container:
|
||||
raise HTTPException(status_code=404, detail="Contenedor no encontrado.")
|
||||
|
||||
raw_value = (seal_data.seal_value or "").strip()
|
||||
if not raw_value:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="El campo precinto no puede estar vacío.",
|
||||
)
|
||||
|
||||
total_seals = (
|
||||
db.query(DodaContainerSeal)
|
||||
.filter(DodaContainerSeal.doda_id == doda_id)
|
||||
.count()
|
||||
)
|
||||
if total_seals >= DodaService.MAX_SEALS_PER_DODA:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"El DODA supera el máximo de precintos (8). "
|
||||
"Revise los precintos registrados."
|
||||
),
|
||||
)
|
||||
|
||||
max_line = (
|
||||
db.query(func.max(DodaContainerSeal.seal_line))
|
||||
.filter(DodaContainerSeal.container_id == container.id)
|
||||
.scalar()
|
||||
)
|
||||
next_line = (max_line or 0) + 1
|
||||
|
||||
try:
|
||||
db_seal = DodaContainerSeal(
|
||||
doda_id=doda_id,
|
||||
container_id=container.id,
|
||||
seal_line=next_line,
|
||||
seal_value=raw_value,
|
||||
tenant_id=doda.tenant_id,
|
||||
company_id=doda.company_id,
|
||||
)
|
||||
db.add(db_seal)
|
||||
db.commit()
|
||||
db.refresh(db_seal)
|
||||
DodaService._invalidate_report_after_mutation(
|
||||
db,
|
||||
doda_id=doda_id,
|
||||
tenant_id=int(doda.tenant_id),
|
||||
company_id=int(doda.company_id),
|
||||
)
|
||||
return db_seal
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(
|
||||
"Error adding seal doda_id=%s container_line=%s: %s",
|
||||
doda_id,
|
||||
container_line,
|
||||
e,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Error al agregar el precinto.")
|
||||
|
||||
@staticmethod
|
||||
def delete_seal(
|
||||
db: Session, doda_id: int, container_line: int, seal_line: int
|
||||
) -> None:
|
||||
"""Elimina un precinto por línea de contenedor y línea de candado."""
|
||||
container = (
|
||||
db.query(DodaContainer)
|
||||
.filter(
|
||||
DodaContainer.doda_id == doda_id,
|
||||
DodaContainer.container_line == container_line,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not container:
|
||||
raise HTTPException(status_code=404, detail="Contenedor no encontrado.")
|
||||
|
||||
seal = (
|
||||
db.query(DodaContainerSeal)
|
||||
.filter(
|
||||
DodaContainerSeal.container_id == container.id,
|
||||
DodaContainerSeal.seal_line == seal_line,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not seal:
|
||||
raise HTTPException(status_code=404, detail="Precinto no encontrado.")
|
||||
|
||||
try:
|
||||
doda = db.get(Doda, doda_id)
|
||||
db.delete(seal)
|
||||
db.commit()
|
||||
if doda:
|
||||
DodaService._invalidate_report_after_mutation(
|
||||
db,
|
||||
doda_id=doda_id,
|
||||
tenant_id=int(doda.tenant_id),
|
||||
company_id=int(doda.company_id),
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(
|
||||
"Error deleting seal doda_id=%s line=%s seal_line=%s: %s",
|
||||
doda_id,
|
||||
container_line,
|
||||
seal_line,
|
||||
e,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Error al eliminar el precinto.")
|
||||
|
||||
# ============ AMERICAN PEDIMENTOS ============
|
||||
@staticmethod
|
||||
def add_american_pedimento(
|
||||
@@ -243,21 +505,62 @@ class DodaService:
|
||||
if not doda:
|
||||
return None
|
||||
|
||||
tipo = (pedimento_data.american_pedimento_type or "").strip()
|
||||
valor = (pedimento_data.american_pedimento_value or "").strip()
|
||||
# Legacy: IF DODA:DespachoAduanero = '3' (PITA) → sin validación de tipo; web usa customs_clearance=1 para PITA
|
||||
clearance = getattr(doda, "customs_clearance", None)
|
||||
if clearance != 1:
|
||||
if not tipo:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="El tipo de pedimento americano es obligatorio.",
|
||||
)
|
||||
op = (doda.operation_type or "").strip().upper()
|
||||
if op in ("I", "1"):
|
||||
allowed = {"1", "2", "3", "4", "5"}
|
||||
elif op in ("E", "2"):
|
||||
allowed = {"6", "7", "8"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="El tipo de operación del DODA no permite validar el pedimento americano.",
|
||||
)
|
||||
if tipo not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="El tipo de pedimento americano no es correcto para el tipo de operación.",
|
||||
)
|
||||
|
||||
max_line = (
|
||||
db.query(DodaAmericanPedimento)
|
||||
.filter(DodaAmericanPedimento.doda_id == doda_id)
|
||||
.count()
|
||||
)
|
||||
|
||||
dump = pedimento_data.model_dump(exclude_unset=True)
|
||||
if clearance == 1:
|
||||
dump["american_pedimento_type"] = tipo or None
|
||||
|
||||
db_pedimento = DodaAmericanPedimento(
|
||||
doda_id=doda_id,
|
||||
american_pedimento_line=max_line + 1,
|
||||
**pedimento_data.model_dump(exclude_unset=True),
|
||||
tenant_id=doda.tenant_id,
|
||||
company_id=doda.company_id,
|
||||
**dump,
|
||||
)
|
||||
db.add(db_pedimento)
|
||||
db.commit()
|
||||
db.refresh(db_pedimento)
|
||||
DodaService._invalidate_report_after_mutation(
|
||||
db,
|
||||
doda_id=doda_id,
|
||||
tenant_id=int(doda.tenant_id),
|
||||
company_id=int(doda.company_id),
|
||||
)
|
||||
return db_pedimento
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error adding American pedimento: {str(e)}")
|
||||
@@ -274,6 +577,44 @@ class DodaService:
|
||||
.all()
|
||||
)
|
||||
|
||||
# ============ AMERICAN PEDIMENTOS (DELETE) ============
|
||||
@staticmethod
|
||||
def delete_american_pedimento(
|
||||
db: Session, doda_id: int, pedimento_line: int
|
||||
) -> None:
|
||||
"""Delete an American pedimento from a DODA."""
|
||||
db_pedimento = (
|
||||
db.query(DodaAmericanPedimento)
|
||||
.filter(
|
||||
DodaAmericanPedimento.doda_id == doda_id,
|
||||
DodaAmericanPedimento.american_pedimento_line == pedimento_line,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not db_pedimento:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Pedimento americano no encontrado."
|
||||
)
|
||||
try:
|
||||
doda = db.get(Doda, doda_id)
|
||||
db.delete(db_pedimento)
|
||||
db.commit()
|
||||
if doda:
|
||||
DodaService._invalidate_report_after_mutation(
|
||||
db,
|
||||
doda_id=doda_id,
|
||||
tenant_id=int(doda.tenant_id),
|
||||
company_id=int(doda.company_id),
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(
|
||||
f"Error deleting american pedimento doda_id={doda_id} line={pedimento_line}: {e}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error al eliminar el pedimento americano."
|
||||
)
|
||||
|
||||
# ============ PEDIMENTOS ============
|
||||
@staticmethod
|
||||
def add_pedimento(
|
||||
@@ -294,12 +635,23 @@ class DodaService:
|
||||
db_pedimento = DodaPedimento(
|
||||
doda_id=doda_id,
|
||||
pedimento_line=max_line + 1,
|
||||
tenant_id=doda.tenant_id,
|
||||
company_id=doda.company_id,
|
||||
**pedimento_data.model_dump(exclude_unset=True),
|
||||
)
|
||||
db.add(db_pedimento)
|
||||
db.commit()
|
||||
db.refresh(db_pedimento)
|
||||
DodaService._invalidate_report_after_mutation(
|
||||
db,
|
||||
doda_id=doda_id,
|
||||
tenant_id=int(doda.tenant_id),
|
||||
company_id=int(doda.company_id),
|
||||
)
|
||||
return db_pedimento
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error adding pedimento: {str(e)}")
|
||||
@@ -313,3 +665,36 @@ class DodaService:
|
||||
db.query(DodaPedimento).filter(
|
||||
DodaPedimento.doda_id == doda_id).all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_pedimento(
|
||||
db: Session, doda_id: int, pedimento_line: int
|
||||
) -> None:
|
||||
"""Delete a pedimento from a DODA."""
|
||||
db_pedimento = (
|
||||
db.query(DodaPedimento)
|
||||
.filter(
|
||||
DodaPedimento.doda_id == doda_id,
|
||||
DodaPedimento.pedimento_line == pedimento_line,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not db_pedimento:
|
||||
raise HTTPException(status_code=404, detail="Pedimento no encontrado.")
|
||||
try:
|
||||
doda = db.get(Doda, doda_id)
|
||||
db.delete(db_pedimento)
|
||||
db.commit()
|
||||
if doda:
|
||||
DodaService._invalidate_report_after_mutation(
|
||||
db,
|
||||
doda_id=doda_id,
|
||||
tenant_id=int(doda.tenant_id),
|
||||
company_id=int(doda.company_id),
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(
|
||||
f"Error deleting pedimento doda_id={doda_id} line={pedimento_line}: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Error al eliminar el pedimento.")
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Reporte DODA</title>
|
||||
<style>
|
||||
body { font-family: DejaVu Sans, Arial, Helvetica, sans-serif; font-size: 10pt; color: #111; }
|
||||
h1 { font-size: 16pt; margin: 0 0 6px; }
|
||||
h2 { font-size: 12pt; margin: 16px 0 8px; border-bottom: 1px solid #ccc; }
|
||||
.muted { color: #555; font-size: 9pt; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #ccc; padding: 4px 6px; vertical-align: top; }
|
||||
th { background: #f3f3f3; text-align: left; }
|
||||
.no-border td { border: none; padding: 2px 0; }
|
||||
.mono { font-family: DejaVu Sans Mono, Consolas, monospace; font-size: 8.5pt; white-space: pre-wrap; word-break: break-all; }
|
||||
.small { font-size: 8.5pt; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Documento de operación (DODA)</h1>
|
||||
<p class="muted">Huella de contenido (SHA-256): <span class="mono">{{ fingerprint_sha256 }}</span></p>
|
||||
|
||||
<h2>Datos generales</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th style="width: 22%;">Folio de integración</th><td>{{ doda.integration_number }}</td>
|
||||
<th style="width: 22%;">Patente</th><td>{{ doda.patent }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Aduana despacho</th><td>{{ doda.dispatch_customs }}</td>
|
||||
<th>Secciones aduaneras</th><td>{{ doda.customs_sections }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Operación</th><td>{{ doda.operation_type }}</td>
|
||||
<th>Estado</th><td>{{ doda.status }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Ident. transporte</th><td>{{ doda.transport_identification }}</td>
|
||||
<th>ID rápida</th><td>{{ doda.fast_id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>CAAT</th><td>{{ doda.caat }}</td>
|
||||
<th>Transacción / folio</th><td>{{ doda.transaction_number }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if linq_sat_qr %}
|
||||
<h2>QR (LINQ / SAT)</h2>
|
||||
<p class="mono small">{{ linq_sat_qr }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if sat_chain_preview %}
|
||||
<h2>Cadena original / SAT (extracto)</h2>
|
||||
<p class="mono small">{{ sat_chain_preview }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if sat_digital_seal_preview %}
|
||||
<h2>Sello digital (SAT) — extracto</h2>
|
||||
<p class="mono small">{{ sat_digital_seal_preview }}</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Contenedores y precintos</h2>
|
||||
{% if containers|length == 0 %}
|
||||
<p class="muted">Sin contenedores registrados.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:8%;">Línea</th>
|
||||
<th>Contenedor</th>
|
||||
<th style="width:38%;">Precintos (línea / valor)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in containers %}
|
||||
<tr>
|
||||
<td>{{ c.container_line }}</td>
|
||||
<td class="mono">{{ c.container_value }}</td>
|
||||
<td>
|
||||
{% if c.seals|length == 0 %}
|
||||
<span class="muted">—</span>
|
||||
{% else %}
|
||||
<table class="no-border">
|
||||
{% for s in c.seals %}
|
||||
<tr>
|
||||
<td class="small" style="width: 18%;">{{ s.seal_line }}</td>
|
||||
<td class="mono small">{{ s.seal_value }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<h2>Pedimentos nacionales</h2>
|
||||
{% if pedimentos_detail|length == 0 %}
|
||||
<p class="muted">Sin partidas de pedimentos nacionales.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:8%;">Línea</th>
|
||||
<th>Patente auth.</th>
|
||||
<th>Documento / ped.</th>
|
||||
<th>Embarque</th>
|
||||
<th>COVE</th>
|
||||
<th>UMC</th>
|
||||
<th>Tipo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in pedimentos_detail %}
|
||||
<tr>
|
||||
<td>{{ p.pedimento_line }}</td>
|
||||
<td class="mono">{{ p.authorization_patent }}</td>
|
||||
<td class="mono">{{ p.document }}</td>
|
||||
<td class="mono">{{ p.shipment }}</td>
|
||||
<td class="mono">{{ p.cove }}</td>
|
||||
<td class="mono">{{ p.umc }}</td>
|
||||
<td class="mono">{{ p.pedimento_type }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<h2>Pedimentos USA</h2>
|
||||
{% if american_pedimentos|length == 0 %}
|
||||
<p class="muted">Sin pedimentos americanos.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:8%;">Línea</th>
|
||||
<th>Tipo</th>
|
||||
<th>Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in american_pedimentos %}
|
||||
<tr>
|
||||
<td>{{ a.american_pedimento_line }}</td>
|
||||
<td class="mono">{{ a.american_pedimento_type }}</td>
|
||||
<td class="mono">{{ a.american_pedimento_value }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<p class="muted" style="margin-top: 18px;">
|
||||
Documento generado automáticamente. Los extractos de cadena / sello se truncan en este reporte;
|
||||
los datos de huella (SHA-256) reflejan el contenido completo persistido.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -57,6 +57,8 @@ class Settings(BaseSettings):
|
||||
COVE_FIEL_HASH_IV: str = ""
|
||||
SITAR_API_USER: str = ""
|
||||
SITAR_API_PASSWORD: str = ""
|
||||
DODA_API_BASE_URL: str = ""
|
||||
DODA_API_VERIFY_SSL: bool = False
|
||||
|
||||
# SMTP Email Configuration
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
|
||||
@@ -15,6 +15,7 @@ funciones de este módulo (no construir ``tenants/...`` a mano en las rutas HTTP
|
||||
- ``tenants/{tid}/companies/{company_id}/``
|
||||
Recursos ligados a una empresa:
|
||||
|
||||
- ``.../doda/{doda_id}/report/doda_report.pdf`` — reporte DODA en PDF. ``doda_report_pdf_key``.
|
||||
- ``.../branding/{filename}`` — logo. ``company_logo_key``.
|
||||
- ``.../certificates/{tipo}_{timestamp}.{cer|key}`` — CER/KEY FIEL, CFDI, cancelación.
|
||||
``company_certificate_key``.
|
||||
@@ -272,6 +273,18 @@ def company_logo_key(
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}branding/{fn}"
|
||||
|
||||
|
||||
def doda_report_pdf_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
doda_id: int,
|
||||
) -> str:
|
||||
"""
|
||||
Reporte DODA en PDF bajo ``.../doda/{doda_id}/report/doda_report.pdf`` (clave estable).
|
||||
"""
|
||||
did = _segment(doda_id, "doda_id")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}doda/{did}/report/doda_report.pdf"
|
||||
|
||||
|
||||
def company_certificate_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
|
||||
72
backend/tests/unit/general_catalogs/doda/test_doda_export.py
Normal file
72
backend/tests/unit/general_catalogs/doda/test_doda_export.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from api.v1.modules.a76.general_catalogs.doda.export_service import (
|
||||
DodaExportFormat,
|
||||
build_export_text,
|
||||
doda_row_values,
|
||||
parse_export_params,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_export_params_valid():
|
||||
d0, d1, fmt, mode = parse_export_params("2024-01-15", "2024-01-20", "csv", "formatted")
|
||||
assert d0 == 20240115
|
||||
assert d1 == 20240120
|
||||
assert fmt == DodaExportFormat.csv
|
||||
assert mode == "formatted"
|
||||
|
||||
|
||||
def test_parse_export_params_rejects_inverted_range():
|
||||
with pytest.raises(ValueError, match="date_from"):
|
||||
parse_export_params("2024-02-01", "2024-01-01", "csv", "formatted")
|
||||
|
||||
|
||||
def _minimal_row():
|
||||
return SimpleNamespace(
|
||||
id=1,
|
||||
integration_number="INT1",
|
||||
doda_date=20240110,
|
||||
doda_time=1430,
|
||||
dispatch_customs="64",
|
||||
customs_sections=None,
|
||||
patent="1234",
|
||||
pedimentos=None,
|
||||
caat=None,
|
||||
transport_identification=None,
|
||||
fast_id=None,
|
||||
operation_type="I",
|
||||
responsible=None,
|
||||
carrier=None,
|
||||
shipments=None,
|
||||
pedimento_type=None,
|
||||
original_chain=None,
|
||||
serial_number=None,
|
||||
electronic_signature=None,
|
||||
transaction_number=None,
|
||||
status="PENDIENTE",
|
||||
linq_sat_qr=None,
|
||||
sat_certificate=None,
|
||||
sat_digital_seal=None,
|
||||
xml_doda_sent_path=None,
|
||||
xml_doda_response_path=None,
|
||||
sat_original_chain=None,
|
||||
customs_clearance=None,
|
||||
unique_badge_number=None,
|
||||
last_user=None,
|
||||
)
|
||||
|
||||
|
||||
def test_doda_row_values_length_matches_headers():
|
||||
row = _minimal_row()
|
||||
assert len(doda_row_values(row, date_mode="formatted")) == 30
|
||||
|
||||
|
||||
def test_build_export_text_includes_header_and_row():
|
||||
row = _minimal_row()
|
||||
text = build_export_text([row], export_format=DodaExportFormat.csv, date_mode="formatted")
|
||||
lines = text.strip().split("\r\n")
|
||||
assert "SYSID" in lines[0]
|
||||
assert "INT1" in lines[1]
|
||||
assert lines[1].startswith("1,")
|
||||
126
backend/tests/unit/general_catalogs/doda/test_doda_print.py
Normal file
126
backend/tests/unit/general_catalogs/doda/test_doda_print.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.general_catalogs.doda.fingerprint import build_doda_fingerprint
|
||||
from api.v1.modules.a76.general_catalogs.doda.models import Doda
|
||||
from api.v1.modules.a76.general_catalogs.doda import routes as doda_routes
|
||||
from core import storage_s3
|
||||
from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.s3_keys import doda_report_pdf_key
|
||||
from core.security import get_current_user
|
||||
from tests.conftest import allocate_ephemeral_tenant_company_ids, ensure_tenant_company
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db_session")
|
||||
def test_doda_fingerprint_changes_when_field_changes(db_session: Session):
|
||||
tid, cid = allocate_ephemeral_tenant_company_ids(db_session)
|
||||
ensure_tenant_company(db_session, tenant_id=tid, company_id=cid)
|
||||
d = Doda(
|
||||
tenant_id=tid,
|
||||
company_id=cid,
|
||||
integration_number="A1",
|
||||
sat_digital_seal="x",
|
||||
)
|
||||
db_session.add(d)
|
||||
db_session.commit()
|
||||
fp1 = build_doda_fingerprint(db_session, d.id)
|
||||
d.patent = "1234"
|
||||
db_session.commit()
|
||||
fp2 = build_doda_fingerprint(db_session, d.id)
|
||||
assert fp1 != fp2
|
||||
|
||||
|
||||
def _print_client(db_session: Session, test_tenant) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(doda_routes.router, prefix="/doda")
|
||||
|
||||
def _override_get_db():
|
||||
yield db_session
|
||||
|
||||
async def _user():
|
||||
return {"sub": "test", "tenant_id": test_tenant.tenant_id}
|
||||
|
||||
app.dependency_overrides[get_core_db] = _override_get_db
|
||||
app.dependency_overrides[get_current_user] = _user
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@patch.object(
|
||||
doda_routes,
|
||||
"validate_access_to_resource",
|
||||
lambda db, company_id, current_user: int(current_user["tenant_id"]),
|
||||
)
|
||||
def test_print_uses_s3_cache_when_fingerprint_matches(
|
||||
db_session: Session, test_tenant, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(settings, "S3_FILE_STORAGE", True, raising=False)
|
||||
monkeypatch.setattr(settings, "CSV_IMPORT_STORAGE", "redis", raising=False)
|
||||
|
||||
tid, cid = test_tenant.tenant_id, test_tenant.company_id
|
||||
ensure_tenant_company(db_session, tenant_id=tid, company_id=cid)
|
||||
d = Doda(
|
||||
tenant_id=tid,
|
||||
company_id=cid,
|
||||
integration_number="X",
|
||||
sat_digital_seal="s",
|
||||
)
|
||||
db_session.add(d)
|
||||
db_session.commit()
|
||||
|
||||
fp = build_doda_fingerprint(db_session, d.id)
|
||||
d.doda_report_source_fingerprint = fp
|
||||
d.doda_report_pdf_path = doda_report_pdf_key(tid, cid, d.id)
|
||||
db_session.commit()
|
||||
|
||||
called = {"render": 0}
|
||||
|
||||
class _NoRender:
|
||||
def build_pdf_for_doda(self, db, doda):
|
||||
called["render"] += 1
|
||||
raise AssertionError("should not render when S3 cache hits")
|
||||
|
||||
monkeypatch.setattr(doda_routes, "DodaReportPdfService", lambda: _NoRender())
|
||||
monkeypatch.setattr(storage_s3, "object_exists", lambda key: True)
|
||||
monkeypatch.setattr(storage_s3, "get_object_bytes", lambda key: b"%PDF-1.4 cached")
|
||||
|
||||
client = _print_client(db_session, test_tenant)
|
||||
resp = client.get(f"/doda/{d.id}/print?company_id={cid}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"%PDF-1.4 cached"
|
||||
assert called["render"] == 0
|
||||
|
||||
|
||||
@patch.object(
|
||||
doda_routes,
|
||||
"validate_access_to_resource",
|
||||
lambda db, company_id, current_user: int(current_user["tenant_id"]),
|
||||
)
|
||||
def test_print_422_without_digital_seal(
|
||||
db_session: Session, test_tenant, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(settings, "S3_FILE_STORAGE", True, raising=False)
|
||||
|
||||
tid, cid = test_tenant.tenant_id, test_tenant.company_id
|
||||
ensure_tenant_company(db_session, tenant_id=tid, company_id=cid)
|
||||
d = Doda(
|
||||
tenant_id=tid, company_id=cid, integration_number="N", sat_digital_seal=" "
|
||||
)
|
||||
db_session.add(d)
|
||||
db_session.commit()
|
||||
|
||||
put_calls = []
|
||||
|
||||
def _put(key, body, content_type="application/octet-stream"):
|
||||
put_calls.append((key, body, content_type))
|
||||
|
||||
monkeypatch.setattr(storage_s3, "put_object_bytes", _put)
|
||||
|
||||
client = _print_client(db_session, test_tenant)
|
||||
resp = client.get(f"/doda/{d.id}/print?company_id={cid}")
|
||||
assert resp.status_code == 422
|
||||
assert put_calls == []
|
||||
@@ -174,6 +174,9 @@ services:
|
||||
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
|
||||
- COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY}
|
||||
- COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV}
|
||||
- COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com}
|
||||
- DODA_API_BASE_URL=${DODA_API_BASE_URL}
|
||||
- DODA_API_VERIFY_SSL=${DODA_API_VERIFY_SSL:-False}
|
||||
- VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0}
|
||||
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""}
|
||||
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production}
|
||||
@@ -242,6 +245,9 @@ services:
|
||||
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
|
||||
- COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY}
|
||||
- COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV}
|
||||
- COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com}
|
||||
- DODA_API_BASE_URL=${DODA_API_BASE_URL}
|
||||
- DODA_API_VERIFY_SSL=${DODA_API_VERIFY_SSL:-False}
|
||||
- CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio}
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}
|
||||
@@ -277,6 +283,9 @@ services:
|
||||
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
|
||||
- COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY}
|
||||
- COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV}
|
||||
- COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com}
|
||||
- DODA_API_BASE_URL=${DODA_API_BASE_URL}
|
||||
- DODA_API_VERIFY_SSL=${DODA_API_VERIFY_SSL:-False}
|
||||
- CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio}
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}
|
||||
|
||||
@@ -179,6 +179,9 @@ services:
|
||||
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
|
||||
- COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY}
|
||||
- COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV}
|
||||
- COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com}
|
||||
- DODA_API_BASE_URL=${DODA_API_BASE_URL}
|
||||
- DODA_API_VERIFY_SSL=${DODA_API_VERIFY_SSL:-False}
|
||||
- VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0}
|
||||
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""}
|
||||
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production}
|
||||
@@ -304,6 +307,9 @@ services:
|
||||
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
|
||||
- COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY}
|
||||
- COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV}
|
||||
- COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com}
|
||||
- DODA_API_BASE_URL=${DODA_API_BASE_URL}
|
||||
- DODA_API_VERIFY_SSL=${DODA_API_VERIFY_SSL:-False}
|
||||
- CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio}
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}
|
||||
@@ -343,6 +349,9 @@ services:
|
||||
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
|
||||
- COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY}
|
||||
- COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV}
|
||||
- COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com}
|
||||
- DODA_API_BASE_URL=${DODA_API_BASE_URL}
|
||||
- DODA_API_VERIFY_SSL=${DODA_API_VERIFY_SSL:-False}
|
||||
- CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio}
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}
|
||||
|
||||
@@ -135,6 +135,68 @@
|
||||
"audit_logs_files_loading": "Loading files...",
|
||||
"audit_logs_files_empty": "No files or folders found in this location.",
|
||||
"audit_logs_files_download": "Download",
|
||||
"despacho": {
|
||||
"title": "Dispatch",
|
||||
"digitalizacion": "Digitization",
|
||||
"doda": "DODA"
|
||||
},
|
||||
"doda_alta": {
|
||||
"title": "DODA",
|
||||
"subtitle": "Customs Clearance Declaration",
|
||||
"new": "New",
|
||||
"refresh": "Refresh",
|
||||
"table_title": "DODAs",
|
||||
"col_integration_number": "Integration No.",
|
||||
"col_patent": "Patent",
|
||||
"col_status": "Status",
|
||||
"col_dispatch_customs": "Dispatch Customs",
|
||||
"col_operation_type": "Operation Type",
|
||||
"col_actions": "Actions",
|
||||
"action_alta_doda": "DODA Filing",
|
||||
"action_alta_pita": "PITA Filing",
|
||||
"action_edit": "Edit",
|
||||
"action_delete": "Delete",
|
||||
"action_new": "New DODA",
|
||||
"progress_title": "Processing DODA filing...",
|
||||
"progress_success": "DODA filing completed successfully.",
|
||||
"progress_error": "Error in DODA filing.",
|
||||
"eligibility_error": "DODA does not meet the requirements for filing.",
|
||||
"eligibility_checking": "Checking eligibility...",
|
||||
"empty": "No DODAs",
|
||||
"loading": "Loading...",
|
||||
"search_placeholder": "Search:",
|
||||
"confirm_delete": "Are you sure you want to delete this DODA?",
|
||||
"delete_success": "DODA deleted successfully",
|
||||
"delete_error": "Error deleting DODA",
|
||||
"delete_missing_company": "Select a company",
|
||||
"delete_select_one": "Select exactly one DODA from the list",
|
||||
"delete_not_found": "Could not locate the DODA. Select the row again and retry",
|
||||
"filter_integration_number": "Integration No.",
|
||||
"filter_patent": "Patent",
|
||||
"filter_status": "Status",
|
||||
"filter_operation_type": "Operation Type",
|
||||
"action_generar": "Submit",
|
||||
"action_export_excel": "Export to Excel",
|
||||
"export_excel_title": "Export DODA list",
|
||||
"export_excel_subtitle": "Filter by DODA date (stored as YYYYMMDD).",
|
||||
"export_excel_badge": "DODA CATALOG",
|
||||
"export_report_heading": "General report by date range",
|
||||
"export_fecha_inicio": "Start date",
|
||||
"export_fecha_final": "End date",
|
||||
"export_julian_label": "Use Julian (numeric) date in Excel file.",
|
||||
"export_report_generar": "Generate",
|
||||
"export_date_from": "From",
|
||||
"export_date_to": "To",
|
||||
"export_format": "File format",
|
||||
"export_date_mode": "Date/time in file",
|
||||
"export_date_mode_formatted": "Formatted (DD/MM/YYYY and time)",
|
||||
"export_date_mode_raw": "Numeric (raw YYYYMMDD)",
|
||||
"export_download": "Download",
|
||||
"export_cancel": "Close",
|
||||
"export_excel_success": "File generated.",
|
||||
"export_excel_error": "Could not generate the file.",
|
||||
"export_excel_invalid_dates": "Enter from and to dates."
|
||||
},
|
||||
"digitalizacion": {
|
||||
"title": "Digitization",
|
||||
"subtitle": "Digitized Documents Catalog",
|
||||
|
||||
@@ -135,6 +135,68 @@
|
||||
"audit_logs_files_loading": "Cargando archivos...",
|
||||
"audit_logs_files_empty": "No hay archivos o carpetas en esta ubicación.",
|
||||
"audit_logs_files_download": "Descargar",
|
||||
"despacho": {
|
||||
"title": "Despacho",
|
||||
"digitalizacion": "Digitalización",
|
||||
"doda": "DODA"
|
||||
},
|
||||
"doda_alta": {
|
||||
"title": "DODA",
|
||||
"subtitle": "Declaración Operación Despacho Aduanero",
|
||||
"new": "Nuevo",
|
||||
"refresh": "Actualizar",
|
||||
"table_title": "DODAs",
|
||||
"col_integration_number": "No. Integración",
|
||||
"col_patent": "Patente",
|
||||
"col_status": "Estatus",
|
||||
"col_dispatch_customs": "Aduana Despacho",
|
||||
"col_operation_type": "Tipo Operación",
|
||||
"col_actions": "Acciones",
|
||||
"action_alta_doda": "Alta DODA",
|
||||
"action_alta_pita": "Alta PITA",
|
||||
"action_edit": "Editar",
|
||||
"action_delete": "Borrar",
|
||||
"action_new": "Nuevo DODA",
|
||||
"progress_title": "Procesando alta DODA...",
|
||||
"progress_success": "Alta DODA completada exitosamente.",
|
||||
"progress_error": "Error en el alta DODA.",
|
||||
"eligibility_error": "El DODA no cumple los requisitos para el alta.",
|
||||
"eligibility_checking": "Verificando elegibilidad...",
|
||||
"empty": "Sin DODAs",
|
||||
"loading": "Cargando...",
|
||||
"search_placeholder": "Buscar:",
|
||||
"confirm_delete": "¿Está seguro de eliminar este DODA?",
|
||||
"delete_success": "DODA eliminado correctamente",
|
||||
"delete_error": "Error al eliminar DODA",
|
||||
"delete_missing_company": "Selecciona una compañía",
|
||||
"delete_select_one": "Selecciona un solo DODA en el listado",
|
||||
"delete_not_found": "No se pudo localizar el DODA. Pulsa otra fila e inténtalo de nuevo",
|
||||
"filter_integration_number": "No. Integración",
|
||||
"filter_patent": "Patente",
|
||||
"filter_status": "Estatus",
|
||||
"filter_operation_type": "Tipo Operación",
|
||||
"action_generar": "Generar",
|
||||
"action_export_excel": "Exportar Excel",
|
||||
"export_excel_title": "Exportar listado DODA",
|
||||
"export_excel_subtitle": "Filtra por Fecha DODA (en base de datos como AAAAMMDD).",
|
||||
"export_excel_badge": "CATÁLOGO DODA",
|
||||
"export_report_heading": "Reporte general por rango de fechas",
|
||||
"export_fecha_inicio": "Fecha inicio",
|
||||
"export_fecha_final": "Fecha final",
|
||||
"export_julian_label": "Imprimir Fecha Juliana en archivo Excel.",
|
||||
"export_report_generar": "Generar",
|
||||
"export_date_from": "Desde",
|
||||
"export_date_to": "Hasta",
|
||||
"export_format": "Formato de archivo",
|
||||
"export_date_mode": "Fechas y hora en el archivo",
|
||||
"export_date_mode_formatted": "Formateado (DD/MM/YYYY y hora)",
|
||||
"export_date_mode_raw": "Numérico (YYYYMMDD / crudo)",
|
||||
"export_download": "Descargar",
|
||||
"export_cancel": "Cerrar",
|
||||
"export_excel_success": "Archivo generado.",
|
||||
"export_excel_error": "No se pudo generar el archivo.",
|
||||
"export_excel_invalid_dates": "Indique fecha desde y hasta."
|
||||
},
|
||||
"digitalizacion": {
|
||||
"title": "Digitalización",
|
||||
"subtitle": "Catálogo de Documentos Digitalizados",
|
||||
|
||||
86
frontend/src/lib/api/dashboard/a76/doda-alta-log.ts
Normal file
86
frontend/src/lib/api/dashboard/a76/doda-alta-log.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { api, type ApiResponse } from '$lib/api';
|
||||
|
||||
export interface DodaAltaLog {
|
||||
id: number;
|
||||
doda_id?: number | null;
|
||||
variant?: string | null;
|
||||
responsible?: string | null;
|
||||
patent?: string | null;
|
||||
dispatch_customs?: string | null;
|
||||
operation_type?: string | null;
|
||||
integration_number?: string | null;
|
||||
task_id?: string | null;
|
||||
status?: string | null;
|
||||
message?: string | null;
|
||||
result_json?: string | null;
|
||||
company_id: number;
|
||||
tenant_id: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DodaAltaLogListResponse {
|
||||
items: DodaAltaLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface DodaAltaLogCreateDTO {
|
||||
doda_id?: number | null;
|
||||
variant?: string | null;
|
||||
responsible?: string | null;
|
||||
patent?: string | null;
|
||||
dispatch_customs?: string | null;
|
||||
operation_type?: string | null;
|
||||
integration_number?: string | null;
|
||||
task_id?: string | null;
|
||||
status?: string | null;
|
||||
message?: string | null;
|
||||
result_json?: string | null;
|
||||
}
|
||||
|
||||
export interface DodaAltaLogUpdateDTO {
|
||||
status?: string | null;
|
||||
message?: string | null;
|
||||
result_json?: string | null;
|
||||
}
|
||||
|
||||
export const dodaAltaLogApi = {
|
||||
list(
|
||||
companyId: number,
|
||||
params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
doda_id?: number;
|
||||
search?: string;
|
||||
}
|
||||
): Promise<ApiResponse<DodaAltaLogListResponse>> {
|
||||
const qs = new URLSearchParams({ company_id: companyId.toString() });
|
||||
if (params?.page) qs.set('page', params.page.toString());
|
||||
if (params?.page_size) qs.set('page_size', params.page_size.toString());
|
||||
if (params?.doda_id) qs.set('doda_id', params.doda_id.toString());
|
||||
if (params?.search) qs.set('search', params.search);
|
||||
return api.get<DodaAltaLogListResponse>(`/v1/a76/doda/alta-logs?${qs}`);
|
||||
},
|
||||
|
||||
get(id: number, companyId: number): Promise<ApiResponse<DodaAltaLog>> {
|
||||
return api.get<DodaAltaLog>(`/v1/a76/doda/alta-logs/${id}?company_id=${companyId}`);
|
||||
},
|
||||
|
||||
create(dto: DodaAltaLogCreateDTO, companyId: number): Promise<ApiResponse<DodaAltaLog>> {
|
||||
return api.post<DodaAltaLog>(`/v1/a76/doda/alta-logs?company_id=${companyId}`, dto);
|
||||
},
|
||||
|
||||
update(
|
||||
id: number,
|
||||
dto: DodaAltaLogUpdateDTO,
|
||||
companyId: number
|
||||
): Promise<ApiResponse<DodaAltaLog>> {
|
||||
return api.put<DodaAltaLog>(`/v1/a76/doda/alta-logs/${id}?company_id=${companyId}`, dto);
|
||||
},
|
||||
|
||||
delete(id: number, companyId: number): Promise<ApiResponse<void>> {
|
||||
return api.delete(`/v1/a76/doda/alta-logs/${id}?company_id=${companyId}`);
|
||||
}
|
||||
};
|
||||
@@ -187,20 +187,272 @@ export async function getDoda(id: number, companyId?: number): Promise<Doda> {
|
||||
if (companyId) {
|
||||
params.append('company_id', companyId.toString());
|
||||
}
|
||||
const response = await api.get(`/v1/a76/doda/${id}/detail?${params.toString()}`);
|
||||
const response = await api.get<Doda>(`/v1/a76/doda/${id}/detail?${params.toString()}`);
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(response.error || 'Error al obtener el DODA');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createDoda(data: DodaCreate, companyId: number): Promise<Doda> {
|
||||
const response = await api.post(`/v1/a76/doda/?company_id=${companyId}`, data);
|
||||
const response = await api.post<Doda>(`/v1/a76/doda/?company_id=${companyId}`, data);
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(response.error || 'Error al crear el DODA');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** POST /v1/a76/doda/{dodaId}/containers — añade contenedor al DODA existente. */
|
||||
export async function addDodaContainer(
|
||||
dodaId: number,
|
||||
body: DodaContainerCreate,
|
||||
companyId: number
|
||||
): Promise<DodaContainer> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
const response = await api.post<DodaContainer>(
|
||||
`/v1/a76/doda/${dodaId}/containers?${params.toString()}`,
|
||||
body
|
||||
);
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(response.error || 'Error al agregar el contenedor');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteDodaContainer(
|
||||
dodaId: number,
|
||||
containerLine: number,
|
||||
companyId: number
|
||||
): Promise<void> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
const response = await api.delete(
|
||||
`/v1/a76/doda/${dodaId}/containers/${containerLine}?${params.toString()}`
|
||||
);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDodaContainer(
|
||||
dodaId: number,
|
||||
containerLine: number,
|
||||
body: DodaContainerCreate,
|
||||
companyId: number
|
||||
): Promise<DodaContainer> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
const response = await api.put<DodaContainer>(
|
||||
`/v1/a76/doda/${dodaId}/containers/${containerLine}?${params.toString()}`,
|
||||
body
|
||||
);
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(response.error || 'Error al actualizar el contenedor');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** POST /v1/a76/doda/{dodaId}/containers/{containerLine}/seals — añade precinto. */
|
||||
export async function addDodaSeal(
|
||||
dodaId: number,
|
||||
containerLine: number,
|
||||
sealValue: string,
|
||||
companyId: number
|
||||
): Promise<DodaContainerSeal> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
const response = await api.post<DodaContainerSeal>(
|
||||
`/v1/a76/doda/${dodaId}/containers/${containerLine}/seals?${params.toString()}`,
|
||||
{ seal_value: sealValue }
|
||||
);
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(response.error || 'Error al agregar el precinto');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteDodaSeal(
|
||||
dodaId: number,
|
||||
containerLine: number,
|
||||
sealLine: number,
|
||||
companyId: number
|
||||
): Promise<void> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
const response = await api.delete(
|
||||
`/v1/a76/doda/${dodaId}/containers/${containerLine}/seals/${sealLine}?${params.toString()}`
|
||||
);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /v1/a76/doda/{dodaId}/pedimentos — añade línea al DODA existente. */
|
||||
export async function addDodaPedimento(
|
||||
dodaId: number,
|
||||
body: DodaPedimentoCreate,
|
||||
companyId: number
|
||||
): Promise<DodaPedimento> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
const response = await api.post<DodaPedimento>(
|
||||
`/v1/a76/doda/${dodaId}/pedimentos?${params.toString()}`,
|
||||
body
|
||||
);
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(response.error || 'Error al guardar el pedimento');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** POST /v1/a76/doda/{dodaId}/american-pedimentos */
|
||||
export async function addDodaAmericanPedimento(
|
||||
dodaId: number,
|
||||
body: DodaAmericanPedimentoCreate,
|
||||
companyId: number
|
||||
): Promise<DodaAmericanPedimento> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
const response = await api.post<DodaAmericanPedimento>(
|
||||
`/v1/a76/doda/${dodaId}/american-pedimentos?${params.toString()}`,
|
||||
body
|
||||
);
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(response.error || 'Error al guardar el pedimento americano');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteDodaAmericanPedimento(
|
||||
dodaId: number,
|
||||
pedimentoLine: number,
|
||||
companyId: number
|
||||
): Promise<void> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
const response = await api.delete(
|
||||
`/v1/a76/doda/${dodaId}/american-pedimentos/${pedimentoLine}?${params.toString()}`
|
||||
);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDoda(id: number, data: DodaUpdate, companyId: number): Promise<Doda> {
|
||||
const response = await api.put(`/v1/a76/doda/${id}/?company_id=${companyId}`, data);
|
||||
const response = await api.put<Doda>(`/v1/a76/doda/${id}/?company_id=${companyId}`, data);
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(response.error || 'Error al actualizar el DODA');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteDoda(id: number, companyId: number): Promise<void> {
|
||||
await api.delete(`/v1/a76/doda/${id}?company_id=${companyId}`);
|
||||
const response = await api.delete(`/v1/a76/doda/${id}?company_id=${companyId}`);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/a76/doda/{id}/print — PDF (requiere sello digital SAT en backend)
|
||||
*/
|
||||
export async function printDoda(dodaId: number, companyId: number): Promise<void> {
|
||||
const params = new URLSearchParams({ company_id: String(companyId) });
|
||||
const blob = await api.getBlob(`/v1/a76/doda/${dodaId}/print?${params.toString()}`);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.download = `doda_${dodaId}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export type DodaExportFileFormat = 'csv' | 'xls' | 'txt';
|
||||
export type DodaExportDateMode = 'raw' | 'formatted';
|
||||
|
||||
/**
|
||||
* GET /v1/a76/doda/export — listado por rango (Fecha DODA YYYYMMDD en BD)
|
||||
*/
|
||||
export async function exportDodaList(
|
||||
companyId: number,
|
||||
opts: {
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
format: DodaExportFileFormat;
|
||||
dateMode: DodaExportDateMode;
|
||||
}
|
||||
): Promise<void> {
|
||||
const params = new URLSearchParams({
|
||||
company_id: String(companyId),
|
||||
date_from: opts.dateFrom,
|
||||
date_to: opts.dateTo,
|
||||
format: opts.format,
|
||||
date_mode: opts.dateMode
|
||||
});
|
||||
const ext = opts.format;
|
||||
const blob = await api.getBlob(`/v1/a76/doda/export?${params.toString()}`);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `doda_export_${opts.dateFrom}_${opts.dateTo}.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ── Alta DODA API ─────────────────────────────────────────────────────────── //
|
||||
|
||||
export interface DodaAltaResponse {
|
||||
task_id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DodaAltaStatusResponse {
|
||||
state?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
result?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DodaElegibilidadReason {
|
||||
field: string;
|
||||
message: string;
|
||||
solution?: string;
|
||||
}
|
||||
|
||||
export interface DodaElegibilidadResponse {
|
||||
can_alta: boolean;
|
||||
reasons: DodaElegibilidadReason[];
|
||||
}
|
||||
|
||||
export async function postDodaAlta(
|
||||
dodaId: number,
|
||||
companyId: number,
|
||||
variant: 'doda' | 'pita' = 'doda'
|
||||
): Promise<ApiResponse<DodaAltaResponse>> {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString(),
|
||||
variant,
|
||||
});
|
||||
return api.post<DodaAltaResponse>(`/v1/a76/doda/${dodaId}/alta?${params}`, {});
|
||||
}
|
||||
|
||||
export async function getDodaAltaStatus(
|
||||
taskId: string
|
||||
): Promise<ApiResponse<DodaAltaStatusResponse>> {
|
||||
return api.get<DodaAltaStatusResponse>(`/v1/a76/doda/alta-status/${taskId}`);
|
||||
}
|
||||
|
||||
export async function getDodaElegibilidad(
|
||||
dodaId: number,
|
||||
companyId: number,
|
||||
variant: 'doda' | 'pita' = 'doda'
|
||||
): Promise<ApiResponse<DodaElegibilidadResponse>> {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString(),
|
||||
variant,
|
||||
});
|
||||
return api.get<DodaElegibilidadResponse>(
|
||||
`/v1/a76/doda/${dodaId}/alta/elegibilidad?${params}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { DodaAltaLog } from '$lib/api/dashboard/a76/doda-alta-log';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import { renderSnippet } from '$lib/components/ui/data-table';
|
||||
|
||||
function formatDateTime(raw?: string | null): string {
|
||||
if (!raw) return '-';
|
||||
try {
|
||||
return new Date(raw).toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_CLASSES: Record<string, string> = {
|
||||
success: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||
failure: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
processing: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
started: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
};
|
||||
|
||||
function statusBadge(status: string | null | undefined) {
|
||||
const s = (status || '').toLowerCase();
|
||||
const cls = STATUS_CLASSES[s] || 'bg-gray-100 text-gray-700';
|
||||
return createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}">${status || '-'}</span>`
|
||||
}));
|
||||
}
|
||||
|
||||
export function createAltaLogColumns(): ColumnDef<DodaAltaLog>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: '#',
|
||||
size: 60,
|
||||
cell: ({ row }) => row.original.id
|
||||
},
|
||||
{
|
||||
accessorKey: 'doda_id',
|
||||
header: 'DODA ID',
|
||||
size: 80,
|
||||
cell: ({ row }) => row.original.doda_id ?? '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'integration_number',
|
||||
header: 'No. Integración',
|
||||
cell: ({ row }) => row.original.integration_number || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'variant',
|
||||
header: 'Tipo',
|
||||
size: 70,
|
||||
cell: ({ row }) => (row.original.variant || '-').toUpperCase()
|
||||
},
|
||||
{
|
||||
accessorKey: 'patent',
|
||||
header: 'Patente',
|
||||
size: 80,
|
||||
cell: ({ row }) => row.original.patent || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'dispatch_customs',
|
||||
header: 'Aduana',
|
||||
size: 80,
|
||||
cell: ({ row }) => row.original.dispatch_customs || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'operation_type',
|
||||
header: 'Operación',
|
||||
size: 90,
|
||||
cell: ({ row }) => row.original.operation_type || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Estatus',
|
||||
size: 110,
|
||||
cell: ({ row }) => renderSnippet(statusBadge(row.original.status), {})
|
||||
},
|
||||
{
|
||||
accessorKey: 'task_id',
|
||||
header: 'Task ID',
|
||||
cell: ({ row }) => {
|
||||
const id = row.original.task_id || '';
|
||||
const snippet = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<span class="font-mono text-xs truncate max-w-[180px] block" title="${id}">${id || '-'}</span>`
|
||||
}));
|
||||
return renderSnippet(snippet, {});
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'message',
|
||||
header: 'Mensaje',
|
||||
cell: ({ row }) => {
|
||||
const msg = row.original.message || '';
|
||||
const snippet = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<span class="text-xs truncate max-w-[220px] block text-muted-foreground" title="${msg}">${msg || '-'}</span>`
|
||||
}));
|
||||
return renderSnippet(snippet, {});
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Fecha',
|
||||
size: 130,
|
||||
cell: ({ row }) => formatDateTime(row.original.created_at)
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { LoaderCircle } from 'lucide-svelte';
|
||||
import {
|
||||
dodaAltaLogApi,
|
||||
type DodaAltaLog,
|
||||
type DodaAltaLogUpdateDTO
|
||||
} from '$lib/api/dashboard/a76/doda-alta-log';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: DodaAltaLog | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? 'Detalle de Alta DODA' : 'Nuevo Registro de Alta DODA');
|
||||
|
||||
let formData = $state<DodaAltaLogUpdateDTO>({
|
||||
status: null,
|
||||
message: null,
|
||||
result_json: null
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
error = null;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
if (item) {
|
||||
formData = {
|
||||
status: item.status ?? null,
|
||||
message: item.message ?? null,
|
||||
result_json: item.result_json ?? null
|
||||
};
|
||||
} else {
|
||||
formData = { status: null, message: null, result_json: null };
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
if (loading || !item) return;
|
||||
error = null;
|
||||
loading = true;
|
||||
try {
|
||||
const company = companyStore.activeCompany;
|
||||
if (!company) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
const res = await dodaAltaLogApi.update(item.id, formData, company.id);
|
||||
if (res.error) throw new Error(res.error);
|
||||
|
||||
open = false;
|
||||
onSuccess?.();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error desconocido';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!item || !confirm('¿Está seguro de eliminar este registro de alta DODA?')) return;
|
||||
const company = companyStore.activeCompany;
|
||||
if (!company) return;
|
||||
loading = true;
|
||||
try {
|
||||
await dodaAltaLogApi.delete(item.id, company.id);
|
||||
open = false;
|
||||
onSuccess?.();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-[700px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
{#if item}
|
||||
<Dialog.Description>
|
||||
Registro #{item.id} — DODA {item.doda_id ?? '-'} — {(item.variant || 'doda').toUpperCase()}
|
||||
</Dialog.Description>
|
||||
{/if}
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-6 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Datos de solo lectura del DODA -->
|
||||
{#if item}
|
||||
<div class="grid grid-cols-2 gap-4 rounded-lg border bg-muted/30 p-4 text-sm">
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">No. Integración</p>
|
||||
<p class="font-medium">{item.integration_number || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">Responsable</p>
|
||||
<p class="font-medium">{item.responsible || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">Patente</p>
|
||||
<p class="font-medium">{item.patent || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">Aduana Despacho</p>
|
||||
<p class="font-medium">{item.dispatch_customs || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">Tipo Operación</p>
|
||||
<p class="font-medium">{item.operation_type || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">Task ID</p>
|
||||
<p class="font-mono text-xs break-all">{item.task_id || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">Fecha Alta</p>
|
||||
<p class="font-medium">
|
||||
{item.created_at
|
||||
? new Date(item.created_at).toLocaleString('es-MX')
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Campos editables -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-medium text-muted-foreground leading-none">Estado</h4>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="status">Estatus</Label>
|
||||
<Input
|
||||
id="status"
|
||||
value={formData.status ?? ''}
|
||||
oninput={(e) => (formData.status = (e.target as HTMLInputElement).value || null)}
|
||||
placeholder="pending / success / failed"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<Label for="message">Mensaje</Label>
|
||||
<Input
|
||||
id="message"
|
||||
value={formData.message ?? ''}
|
||||
oninput={(e) => (formData.message = (e.target as HTMLInputElement).value || null)}
|
||||
placeholder="Mensaje del servicio externo"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{#if item?.result_json}
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<Label>Resultado JSON</Label>
|
||||
<pre class="text-xs rounded-md border bg-muted p-3 overflow-auto max-h-48 whitespace-pre-wrap break-all">{(() => {
|
||||
try { return JSON.stringify(JSON.parse(item.result_json || '{}'), null, 2); }
|
||||
catch { return item.result_json || ''; }
|
||||
})()}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="flex justify-between gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onclick={handleDelete}
|
||||
disabled={loading || !item}
|
||||
>
|
||||
Eliminar
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" variant="outline" onclick={() => (open = false)} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
{#if isEdit}
|
||||
<Button type="button" onclick={handleSubmit} disabled={loading}>
|
||||
{#if loading}<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />{/if}
|
||||
Guardar
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Loader2, FileSpreadsheet } from 'lucide-svelte';
|
||||
import { m } from '$lib/i18n/messages';
|
||||
import { exportDodaList, type DodaExportFileFormat, type DodaExportDateMode } from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
companyId
|
||||
}: {
|
||||
open: boolean;
|
||||
companyId: number | undefined;
|
||||
} = $props();
|
||||
|
||||
function isoFirstDayOfMonth(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||
return `${y}-${mo}-01`;
|
||||
}
|
||||
|
||||
function isoToday(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${mo}-${day}`;
|
||||
}
|
||||
|
||||
let dateFrom = $state(isoFirstDayOfMonth());
|
||||
let dateTo = $state(isoToday());
|
||||
let fileFormat = $state<DodaExportFileFormat>('xls');
|
||||
/** Alineado al legado: “Imprimir Fecha Juliana” = fechas/hora en archivo numérico (date_mode=raw). */
|
||||
let printJulian = $state(false);
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
dateFrom = isoFirstDayOfMonth();
|
||||
dateTo = isoToday();
|
||||
fileFormat = 'xls';
|
||||
printJulian = false;
|
||||
}
|
||||
});
|
||||
|
||||
function dateModeValue(): DodaExportDateMode {
|
||||
return printJulian ? 'raw' : 'formatted';
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (!companyId) {
|
||||
toast.error('Seleccione una compañía.');
|
||||
return;
|
||||
}
|
||||
if (!dateFrom || !dateTo) {
|
||||
toast.error(m['sidebar.doda_alta.export_excel_invalid_dates']());
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await exportDodaList(companyId, {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
format: fileFormat,
|
||||
dateMode: dateModeValue()
|
||||
});
|
||||
open = false;
|
||||
toast.success(m['sidebar.doda_alta.export_excel_success']());
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
toast.error(msg || m['sidebar.doda_alta.export_excel_error']());
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="gap-0 overflow-hidden p-0 sm:max-w-md" showCloseButton={true}>
|
||||
<Dialog.Header class="sr-only">
|
||||
<Dialog.Title>{m['sidebar.doda_alta.export_excel_badge']()} — {m['sidebar.doda_alta.export_report_heading']()}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div
|
||||
class="bg-slate-900 py-2.5 pr-10 pl-4 text-center text-sm font-medium tracking-wide text-slate-50 dark:bg-slate-950"
|
||||
>
|
||||
{m['sidebar.doda_alta.export_excel_badge']()}
|
||||
</div>
|
||||
|
||||
<div class="space-y-5 px-6 pb-1 pt-5">
|
||||
<h2
|
||||
class="text-foreground text-center text-sm font-semibold uppercase leading-snug tracking-wide sm:text-base"
|
||||
>
|
||||
{m['sidebar.doda_alta.export_report_heading']()}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="doda-exp-from" class="text-foreground/90 font-medium">{m['sidebar.doda_alta.export_fecha_inicio']()}</Label>
|
||||
<input
|
||||
id="doda-exp-from"
|
||||
type="date"
|
||||
bind:value={dateFrom}
|
||||
class="border-input bg-background text-foreground flex h-9 w-full rounded-md border px-3 text-sm shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="doda-exp-to" class="text-foreground/90 font-medium">{m['sidebar.doda_alta.export_fecha_final']()}</Label>
|
||||
<input
|
||||
id="doda-exp-to"
|
||||
type="date"
|
||||
bind:value={dateTo}
|
||||
class="border-input bg-background text-foreground flex h-9 w-full rounded-md border px-3 text-sm shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<Button
|
||||
size="default"
|
||||
class="w-full min-w-48"
|
||||
variant="default"
|
||||
onclick={download}
|
||||
disabled={busy || !companyId}
|
||||
>
|
||||
{#if busy}
|
||||
<Loader2 class="mr-2 h-4 w-4 shrink-0 animate-spin" />
|
||||
{:else}
|
||||
<FileSpreadsheet class="mr-2 h-4 w-4 shrink-0" />
|
||||
{/if}
|
||||
{m['sidebar.doda_alta.export_report_generar']()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2.5">
|
||||
<Checkbox id="doda-exp-julian" bind:checked={printJulian} class="mt-0.5" />
|
||||
<Label for="doda-exp-julian" class="text-muted-foreground text-sm font-normal leading-snug">
|
||||
{m['sidebar.doda_alta.export_julian_label']()}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-3">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Label for="doda-exp-format" class="text-foreground/80 shrink-0 text-sm"
|
||||
>{m['sidebar.doda_alta.export_format']()}</Label
|
||||
>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={fileFormat}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'csv' || v === 'xls' || v === 'txt') fileFormat = v;
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="doda-exp-format" class="w-full min-w-0 sm:max-w-[12rem]">
|
||||
.{fileFormat}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="csv">.csv (coma)</Select.Item>
|
||||
<Select.Item value="xls">.xls (tabulador, Excel)</Select.Item>
|
||||
<Select.Item value="txt">.txt (|)</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="border-t px-6 py-4 sm:justify-end">
|
||||
<Button type="button" variant="outline" class="min-w-24" onclick={() => (open = false)} disabled={busy}>
|
||||
{m['sidebar.doda_alta.export_cancel']()}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,217 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import { Loader2, CheckCircle, XCircle } from 'lucide-svelte';
|
||||
import {
|
||||
getDodaAltaStatus,
|
||||
type DodaAltaStatusResponse
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
import { m } from '$lib/i18n/messages';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
taskId,
|
||||
dodaId,
|
||||
variant = 'doda',
|
||||
onComplete,
|
||||
onCancel
|
||||
}: {
|
||||
open: boolean;
|
||||
taskId: string;
|
||||
dodaId?: number;
|
||||
variant?: 'doda' | 'pita';
|
||||
onComplete?: (result: DodaAltaStatusResponse) => void;
|
||||
onCancel?: () => void;
|
||||
} = $props();
|
||||
|
||||
type TaskState = 'PENDING' | 'PROGRESS' | 'SUCCESS' | 'FAILURE' | 'STARTED';
|
||||
|
||||
let state = $state<TaskState>('PENDING');
|
||||
let currentStep = $state<string>('Iniciando...');
|
||||
let progress = $state(0);
|
||||
let result = $state<DodaAltaStatusResponse | null>(null);
|
||||
let errorMsg = $state<string | null>(null);
|
||||
let consecutivePollErrors = $state(0);
|
||||
let pollHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
let pollInFlight = false;
|
||||
let pollingActive = false;
|
||||
let pollingTaskId: string | null = null;
|
||||
|
||||
$effect(() => {
|
||||
if (open && taskId) {
|
||||
void startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
if (!open) resetState();
|
||||
}
|
||||
return () => stopPolling();
|
||||
});
|
||||
|
||||
function resetState() {
|
||||
state = 'PENDING';
|
||||
currentStep = 'Iniciando...';
|
||||
progress = 0;
|
||||
result = null;
|
||||
errorMsg = null;
|
||||
consecutivePollErrors = 0;
|
||||
}
|
||||
|
||||
async function startPolling() {
|
||||
if (pollingActive && pollingTaskId === taskId) return;
|
||||
stopPolling();
|
||||
pollingActive = true;
|
||||
pollingTaskId = taskId;
|
||||
await poll();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
pollingActive = false;
|
||||
pollingTaskId = null;
|
||||
if (pollHandle !== null) {
|
||||
clearTimeout(pollHandle);
|
||||
pollHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextPoll(delayMs = 2000) {
|
||||
if (!pollingActive || !taskId) return;
|
||||
if (pollHandle !== null) clearTimeout(pollHandle);
|
||||
pollHandle = setTimeout(() => void poll(), delayMs);
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!taskId || !pollingActive || pollInFlight) return;
|
||||
pollInFlight = true;
|
||||
try {
|
||||
const res = await getDodaAltaStatus(taskId);
|
||||
|
||||
if (res.error) {
|
||||
consecutivePollErrors += 1;
|
||||
if (consecutivePollErrors >= 3) {
|
||||
state = 'FAILURE';
|
||||
errorMsg = res.error || 'No se pudo consultar el estado del alta DODA';
|
||||
stopPolling();
|
||||
} else {
|
||||
scheduleNextPoll();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.data) {
|
||||
scheduleNextPoll();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
consecutivePollErrors = 0;
|
||||
|
||||
const rawState = (data.state || data.status || 'PENDING').toUpperCase();
|
||||
state = (rawState === 'FAILED' ? 'FAILURE' : rawState) as TaskState;
|
||||
currentStep = (data.message as string) || 'Procesando...';
|
||||
|
||||
if (typeof (data as Record<string, unknown>)['progress'] === 'number') {
|
||||
progress = (data as Record<string, unknown>)['progress'] as number;
|
||||
}
|
||||
|
||||
if (rawState === 'SUCCESS') {
|
||||
result = data;
|
||||
stopPolling();
|
||||
onComplete?.(data);
|
||||
} else if (rawState === 'FAILURE' || rawState === 'FAILED') {
|
||||
errorMsg = data.error || data.message || 'Error en el alta DODA';
|
||||
stopPolling();
|
||||
} else {
|
||||
scheduleNextPoll();
|
||||
}
|
||||
} catch {
|
||||
consecutivePollErrors += 1;
|
||||
if (consecutivePollErrors >= 3) {
|
||||
state = 'FAILURE';
|
||||
errorMsg = 'No se pudo consultar el estado del alta DODA';
|
||||
stopPolling();
|
||||
} else {
|
||||
scheduleNextPoll();
|
||||
}
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
stopPolling();
|
||||
open = false;
|
||||
onCancel?.();
|
||||
}
|
||||
|
||||
const isTerminal = $derived(state === 'SUCCESS' || state === 'FAILURE');
|
||||
const variantLabel = $derived(variant === 'pita' ? 'PITA' : 'DODA');
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m['sidebar.doda_alta.progress_title']()}</Dialog.Title>
|
||||
<Dialog.Description>Alta {variantLabel} — Task ID: {taskId}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4 py-2">
|
||||
{#if state === 'SUCCESS'}
|
||||
<div class="flex items-center gap-3 text-green-600">
|
||||
<CheckCircle class="h-6 w-6 shrink-0" />
|
||||
<p class="text-sm font-medium">{m['sidebar.doda_alta.progress_success']()}</p>
|
||||
</div>
|
||||
{#if result}
|
||||
<dl class="text-sm space-y-1">
|
||||
{#each Object.entries(result) as [key, value]}
|
||||
{#if key !== 'state' && value && typeof value === 'string'}
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-muted-foreground min-w-[130px] capitalize">{key.replace(/_/g, ' ')}:</dt>
|
||||
<dd class="font-medium break-all">{value}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
{:else if state === 'FAILURE'}
|
||||
<div class="flex items-start gap-3 text-destructive">
|
||||
<XCircle class="h-6 w-6 shrink-0 mt-0.5" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{m['sidebar.doda_alta.progress_error']()}</p>
|
||||
{#if errorMsg}
|
||||
<p class="text-xs text-muted-foreground">{errorMsg}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<Loader2 class="h-5 w-5 animate-spin text-primary shrink-0" />
|
||||
<p class="text-sm text-muted-foreground">{currentStep}</p>
|
||||
</div>
|
||||
{#if progress > 0}
|
||||
<Progress value={progress} max={100} class="h-2" />
|
||||
<p class="text-xs text-right text-muted-foreground">{progress}%</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if taskId}
|
||||
<div class="rounded-md border bg-muted/30 px-3 py-2">
|
||||
<p class="text-xs text-muted-foreground">Task ID:</p>
|
||||
<p class="text-xs font-mono break-all">{taskId}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="flex justify-end">
|
||||
{#if isTerminal}
|
||||
<Button onclick={() => { open = false; }}>Cerrar</Button>
|
||||
{:else}
|
||||
<Button variant="outline" onclick={handleCancel}>Cancelar</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import { Plus, Pencil, Trash2, Inbox } from 'lucide-svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Column {
|
||||
@@ -17,6 +17,10 @@
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
/** Se dispara al elegir una fila (click / Enter). Útil p. ej. para precintos por contenedor. */
|
||||
onRowSelect,
|
||||
/** p. ej. `max-h-48` para limitar altura y scroll interno en tablas amplias */
|
||||
bodyMaxClass = '',
|
||||
class: className = ''
|
||||
}: {
|
||||
title?: string;
|
||||
@@ -25,43 +29,93 @@
|
||||
onAdd?: () => void;
|
||||
onEdit?: (item: any, index: number) => void;
|
||||
onDelete?: (item: any, index: number) => void;
|
||||
onRowSelect?: (item: any, index: number) => void;
|
||||
bodyMaxClass?: string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let selectedIndex = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (data.length === 0) {
|
||||
selectedIndex = null;
|
||||
} else if (selectedIndex != null && selectedIndex >= data.length) {
|
||||
selectedIndex = data.length - 1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={cn('space-y-4 rounded-xl border bg-card p-4 shadow-sm', className)}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class={cn(
|
||||
'space-y-0 overflow-hidden rounded-lg border border-border/60 bg-card/80 p-0 shadow-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div class="flex items-center border-b border-border/50 bg-muted/25 px-3 py-1.5">
|
||||
{#if title}
|
||||
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">{title}</h3>
|
||||
<h3 class="text-xs font-medium text-muted-foreground">{title}</h3>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="relative overflow-hidden rounded-md border bg-background">
|
||||
<div
|
||||
class={cn(
|
||||
'relative overflow-x-auto border-b border-border/40 bg-background/50',
|
||||
bodyMaxClass
|
||||
)}
|
||||
>
|
||||
<Table.Root>
|
||||
<Table.Header class="bg-muted/50">
|
||||
<Table.Row inTabOrder={false}>
|
||||
<Table.Header class="bg-muted/40">
|
||||
<Table.Row inTabOrder={false} class="hover:bg-transparent">
|
||||
{#each columns as col}
|
||||
<Table.Head class="h-10 px-4 text-xs font-semibold whitespace-nowrap"
|
||||
>{col.header}</Table.Head
|
||||
<Table.Head
|
||||
class="h-7 px-2 py-1.5 text-left text-[0.65rem] font-medium uppercase leading-tight text-muted-foreground sm:px-3 sm:text-[0.7rem]"
|
||||
>
|
||||
{col.header}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if data.length === 0}
|
||||
<Table.Row inTabOrder={false}>
|
||||
<Table.Row inTabOrder={false} class="hover:bg-transparent">
|
||||
<Table.Cell
|
||||
colspan={columns.length}
|
||||
class="h-24 text-center text-sm text-muted-foreground"
|
||||
class="h-16 py-2 text-center"
|
||||
>
|
||||
No hay registros.
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-1 text-muted-foreground"
|
||||
>
|
||||
<Inbox class="h-5 w-5 opacity-45" />
|
||||
<span class="text-xs">Sin filas. «Nuevo» para añadir.</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each data as row, i}
|
||||
<Table.Row inTabOrder={false} class="group transition-colors hover:bg-muted/30">
|
||||
<Table.Row
|
||||
inTabOrder={false}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-state={selectedIndex === i ? 'selected' : undefined}
|
||||
class="group transition-colors hover:bg-muted/30 {selectedIndex === i
|
||||
? 'bg-primary/5 ring-1 ring-inset ring-primary/25'
|
||||
: ''}"
|
||||
onclick={() => {
|
||||
selectedIndex = i;
|
||||
onRowSelect?.(row, i);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectedIndex = i;
|
||||
onRowSelect?.(row, i);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#each columns as col}
|
||||
<Table.Cell class="px-4 py-2 text-sm whitespace-nowrap">
|
||||
<Table.Cell
|
||||
class="max-w-[12rem] truncate px-2 py-1.5 text-xs sm:max-w-[14rem] sm:px-3 sm:text-sm"
|
||||
>
|
||||
{#if col.render}
|
||||
{col.render(row[col.key])}
|
||||
{:else}
|
||||
@@ -76,17 +130,21 @@
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onclick={onAdd} class="h-8 gap-1 px-3 text-xs font-medium">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 border-t border-border/30 bg-muted/20 px-3 py-2 sm:px-4">
|
||||
<Button variant="outline" size="sm" onclick={onAdd} class="h-8 gap-1.5 px-3 text-xs font-medium">
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
Nuevo
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => {}}
|
||||
onclick={() => {
|
||||
if (selectedIndex == null) return;
|
||||
const row = data[selectedIndex];
|
||||
if (row) onEdit?.(row, selectedIndex);
|
||||
}}
|
||||
class="h-8 gap-1 px-3 text-xs font-medium"
|
||||
disabled={data.length === 0}
|
||||
disabled={data.length === 0 || selectedIndex == null}
|
||||
>
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
Editar
|
||||
@@ -94,9 +152,13 @@
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => {}}
|
||||
onclick={() => {
|
||||
if (selectedIndex == null) return;
|
||||
const row = data[selectedIndex];
|
||||
if (row) onDelete?.(row, selectedIndex);
|
||||
}}
|
||||
class="h-8 gap-1 px-3 text-xs font-medium"
|
||||
disabled={data.length === 0}
|
||||
disabled={data.length === 0 || selectedIndex == null}
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
Borrar
|
||||
|
||||
@@ -3,120 +3,153 @@ import type { Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
import { renderSnippet } from '$lib/components/ui/data-table';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
|
||||
function formatDate(date?: string | null): string {
|
||||
if (!date) return '-';
|
||||
// Supposing created_at is an ISO string or similar
|
||||
/**
|
||||
* doda_date se almacena como Integer con formato YYYYMMDD (ej. 20180409).
|
||||
* Lo convertimos a DD/MM/YYYY para mostrar.
|
||||
*/
|
||||
function formatDodaDate(val?: number | string | null): string {
|
||||
if (!val) return '-';
|
||||
const s = String(val);
|
||||
if (s.length === 8) {
|
||||
const y = s.slice(0, 4);
|
||||
const m = s.slice(4, 6);
|
||||
const d = s.slice(6, 8);
|
||||
return `${d}/${m}/${y}`;
|
||||
}
|
||||
// Fallback: ISO string
|
||||
try {
|
||||
return new Date(date).toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
return new Date(s).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
year: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
return date;
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_CLASSES: Record<string, string> = {
|
||||
GENERADO: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
'EN PROCESO':'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
VALIDADO: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||
PENDIENTE: 'bg-gray-100 text-gray-700 dark:bg-gray-800/50 dark:text-gray-300',
|
||||
ELIMINADO: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
};
|
||||
|
||||
export function createColumns(): ColumnDef<Doda>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'Folio',
|
||||
size: 70,
|
||||
cell: ({ row }) => {
|
||||
const numberSnippet = createRawSnippet<[{ number: number }]>((getProps) => {
|
||||
const { number } = getProps();
|
||||
return {
|
||||
render: () =>
|
||||
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${number}</code>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(numberSnippet, { number: row.original.id });
|
||||
const n = row.original.id;
|
||||
const s = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<code class="rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${n}</code>`
|
||||
}));
|
||||
return renderSnippet(s, {});
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Fecha doda',
|
||||
accessorKey: 'doda_date',
|
||||
header: 'Fecha Doda',
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const dateSnippet = createRawSnippet<[{ date: string }]>((getProps) => {
|
||||
const { date } = getProps();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="text-sm text-muted-foreground">${date}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(dateSnippet, { date: formatDate(row.original.created_at) });
|
||||
const d = formatDodaDate(row.original.doda_date);
|
||||
const s = createRawSnippet(() => ({
|
||||
render: () => `<span class="text-sm tabular-nums">${d}</span>`
|
||||
}));
|
||||
return renderSnippet(s, {});
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'dispatch_customs',
|
||||
header: 'Desp',
|
||||
cell: ({ row }) => row.original.dispatch_customs || 'N/A'
|
||||
header: 'Desp.',
|
||||
size: 60,
|
||||
cell: ({ row }) => row.original.dispatch_customs || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'patent',
|
||||
header: 'Patente',
|
||||
cell: ({ row }) => row.original.patent || 'N/A'
|
||||
size: 70,
|
||||
cell: ({ row }) => row.original.patent || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'pedimentos',
|
||||
header: 'Pedimento(s)',
|
||||
cell: ({ row }) => row.original.pedimentos || 'N/A'
|
||||
cell: ({ row }) => {
|
||||
const v = row.original.pedimentos || '-';
|
||||
const s = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<span class="block truncate max-w-[160px]" title="${v}">${v}</span>`
|
||||
}));
|
||||
return renderSnippet(s, {});
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'shipments',
|
||||
header: 'Remesa(s)',
|
||||
cell: ({ row }) => row.original.shipments || 'N/A'
|
||||
size: 90,
|
||||
cell: ({ row }) => row.original.shipments || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'integration_number',
|
||||
header: 'Integracion',
|
||||
header: 'Integración',
|
||||
size: 110,
|
||||
cell: ({ row }) => {
|
||||
const numberSnippet = createRawSnippet<[{ number?: string | null }]>((getProps) => {
|
||||
const { number } = getProps();
|
||||
return {
|
||||
render: () =>
|
||||
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${number || 'N/A'}</code>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(numberSnippet, { number: row.original.integration_number });
|
||||
const v = row.original.integration_number;
|
||||
const s = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
v
|
||||
? `<code class="rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${v}</code>`
|
||||
: `<span class="text-muted-foreground">-</span>`
|
||||
}));
|
||||
return renderSnippet(s, {});
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'transaction_number',
|
||||
header: 'No transaccion',
|
||||
cell: ({ row }) => row.original.transaction_number || 'N/A'
|
||||
header: 'No. Transacción',
|
||||
cell: ({ row }) => {
|
||||
const v = row.original.transaction_number || '-';
|
||||
const s = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<span class="font-mono text-xs block truncate max-w-[160px]" title="${v}">${v}</span>`
|
||||
}));
|
||||
return renderSnippet(s, {});
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'transport_identification',
|
||||
header: 'Id transporte',
|
||||
cell: ({ row }) => row.original.transport_identification || 'N/A'
|
||||
header: 'Id. Transporte',
|
||||
size: 120,
|
||||
cell: ({ row }) => row.original.transport_identification || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'caat',
|
||||
header: 'CAAT',
|
||||
cell: ({ row }) => row.original.caat || 'N/A'
|
||||
size: 70,
|
||||
cell: ({ row }) => row.original.caat || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_user',
|
||||
header: 'Usuario',
|
||||
cell: ({ row }) => row.original.last_user || 'N/A'
|
||||
size: 90,
|
||||
cell: ({ row }) => row.original.last_user || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Estatus',
|
||||
size: 110,
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status;
|
||||
const statusSnippet = createRawSnippet<[{ status?: string | null }]>((getProps) => {
|
||||
const { status } = getProps();
|
||||
const colorClass = status === 'VALIDADO' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
|
||||
return {
|
||||
render: () =>
|
||||
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">
|
||||
${status || '-'}
|
||||
</span>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(statusSnippet, { status });
|
||||
const status = (row.original.status || '').toUpperCase();
|
||||
const cls = STATUS_CLASSES[status] ?? 'bg-gray-100 text-gray-700 dark:bg-gray-800/50 dark:text-gray-300';
|
||||
const s = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap ${cls}">${status || '-'}</span>`
|
||||
}));
|
||||
return renderSnippet(s, {});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { createDoda, updateDoda, type Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
|
||||
import { obtenerAtajosFormularioDoda } from '$lib/config/shortcuts/dashboard/general_catalogs/doda/edit';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Doda | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? `Editar DODA ${item?.integration_number || ''}` : 'Nuevo DODA');
|
||||
|
||||
// Estado del formulario
|
||||
let activeTab = $state('general');
|
||||
let formData = $state({
|
||||
integration_number: '',
|
||||
doda_date: undefined as number | undefined,
|
||||
doda_time: undefined as number | undefined,
|
||||
dispatch_customs: '',
|
||||
customs_sections: '',
|
||||
patent: '',
|
||||
pedimentos: '',
|
||||
caat: '',
|
||||
transport_identification: '',
|
||||
fast_id: '',
|
||||
operation_type: '',
|
||||
selected: false,
|
||||
user_selected: '',
|
||||
last_user: '',
|
||||
responsible: '',
|
||||
carrier: '',
|
||||
shipments: '',
|
||||
pedimento_type: '',
|
||||
original_chain: '',
|
||||
serial_number: '',
|
||||
electronic_signature: '',
|
||||
transaction_number: '',
|
||||
status: '',
|
||||
linq_sat_qr: '',
|
||||
sat_certificate: '',
|
||||
sat_digital_seal: '',
|
||||
xml_doda_sent_path: '',
|
||||
xml_doda_response_path: '',
|
||||
sat_original_chain: '',
|
||||
customs_clearance: undefined as number | undefined,
|
||||
unique_badge_number: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Atajos
|
||||
|
||||
// Cargar datos
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
activeTab = 'general';
|
||||
if (item) {
|
||||
formData = {
|
||||
integration_number: item.integration_number || '',
|
||||
doda_date: item.doda_date,
|
||||
doda_time: item.doda_time,
|
||||
dispatch_customs: item.dispatch_customs || '',
|
||||
customs_sections: item.customs_sections || '',
|
||||
patent: item.patent || '',
|
||||
pedimentos: item.pedimentos || '',
|
||||
caat: item.caat || '',
|
||||
transport_identification: item.transport_identification || '',
|
||||
fast_id: item.fast_id || '',
|
||||
operation_type: item.operation_type || '',
|
||||
selected: item.selected || false,
|
||||
user_selected: item.user_selected || '',
|
||||
last_user: item.last_user || '',
|
||||
responsible: item.responsible || '',
|
||||
carrier: item.carrier || '',
|
||||
shipments: item.shipments || '',
|
||||
pedimento_type: item.pedimento_type || '',
|
||||
original_chain: item.original_chain || '',
|
||||
serial_number: item.serial_number || '',
|
||||
electronic_signature: item.electronic_signature || '',
|
||||
transaction_number: item.transaction_number || '',
|
||||
status: item.status || '',
|
||||
linq_sat_qr: item.linq_sat_qr || '',
|
||||
sat_certificate: item.sat_certificate || '',
|
||||
sat_digital_seal: item.sat_digital_seal || '',
|
||||
xml_doda_sent_path: item.xml_doda_sent_path || '',
|
||||
xml_doda_response_path: item.xml_doda_response_path || '',
|
||||
sat_original_chain: item.sat_original_chain || '',
|
||||
customs_clearance: item.customs_clearance,
|
||||
unique_badge_number: item.unique_badge_number || ''
|
||||
};
|
||||
} else {
|
||||
// Reset
|
||||
formData = {
|
||||
integration_number: '',
|
||||
doda_date: undefined,
|
||||
doda_time: undefined,
|
||||
dispatch_customs: '',
|
||||
customs_sections: '',
|
||||
patent: '',
|
||||
pedimentos: '',
|
||||
caat: '',
|
||||
transport_identification: '',
|
||||
fast_id: '',
|
||||
operation_type: '',
|
||||
selected: false,
|
||||
user_selected: '',
|
||||
last_user: '',
|
||||
responsible: '',
|
||||
carrier: '',
|
||||
shipments: '',
|
||||
pedimento_type: '',
|
||||
original_chain: '',
|
||||
serial_number: '',
|
||||
electronic_signature: '',
|
||||
transaction_number: '',
|
||||
status: '',
|
||||
linq_sat_qr: '',
|
||||
sat_certificate: '',
|
||||
sat_digital_seal: '',
|
||||
xml_doda_sent_path: '',
|
||||
xml_doda_response_path: '',
|
||||
sat_original_chain: '',
|
||||
customs_clearance: undefined,
|
||||
unique_badge_number: ''
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
error = 'No hay compañía seleccionada';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const idToUpdate = item?.id;
|
||||
|
||||
if (isEdit && idToUpdate) {
|
||||
await updateDoda(idToUpdate, formData, companyId);
|
||||
} else {
|
||||
await createDoda(formData, companyId);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error = e instanceof Error ? e.message : 'Error al guardar DODA';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="py-4"
|
||||
>
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tabs.Root bind:value={activeTab} class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-4">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="transport">Aduana/Transp.</Tabs.Trigger>
|
||||
<Tabs.Trigger value="sat">SAT / Digital</Tabs.Trigger>
|
||||
<Tabs.Trigger value="other">Otros</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<!-- TAB: GENERAL -->
|
||||
<Tabs.Content value="general" class="space-y-4 py-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="integration_number">No. Integración</Label>
|
||||
<Input
|
||||
id="integration_number"
|
||||
bind:value={formData.integration_number}
|
||||
maxlength={30}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="status">Estatus</Label>
|
||||
<Input id="status" bind:value={formData.status} maxlength={30} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="doda_date">Fecha (YYYYMMDD)</Label>
|
||||
<Input type="number" id="doda_date" bind:value={formData.doda_date} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="doda_time">Hora (HHMMSS)</Label>
|
||||
<Input type="number" id="doda_time" bind:value={formData.doda_time} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="operation_type">Tipo Operación</Label>
|
||||
<Input id="operation_type" bind:value={formData.operation_type} maxlength={1} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="pedimentos">Pedimentos</Label>
|
||||
<Input id="pedimentos" bind:value={formData.pedimentos} maxlength={80} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="pedimento_type">Tipo Pedimento</Label>
|
||||
<Input id="pedimento_type" bind:value={formData.pedimento_type} maxlength={30} />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- TAB: ADUANA / TRANSPORTE -->
|
||||
<Tabs.Content value="transport" class="space-y-4 py-4">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="patent">Patente</Label>
|
||||
<Input id="patent" bind:value={formData.patent} maxlength={4} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="dispatch_customs">Aduana Despacho</Label>
|
||||
<Input id="dispatch_customs" bind:value={formData.dispatch_customs} maxlength={3} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="customs_sections">Sección Aduanera</Label>
|
||||
<Input id="customs_sections" bind:value={formData.customs_sections} maxlength={3} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="caat">CAAT</Label>
|
||||
<Input id="caat" bind:value={formData.caat} maxlength={10} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="carrier">Transportista (Carrier)</Label>
|
||||
<Input id="carrier" bind:value={formData.carrier} maxlength={8} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="transport_identification">Ident. Transporte</Label>
|
||||
<Input
|
||||
id="transport_identification"
|
||||
bind:value={formData.transport_identification}
|
||||
maxlength={20}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="fast_id">FAST ID</Label>
|
||||
<Input id="fast_id" bind:value={formData.fast_id} maxlength={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="shipments">Embarques (Shipments)</Label>
|
||||
<Input id="shipments" bind:value={formData.shipments} maxlength={80} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="customs_clearance">Despacho Aduanero (ID)</Label>
|
||||
<Input type="number" id="customs_clearance" bind:value={formData.customs_clearance} />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- TAB: SAT / DIGITAL -->
|
||||
<Tabs.Content value="sat" class="space-y-4 py-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="serial_number">Número de Serie</Label>
|
||||
<Input id="serial_number" bind:value={formData.serial_number} maxlength={21} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="transaction_number">No. Transacción</Label>
|
||||
<Input
|
||||
id="transaction_number"
|
||||
bind:value={formData.transaction_number}
|
||||
maxlength={30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="unique_badge_number">Número Único de Gafete</Label>
|
||||
<Input
|
||||
id="unique_badge_number"
|
||||
bind:value={formData.unique_badge_number}
|
||||
maxlength={250}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="original_chain">Cadena Original</Label>
|
||||
<Textarea id="original_chain" bind:value={formData.original_chain} class="h-20" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="electronic_signature">Firma Electrónica</Label>
|
||||
<Textarea
|
||||
id="electronic_signature"
|
||||
bind:value={formData.electronic_signature}
|
||||
class="h-20"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="sat_digital_seal">Sello Digital SAT</Label>
|
||||
<Textarea id="sat_digital_seal" bind:value={formData.sat_digital_seal} class="h-20" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="sat_original_chain">Cadena Original SAT</Label>
|
||||
<Textarea
|
||||
id="sat_original_chain"
|
||||
bind:value={formData.sat_original_chain}
|
||||
class="h-20"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="xml_doda_sent_path">Ruta XML Enviado</Label>
|
||||
<Input id="xml_doda_sent_path" bind:value={formData.xml_doda_sent_path} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="xml_doda_response_path">Ruta XML Respuesta</Label>
|
||||
<Input id="xml_doda_response_path" bind:value={formData.xml_doda_response_path} />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- TAB: OTROS -->
|
||||
<Tabs.Content value="other" class="space-y-4 py-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch id="selected" bind:checked={formData.selected} />
|
||||
<Label for="selected">Seleccionado</Label>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="user_selected">Usuario Selección</Label>
|
||||
<Input id="user_selected" bind:value={formData.user_selected} maxlength={30} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="last_user">Último Usuario</Label>
|
||||
<Input id="last_user" bind:value={formData.last_user} maxlength={30} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="responsible">Responsable</Label>
|
||||
<Input id="responsible" bind:value={formData.responsible} maxlength={14} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="linq_sat_qr">LINQ SAT QR</Label>
|
||||
<Input id="linq_sat_qr" bind:value={formData.linq_sat_qr} maxlength={1000} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="sat_certificate">Certificado SAT</Label>
|
||||
<Input id="sat_certificate" bind:value={formData.sat_certificate} maxlength={2001} />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<Dialog.Footer class="mt-6">
|
||||
<Button type="button" variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -5,8 +5,8 @@
|
||||
import type { Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
import { deleteDoda } from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let {
|
||||
item,
|
||||
@@ -17,9 +17,6 @@
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Doda | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Está seguro de eliminar este registro DODA?')) {
|
||||
@@ -28,6 +25,7 @@
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
toast.error('Selecciona una compañía');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,19 +33,15 @@
|
||||
|
||||
try {
|
||||
await deleteDoda(item.id, companyId);
|
||||
toast.success('DODA eliminado correctamente');
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al eliminar el registro';
|
||||
console.error('Error deleting doda:', err);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Error al eliminar DODA';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
@@ -61,7 +55,7 @@
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={() => goto(`/dashboard/general_catalogs/doda/edit/${item.id}`)}>
|
||||
<DropdownMenu.Item onclick={() => goto(`/dashboard/general_catalogs/doda?doda_id=${item.id}`)}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
@@ -76,8 +70,3 @@
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={selectedItem}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
type WithId = { id: number | string };
|
||||
|
||||
export function applyOptimisticDelete<T extends WithId>(
|
||||
items: T[],
|
||||
total: number,
|
||||
deletedId: number | string
|
||||
): { items: T[]; total: number } {
|
||||
return {
|
||||
items: items.filter((item) => String(item.id) !== String(deletedId)),
|
||||
total: Math.max(0, total - 1)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Pedimento } from '$lib/api/dashboard/a76/pedimentos';
|
||||
import type { DodaPedimento, DodaPedimentoCreate } from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
|
||||
export type PedimentoDetailRow = DodaPedimentoCreate & { id?: number; pedimento_line?: number };
|
||||
|
||||
/**
|
||||
* Misma convención que digitalización: AA-Patente-Pedimento
|
||||
*/
|
||||
export function buildPedimentoLabel(pedimento: Pedimento): string {
|
||||
return `${pedimento.customs_office?.slice(0, 2) || ''}-${pedimento.license || ''}-${pedimento.pedimento_number || ''}`.replace(
|
||||
/^-+|-+$/g,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export function yyyymmddTodayInt(): number {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return Number(`${y}${m}${day}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincroniza el campo `shipments` a partir de remesas en detalle (coma-separado).
|
||||
*/
|
||||
export function syncShipmentsFromPedimentos(
|
||||
detail: Array<Pick<DodaPedimento | PedimentoDetailRow, 'shipment'>>
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
for (const p of detail) {
|
||||
const s = (p.shipment || '').toString().trim();
|
||||
if (s && !parts.includes(s)) parts.push(s);
|
||||
}
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Une etiquetas y números de documento al campo legado `pedimentos`.
|
||||
*/
|
||||
export function appendPedimentosString(
|
||||
current: string | undefined,
|
||||
segment: string
|
||||
): string {
|
||||
const next = (segment || '').trim();
|
||||
if (!next) return current || '';
|
||||
const cur = (current || '').trim();
|
||||
if (!cur) return next;
|
||||
if (cur.split(/[;,]/).map((s) => s.trim()).includes(next)) return cur;
|
||||
return `${cur}; ${next}`;
|
||||
}
|
||||
|
||||
export function pedimentoRowFromCatalog(
|
||||
p: Pedimento,
|
||||
authorizationPatent: string
|
||||
): { row: DodaPedimentoCreate; label: string } {
|
||||
const document =
|
||||
(p.pedimento_number && String(p.pedimento_number)) ||
|
||||
buildPedimentoLabel(p) ||
|
||||
'';
|
||||
return {
|
||||
row: {
|
||||
authorization_patent: (authorizationPatent || p.license || '').trim() || undefined,
|
||||
document: document
|
||||
},
|
||||
label: buildPedimentoLabel(p)
|
||||
};
|
||||
}
|
||||
|
||||
/** `customs_clearance === 1` = PITA (legacy DespachoAduanero '3'): no aplica catálogo de tipo americano. */
|
||||
export function isPitaCustomsClearance(customsClearance: number | undefined | null): boolean {
|
||||
return customsClearance === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida `american_pedimento_type` frente a `operation_type` (legacy Clarion).
|
||||
* Importación: tipos 1–5. Exportación: tipos 6–8.
|
||||
*/
|
||||
export function validateAmericanPedimentoTipo(
|
||||
operationType: string | undefined,
|
||||
tipo: string | undefined
|
||||
): string | null {
|
||||
const t = (tipo || '').trim();
|
||||
if (!t) return 'El tipo de pedimento americano es obligatorio.';
|
||||
|
||||
const op = (operationType || '').trim().toUpperCase();
|
||||
const isImport = op === 'I' || op === '1';
|
||||
const isExport = op === 'E' || op === '2';
|
||||
|
||||
if (isImport) {
|
||||
if (!['1', '2', '3', '4', '5'].includes(t)) {
|
||||
return 'El tipo de pedimento americano no es correcto para importación (debe ser 1, 2, 3, 4 o 5).';
|
||||
}
|
||||
} else if (isExport) {
|
||||
if (!['6', '7', '8'].includes(t)) {
|
||||
return 'El tipo de pedimento americano no es correcto para exportación (debe ser 6, 7 u 8).';
|
||||
}
|
||||
} else {
|
||||
return 'Define el tipo de operación (I/E) antes de validar el pedimento americano.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import {
|
||||
Hash,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Settings2,
|
||||
Shield,
|
||||
Ship,
|
||||
@@ -283,10 +284,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",
|
||||
@@ -519,10 +516,19 @@ export function getSidebarData(): SidebarData {
|
||||
],
|
||||
},
|
||||
{
|
||||
title: m["sidebar.digitalizacion.title"](),
|
||||
url: "/dashboard/digitalizacion",
|
||||
icon: FolderArchive,
|
||||
items: [],
|
||||
title: m["sidebar.despacho.title"](),
|
||||
url: "#",
|
||||
icon: PackageCheck,
|
||||
items: [
|
||||
{
|
||||
title: m["sidebar.despacho.digitalizacion"](),
|
||||
url: "/dashboard/digitalizacion",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.despacho.doda"](),
|
||||
url: "/dashboard/despacho/doda",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: m["sidebar.reference_data.configuracion"](),
|
||||
|
||||
@@ -1,22 +1,46 @@
|
||||
import type { ShortcutDef } from '$lib/stores/shortcut-store';
|
||||
|
||||
export const obtenerAtajosFormularioDoda = (acciones: {
|
||||
cambiarPestana: (pestana: string) => void;
|
||||
manejarGuardar: () => void;
|
||||
manejarCerrar: () => void;
|
||||
cambiarPestana: (pestana: string) => void;
|
||||
manejarGuardar: () => void;
|
||||
manejarCerrar: () => void;
|
||||
}): ShortcutDef[] => [
|
||||
{ key: 'Alt+Digit1', description: 'Pestaña General', action: () => acciones.cambiarPestana('general') },
|
||||
{ key: 'Alt+Digit2', description: 'Pestaña Aduana/Transp.', action: () => acciones.cambiarPestana('transport') },
|
||||
{ key: 'Alt+Digit3', description: 'Pestaña SAT / Digital', action: () => acciones.cambiarPestana('sat') },
|
||||
{ key: 'Alt+Digit4', description: 'Pestaña Otros', action: () => acciones.cambiarPestana('other') },
|
||||
{
|
||||
key: 'Ctrl+S',
|
||||
description: 'Guardar DODA',
|
||||
action: () => acciones.manejarGuardar()
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
description: 'Cerrar / Cancelar',
|
||||
action: acciones.manejarCerrar
|
||||
}
|
||||
];
|
||||
{ key: 'Alt+Digit1', description: 'Pestaña General', action: () => acciones.cambiarPestana('general') },
|
||||
{ key: 'Alt+Digit2', description: 'Pestaña Aduana/Transp.', action: () => acciones.cambiarPestana('transport') },
|
||||
{ key: 'Alt+Digit3', description: 'Pestaña SAT / Digital', action: () => acciones.cambiarPestana('sat') },
|
||||
{ key: 'Alt+Digit4', description: 'Pestaña Otros', action: () => acciones.cambiarPestana('other') },
|
||||
{
|
||||
key: 'Ctrl+S',
|
||||
description: 'Guardar DODA',
|
||||
action: () => acciones.manejarGuardar()
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
description: 'Cerrar / Cancelar',
|
||||
action: acciones.manejarCerrar
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Atajos específicos para el formulario completo en pantalla
|
||||
* (modal de la lista con `?doda_id=`), donde solo existen
|
||||
* las pestañas General y Sellos.
|
||||
*/
|
||||
export const obtenerAtajosFormularioDodaPagina = (acciones: {
|
||||
cambiarPestana: (pestana: string) => void;
|
||||
manejarGuardar: () => void;
|
||||
manejarCerrar: () => void;
|
||||
}): ShortcutDef[] => [
|
||||
{ key: 'Alt+Digit1', description: 'Pestaña General', action: () => acciones.cambiarPestana('general') },
|
||||
{ key: 'Alt+Digit2', description: 'Pestaña Sellos', action: () => acciones.cambiarPestana('sellos') },
|
||||
{
|
||||
key: 'Ctrl+S',
|
||||
description: 'Guardar DODA',
|
||||
action: () => acciones.manejarGuardar()
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
description: 'Cerrar / Cancelar',
|
||||
action: acciones.manejarCerrar
|
||||
}
|
||||
];
|
||||
|
||||
68
frontend/src/routes/dashboard/despacho/doda/+page.server.ts
Normal file
68
frontend/src/routes/dashboard/despacho/doda/+page.server.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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);
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
error: 'No authenticated',
|
||||
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const page = Number(url.searchParams.get('page')) || 1;
|
||||
const pageSize = Number(url.searchParams.get('pageSize')) || 50;
|
||||
|
||||
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 }
|
||||
};
|
||||
}
|
||||
|
||||
const filters: Record<string, string> = {};
|
||||
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');
|
||||
|
||||
if (integrationNumber) filters.integration_number = integrationNumber;
|
||||
if (patent) filters.patent = patent;
|
||||
if (status) filters.status = status;
|
||||
if (operationType) filters.operation_type = operationType;
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
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 } };
|
||||
}
|
||||
};
|
||||
374
frontend/src/routes/dashboard/despacho/doda/+page.svelte
Normal file
374
frontend/src/routes/dashboard/despacho/doda/+page.svelte
Normal file
@@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { m } from '$lib/i18n/messages';
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
RotateCcw,
|
||||
Send,
|
||||
Loader2,
|
||||
FileSpreadsheet
|
||||
} from 'lucide-svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { isPitaCustomsClearance } from '$lib/components/dashboard/general_catalogs/doda/doda-form-helpers';
|
||||
|
||||
import DataTable from '$lib/components/dashboard/general_catalogs/doda/data-table.svelte';
|
||||
import { createColumns } from '$lib/components/dashboard/general_catalogs/doda/columns';
|
||||
import DodaProgressDialog from '$lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte';
|
||||
import DodaExportExcelDialog from '$lib/components/dashboard/despacho/doda/doda-export-excel-dialog.svelte';
|
||||
import { applyOptimisticDelete } from '$lib/components/dashboard/general_catalogs/doda/delete-list-state';
|
||||
|
||||
import {
|
||||
getDodas,
|
||||
deleteDoda,
|
||||
postDodaAlta,
|
||||
getDodaElegibilidad,
|
||||
type Doda
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let allDodas = $state<Doda[]>(data.dodas?.items || []);
|
||||
let dodaPage = $state(data.dodas?.page || 1);
|
||||
let dodaPageSize = $state(50);
|
||||
let dodaTotal = $state(data.dodas?.total || 0);
|
||||
let dodaLoading = $state(false);
|
||||
let dodaHasMore = $derived(allDodas.length < dodaTotal);
|
||||
|
||||
let filters = $state({
|
||||
integration_number: $page.url.searchParams.get('integration_number') || '',
|
||||
patent: $page.url.searchParams.get('patent') || '',
|
||||
status: $page.url.searchParams.get('status') || '',
|
||||
operation_type: $page.url.searchParams.get('operation_type') || ''
|
||||
});
|
||||
|
||||
let dodaFilterTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
let selectedDodaIds = $state<(string | number)[]>([]);
|
||||
const selectedDoda = $derived(
|
||||
selectedDodaIds.length === 1
|
||||
? allDodas.find((item) => String(item.id) === String(selectedDodaIds[0])) ?? null
|
||||
: null
|
||||
);
|
||||
const altaVariant = $derived<'doda' | 'pita'>(
|
||||
selectedDoda && isPitaCustomsClearance(selectedDoda.customs_clearance) ? 'pita' : 'doda'
|
||||
);
|
||||
|
||||
let progressDialogOpen = $state(false);
|
||||
let exportDialogOpen = $state(false);
|
||||
let currentTaskId = $state('');
|
||||
let currentVariant = $state<'doda' | 'pita'>('doda');
|
||||
let altaLoading = $state(false);
|
||||
let deleteLoading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (data.dodas) {
|
||||
allDodas = data.dodas.items || [];
|
||||
dodaPage = data.dodas.page || 1;
|
||||
dodaTotal = data.dodas.total || 0;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const _ = { ...filters };
|
||||
clearTimeout(dodaFilterTimeout);
|
||||
dodaFilterTimeout = setTimeout(() => reloadDodas(), 400);
|
||||
});
|
||||
|
||||
async function reloadDodas() {
|
||||
if (!browser) return;
|
||||
dodaLoading = true;
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
const active = Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== ''));
|
||||
const res = await getDodas(1, dodaPageSize, active, Number(companyId));
|
||||
if (res.data) {
|
||||
allDodas = res.data.items;
|
||||
dodaPage = 1;
|
||||
dodaTotal = res.data.total;
|
||||
selectedDodaIds = [];
|
||||
}
|
||||
} catch {
|
||||
if (allDodas.length > 0) toast.error('Error al recargar DODAs');
|
||||
} finally {
|
||||
dodaLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreDodas() {
|
||||
if (dodaLoading || !dodaHasMore) return;
|
||||
dodaLoading = true;
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
const active = Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== ''));
|
||||
const res = await getDodas(dodaPage + 1, dodaPageSize, active, Number(companyId));
|
||||
if (res.data) {
|
||||
allDodas = [...allDodas, ...res.data.items];
|
||||
dodaPage++;
|
||||
dodaTotal = res.data.total;
|
||||
}
|
||||
} finally {
|
||||
dodaLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
if (selectedDoda) {
|
||||
void goto(`/dashboard/general_catalogs/doda?doda_id=${selectedDoda.id}`, { noScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (deleteLoading) return;
|
||||
if (!companyStore.activeCompany) {
|
||||
toast.error(m['sidebar.doda_alta.delete_missing_company']());
|
||||
return;
|
||||
}
|
||||
if (selectedDodaIds.length !== 1) {
|
||||
toast.error(m['sidebar.doda_alta.delete_select_one']());
|
||||
return;
|
||||
}
|
||||
if (!selectedDoda) {
|
||||
toast.error(m['sidebar.doda_alta.delete_not_found']());
|
||||
return;
|
||||
}
|
||||
if (!confirm(m['sidebar.doda_alta.confirm_delete']())) return;
|
||||
deleteLoading = true;
|
||||
try {
|
||||
const deletedId = selectedDoda.id;
|
||||
await deleteDoda(deletedId, companyStore.activeCompany.id);
|
||||
toast.success(m['sidebar.doda_alta.delete_success']());
|
||||
const next = applyOptimisticDelete(allDodas, dodaTotal, deletedId);
|
||||
allDodas = next.items;
|
||||
dodaTotal = next.total;
|
||||
selectedDodaIds = [];
|
||||
await reloadDodas();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : m['sidebar.doda_alta.delete_error']();
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
deleteLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filters = { integration_number: '', patent: '', status: '', operation_type: '' };
|
||||
}
|
||||
|
||||
async function handleAlta() {
|
||||
if (!selectedDoda || !companyStore.activeCompany) return;
|
||||
const companyId = companyStore.activeCompany.id;
|
||||
const dodaId = selectedDoda.id;
|
||||
const variant = altaVariant;
|
||||
altaLoading = true;
|
||||
try {
|
||||
const elig = await getDodaElegibilidad(dodaId, companyId, variant);
|
||||
if (elig.error) {
|
||||
toast.error(`Error al verificar elegibilidad: ${elig.error}`);
|
||||
return;
|
||||
}
|
||||
if (elig.data && !elig.data.can_alta) {
|
||||
const msgs = elig.data.reasons.map((r) => `• ${r.message}`).join('\n');
|
||||
toast.error(msgs || m['sidebar.doda_alta.eligibility_error']());
|
||||
return;
|
||||
}
|
||||
const resp = await postDodaAlta(dodaId, companyId, variant);
|
||||
if (resp.error) {
|
||||
toast.error(`Error al enviar alta: ${resp.error}`);
|
||||
return;
|
||||
}
|
||||
currentTaskId = resp.data!.task_id;
|
||||
currentVariant = variant;
|
||||
progressDialogOpen = true;
|
||||
} finally {
|
||||
altaLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAltaComplete() {
|
||||
progressDialogOpen = false;
|
||||
reloadDodas();
|
||||
toast.success(m['sidebar.doda_alta.progress_success']());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
|
||||
<div class="flex-none flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-2xl font-bold tracking-tight">{m['sidebar.doda_alta.title']()}</h1>
|
||||
<p class="text-muted-foreground">{m['sidebar.doda_alta.subtitle']()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onclick={() => reloadDodas()} disabled={dodaLoading}>
|
||||
<RefreshCw class="mr-2 h-4 w-4 {dodaLoading ? 'animate-spin' : ''}" />
|
||||
{m['sidebar.doda_alta.refresh']()}
|
||||
</Button>
|
||||
<Button size="sm" onclick={() => goto('/dashboard/general_catalogs/doda?doda_id=new')}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
{m['sidebar.doda_alta.action_new']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card.Root class="flex min-h-0 flex-1 flex-col border bg-background">
|
||||
<Card.Header>
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<Card.Title>{m['sidebar.doda_alta.table_title']()}</Card.Title>
|
||||
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-[220px_180px_170px_170px_auto] xl:items-center">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={m['sidebar.doda_alta.filter_integration_number']()}
|
||||
bind:value={filters.integration_number}
|
||||
class="h-9 bg-card pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={m['sidebar.doda_alta.filter_patent']()}
|
||||
bind:value={filters.patent}
|
||||
class="h-9 bg-card"
|
||||
/>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={filters.status}
|
||||
onValueChange={(v) => (filters.status = v)}
|
||||
>
|
||||
<Select.Trigger class="h-9 w-full bg-card">
|
||||
{filters.status || m['sidebar.doda_alta.filter_status']()}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="">Todos</Select.Item>
|
||||
<Select.Item value="PENDIENTE">PENDIENTE</Select.Item>
|
||||
<Select.Item value="GENERADO">GENERADO</Select.Item>
|
||||
<Select.Item value="VALIDADO">VALIDADO</Select.Item>
|
||||
<Select.Item value="ELIMINADO">ELIMINADO</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={filters.operation_type}
|
||||
onValueChange={(v) => (filters.operation_type = v)}
|
||||
>
|
||||
<Select.Trigger class="h-9 w-full bg-card">
|
||||
{filters.operation_type === 'I'
|
||||
? 'Importación'
|
||||
: filters.operation_type === 'E'
|
||||
? 'Exportación'
|
||||
: m['sidebar.doda_alta.filter_operation_type']()}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="">Todas</Select.Item>
|
||||
<Select.Item value="I">I - Importación</Select.Item>
|
||||
<Select.Item value="E">E - Exportación</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Button variant="outline" size="sm" class="h-9" onclick={clearFilters}>
|
||||
<RotateCcw class="mr-2 h-4 w-4" />
|
||||
Limpiar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="min-h-0 p-0">
|
||||
<div class="rounded-md border bg-background">
|
||||
<DataTable
|
||||
data={allDodas}
|
||||
columns={createColumns()}
|
||||
loading={dodaLoading}
|
||||
hasMore={dodaHasMore}
|
||||
loadMore={loadMoreDodas}
|
||||
selectedId={selectedDodaIds.length === 1 ? selectedDodaIds[0] : null}
|
||||
onRowClick={(row) => {
|
||||
selectedDodaIds = selectedDodaIds.includes(row.id) ? [] : [row.id];
|
||||
}}
|
||||
onRowDoubleClick={(item) =>
|
||||
goto(`/dashboard/general_catalogs/doda?doda_id=${item.id}`, { noScroll: true })}
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<div class="flex-none text-sm text-muted-foreground pt-2">
|
||||
Mostrando {allDodas.length} de {dodaTotal} registros
|
||||
<span class="ml-2">•</span>
|
||||
<span class="ml-2">Filtros activos: {Object.values(filters).filter((v) => v !== '').length}</span>
|
||||
</div>
|
||||
|
||||
<div class="h-20"></div>
|
||||
|
||||
<div
|
||||
class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
|
||||
>
|
||||
<div class="mx-auto max-w-[1400px] px-4 py-4">
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={handleEdit}
|
||||
disabled={selectedDodaIds.length !== 1}
|
||||
>
|
||||
<Pencil size={16} class="mr-2" />
|
||||
{m['sidebar.doda_alta.action_edit']()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={handleDelete}
|
||||
disabled={selectedDodaIds.length !== 1 || deleteLoading}
|
||||
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
{#if deleteLoading}
|
||||
<Loader2 size={16} class="mr-2 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
{/if}
|
||||
{m['sidebar.doda_alta.action_delete']()}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => (exportDialogOpen = true)}>
|
||||
<FileSpreadsheet size={16} class="mr-2" />
|
||||
{m['sidebar.doda_alta.action_export_excel']()}
|
||||
</Button>
|
||||
<Separator orientation="vertical" class="mx-1 h-8 hidden sm:block" />
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={handleAlta}
|
||||
disabled={selectedDodaIds.length !== 1 || altaLoading}
|
||||
title={altaVariant === 'pita' ? 'PITA' : 'DODA'}
|
||||
>
|
||||
{#if altaLoading}
|
||||
<Loader2 size={16} class="mr-2 animate-spin" />
|
||||
{:else}
|
||||
<Send size={16} class="mr-2" />
|
||||
{/if}
|
||||
{m['sidebar.doda_alta.action_generar']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DodaExportExcelDialog bind:open={exportDialogOpen} companyId={companyStore.activeCompany?.id} />
|
||||
|
||||
{#if progressDialogOpen}
|
||||
<DodaProgressDialog
|
||||
bind:open={progressDialogOpen}
|
||||
taskId={currentTaskId}
|
||||
dodaId={selectedDoda?.id}
|
||||
variant={currentVariant}
|
||||
onComplete={onAltaComplete}
|
||||
onCancel={() => (progressDialogOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -5,8 +5,19 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
|
||||
const parentData = await parent();
|
||||
const { accessToken } = getAuthTokens(cookies);
|
||||
|
||||
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() || '') || '';
|
||||
|
||||
if (!accessToken) {
|
||||
return { error: 'No authenticated', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } };
|
||||
return {
|
||||
error: 'No authenticated',
|
||||
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 },
|
||||
defaultLastUser
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -20,7 +31,11 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
|
||||
: parentData.companies?.[0]?.id;
|
||||
|
||||
if (!companyId) {
|
||||
return { error: 'No company selected', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } };
|
||||
return {
|
||||
error: 'No company selected',
|
||||
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 },
|
||||
defaultLastUser
|
||||
};
|
||||
}
|
||||
|
||||
const filters: Record<string, string> = {};
|
||||
@@ -37,12 +52,20 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
|
||||
const response = await authenticatedFetch(`v1/a76/doda?${queryParams.toString()}`, { method: 'GET' }, cookies, fetch);
|
||||
|
||||
if (!response.ok) {
|
||||
return { error: 'Failed to load', dodas: { items: [], total: 0, page, page_size: pageSize, pages: 0 } };
|
||||
return {
|
||||
error: 'Failed to load',
|
||||
dodas: { items: [], total: 0, page, page_size: pageSize, pages: 0 },
|
||||
defaultLastUser
|
||||
};
|
||||
}
|
||||
|
||||
return { dodas: await response.json() };
|
||||
return { dodas: await response.json(), defaultLastUser };
|
||||
} catch (error) {
|
||||
console.error('Error loading DODAs:', error);
|
||||
return { error: 'Error loading', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } };
|
||||
return {
|
||||
error: 'Error loading',
|
||||
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 },
|
||||
defaultLastUser
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,8 +2,17 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { getDodas, deleteDoda, type Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
import {
|
||||
getDodas,
|
||||
deleteDoda,
|
||||
printDoda,
|
||||
postDodaAlta,
|
||||
getDodaElegibilidad,
|
||||
type Doda
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { m } from '$lib/i18n/messages';
|
||||
import { isPitaCustomsClearance } from '$lib/components/dashboard/general_catalogs/doda/doda-form-helpers';
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -13,22 +22,50 @@
|
||||
RotateCcw,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Printer
|
||||
Printer,
|
||||
Send,
|
||||
Loader2
|
||||
} from 'lucide-svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import DataTable from '$lib/components/dashboard/general_catalogs/doda/data-table.svelte';
|
||||
import { createColumns } from '$lib/components/dashboard/general_catalogs/doda/columns';
|
||||
import CreateEditDialog from '$lib/components/dashboard/general_catalogs/doda/create-edit-dialog.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { useShortcuts } from '$lib/hooks/use-shortcuts';
|
||||
import { obtenerAtajosListaDoda } from '$lib/config/shortcuts/dashboard/general_catalogs/doda/list';
|
||||
import DodaProgressDialog from '$lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte';
|
||||
import DodaFormModal from '$lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte';
|
||||
import { applyOptimisticDelete } from '$lib/components/dashboard/general_catalogs/doda/delete-list-state';
|
||||
|
||||
let { data } = $props();
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
const dodaIdFromUrl = $derived($page.url.searchParams.get('doda_id') ?? '');
|
||||
|
||||
function closeDodaForm() {
|
||||
if (!browser) return;
|
||||
const u = new URL($page.url);
|
||||
u.searchParams.delete('doda_id');
|
||||
const q = u.searchParams.toString();
|
||||
void goto(u.pathname + (q ? `?${q}` : ''), { replaceState: true, noScroll: true });
|
||||
}
|
||||
|
||||
function setDodaIdQuery(id: number | 'new') {
|
||||
const u = new URL($page.url);
|
||||
u.searchParams.set('doda_id', id === 'new' ? 'new' : String(id));
|
||||
const q = u.searchParams.toString();
|
||||
void goto(`${u.pathname}?${q}`, { noScroll: true });
|
||||
}
|
||||
|
||||
function onCreatedDodaToEdit(dodaId: number) {
|
||||
const u = new URL($page.url);
|
||||
u.searchParams.set('doda_id', String(dodaId));
|
||||
const q = u.searchParams.toString();
|
||||
void goto(`${u.pathname}?${q}`, { replaceState: true, noScroll: true });
|
||||
void reloadData();
|
||||
}
|
||||
|
||||
// Filtros centralizados
|
||||
let filters = $state({
|
||||
@@ -52,7 +89,18 @@
|
||||
// Selection
|
||||
let selectedIds = $state<(string | number)[]>([]);
|
||||
const selectedDoda = $derived(
|
||||
selectedIds.length === 1 ? allItems.find((item) => item.id === selectedIds[0]) ?? null : null
|
||||
selectedIds.length === 1
|
||||
? allItems.find((item) => String(item.id) === String(selectedIds[0])) ?? null
|
||||
: null
|
||||
);
|
||||
let altaLoading = $state(false);
|
||||
let printLoading = $state(false);
|
||||
let deleteLoading = $state(false);
|
||||
let progressDialogOpen = $state(false);
|
||||
let currentTaskId = $state('');
|
||||
let currentVariant = $state<'doda' | 'pita'>('doda');
|
||||
const selectedVariant = $derived(
|
||||
selectedDoda?.customs_clearance === 1 ? ('pita' as const) : ('doda' as const)
|
||||
);
|
||||
|
||||
// Sincronizar con datos del servidor al cargar (primera carga)
|
||||
@@ -64,7 +112,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Sincronizar filtros con la URL de forma reactiva
|
||||
// Sincronizar filtros con la URL de forma reactiva (preserva ?doda_id= del modal)
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
const params = new URLSearchParams();
|
||||
@@ -73,6 +121,9 @@
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.operation_type) params.set('operation_type', filters.operation_type);
|
||||
|
||||
const modalId = $page.url.searchParams.get('doda_id');
|
||||
if (modalId) params.set('doda_id', modalId);
|
||||
|
||||
const queryString = params.toString();
|
||||
const newUrl = queryString ? `?${queryString}` : window.location.pathname;
|
||||
|
||||
@@ -167,28 +218,99 @@
|
||||
}
|
||||
|
||||
function handleCreateClick() {
|
||||
goto('/dashboard/general_catalogs/doda/edit');
|
||||
setDodaIdQuery('new');
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
if (selectedDoda) {
|
||||
goto(`/dashboard/general_catalogs/doda/edit/${selectedDoda.id}`);
|
||||
setDodaIdQuery(selectedDoda.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedDoda || !companyStore.activeCompany) return;
|
||||
|
||||
if (confirm('¿Estás seguro de eliminar este DODA?')) {
|
||||
try {
|
||||
await deleteDoda(selectedDoda.id, companyStore.activeCompany.id);
|
||||
toast.success('DODA eliminado correctamente');
|
||||
selectedIds = [];
|
||||
reloadData();
|
||||
} catch (e) {
|
||||
toast.error('Error al eliminar DODA');
|
||||
}
|
||||
if (deleteLoading) return;
|
||||
if (!companyStore.activeCompany) {
|
||||
toast.error(m['sidebar.doda_alta.delete_missing_company']());
|
||||
return;
|
||||
}
|
||||
if (selectedIds.length !== 1) {
|
||||
toast.error(m['sidebar.doda_alta.delete_select_one']());
|
||||
return;
|
||||
}
|
||||
if (!selectedDoda) {
|
||||
toast.error(m['sidebar.doda_alta.delete_not_found']());
|
||||
return;
|
||||
}
|
||||
if (!confirm(m['sidebar.doda_alta.confirm_delete']())) return;
|
||||
deleteLoading = true;
|
||||
try {
|
||||
const deletedId = selectedDoda.id;
|
||||
await deleteDoda(deletedId, companyStore.activeCompany.id);
|
||||
toast.success(m['sidebar.doda_alta.delete_success']());
|
||||
const next = applyOptimisticDelete(allItems, totalItems, deletedId);
|
||||
allItems = next.items;
|
||||
totalItems = next.total;
|
||||
selectedIds = [];
|
||||
await reloadData();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : m['sidebar.doda_alta.delete_error']();
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
deleteLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAlta() {
|
||||
if (!selectedDoda || !companyStore.activeCompany) return;
|
||||
const companyId = companyStore.activeCompany.id;
|
||||
const dodaId = selectedDoda.id;
|
||||
const variant = selectedVariant;
|
||||
altaLoading = true;
|
||||
try {
|
||||
const elig = await getDodaElegibilidad(dodaId, companyId, variant);
|
||||
if (elig.error) {
|
||||
toast.error(`Error al verificar elegibilidad: ${elig.error}`);
|
||||
return;
|
||||
}
|
||||
if (elig.data && !elig.data.can_alta) {
|
||||
const msgs = elig.data.reasons.map((r) => `- ${r.message}`).join('\n');
|
||||
toast.error(msgs || 'El DODA no cumple con los requisitos de alta.');
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await postDodaAlta(dodaId, companyId, variant);
|
||||
if (resp.error) {
|
||||
toast.error(`Error al enviar alta DODA: ${resp.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
currentTaskId = resp.data!.task_id;
|
||||
currentVariant = variant;
|
||||
progressDialogOpen = true;
|
||||
} finally {
|
||||
altaLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePrint() {
|
||||
if (!selectedDoda || !companyStore.activeCompany) return;
|
||||
const companyId = companyStore.activeCompany.id;
|
||||
const dodaId = selectedDoda.id;
|
||||
printLoading = true;
|
||||
try {
|
||||
await printDoda(dodaId, companyId);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Error al generar el PDF del DODA';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
printLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAltaComplete() {
|
||||
progressDialogOpen = false;
|
||||
reloadData();
|
||||
toast.success('Alta DODA completada correctamente');
|
||||
}
|
||||
|
||||
function handleRowClick(row: Doda) {
|
||||
@@ -276,7 +398,7 @@
|
||||
{loadMore}
|
||||
selectedId={selectedIds.length === 1 ? selectedIds[0] : null}
|
||||
onRowClick={handleRowClick}
|
||||
onRowDoubleClick={(item) => goto(`/dashboard/general_catalogs/doda/edit/${item.id}`)}
|
||||
onRowDoubleClick={(item) => setDodaIdQuery(item.id)}
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
@@ -310,22 +432,66 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={handleDelete}
|
||||
disabled={selectedIds.length !== 1}
|
||||
disabled={selectedIds.length !== 1 || deleteLoading}
|
||||
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
Eliminar
|
||||
{#if deleteLoading}
|
||||
<Loader2 size={16} class="mr-2 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
{/if}
|
||||
{m['sidebar.doda_alta.action_delete']()}
|
||||
</Button>
|
||||
<Separator orientation="vertical" class="mx-1 h-8" />
|
||||
<Button variant="secondary" size="sm" disabled={selectedIds.length !== 1}>
|
||||
<Printer size={16} class="mr-2" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={selectedIds.length !== 1 || printLoading}
|
||||
onclick={handlePrint}
|
||||
>
|
||||
{#if printLoading}
|
||||
<Loader2 size={16} class="mr-2 animate-spin" />
|
||||
{:else}
|
||||
<Printer size={16} class="mr-2" />
|
||||
{/if}
|
||||
Imprimir
|
||||
</Button>
|
||||
<Separator orientation="vertical" class="mx-1 h-8" />
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={handleAlta}
|
||||
disabled={selectedIds.length !== 1 || altaLoading}
|
||||
title={isPitaCustomsClearance(selectedDoda?.customs_clearance) ? 'PITA' : 'DODA'}
|
||||
>
|
||||
{#if altaLoading}
|
||||
<Loader2 size={16} class="mr-2 animate-spin" />
|
||||
{:else}
|
||||
<Send size={16} class="mr-2" />
|
||||
{/if}
|
||||
{m['sidebar.doda_alta.action_generar']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if dialogOpen}
|
||||
<CreateEditDialog bind:open={dialogOpen} onSuccess={reloadData} />
|
||||
{#if progressDialogOpen}
|
||||
<DodaProgressDialog
|
||||
bind:open={progressDialogOpen}
|
||||
taskId={currentTaskId}
|
||||
dodaId={selectedDoda?.id}
|
||||
variant={currentVariant}
|
||||
onComplete={onAltaComplete}
|
||||
onCancel={() => (progressDialogOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if dodaIdFromUrl}
|
||||
<DodaFormModal
|
||||
dodaIdParam={dodaIdFromUrl}
|
||||
defaultLastUser={data?.defaultLastUser ?? ''}
|
||||
onClose={closeDodaForm}
|
||||
onCreatedNavigateTo={onCreatedDodaToEdit}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,751 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import {
|
||||
createDoda,
|
||||
updateDoda,
|
||||
getDoda,
|
||||
type DodaCreate
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
RefreshCw,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Printer,
|
||||
Trash2,
|
||||
FolderSearch,
|
||||
ShieldCheck,
|
||||
LoaderCircle
|
||||
} from 'lucide-svelte';
|
||||
import { useShortcuts } from '$lib/hooks/use-shortcuts';
|
||||
import { obtenerAtajosFormularioDoda } from '$lib/config/shortcuts/dashboard/general_catalogs/doda/edit';
|
||||
import ChildDetailTable from '$lib/components/dashboard/general_catalogs/doda/child-detail-table.svelte';
|
||||
|
||||
// Modales de Selección
|
||||
import BrokerSelectorDialog from '$lib/components/dashboard/export/manifest/modals/broker-selector-dialog.svelte';
|
||||
import CustomsSectionSelectorDialog from '$lib/components/dashboard/shared/modals/customs-section-selector-dialog.svelte';
|
||||
import TransporterSelectorDialog from '$lib/components/dashboard/export/manifest/modals/transporter-selector-dialog.svelte';
|
||||
|
||||
// 1. Identificación reactiva
|
||||
let id = $derived($page.params.id);
|
||||
let isEdit = $derived(!!$page.params.id);
|
||||
let title = $derived(isEdit ? 'Editar DODA' : 'Nuevo DODA');
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let activeTab = $state('general');
|
||||
|
||||
// Estados de Modales
|
||||
let showBrokerSelector = $state(false);
|
||||
let showAduanaSelector = $state(false);
|
||||
let showSectionSelector = $state(false);
|
||||
let showTransporterSelector = $state(false);
|
||||
|
||||
// Atajos
|
||||
useShortcuts(
|
||||
'Formulario DODA',
|
||||
obtenerAtajosFormularioDoda({
|
||||
cambiarPestana: (pestana) => (activeTab = pestana),
|
||||
manejarGuardar: handleSubmit,
|
||||
manejarCerrar: () => goto('/dashboard/general_catalogs/doda')
|
||||
})
|
||||
);
|
||||
|
||||
function getEmptyForm(): DodaCreate & {
|
||||
pedimentos_detail?: any[];
|
||||
containers?: any[];
|
||||
american_pedimentos?: any[];
|
||||
uuid_carta_porte?: string;
|
||||
} {
|
||||
return {
|
||||
integration_number: '',
|
||||
doda_date: undefined,
|
||||
doda_time: undefined,
|
||||
dispatch_customs: '',
|
||||
customs_sections: '',
|
||||
patent: '',
|
||||
pedimentos: '',
|
||||
caat: '',
|
||||
transport_identification: '',
|
||||
fast_id: '',
|
||||
operation_type: 'I',
|
||||
selected: false,
|
||||
user_selected: '',
|
||||
last_user: '',
|
||||
responsible: '',
|
||||
carrier: '',
|
||||
shipments: '',
|
||||
pedimento_type: '',
|
||||
original_chain: '',
|
||||
serial_number: '',
|
||||
electronic_signature: '',
|
||||
transaction_number: '',
|
||||
status: 'PENDIENTE',
|
||||
linq_sat_qr: '',
|
||||
sat_certificate: '',
|
||||
sat_digital_seal: '',
|
||||
xml_doda_sent_path: '',
|
||||
xml_doda_response_path: '',
|
||||
sat_original_chain: '',
|
||||
customs_clearance: 2, // 2 = DODA, 1 = PITA
|
||||
unique_badge_number: '',
|
||||
pedimentos_detail: [],
|
||||
containers: [],
|
||||
american_pedimentos: [],
|
||||
uuid_carta_porte: ''
|
||||
};
|
||||
}
|
||||
|
||||
let formData = $state(getEmptyForm());
|
||||
|
||||
$effect(() => {
|
||||
const currentId = $page.params.id;
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
|
||||
|
||||
if (currentId && companyId) {
|
||||
loadDoda(Number(currentId));
|
||||
} else if (!currentId) {
|
||||
formData = getEmptyForm();
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadDoda(dodaId: number) {
|
||||
loading = true;
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
|
||||
const data = await getDoda(dodaId, companyId);
|
||||
|
||||
if (data) {
|
||||
formData = {
|
||||
integration_number: data.integration_number || '',
|
||||
doda_date: data.doda_date,
|
||||
doda_time: data.doda_time,
|
||||
dispatch_customs: data.dispatch_customs || '',
|
||||
customs_sections: data.customs_sections || '',
|
||||
patent: data.patent || '',
|
||||
pedimentos: data.pedimentos || '',
|
||||
caat: data.caat || '',
|
||||
transport_identification: data.transport_identification || '',
|
||||
fast_id: data.fast_id || '',
|
||||
operation_type: data.operation_type || 'I',
|
||||
selected: data.selected || false,
|
||||
user_selected: data.user_selected || '',
|
||||
last_user: data.last_user || '',
|
||||
responsible: data.responsible || '',
|
||||
carrier: data.carrier || '',
|
||||
shipments: data.shipments || '',
|
||||
pedimento_type: data.pedimento_type || '',
|
||||
original_chain: data.original_chain || '',
|
||||
serial_number: data.serial_number || '',
|
||||
electronic_signature: data.electronic_signature || '',
|
||||
transaction_number: data.transaction_number || '',
|
||||
status: data.status || 'PENDIENTE',
|
||||
linq_sat_qr: data.linq_sat_qr || '',
|
||||
sat_certificate: data.sat_certificate || '',
|
||||
sat_digital_seal: data.sat_digital_seal || '',
|
||||
xml_doda_sent_path: data.xml_doda_sent_path || '',
|
||||
xml_doda_response_path: data.xml_doda_response_path || '',
|
||||
sat_original_chain: data.sat_original_chain || '',
|
||||
customs_clearance: data.customs_clearance || 2,
|
||||
unique_badge_number: data.unique_badge_number || '',
|
||||
pedimentos_detail: data.pedimentos_detail || [],
|
||||
containers: data.containers || [],
|
||||
american_pedimentos: data.american_pedimentos || [],
|
||||
uuid_carta_porte: '' // Simulated field
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'No se pudo cargar la información del DODA';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('Selecciona una compañía');
|
||||
if (!formData.patent?.trim()) throw new Error('El Agente Aduanal (Patente) es requerido');
|
||||
|
||||
const payload: DodaCreate = {
|
||||
...formData,
|
||||
integration_number: formData.integration_number?.trim() || '',
|
||||
doda_date: formData.doda_date || undefined,
|
||||
doda_time: formData.doda_time || undefined,
|
||||
customs_clearance: formData.customs_clearance || undefined
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await updateDoda(Number(id), payload, companyId);
|
||||
} else {
|
||||
await createDoda(payload, companyId);
|
||||
}
|
||||
|
||||
goto('/dashboard/general_catalogs/doda');
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Error al guardar';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const pedimentosColumns = [
|
||||
{ header: 'Doda Sysid', key: 'id' },
|
||||
{ header: 'Línea Pedimento', key: 'pedimento_line' },
|
||||
{ header: 'Patente Autorización', key: 'authorization_patent' },
|
||||
{ header: 'Documento', key: 'document' },
|
||||
{ header: 'Remesa', key: 'shipment' },
|
||||
{ header: 'Cove', key: 'cove' },
|
||||
{ header: 'UMC', key: 'umc' },
|
||||
{ header: 'Importe Efectivo USD', key: 'effective_amount_usd' },
|
||||
{ header: 'Importe Diferencia USD', key: 'difference_amount_usd' },
|
||||
{ header: 'DTA NIU', key: 'dta_niu' },
|
||||
{ header: 'Articulo 7', key: 'article_7', render: (v: any) => (v ? 'Sí' : 'No') }
|
||||
];
|
||||
|
||||
const containersColumns = [
|
||||
{ header: 'Contenedor', key: 'container_value' },
|
||||
{ header: 'Percinto', key: 'seals' }
|
||||
];
|
||||
|
||||
const americanPedimentosColumns = [
|
||||
{ header: 'Tipo', key: 'american_pedimento_type' },
|
||||
{ header: 'Pedido Americano', key: 'american_pedimento_value' }
|
||||
];
|
||||
|
||||
// Handlers de Selección
|
||||
function handleBrokerSelect(broker: any) {
|
||||
formData.responsible = broker.broker_key || '';
|
||||
formData.patent = broker.license || '';
|
||||
}
|
||||
|
||||
function handleAduanaSelect(section: any) {
|
||||
formData.dispatch_customs = section.customs_code || '';
|
||||
}
|
||||
|
||||
function handleSectionSelect(section: any) {
|
||||
formData.customs_sections = section.customs_code || '';
|
||||
}
|
||||
|
||||
function handleTransporterSelect(transporter: any) {
|
||||
formData.carrier = transporter.name || '';
|
||||
}
|
||||
|
||||
function openOnEnterOrSpace(event: KeyboardEvent, action: () => void) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
action();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3 p-6 pb-48">
|
||||
<!-- Header Estilo Facturas -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => goto('/dashboard/general_catalogs/doda')}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</Button>
|
||||
<h1 class="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
</div>
|
||||
<p class="text-muted-foreground">Catálogos Generales / Doda</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Tabs.Root bind:value={activeTab} class="w-full">
|
||||
<!-- Contenido Principal con Campos Superiores -->
|
||||
<div class="space-y-6">
|
||||
<!-- Fila Compacta de Datos Principales (Estilo InvoiceTopFields) con líneas blancas entre campos -->
|
||||
<div class="grid grid-cols-12 items-end gap-0 pb-3">
|
||||
<div class="col-span-3 space-y-1 border-r border-white/20 pr-3">
|
||||
<Label
|
||||
class="cursor-pointer text-xs leading-none text-muted-foreground transition-colors hover:text-primary"
|
||||
onclick={() => (showBrokerSelector = true)}
|
||||
>
|
||||
Responsable Agentes
|
||||
</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
bind:value={formData.responsible}
|
||||
class="h-8 flex-1 cursor-pointer bg-background/5 text-sm font-medium transition-colors hover:bg-background/10"
|
||||
maxlength={14}
|
||||
placeholder="Clave"
|
||||
onclick={() => (showBrokerSelector = true)}
|
||||
onkeydown={(event) =>
|
||||
openOnEnterOrSpace(event, () => (showBrokerSelector = true))}
|
||||
tabindex="0"
|
||||
readonly
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
type="button"
|
||||
onclick={() => (showBrokerSelector = true)}
|
||||
class="h-8 w-8 shrink-0 transition-colors hover:bg-background/20"
|
||||
>
|
||||
<FolderSearch class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2 space-y-1 border-r border-white/20 px-3">
|
||||
<Label
|
||||
class="cursor-pointer text-xs leading-none text-muted-foreground transition-colors hover:text-primary"
|
||||
onclick={() => (showAduanaSelector = true)}
|
||||
>
|
||||
Aduana
|
||||
</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
bind:value={formData.dispatch_customs}
|
||||
class="h-8 flex-1 cursor-pointer bg-background/5 text-sm font-medium transition-colors hover:bg-background/10"
|
||||
maxlength={3}
|
||||
placeholder="000"
|
||||
onclick={() => (showAduanaSelector = true)}
|
||||
onkeydown={(event) =>
|
||||
openOnEnterOrSpace(event, () => (showAduanaSelector = true))}
|
||||
tabindex="0"
|
||||
readonly
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
onclick={() => (showAduanaSelector = true)}
|
||||
class="h-8 w-8 shrink-0 border-white/10 transition-colors hover:bg-background/20"
|
||||
>
|
||||
<FolderSearch class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2 space-y-1 border-r border-white/20 px-3">
|
||||
<Label
|
||||
class="cursor-pointer text-xs leading-none text-muted-foreground transition-colors hover:text-primary"
|
||||
onclick={() => (showSectionSelector = true)}
|
||||
>
|
||||
Sección
|
||||
</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
bind:value={formData.customs_sections}
|
||||
class="h-8 flex-1 cursor-pointer bg-background/5 text-sm font-medium transition-colors hover:bg-background/10"
|
||||
maxlength={3}
|
||||
placeholder="000"
|
||||
onclick={() => (showSectionSelector = true)}
|
||||
onkeydown={(event) =>
|
||||
openOnEnterOrSpace(event, () => (showSectionSelector = true))}
|
||||
tabindex="0"
|
||||
readonly
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
onclick={() => (showSectionSelector = true)}
|
||||
class="h-8 w-8 shrink-0 border-white/10 transition-colors hover:bg-background/20"
|
||||
>
|
||||
<FolderSearch class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2 space-y-1 pl-3">
|
||||
<Label class="text-xs leading-none text-muted-foreground">Operación</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={formData.operation_type}
|
||||
onValueChange={(v) => (formData.operation_type = v)}
|
||||
>
|
||||
<Select.Trigger class="h-8 border-none bg-background/5 text-sm font-medium shadow-none">
|
||||
<span class="truncate"
|
||||
>{formData.operation_type === 'E'
|
||||
? 'E'
|
||||
: formData.operation_type === 'I'
|
||||
? 'I'
|
||||
: '...'}</span
|
||||
>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="I">I - Importación</Select.Item>
|
||||
<Select.Item value="E">E - Exportación</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="animate-in fade-in slide-in-from-top-2 mb-6 flex items-center gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-4 text-sm font-semibold text-destructive"
|
||||
>
|
||||
<span class="text-lg">⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tabs.Content value="general" class="animate-in fade-in space-y-6 duration-300 outline-none">
|
||||
<!-- Grid de Campos Generales Reorganizado -->
|
||||
<div class="grid grid-cols-12 items-start gap-8">
|
||||
<!-- Columna 1 (Izquierda): Stack Vertical Principal -->
|
||||
<div class="col-span-3 space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
class="cursor-pointer text-xs font-semibold text-muted-foreground uppercase transition-colors hover:text-primary"
|
||||
onclick={() => (showTransporterSelector = true)}
|
||||
>
|
||||
Transportista
|
||||
</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
bind:value={formData.carrier}
|
||||
placeholder="Transportista"
|
||||
class="h-9 flex-1 cursor-pointer text-sm font-medium shadow-sm transition-colors hover:bg-background/5"
|
||||
onclick={() => (showTransporterSelector = true)}
|
||||
onkeydown={(event) =>
|
||||
openOnEnterOrSpace(event, () => (showTransporterSelector = true))}
|
||||
tabindex="0"
|
||||
readonly
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
type="button"
|
||||
onclick={() => (showTransporterSelector = true)}
|
||||
class="h-9 w-9 shrink-0 shadow-sm transition-all hover:translate-y-[-1px]"
|
||||
>
|
||||
<FolderSearch class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase"
|
||||
>Identificación</Label
|
||||
>
|
||||
<Input
|
||||
bind:value={formData.transport_identification}
|
||||
placeholder="Identificación"
|
||||
class="h-9 text-sm font-medium shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase"
|
||||
>No. de Integración</Label
|
||||
>
|
||||
<Input
|
||||
bind:value={formData.integration_number}
|
||||
placeholder="Integración"
|
||||
class="h-9 bg-muted/20 text-sm font-medium shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase"
|
||||
>Transacción</Label
|
||||
>
|
||||
<Input
|
||||
bind:value={formData.transaction_number}
|
||||
placeholder="Transacción"
|
||||
class="h-9 text-sm font-medium shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase">Fast ID</Label>
|
||||
<Input
|
||||
bind:value={formData.fast_id}
|
||||
placeholder="Fast ID"
|
||||
class="h-9 text-sm font-medium shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columna 4 (Alineada con Operación): Stack de Estatus/Patente -->
|
||||
<div class="col-span-3 col-start-8 space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase">CAAT</Label>
|
||||
<Input
|
||||
bind:value={formData.caat}
|
||||
placeholder="CAAT"
|
||||
class="h-9 text-sm font-medium shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
class="cursor-pointer text-xs font-semibold text-muted-foreground uppercase transition-colors hover:text-primary"
|
||||
onclick={() => (showBrokerSelector = true)}
|
||||
>
|
||||
Patente <span class="text-destructive">*</span>
|
||||
</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
bind:value={formData.patent}
|
||||
maxlength={4}
|
||||
placeholder="Patente"
|
||||
class="h-9 flex-1 cursor-pointer text-sm font-medium shadow-sm transition-colors hover:bg-background/5"
|
||||
onclick={() => (showBrokerSelector = true)}
|
||||
onkeydown={(event) =>
|
||||
openOnEnterOrSpace(event, () => (showBrokerSelector = true))}
|
||||
tabindex="0"
|
||||
readonly
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
onclick={() => (showBrokerSelector = true)}
|
||||
class="h-9 w-9 shrink-0 shadow-sm transition-all hover:translate-y-[-1px]"
|
||||
>
|
||||
<FolderSearch class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase">Estatus</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={formData.status}
|
||||
onValueChange={(v) => (formData.status = v)}
|
||||
>
|
||||
<Select.Trigger class="h-9 w-full text-sm font-medium shadow-sm">
|
||||
{formData.status || 'Seleccionar...'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="PENDIENTE">PENDIENTE</Select.Item>
|
||||
<Select.Item value="GENERADO">GENERADO</Select.Item>
|
||||
<Select.Item value="ELIMINADO">ELIMINADO</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fila Horizontal: Despacho y Gafete (A la derecha de Fast ID) -->
|
||||
<div class="col-span-12 grid grid-cols-12 items-end gap-8">
|
||||
<div class="col-span-3">
|
||||
<!-- Espacio vacío para alinear con la primera columna si es necesario -->
|
||||
</div>
|
||||
|
||||
<!-- Despacho Aduanero (A la derecha de Fast ID en términos lógicos) -->
|
||||
<div class="col-span-4">
|
||||
<div
|
||||
class="flex items-center justify-between rounded-xl border bg-muted/30 p-4 shadow-inner"
|
||||
>
|
||||
<Label class="text-xs font-semibold tracking-widest text-muted-foreground uppercase"
|
||||
>Despacho Aduanero</Label
|
||||
>
|
||||
<RadioGroup.Root
|
||||
value={formData.customs_clearance?.toString()}
|
||||
onValueChange={(v) => (formData.customs_clearance = parseInt(v))}
|
||||
class="flex gap-6"
|
||||
>
|
||||
<div class="flex cursor-pointer items-center space-x-2">
|
||||
<RadioGroup.Item value="1" id="pita" class="h-4 w-4 border-primary" />
|
||||
<Label for="pita" class="cursor-pointer text-xs font-medium uppercase"
|
||||
>PITA</Label
|
||||
>
|
||||
</div>
|
||||
<div class="flex cursor-pointer items-center space-x-2">
|
||||
<RadioGroup.Item value="2" id="doda" class="h-4 w-4 border-primary" />
|
||||
<Label for="doda" class="cursor-pointer text-xs font-medium uppercase"
|
||||
>DODA</Label
|
||||
>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gafete Único (A la derecha de Despacho) -->
|
||||
<div class="col-span-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase"
|
||||
>Número de gafete único</Label
|
||||
>
|
||||
<Input
|
||||
bind:value={formData.unique_badge_number}
|
||||
placeholder="Gafete único"
|
||||
class="h-9 text-sm font-medium shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabla Principal -->
|
||||
<div class="pt-4">
|
||||
<ChildDetailTable
|
||||
title="Detalle de Pedimentos"
|
||||
columns={pedimentosColumns}
|
||||
data={formData.pedimentos_detail || []}
|
||||
class="border-border bg-card shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tablas Inferiores -->
|
||||
<div class="grid grid-cols-1 gap-6 pt-4 lg:grid-cols-2">
|
||||
<ChildDetailTable
|
||||
title="Contenedores"
|
||||
columns={containersColumns}
|
||||
data={formData.containers || []}
|
||||
/>
|
||||
<ChildDetailTable
|
||||
title="Pedimento Americano"
|
||||
columns={americanPedimentosColumns}
|
||||
data={formData.american_pedimentos || []}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="sellos" class="animate-in fade-in duration-300 outline-none">
|
||||
<div class="max-w-4xl space-y-8 py-4">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-xl font-bold tracking-tight">Sellos y Firmas</h2>
|
||||
<p class="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Validación electrónica ante el SAT
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>Cadena original</Label
|
||||
>
|
||||
<Textarea
|
||||
bind:value={formData.original_chain}
|
||||
placeholder="Cadena Original..."
|
||||
class="min-h-[120px] w-full border-muted/60 font-mono text-xs font-medium shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>Número de certificado</Label
|
||||
>
|
||||
<Input
|
||||
bind:value={formData.serial_number}
|
||||
placeholder="Certificado"
|
||||
class="w-full border-muted/60 text-sm font-medium shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>Firma electrónica</Label
|
||||
>
|
||||
<Textarea
|
||||
bind:value={formData.electronic_signature}
|
||||
placeholder="Firma..."
|
||||
class="min-h-[100px] w-full border-muted/60 font-mono text-xs font-medium shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-semibold tracking-wide text-primary uppercase"
|
||||
>UUID Carta Porte</Label
|
||||
>
|
||||
<Input
|
||||
bind:value={formData.uuid_carta_porte}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
class="w-full border-muted/60 font-mono text-sm font-medium shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>Número certificado SAT</Label
|
||||
>
|
||||
<Input
|
||||
bind:value={formData.sat_certificate}
|
||||
placeholder="Certificado SAT"
|
||||
class="w-full border-muted/60 text-sm font-medium shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>Firma electrónica SAT (Cadena Original SAT)</Label
|
||||
>
|
||||
<Textarea
|
||||
bind:value={formData.sat_original_chain}
|
||||
placeholder="Firma SAT..."
|
||||
class="min-h-[120px] w-full border-muted/60 font-mono text-xs font-medium shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
|
||||
<!-- Footer fijo con Tabs.List y Botones de Acción -->
|
||||
<div
|
||||
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
|
||||
>
|
||||
<div class="mx-auto max-w-[1400px] space-y-4 px-4 py-4">
|
||||
<div class="w-full overflow-x-auto pb-2">
|
||||
<Tabs.List class="inline-flex md:grid md:w-full md:grid-cols-2">
|
||||
<Tabs.Trigger value="general" class="whitespace-nowrap">
|
||||
<LayoutGrid size={16} class="mr-2" />
|
||||
General
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="sellos" class="whitespace-nowrap">
|
||||
<ShieldCheck size={16} class="mr-2" />
|
||||
Sellos
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() => goto('/dashboard/general_catalogs/doda')}
|
||||
disabled={loading}
|
||||
class="rounded px-8 font-medium"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleSubmit}
|
||||
disabled={loading}
|
||||
class="min-w-[200px] rounded font-bold uppercase shadow-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
{isEdit ? 'Guardar Cambios' : 'Crear DODA'}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
<!-- Diálogos de Selección de Catálogos -->
|
||||
<BrokerSelectorDialog bind:open={showBrokerSelector} onSelect={handleBrokerSelect} />
|
||||
<CustomsSectionSelectorDialog bind:open={showAduanaSelector} onSelect={handleAduanaSelect} />
|
||||
<CustomsSectionSelectorDialog bind:open={showSectionSelector} onSelect={handleSectionSelect} />
|
||||
<TransporterSelectorDialog
|
||||
bind:open={showTransporterSelector}
|
||||
onSelect={handleTransporterSelect}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
/**
|
||||
* Mantiene enlaces antiguos /edit o /edit/:id: el formulario vive en la lista con ?doda_id=
|
||||
*/
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
if (params.id) {
|
||||
throw redirect(
|
||||
303,
|
||||
`/dashboard/general_catalogs/doda?doda_id=${encodeURIComponent(String(params.id))}`
|
||||
);
|
||||
}
|
||||
throw redirect(303, '/dashboard/general_catalogs/doda?doda_id=new');
|
||||
};
|
||||
Reference in New Issue
Block a user