From 9d232998d6c81b319ff0760e82e90da647dfaaf9 Mon Sep 17 00:00:00 2001 From: hreyes Date: Sun, 26 Apr 2026 18:13:20 -0600 Subject: [PATCH] feature/api-doda --- backend/.env.example | 7 + .../7c8d9e0f1a2b_add_annex30_duration_days.py | 4 +- .../a1b2c3d4e5f6_create_doda_alta_log.py | 117 ++ .../v1/modules/a76/factura_cove/schemas.py | 32 +- .../v1/modules/a76/factura_cove/service.py | 30 +- .../a76/general_catalogs/doda/alta_log_dto.py | 53 + .../general_catalogs/doda/alta_log_models.py | 38 + .../general_catalogs/doda/alta_log_service.py | 140 ++ .../a76/general_catalogs/doda/alta_service.py | 551 ++++++ .../modules/a76/general_catalogs/doda/dto.py | 24 +- .../general_catalogs/doda/export_service.py | 233 +++ .../general_catalogs/doda/external_service.py | 70 + .../a76/general_catalogs/doda/fingerprint.py | 132 ++ .../a76/general_catalogs/doda/models.py | 8 +- .../doda/payload_normalizer.py | 128 ++ .../a76/general_catalogs/doda/print_cache.py | 75 + .../general_catalogs/doda/report_service.py | 205 +++ .../a76/general_catalogs/doda/routes.py | 541 +++++- .../a76/general_catalogs/doda/service.py | 399 +++- .../doda/templates/doda_report.html | 160 ++ backend/core/config.py | 2 + backend/core/s3_keys.py | 13 + .../general_catalogs/doda/test_doda_export.py | 72 + .../general_catalogs/doda/test_doda_print.py | 126 ++ docker-compose.prod.yml | 9 + docker-compose.yml | 9 + frontend/messages/en.json | 62 + frontend/messages/es.json | 62 + .../lib/api/dashboard/a76/doda-alta-log.ts | 86 + .../dashboard/a76/general_catalogs/doda.ts | 260 ++- .../despacho/doda/doda-alta-log-columns.ts | 119 ++ .../despacho/doda/doda-alta-log-dialog.svelte | 203 ++ .../doda/doda-export-excel-dialog.svelte | 178 ++ .../despacho/doda/doda-progress-dialog.svelte | 217 +++ .../doda/child-detail-table.svelte | 102 +- .../general_catalogs/doda/columns.ts | 149 +- .../doda/create-edit-dialog.svelte | 392 ---- .../doda/data-table-actions.svelte | 25 +- .../doda/delete-list-state.ts | 12 + .../doda/doda-form-helpers.ts | 102 + .../doda/doda-form-modal.svelte | 1640 +++++++++++++++++ .../src/lib/components/sidebar/modules.ts | 22 +- .../dashboard/general_catalogs/doda/edit.ts | 60 +- .../dashboard/despacho/doda/+page.server.ts | 68 + .../dashboard/despacho/doda/+page.svelte | 374 ++++ .../general_catalogs/doda/+page.server.ts | 33 +- .../general_catalogs/doda/+page.svelte | 220 ++- .../doda/edit/[[id]]/+page.svelte | 751 -------- .../doda/edit/[[id]]/+page.ts | 15 + 49 files changed, 6974 insertions(+), 1356 deletions(-) create mode 100644 backend/alembic/versions/a1b2c3d4e5f6_create_doda_alta_log.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/doda/export_service.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/doda/external_service.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/doda/fingerprint.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/doda/payload_normalizer.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/doda/print_cache.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/doda/report_service.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/doda/templates/doda_report.html create mode 100644 backend/tests/unit/general_catalogs/doda/test_doda_export.py create mode 100644 backend/tests/unit/general_catalogs/doda/test_doda_print.py create mode 100644 frontend/src/lib/api/dashboard/a76/doda-alta-log.ts create mode 100644 frontend/src/lib/components/dashboard/despacho/doda/doda-alta-log-columns.ts create mode 100644 frontend/src/lib/components/dashboard/despacho/doda/doda-alta-log-dialog.svelte create mode 100644 frontend/src/lib/components/dashboard/despacho/doda/doda-export-excel-dialog.svelte create mode 100644 frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte delete mode 100644 frontend/src/lib/components/dashboard/general_catalogs/doda/create-edit-dialog.svelte create mode 100644 frontend/src/lib/components/dashboard/general_catalogs/doda/delete-list-state.ts create mode 100644 frontend/src/lib/components/dashboard/general_catalogs/doda/doda-form-helpers.ts create mode 100644 frontend/src/lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte create mode 100644 frontend/src/routes/dashboard/despacho/doda/+page.server.ts create mode 100644 frontend/src/routes/dashboard/despacho/doda/+page.svelte delete mode 100644 frontend/src/routes/dashboard/general_catalogs/doda/edit/[[id]]/+page.svelte create mode 100644 frontend/src/routes/dashboard/general_catalogs/doda/edit/[[id]]/+page.ts diff --git a/backend/.env.example b/backend/.env.example index bfaae05d..acea07a6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/alembic/versions/7c8d9e0f1a2b_add_annex30_duration_days.py b/backend/alembic/versions/7c8d9e0f1a2b_add_annex30_duration_days.py index ec565c31..b742dc80 100644 --- a/backend/alembic/versions/7c8d9e0f1a2b_add_annex30_duration_days.py +++ b/backend/alembic/versions/7c8d9e0f1a2b_add_annex30_duration_days.py @@ -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 diff --git a/backend/alembic/versions/a1b2c3d4e5f6_create_doda_alta_log.py b/backend/alembic/versions/a1b2c3d4e5f6_create_doda_alta_log.py new file mode 100644 index 00000000..e6796361 --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_create_doda_alta_log.py @@ -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") diff --git a/backend/api/v1/modules/a76/factura_cove/schemas.py b/backend/api/v1/modules/a76/factura_cove/schemas.py index d9365630..3abb34c3 100644 --- a/backend/api/v1/modules/a76/factura_cove/schemas.py +++ b/backend/api/v1/modules/a76/factura_cove/schemas.py @@ -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) diff --git a/backend/api/v1/modules/a76/factura_cove/service.py b/backend/api/v1/modules/a76/factura_cove/service.py index b7ad4354..c7a57df9 100644 --- a/backend/api/v1/modules/a76/factura_cove/service.py +++ b/backend/api/v1/modules/a76/factura_cove/service.py @@ -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( diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py new file mode 100644 index 00000000..72ef6aab --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py new file mode 100644 index 00000000..581c5f55 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py new file mode 100644 index 00000000..f4b83615 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py new file mode 100644 index 00000000..ffca5a57 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/dto.py b/backend/api/v1/modules/a76/general_catalogs/doda/dto.py index 0d188199..5e7b1822 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/export_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/export_service.py new file mode 100644 index 00000000..59a52232 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/export_service.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py new file mode 100644 index 00000000..81c00dec --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py @@ -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() diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/fingerprint.py b/backend/api/v1/modules/a76/general_catalogs/doda/fingerprint.py new file mode 100644 index 00000000..0687b871 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/fingerprint.py @@ -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() diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/models.py b/backend/api/v1/modules/a76/general_catalogs/doda/models.py index 810d4977..18bc0f9a 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/models.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/payload_normalizer.py b/backend/api/v1/modules/a76/general_catalogs/doda/payload_normalizer.py new file mode 100644 index 00000000..88dd91d3 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/payload_normalizer.py @@ -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], + } diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/print_cache.py b/backend/api/v1/modules/a76/general_catalogs/doda/print_cache.py new file mode 100644 index 00000000..7d1df83d --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/print_cache.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/report_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/report_service.py new file mode 100644 index 00000000..6aaa2696 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/report_service.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py index b687bdfd..57951aac 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/service.py b/backend/api/v1/modules/a76/general_catalogs/doda/service.py index 98a0b05b..ee60e5e5 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/service.py @@ -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.") diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/templates/doda_report.html b/backend/api/v1/modules/a76/general_catalogs/doda/templates/doda_report.html new file mode 100644 index 00000000..bc92e065 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/templates/doda_report.html @@ -0,0 +1,160 @@ + + + + + Reporte DODA + + + +

Documento de operación (DODA)

+

Huella de contenido (SHA-256): {{ fingerprint_sha256 }}

+ +

Datos generales

+ + + + + + + + + + + + + + + + + + + + + +
Folio de integración{{ doda.integration_number }}Patente{{ doda.patent }}
Aduana despacho{{ doda.dispatch_customs }}Secciones aduaneras{{ doda.customs_sections }}
Operación{{ doda.operation_type }}Estado{{ doda.status }}
Ident. transporte{{ doda.transport_identification }}ID rápida{{ doda.fast_id }}
CAAT{{ doda.caat }}Transacción / folio{{ doda.transaction_number }}
+ + {% if linq_sat_qr %} +

QR (LINQ / SAT)

+

{{ linq_sat_qr }}

+ {% endif %} + + {% if sat_chain_preview %} +

Cadena original / SAT (extracto)

+

{{ sat_chain_preview }}

+ {% endif %} + + {% if sat_digital_seal_preview %} +

Sello digital (SAT) — extracto

+

{{ sat_digital_seal_preview }}

+ {% endif %} + +

Contenedores y precintos

+ {% if containers|length == 0 %} +

Sin contenedores registrados.

+ {% else %} + + + + + + + + + + {% for c in containers %} + + + + + + {% endfor %} + +
LíneaContenedorPrecintos (línea / valor)
{{ c.container_line }}{{ c.container_value }} + {% if c.seals|length == 0 %} + + {% else %} + + {% for s in c.seals %} + + + + + {% endfor %} +
{{ s.seal_line }}{{ s.seal_value }}
+ {% endif %} +
+ {% endif %} + +

Pedimentos nacionales

+ {% if pedimentos_detail|length == 0 %} +

Sin partidas de pedimentos nacionales.

+ {% else %} + + + + + + + + + + + + + + {% for p in pedimentos_detail %} + + + + + + + + + + {% endfor %} + +
LíneaPatente auth.Documento / ped.EmbarqueCOVEUMCTipo
{{ p.pedimento_line }}{{ p.authorization_patent }}{{ p.document }}{{ p.shipment }}{{ p.cove }}{{ p.umc }}{{ p.pedimento_type }}
+ {% endif %} + +

Pedimentos USA

+ {% if american_pedimentos|length == 0 %} +

Sin pedimentos americanos.

+ {% else %} + + + + + + + + + + {% for a in american_pedimentos %} + + + + + + {% endfor %} + +
LíneaTipoValor
{{ a.american_pedimento_line }}{{ a.american_pedimento_type }}{{ a.american_pedimento_value }}
+ {% endif %} + +

+ 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. +

+ + diff --git a/backend/core/config.py b/backend/core/config.py index bcda63cc..e41f207b 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -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" diff --git a/backend/core/s3_keys.py b/backend/core/s3_keys.py index ac3e5efe..bf5720fa 100644 --- a/backend/core/s3_keys.py +++ b/backend/core/s3_keys.py @@ -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, diff --git a/backend/tests/unit/general_catalogs/doda/test_doda_export.py b/backend/tests/unit/general_catalogs/doda/test_doda_export.py new file mode 100644 index 00000000..0b3bb2a5 --- /dev/null +++ b/backend/tests/unit/general_catalogs/doda/test_doda_export.py @@ -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,") diff --git a/backend/tests/unit/general_catalogs/doda/test_doda_print.py b/backend/tests/unit/general_catalogs/doda/test_doda_print.py new file mode 100644 index 00000000..e6604ea6 --- /dev/null +++ b/backend/tests/unit/general_catalogs/doda/test_doda_print.py @@ -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 == [] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 90840164..854a98d8 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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}} diff --git a/docker-compose.yml b/docker-compose.yml index 55c17606..4653967c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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}} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 9e8dd9a8..ffd67c09 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 9e913d89..27ff3b34 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -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", diff --git a/frontend/src/lib/api/dashboard/a76/doda-alta-log.ts b/frontend/src/lib/api/dashboard/a76/doda-alta-log.ts new file mode 100644 index 00000000..04c4f0eb --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/doda-alta-log.ts @@ -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> { + 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(`/v1/a76/doda/alta-logs?${qs}`); + }, + + get(id: number, companyId: number): Promise> { + return api.get(`/v1/a76/doda/alta-logs/${id}?company_id=${companyId}`); + }, + + create(dto: DodaAltaLogCreateDTO, companyId: number): Promise> { + return api.post(`/v1/a76/doda/alta-logs?company_id=${companyId}`, dto); + }, + + update( + id: number, + dto: DodaAltaLogUpdateDTO, + companyId: number + ): Promise> { + return api.put(`/v1/a76/doda/alta-logs/${id}?company_id=${companyId}`, dto); + }, + + delete(id: number, companyId: number): Promise> { + return api.delete(`/v1/a76/doda/alta-logs/${id}?company_id=${companyId}`); + } +}; diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts index bea5bf50..3abfd82d 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts @@ -187,20 +187,272 @@ export async function getDoda(id: number, companyId?: number): Promise { 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(`/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 { - const response = await api.post(`/v1/a76/doda/?company_id=${companyId}`, data); + const response = await api.post(`/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 { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.post( + `/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 { + 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 { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.put( + `/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 { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.post( + `/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 { + 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 { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.post( + `/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 { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.post( + `/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 { + 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 { - const response = await api.put(`/v1/a76/doda/${id}/?company_id=${companyId}`, data); + const response = await api.put(`/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 { - 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 { + 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 { + 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; + 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> { + const params = new URLSearchParams({ + company_id: companyId.toString(), + variant, + }); + return api.post(`/v1/a76/doda/${dodaId}/alta?${params}`, {}); +} + +export async function getDodaAltaStatus( + taskId: string +): Promise> { + return api.get(`/v1/a76/doda/alta-status/${taskId}`); +} + +export async function getDodaElegibilidad( + dodaId: number, + companyId: number, + variant: 'doda' | 'pita' = 'doda' +): Promise> { + const params = new URLSearchParams({ + company_id: companyId.toString(), + variant, + }); + return api.get( + `/v1/a76/doda/${dodaId}/alta/elegibilidad?${params}` + ); } \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/despacho/doda/doda-alta-log-columns.ts b/frontend/src/lib/components/dashboard/despacho/doda/doda-alta-log-columns.ts new file mode 100644 index 00000000..d887ad21 --- /dev/null +++ b/frontend/src/lib/components/dashboard/despacho/doda/doda-alta-log-columns.ts @@ -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 = { + 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: () => + `${status || '-'}` + })); +} + +export function createAltaLogColumns(): ColumnDef[] { + 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: () => + `${id || '-'}` + })); + return renderSnippet(snippet, {}); + } + }, + { + accessorKey: 'message', + header: 'Mensaje', + cell: ({ row }) => { + const msg = row.original.message || ''; + const snippet = createRawSnippet(() => ({ + render: () => + `${msg || '-'}` + })); + return renderSnippet(snippet, {}); + } + }, + { + accessorKey: 'created_at', + header: 'Fecha', + size: 130, + cell: ({ row }) => formatDateTime(row.original.created_at) + } + ]; +} diff --git a/frontend/src/lib/components/dashboard/despacho/doda/doda-alta-log-dialog.svelte b/frontend/src/lib/components/dashboard/despacho/doda/doda-alta-log-dialog.svelte new file mode 100644 index 00000000..0b4ccb24 --- /dev/null +++ b/frontend/src/lib/components/dashboard/despacho/doda/doda-alta-log-dialog.svelte @@ -0,0 +1,203 @@ + + + + + + {title} + {#if item} + + Registro #{item.id} — DODA {item.doda_id ?? '-'} — {(item.variant || 'doda').toUpperCase()} + + {/if} + + +
+ {#if error} +
+ {error} +
+ {/if} + + + {#if item} +
+
+

No. Integración

+

{item.integration_number || '-'}

+
+
+

Responsable

+

{item.responsible || '-'}

+
+
+

Patente

+

{item.patent || '-'}

+
+
+

Aduana Despacho

+

{item.dispatch_customs || '-'}

+
+
+

Tipo Operación

+

{item.operation_type || '-'}

+
+
+

Task ID

+

{item.task_id || '-'}

+
+
+

Fecha Alta

+

+ {item.created_at + ? new Date(item.created_at).toLocaleString('es-MX') + : '-'} +

+
+
+ {/if} + + +
+

Estado

+
+
+ + (formData.status = (e.target as HTMLInputElement).value || null)} + placeholder="pending / success / failed" + disabled={loading} + /> +
+
+ + (formData.message = (e.target as HTMLInputElement).value || null)} + placeholder="Mensaje del servicio externo" + disabled={loading} + /> +
+ {#if item?.result_json} +
+ +
{(() => {
+								try { return JSON.stringify(JSON.parse(item.result_json || '{}'), null, 2); }
+								catch { return item.result_json || ''; }
+							})()}
+
+ {/if} +
+
+
+ + + +
+ + {#if isEdit} + + {/if} +
+
+
+
diff --git a/frontend/src/lib/components/dashboard/despacho/doda/doda-export-excel-dialog.svelte b/frontend/src/lib/components/dashboard/despacho/doda/doda-export-excel-dialog.svelte new file mode 100644 index 00000000..bd0b6b3c --- /dev/null +++ b/frontend/src/lib/components/dashboard/despacho/doda/doda-export-excel-dialog.svelte @@ -0,0 +1,178 @@ + + + + + + {m['sidebar.doda_alta.export_excel_badge']()} — {m['sidebar.doda_alta.export_report_heading']()} + + +
+ {m['sidebar.doda_alta.export_excel_badge']()} +
+ +
+

+ {m['sidebar.doda_alta.export_report_heading']()} +

+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+ + +
+ +
+
+ + { + if (v === 'csv' || v === 'xls' || v === 'txt') fileFormat = v; + }} + > + + .{fileFormat} + + + .csv (coma) + .xls (tabulador, Excel) + .txt (|) + + +
+
+
+ + + + +
+
diff --git a/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte b/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte new file mode 100644 index 00000000..6580d455 --- /dev/null +++ b/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte @@ -0,0 +1,217 @@ + + + + + + {m['sidebar.doda_alta.progress_title']()} + Alta {variantLabel} — Task ID: {taskId} + + +
+ {#if state === 'SUCCESS'} +
+ +

{m['sidebar.doda_alta.progress_success']()}

+
+ {#if result} +
+ {#each Object.entries(result) as [key, value]} + {#if key !== 'state' && value && typeof value === 'string'} +
+
{key.replace(/_/g, ' ')}:
+
{value}
+
+ {/if} + {/each} +
+ {/if} + + {:else if state === 'FAILURE'} +
+ +
+

{m['sidebar.doda_alta.progress_error']()}

+ {#if errorMsg} +

{errorMsg}

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

{currentStep}

+
+ {#if progress > 0} + +

{progress}%

+ {/if} +
+ {/if} + + {#if taskId} +
+

Task ID:

+

{taskId}

+
+ {/if} +
+ + + {#if isTerminal} + + {:else} + + {/if} + +
+
diff --git a/frontend/src/lib/components/dashboard/general_catalogs/doda/child-detail-table.svelte b/frontend/src/lib/components/dashboard/general_catalogs/doda/child-detail-table.svelte index b660ea7f..e825c8ed 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/doda/child-detail-table.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/doda/child-detail-table.svelte @@ -1,7 +1,7 @@ -
-
+
+
{#if title} -

{title}

+

{title}

{/if}
-
+
- - + + {#each columns as col} - {col.header} + {col.header} + {/each} {#if data.length === 0} - + - No hay registros. +
+ + Sin filas. «Nuevo» para añadir. +
{:else} {#each data as row, i} - + { + selectedIndex = i; + onRowSelect?.(row, i); + }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + selectedIndex = i; + onRowSelect?.(row, i); + } + }} + > {#each columns as col} - + {#if col.render} {col.render(row[col.key])} {:else} @@ -76,17 +130,21 @@
-
-