From 4541629b3fd355093c4a426635c06938fa832774 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Mon, 30 Mar 2026 13:05:21 -0500 Subject: [PATCH 001/167] Parametros generales SCAII, con endpint y con la mayoria de pestanias hechas y funcionalaes --- .../versions/e76_app_settings_add_table.py | 40 + .../v1/modules/a76/app_settings/__init__.py | 1 + .../api/v1/modules/a76/app_settings/models.py | 33 + .../api/v1/modules/a76/app_settings/routes.py | 59 + .../v1/modules/a76/app_settings/schemas.py | 989 ++++++++++ .../v1/modules/a76/app_settings/service.py | 137 ++ backend/api/v1/modules/a76/router.py | 3 + .../src/lib/api/dashboard/a76/app-settings.ts | 34 + .../invoices/edit/items/items-tab-form.svelte | 1750 ----------------- .../settings/DynamicSettingForm.svelte | 79 + .../settings/SettingCategoryNav.svelte | 72 + .../settings/SettingFormField.svelte | 168 ++ .../settings/SettingHierarchyBadge.svelte | 31 + .../dashboard/settings/SsisGenTabsForm.svelte | 626 ++++++ .../dashboard/settings/settings-metadata.ts | 35 + .../src/lib/components/sidebar/modules.ts | 4 + .../dashboard/settings/general/+page.svelte | 171 ++ .../dashboard/settings/general/+page.ts | 1 + 18 files changed, 2483 insertions(+), 1750 deletions(-) create mode 100644 backend/alembic/versions/e76_app_settings_add_table.py create mode 100644 backend/api/v1/modules/a76/app_settings/__init__.py create mode 100644 backend/api/v1/modules/a76/app_settings/models.py create mode 100644 backend/api/v1/modules/a76/app_settings/routes.py create mode 100644 backend/api/v1/modules/a76/app_settings/schemas.py create mode 100644 backend/api/v1/modules/a76/app_settings/service.py create mode 100644 frontend/src/lib/api/dashboard/a76/app-settings.ts delete mode 100644 frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte create mode 100644 frontend/src/lib/components/dashboard/settings/DynamicSettingForm.svelte create mode 100644 frontend/src/lib/components/dashboard/settings/SettingCategoryNav.svelte create mode 100644 frontend/src/lib/components/dashboard/settings/SettingFormField.svelte create mode 100644 frontend/src/lib/components/dashboard/settings/SettingHierarchyBadge.svelte create mode 100644 frontend/src/lib/components/dashboard/settings/SsisGenTabsForm.svelte create mode 100644 frontend/src/lib/components/dashboard/settings/settings-metadata.ts create mode 100644 frontend/src/routes/dashboard/settings/general/+page.svelte create mode 100644 frontend/src/routes/dashboard/settings/general/+page.ts diff --git a/backend/alembic/versions/e76_app_settings_add_table.py b/backend/alembic/versions/e76_app_settings_add_table.py new file mode 100644 index 00000000..ba6391be --- /dev/null +++ b/backend/alembic/versions/e76_app_settings_add_table.py @@ -0,0 +1,40 @@ +"""add_app_settings_table + +Revision ID: e76_app_settings +Revises: c1a2b3d4e5f6 +Create Date: 2026-03-27 16:10:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'e76_app_settings' +down_revision = 'c1a2b3d4e5f6' +branch_labels = None +depends_on = None + +def upgrade(): + # Create a76.app_settings table + op.create_table( + 'app_settings', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=True), + sa.Column('company_id', sa.Integer(), nullable=True), + sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'company_id', name='uq_app_settings_tenant_company'), + schema='a76' + ) + op.create_index(op.f('ix_a76_app_settings_company_id'), 'app_settings', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_app_settings_tenant_id'), 'app_settings', ['tenant_id'], unique=False, schema='a76') + +def downgrade(): + op.drop_index(op.f('ix_a76_app_settings_tenant_id'), table_name='app_settings', schema='a76') + op.drop_index(op.f('ix_a76_app_settings_company_id'), table_name='app_settings', schema='a76') + op.drop_table('app_settings', schema='a76') diff --git a/backend/api/v1/modules/a76/app_settings/__init__.py b/backend/api/v1/modules/a76/app_settings/__init__.py new file mode 100644 index 00000000..2af0782c --- /dev/null +++ b/backend/api/v1/modules/a76/app_settings/__init__.py @@ -0,0 +1 @@ +# Module initialization for app_settings diff --git a/backend/api/v1/modules/a76/app_settings/models.py b/backend/api/v1/modules/a76/app_settings/models.py new file mode 100644 index 00000000..d9c8f8ca --- /dev/null +++ b/backend/api/v1/modules/a76/app_settings/models.py @@ -0,0 +1,33 @@ +from typing import Optional +from sqlalchemy import Integer, ForeignKey, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column +from core.database import Base +from api.v1.common.base_models import TimestampMixin + +class AppSetting(Base, TimestampMixin): + """ + Unified configuration table for Anexo 76. + Replaces 14 legacy tables using a hierarchical JSONB override system. + """ + __tablename__ = "app_settings" + __table_args__ = ( + UniqueConstraint("tenant_id", "company_id", name="uq_app_settings_tenant_company"), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # Hierarchy levels (Nullable to allow Global/Tenant/Company scoping) + tenant_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("core.tenants.id"), nullable=True, index=True + ) + company_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("a76.company.id"), nullable=True, index=True + ) + + # The actual configuration payload + settings: Mapped[dict] = mapped_column(JSONB, nullable=False, default={}) + + def __repr__(self): + return f"" diff --git a/backend/api/v1/modules/a76/app_settings/routes.py b/backend/api/v1/modules/a76/app_settings/routes.py new file mode 100644 index 00000000..dde03984 --- /dev/null +++ b/backend/api/v1/modules/a76/app_settings/routes.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.orm import Session +from typing import Optional, Dict, Any +from core.database import get_core_db +from .service import AppSettingsService +from .schemas import AppSettingRequest, AppSettingResponse + +router = APIRouter(prefix="/a76/app-settings", tags=["a76 / app_settings"]) + +import logging +import traceback + +logger = logging.getLogger(__name__) + +@router.get("/resolved") +def get_resolved_settings( + tenant_id: int = Query(...), + company_id: int = Query(...), + db: Session = Depends(get_core_db) +): + """ + Returns the final merged configuration for a company. + Merges Global -> Tenant -> Company levels. + """ + try: + return AppSettingsService.get_resolved_settings(db, tenant_id, company_id) + except Exception as e: + logger.error(f"RESOLVE ERROR: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/upsert") +def upsert_settings( + payload: AppSettingRequest, + db: Session = Depends(get_core_db) +): + """ + Creates or updates an override for a specific level (Global, Tenant, or Company). + """ + try: + data = payload.settings.model_dump(exclude_unset=True) + return AppSettingsService.upsert_settings( + db, + payload.tenant_id, + payload.company_id, + data + ) + except Exception as e: + logger.error(f"UPSERT ERROR: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/upsert") +def update_settings( + payload: AppSettingRequest, + db: Session = Depends(get_core_db) +): + """ + Alias for upsert_settings. + """ + return upsert_settings(payload, db) diff --git a/backend/api/v1/modules/a76/app_settings/schemas.py b/backend/api/v1/modules/a76/app_settings/schemas.py new file mode 100644 index 00000000..4cf77221 --- /dev/null +++ b/backend/api/v1/modules/a76/app_settings/schemas.py @@ -0,0 +1,989 @@ +from typing import Optional, Any, Dict +from pydantic import BaseModel, Field +from decimal import Decimal + +# --- Legacy Table Domains (Tarea 1) --- + +class SSisGenSettings(BaseModel): + """ + Legacy Table: SSisGen + General system parameters migrated from Clarion/WinDev. + """ + consecutivo: Optional[int] = None + dta: Optional[int] = None + dtaexpo: Optional[int] = None + subempresa: Optional[str] = None + patharch: Optional[str] = None + patharchtransmision: Optional[str] = None + pathtransexpo: Optional[str] = None + pathrespuesta: Optional[str] = None + patharchped: Optional[str] = None + patharchpedconsm: Optional[str] = None + pathgenimpotemp: Optional[str] = None + pathgenexpo: Optional[str] = None + actseguridad: Optional[int] = None + controldes: Optional[int] = None + diadesactual: Optional[int] = None + diavencimiento: Optional[int] = None + mensajevenc: Optional[int] = None + fechades: Optional[int] = None + factoriva: Optional[Decimal] = None + validasifra: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + calvalbasetcped: Optional[int] = None + calvalbasetcpedexpo: Optional[int] = None + filtrocantidad: Optional[Decimal] = None + muestraarchcodbarras: Optional[int] = None + datoshist: Optional[int] = None + tipovenro: Optional[str] = None + cantvenro: Optional[int] = None + costoplanta: Optional[Decimal] = None + firmapacking: Optional[int] = None + advertenciatm: Optional[int] = None + tomarsaldosvenc: Optional[int] = None + costoimpofijo: Optional[int] = None + valparteexiste: Optional[int] = None + valmanifusado: Optional[int] = None + temporalfechapago: Optional[int] = None + asignadiasantdesc: Optional[int] = None + diasantdesc: Optional[int] = None + deshabilitardescparte: Optional[int] = None + deshabilitardescparteing: Optional[int] = None + asignafracameparte: Optional[int] = None + validadecencant: Optional[int] = None + usartranspamedocame: Optional[int] = None + mostraradvertenciaro: Optional[int] = None + escondaamexpacking: Optional[int] = None + calcdutypacking: Optional[int] = None + parammultiples: Optional[int] = None + fraccnivelpais: Optional[int] = None + covefechaemision: Optional[int] = None + valordllstcfacturaexpo: Optional[int] = None + interfaceaaconsolidada: Optional[int] = None + interfaceaatcfpff: Optional[str] = None + incluirobscoveobsimpo: Optional[int] = None + agregarincreimpo: Optional[int] = None + componentebom: Optional[int] = None + limitesubensamble: Optional[int] = None + actpdfreportes: Optional[int] = None + patharchpdfimpo: Optional[str] = None + patharchpdfexpo: Optional[str] = None + noimprimircons: Optional[int] = None + mostraradvertenciarovalor: Optional[int] = None + partesypedimentosporcliente: Optional[str] = None + mensajesvurfc: Optional[int] = None + mostrarpackinglistingles: Optional[int] = None + omitirempaqueencodigobarras: Optional[int] = None + restringepaisimpo: Optional[int] = None + bloqueoaldesactivarnumerodeparte: Optional[int] = None + restringpaisexpo: Optional[int] = None + geninformeanexo31: Optional[str] = None + utilizarfechapagopeddeundiaanterior: Optional[int] = None + utilizarequivalenciasdeumpornumerodeparte: Optional[int] = None + utilizartitulosalternativosimpresionfactura: Optional[int] = None + usarfactorconversionpornumerodeparte: Optional[int] = None + usarvude128o256: Optional[int] = None + usartcdelafechapagopedimpoendescarga: Optional[int] = None + solicitarcontrasenaadministrador: Optional[int] = None + agregarnumeroembarque: Optional[int] = None + utilizarumdeexistenciaentransmisionvu: Optional[int] = None + utilizarcodigodebrokerdeclienteenmainx30: Optional[int] = None + hojacalculosepararincrementablesanexo3: Optional[int] = None + hojacalculodesglosefacturaanexo3: Optional[int] = None + usarvaloragregadoenfacturaamericana: Optional[int] = None + ocultarinformacionfraccion: Optional[int] = None + resaltarsaldostempconcolor: Optional[int] = None + valoragregadoenfacturamexicana: Optional[int] = None + validarsectorprosecr8: Optional[int] = None + agregarsubtotalinterfazaa: Optional[int] = None + utilizarnombregenericomainx30: Optional[int] = None + utilizartcrespectoatipoped: Optional[int] = None + tomardecimalescompletos: Optional[int] = None + incluirremesaeninterfazaawinsaai: Optional[int] = None + cambiarpesosporcostounitario: Optional[int] = None + utilizarsolopartesnaftaenco: Optional[int] = None + bloqueodeediciondefacturas: Optional[int] = None + reasignafraccionclase: Optional[int] = None + calcularcostounitarioenbaseavalortotal: Optional[int] = None + parametroauxiliar: Optional[int] = None + usarcontroldefechasdeversion: Optional[int] = None + desactivaciondemodulos: Optional[int] = None + imprimirfacturaalterna: Optional[int] = None + activarexpedienteelectronico: Optional[int] = None + activarcatalogofraccionesamericanassifra: Optional[int] = None + informacionamericanasubtotal: Optional[int] = None + mostrarprogramaimmexprosec: Optional[int] = None + costounitarioporempaquefac: Optional[int] = None + transmitirfacalterna: Optional[int] = None + + +class SSisGen2Settings(BaseModel): + """ + Legacy Table: SSisGen2 + Extended system parameters. + """ + consecutivo: Optional[int] = None + actvaloragre: Optional[int] = None + valoragregadogen: Optional[Decimal] = None + + +class SSisGen3Settings(BaseModel): + """ + Legacy Table: SSisGen3 + Additional name-value parameters. + """ + parametro: Optional[str] = None + valorparametro: Optional[int] = None + + +class SSisMexSettings(BaseModel): + """Legacy Table: SSisMex (Mexican Purchases)""" + consecutivo: Optional[int] = None + prefijocm: Optional[str] = None + consecutivocm: Optional[str] = None + porparteclasemex: Optional[str] = None + porparteclaseame: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + flete: Optional[Decimal] = None + paisorigenmex: Optional[int] = None + numpartemex: Optional[int] = None + firmafmex: Optional[str] = None + fraccionimp: Optional[int] = None + tipofraccmex: Optional[int] = None + tasafraccmex: Optional[int] = None + umequivalentemex: Optional[int] = None + numparteame: Optional[int] = None + fraccioname: Optional[int] = None + paisorigename: Optional[int] = None + umequivalenteame: Optional[int] = None + firmafame: Optional[str] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + clavemoneda: Optional[str] = None + umauxiliarmex: Optional[int] = None + umalternamex: Optional[int] = None + transportista: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + clavebultos: Optional[str] = None + packingordencompra: Optional[int] = None + firmaelectronica: Optional[str] = None + incluirlineadelpo: Optional[int] = None + lineapersonaaa: Optional[int] = None + agregarvaenprodterminados: Optional[int] = None + ocultarfechahora: Optional[int] = None + + +class SSisDefSettings(BaseModel): + """Legacy Table: SSisDef (Definitive Import)""" + consecutivo: Optional[int] = None + prefijoimpo: Optional[str] = None + consecutivoimpo: Optional[str] = None + porparteclasemex: Optional[str] = None + porparteclaseame: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + firmafmex: Optional[str] = None + firmafame: Optional[str] = None + codigobarras: Optional[int] = None + numpartemex: Optional[int] = None + numparteame: Optional[int] = None + fraccionimp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + paisorigenmex: Optional[int] = None + umequivalentemex: Optional[int] = None + umauxiliarmex: Optional[int] = None + fraccioname: Optional[int] = None + paisorigename: Optional[int] = None + umequivalenteame: Optional[int] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + clavemoneda: Optional[str] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + umalternamex: Optional[int] = None + restringeimpopt: Optional[int] = None + partecomplementariamex: Optional[int] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + metvalor: Optional[str] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + packingpedclave: Optional[int] = None + porordenporocpack: Optional[str] = None + actualizarpartepartida: Optional[int] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + clavebultos: Optional[str] = None + partecomplemexpartida: Optional[int] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + packingordencompra: Optional[int] = None + firmaelectronica: Optional[str] = None + solicitarcontrasenaadministrador: Optional[int] = None + incluirlineadelpo: Optional[int] = None + lineapersonaaa: Optional[int] = None + agregarvaenprodterminados: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + ocultarfechahora: Optional[int] = None + + +class SSisExpoSettings(BaseModel): + """Legacy Table: SSisExpo (Exports)""" + tipofactura: Optional[str] = None + porparteclasemex: Optional[str] = None + porparteclaseame: Optional[str] = None + prefijoexpo: Optional[str] = None + consecutivoexpo: Optional[str] = None + pedimento: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + vendidopor: Optional[str] = None + aaduanal: Optional[str] = None + tipo: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + tipomoneda: Optional[str] = None + moneda: Optional[str] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + numpartemex: Optional[int] = None + paisorigenmex: Optional[int] = None + fraccionexp: Optional[int] = None + tasafraccmex: Optional[int] = None + costounitmex: Optional[int] = None + ordencompmex: Optional[int] = None + consfacmex: Optional[int] = None + umequivalentemex: Optional[int] = None + ocultarempaquemex: Optional[int] = None + costounitmpmex: Optional[int] = None + codigobarras: Optional[int] = None + partecomplementariamex: Optional[int] = None + valordllsmexpesos: Optional[int] = None + cantpncodbar: Optional[int] = None + dtacerocodbar: Optional[int] = None + firmafmex: Optional[str] = None + numparteame: Optional[int] = None + firmafame: Optional[str] = None + fraccioname: Optional[int] = None + paisorigename: Optional[int] = None + ordencompame: Optional[int] = None + umequivalenteame: Optional[int] = None + fdainformacioname: Optional[int] = None + paisopcame: Optional[int] = None + muestrafree: Optional[int] = None + fraccmexdes: Optional[int] = None + tipofraccmexdes: Optional[int] = None + colvalordes: Optional[int] = None + colpesodes: Optional[int] = None + descargaclase: Optional[int] = None + descargasust: Optional[int] = None + descargadef: Optional[int] = None + ventqueueact: Optional[int] = None + codigoscac: Optional[int] = None + formadesperdicio: Optional[str] = None + calvalorbasecosto: Optional[int] = None + calvabasecapt: Optional[int] = None + calpesobasedescarga: Optional[int] = None + calempaquebasecapt: Optional[int] = None + ocultcantcons: Optional[int] = None + prefijofraccmult: Optional[int] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + costamepaisparte: Optional[int] = None + basecostompototal: Optional[str] = None + umequivalentemex2: Optional[int] = None + umequivalenteame2: Optional[int] = None + totalequivmex: Optional[int] = None + totalequivame: Optional[int] = None + infoadicional: Optional[str] = None + restringeexpomp: Optional[int] = None + calvatotagremp: Optional[str] = None + activaadvpedrtv1: Optional[int] = None + asignafrac9801: Optional[int] = None + valorexcfrac9801: Optional[Decimal] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + scrapsolonoduty: Optional[int] = None + descextraamepartes: Optional[int] = None + incambioregdesc: Optional[int] = None + escambioregimen: Optional[str] = None + descnocontemcant: Optional[int] = None + metvalor: Optional[str] = None + ocultarempaqueame: Optional[int] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + pesonetodesdiffac: Optional[Decimal] = None + enviadoporvendidopor: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + clavebultos: Optional[str] = None + ocultacolva: Optional[int] = None + calccostosamebasemex: Optional[int] = None + proveedorexportador: Optional[str] = None + tipofraccmex: Optional[int] = None + partecomplemexpartida: Optional[int] = None + validacantparcandes: Optional[int] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + costosamepais: Optional[str] = None + incluirfracamebil: Optional[int] = None + solicitarpswdactual: Optional[int] = None + solicitarpswddesactual: Optional[int] = None + buscarsaldosrecientes: Optional[int] = None + packingordenventa: Optional[int] = None + imprimirordenventa: Optional[int] = None + firmaelectronica: Optional[str] = None + mpf: Optional[int] = None + valormpf: Optional[Decimal] = None + colcantporpeso: Optional[int] = None + packingpedclave: Optional[int] = None + generafacturaimd: Optional[str] = None + generafacturaimdenbasea: Optional[str] = None + solicitarcontrasenaadministrador: Optional[int] = None + incluirlineadelpo: Optional[int] = None + valorpacking1dlls: Optional[int] = None + tomarvalormexenfacame: Optional[int] = None + lineapersonaaa: Optional[int] = None + calamebasemexmp: Optional[int] = None + calamebasemexva: Optional[int] = None + calamebasemexempaque: Optional[int] = None + usarvaenfacturaamericana: Optional[int] = None + activarfechacortesaldos: Optional[int] = None + fechacortesaldos: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + respaldardescargaendescargam: Optional[int] = None + ocultarfechahora: Optional[int] = None + calcularvaloresamericanosenbaseapartida: Optional[int] = None + validarestatusped: Optional[int] = None + descapais: Optional[int] = None + + +class SSisImpoSettings(BaseModel): + """Legacy Table: SSisImpo (General Import)""" + consecutivo: Optional[int] = None + prefijoimpo: Optional[str] = None + prefijocr: Optional[str] = None + consecutivoimpo: Optional[str] = None + consecutivocr: Optional[int] = None + porparteclasemex: Optional[str] = None + porparteclaseame: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + numpartemex: Optional[int] = None + numparteame: Optional[int] = None + firmafmex: Optional[str] = None + firmafame: Optional[str] = None + codigobarras: Optional[int] = None + fraccionimp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + paisorigenmex: Optional[int] = None + umequivalentemex: Optional[int] = None + umauxiliarmex: Optional[int] = None + perpagrenglon: Optional[int] = None + fraccioname: Optional[int] = None + paisorigename: Optional[int] = None + umequivalenteame: Optional[int] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + clavemoneda: Optional[str] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + valorlimitepar: Optional[Decimal] = None + umalternamex: Optional[int] = None + restringeimpopt: Optional[int] = None + controlremesa: Optional[str] = None + remesainicio: Optional[int] = None + remesafinal: Optional[int] = None + leyendafacame: Optional[str] = None + calcigibasecapt: Optional[int] = None + umequivalentemex2: Optional[int] = None + umequivalenteame2: Optional[int] = None + partecomplementariamex: Optional[int] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + metvalor: Optional[str] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + packingpedclave: Optional[int] = None + porordenporocpack: Optional[str] = None + actualizarpartepartida: Optional[int] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + clavebultos: Optional[str] = None + partecomplemexpartida: Optional[int] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + valorlimiteparmin: Optional[Decimal] = None + packingordencompra: Optional[int] = None + firmaelectronica: Optional[str] = None + solicitarcontrasenaadministrador: Optional[int] = None + incluirlineadelpo: Optional[int] = None + lineapersonaaa: Optional[int] = None + agregarvaenprodterminados: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + ocultarfechahora: Optional[int] = None + + + + + +class QSisCMexSettings(BaseModel): + """Legacy Table: QSisCMex (Fixed Asset Mexican Purchase)""" + consecutivo: Optional[int] = None + prefijocm: Optional[str] = None + consecutivocm: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + flete: Optional[Decimal] = None + paisorigenmex: Optional[int] = None + firmafmex: Optional[str] = None + fraccionimp: Optional[int] = None + tipofraccmex: Optional[int] = None + tasafraccmex: Optional[int] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + clavemoneda: Optional[str] = None + transportista: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + packingordencompra: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + + +class QSisDefSettings(BaseModel): + """Legacy Table: QSisDef (Fixed Asset Definitive Import)""" + consecutivo: Optional[int] = None + prefijoimpo: Optional[str] = None + consecutivoimpo: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + firmafmex: Optional[str] = None + firmafame: Optional[str] = None + generarassettag: Optional[int] = None + codigobarras: Optional[int] = None + fraccionimp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + fraccioname: Optional[int] = None + paisorigenmex: Optional[int] = None + paisorigename: Optional[int] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + tipomn: Optional[str] = None + tipome: Optional[str] = None + clavemoneda: Optional[str] = None + controlremesa: Optional[str] = None + remesainicio: Optional[int] = None + remesafinal: Optional[int] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + metvalor: Optional[str] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + numpartemex: Optional[int] = None + numparteame: Optional[int] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + actualizarpartepartida: Optional[int] = None + packingordencompra: Optional[int] = None + solicitarcontrasenaadministrador: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + + +class QSisGenSettings(BaseModel): + """Legacy Table: QSisGen (Fixed Asset General)""" + consecutivo: Optional[int] = None + dta: Optional[int] = None + dtaexpo: Optional[int] = None + subempresa: Optional[str] = None + patharch: Optional[str] = None + patharchtransmision: Optional[str] = None + pathrespuesta: Optional[str] = None + patharchped: Optional[str] = None + patharchpedconsm: Optional[str] = None + pathgenimpotemp: Optional[str] = None + pathgenexpo: Optional[str] = None + aplicapermat: Optional[int] = None + actseguridad: Optional[int] = None + controldes: Optional[int] = None + diadesactual: Optional[int] = None + fechades: Optional[int] = None + ubiplanta: Optional[str] = None + datoshistoricos: Optional[int] = None + tipovenro: Optional[str] = None + cantvenro: Optional[int] = None + omitirimposubpcodbarras: Optional[str] = None + muestraarchcodbarras: Optional[int] = None + calvalbasetcped: Optional[int] = None + calvalbasetcpedexpo: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + factoriva: Optional[Decimal] = None + filtrocantidad: Optional[Decimal] = None + firmapacking: Optional[int] = None + advertenciatm: Optional[int] = None + calcdepreciacion: Optional[str] = None + valmanifusado: Optional[int] = None + validadecencant: Optional[int] = None + mostraradvertenciaro: Optional[int] = None + usartranspamedocame: Optional[int] = None + escondaamexpacking: Optional[int] = None + cantvscantseries: Optional[int] = None + covefechaemision: Optional[int] = None + interfaceaaconsolidada: Optional[int] = None + interfaceaatcfpff: Optional[str] = None + incluirobscoveobsimpo: Optional[int] = None + agregarincreimpo: Optional[int] = None + repdescargolineal: Optional[int] = None + identificadornodoseriecove: Optional[int] = None + actpdfreportes: Optional[int] = None + patharchpdfimpo: Optional[str] = None + patharchpdfexpo: Optional[str] = None + mensajesvurfc: Optional[int] = None + mostrarpackinglistingles: Optional[int] = None + enviarsubpartidascove: Optional[int] = None + restringepaisimpo: Optional[int] = None + restringpaisexpo: Optional[int] = None + utilizarfechapagopeddeundiaanterior: Optional[int] = None + utilizartitulosalternativosimpresionfactura: Optional[int] = None + usartcdelafechapagopedimpoendescarga: Optional[int] = None + usarvude128o256: Optional[int] = None + agregarnumeroembarque: Optional[int] = None + utilizarumdeexistenciaentransmisionvu: Optional[int] = None + utilizarcodigodebrokerdeclienteenmainx30: Optional[int] = None + hojacalculosepararincrementablesanexo3: Optional[int] = None + hojacalculodesglosefacturaanexo3: Optional[int] = None + utilizarnombregenericomainx30: Optional[int] = None + utilizartcrespectoatipoped: Optional[int] = None + utilizarsolopartesnaftaenco: Optional[int] = None + cambiarpesosporcostounitario: Optional[int] = None + bloqueodeediciondefacturas: Optional[int] = None + calcularcostounitarioenbaseavalortotalscaf: Optional[int] = None + impresionfacturaalterna: Optional[int] = None + transmitirfacalterna: Optional[int] = None + + +class QSisExpoRepSettings(BaseModel): + """Legacy Table: QSisExpoRep (Fixed Asset Export Repeat?)""" + tipofactura: Optional[str] = None + prefijoexpo: Optional[str] = None + consecutivoexpo: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + vendidopor: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + codigobarras: Optional[int] = None + cantpncodbar: Optional[int] = None + dtacerocodbar: Optional[int] = None + cbarrasvalor: Optional[str] = None + firmafmex: Optional[str] = None + fraccionexp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + numpartemex: Optional[int] = None + paisorigenmex: Optional[int] = None + firmafame: Optional[str] = None + fraccioname: Optional[int] = None + paisorigename: Optional[int] = None + numparteame: Optional[int] = None + codigoscac: Optional[int] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + clavemoneda: Optional[str] = None + tipomn: Optional[str] = None + tipome: Optional[str] = None + mostrarvalact: Optional[int] = None + pepsporclase: Optional[int] = None + pepsporfraccion: Optional[int] = None + pepspordescripcion: Optional[int] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + ventqueueact: Optional[int] = None + valfacttc: Optional[str] = None + metvalor: Optional[str] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + enviadoporvendidopor: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + proveedorexportador: Optional[str] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + solicitarpswdactual: Optional[int] = None + solicitarpswddesactual: Optional[int] = None + packingordenventa: Optional[int] = None + imprimirpo: Optional[int] = None + fdainformacioname: Optional[int] = None + packingpedclave: Optional[int] = None + solicitarcontrasenaadministrador: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + + +class QSisImpoSettings(BaseModel): + """Legacy Table: QSisImpo (Fixed Asset Import)""" + consecutivo: Optional[int] = None + prefijoimpo: Optional[str] = None + consecutivoimpo: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + firmafmex: Optional[str] = None + firmafame: Optional[str] = None + codigobarras: Optional[int] = None + fraccionimp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + fraccioname: Optional[int] = None + paisorigenmex: Optional[int] = None + paisorigename: Optional[int] = None + impordencomp: Optional[int] = None + generarassettag: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + tipomn: Optional[str] = None + tipome: Optional[str] = None + clavemoneda: Optional[str] = None + controlremesa: Optional[str] = None + remesainicio: Optional[int] = None + remesafinal: Optional[int] = None + perpagrenglon: Optional[int] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + metvalor: Optional[str] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + numpartemex: Optional[int] = None + numparteame: Optional[int] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + actualizarpartepartida: Optional[int] = None + packingordencompra: Optional[int] = None + solicitarcontrasenaadministrador: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + + +class QSisImpoRepSettings(BaseModel): + """Legacy Table: QSisImpoRep (Fixed Asset Import Repeat?)""" + tipofactura: Optional[str] = None + consecutivo: Optional[int] = None + prefijoimpo: Optional[str] = None + consecutivoimpo: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + firmafmex: Optional[str] = None + firmafame: Optional[str] = None + codigobarras: Optional[int] = None + fraccionimp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + fraccioname: Optional[int] = None + paisorigenmex: Optional[int] = None + paisorigename: Optional[int] = None + impordencomp: Optional[int] = None + generarassettag: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + tipomn: Optional[str] = None + tipome: Optional[str] = None + clavemoneda: Optional[str] = None + controlremesa: Optional[str] = None + remesainicio: Optional[int] = None + remesafinal: Optional[int] = None + perpagrenglon: Optional[int] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + ventqueueact: Optional[int] = None + metvalor: Optional[str] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + numpartemex: Optional[int] = None + numparteame: Optional[int] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + packingordencompra: Optional[int] = None + solicitarcontrasenaadministrador: Optional[int] = None + + +class QSisExpoSettings(BaseModel): + """Legacy Table: QSisExpo (Fixed Asset Export)""" + tipofactura: Optional[str] = None + escambioregimen: Optional[str] = None + prefijoexpo: Optional[str] = None + consecutivoexpo: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + vendidopor: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + codigobarras: Optional[int] = None + cantpncodbar: Optional[int] = None + dtacerocodbar: Optional[int] = None + cbarrasvalor: Optional[str] = None + firmafmex: Optional[str] = None + fraccionexp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + numpartemex: Optional[int] = None + paisorigenmex: Optional[int] = None + costounitmex: Optional[int] = None + ocultarempaquemex: Optional[int] = None + firmafame: Optional[str] = None + fraccioname: Optional[int] = None + paisorigename: Optional[int] = None + fdainformacioname: Optional[int] = None + numparteame: Optional[int] = None + codigoscac: Optional[int] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + clavemoneda: Optional[str] = None + tipomn: Optional[str] = None + tipome: Optional[str] = None + mostrarvalact: Optional[int] = None + pepsporclase: Optional[int] = None + pepsporfraccion: Optional[int] = None + pepspordescripcion: Optional[int] = None + pepsporfraccalterna: Optional[int] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + ventqueueact: Optional[int] = None + valfacttc: Optional[str] = None + metvalor: Optional[str] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + colcantporpeso: Optional[int] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + enviadoporvendidopor: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + proveedorexportador: Optional[str] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + solicitarpswdactual: Optional[int] = None + solicitarpswddesactual: Optional[int] = None + packingordenventa: Optional[int] = None + imprimirpo: Optional[int] = None + packingpedclave: Optional[int] = None + generafacturaimd: Optional[str] = None + generafacturaimdenbasea: Optional[str] = None + solicitarcontrasenaadministrador: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + + +class SettingsPayload(BaseModel): + """ + Master container for all possible overrides. + Using Optional fields so only the delta is required in JSONB. + """ + # SCAII (Invoices / Inventory) + ssisgen: Optional[SSisGenSettings] = None + ssisgen2: Optional[SSisGen2Settings] = None + ssisgen3: Optional[SSisGen3Settings] = None + ssismex: Optional[SSisMexSettings] = None + ssisdef: Optional[SSisDefSettings] = None + ssisexpo: Optional[SSisExpoSettings] = None + ssisimpo: Optional[SSisImpoSettings] = None + + # SCAF (Fixed Assets) + qsiscmex: Optional[QSisCMexSettings] = None + qsisdef: Optional[QSisDefSettings] = None + qsisgen: Optional[QSisGenSettings] = None + qsisexporep: Optional[QSisExpoRepSettings] = None + qsisimpo: Optional[QSisImpoSettings] = None + qsisimporep: Optional[QSisImpoRepSettings] = None + qsisexpo: Optional[QSisExpoSettings] = None + +class AppSettingRequest(BaseModel): + tenant_id: Optional[int] = None + company_id: Optional[int] = None + settings: SettingsPayload + +class AppSettingResponse(BaseModel): + id: int + tenant_id: Optional[int] + company_id: Optional[int] + settings: Dict[str, Any] + + class Config: + from_attributes = True + diff --git a/backend/api/v1/modules/a76/app_settings/service.py b/backend/api/v1/modules/a76/app_settings/service.py new file mode 100644 index 00000000..f2e6f36f --- /dev/null +++ b/backend/api/v1/modules/a76/app_settings/service.py @@ -0,0 +1,137 @@ +from typing import Optional, List, Dict, Any +from sqlalchemy import or_, and_, select, case, nulls_first +from sqlalchemy.orm import Session +from .models import AppSetting + +import logging +logger = logging.getLogger(__name__) + +from decimal import Decimal +def convert_decimals(obj: Any) -> Any: + """ + Recursively converts Decimal objects to floats for JSON serialization. + """ + if isinstance(obj, list): + return [convert_decimals(i) for i in obj] + elif isinstance(obj, dict): + return {k: convert_decimals(v) for k, v in obj.items()} + elif isinstance(obj, Decimal): + return float(obj) + return obj + +def deep_merge(dict1: Dict[str, Any], dict2: Dict[str, Any]) -> Dict[str, Any]: + """ + Recursively merges dict2 into dict1. + """ + for key, value in dict2.items(): + if key in dict1 and isinstance(dict1[key], dict) and isinstance(value, dict): + deep_merge(dict1[key], value) + else: + dict1[key] = value + return dict1 + +class AppSettingsService: + """ + Service to manage hierarchical configuration overrides. + Hierarchy: System (Global) -> Tenant -> Company. + """ + + @staticmethod + def get_resolved_settings(db: Session, tenant_id: int, company_id: int) -> Dict[str, Any]: + """ + Retrieves settings from all levels (Global -> Tenant -> Company) and merges them. + """ + stmt = ( + select(AppSetting) + .where( + or_( + and_(AppSetting.tenant_id.is_(None), AppSetting.company_id.is_(None)), + and_(AppSetting.tenant_id == tenant_id, AppSetting.company_id.is_(None)), + and_(AppSetting.tenant_id == tenant_id, AppSetting.company_id == company_id), + ) + ) + .order_by( + # Ensure the order is: Global (1) -> Tenant (2) -> Company (3) + case( + (and_(AppSetting.tenant_id.is_(None), AppSetting.company_id.is_(None)), 1), + (and_(AppSetting.tenant_id.is_not(None), AppSetting.company_id.is_(None)), 2), + (and_(AppSetting.tenant_id.is_not(None), AppSetting.company_id.is_not(None)), 3), + else_=4 + ).asc() + ) + ) + + results = db.execute(stmt).scalars().all() + logger.info(f"RESOLVE: Found {len(results)} rows for Hierarchy") + + resolved_settings = {} + for row in results: + level_name = "GLOBAL" if not row.tenant_id else ("TENANT" if not row.company_id else "COMPANY") + + # Use safe data logging to avoid crashes + settings_data = row.settings if row.settings else {} + keys = list(settings_data.keys()) if isinstance(settings_data, dict) else "not-a-dict" + logger.info(f"RESOLVE: Merging {level_name} layer with keys: {keys}") + + if isinstance(settings_data, dict): + deep_merge(resolved_settings, settings_data) + + return resolved_settings + + @staticmethod + def upsert_settings(db: Session, tenant_id: Optional[int], company_id: Optional[int], settings: Dict[str, Any]) -> AppSetting: + """ + Inserts or updates settings for a specific level. + """ + level_label = f"level(tenant={tenant_id}, company={company_id})" + logger.info(f"UPSERT Settings START: {level_label}, keys_to_update={list(settings.keys())}") + + # Ensure all Decimals are converted to floats before deep merge and save + settings = convert_decimals(settings) + + stmt = select(AppSetting).where( + and_( + AppSetting.tenant_id == tenant_id if tenant_id is not None else AppSetting.tenant_id.is_(None), + AppSetting.company_id == company_id if company_id is not None else AppSetting.company_id.is_(None) + ) + ) + + existing = db.execute(stmt).scalar_one_or_none() + + if existing: + # Deep merge at the root level (merging categories like ssisgen, ssismex, etc.) + logger.info(f"UPSERT: Updating existing row ID={existing.id}") + # Create a shallow copy of the top-level dict to ensure SQLAlchemy sees a new reference + new_settings = dict(existing.settings) if existing.settings else {} + + # Detailed logging of what's changing + for cat, data in settings.items(): + old_keys = list(new_settings.get(cat, {}).keys()) + new_keys = list(data.keys()) if isinstance(data, dict) else [] + logger.info(f"UPSERT: Merging category [{cat}]. Old keys: {old_keys}, New keys to merge/overwrite: {new_keys}") + + deep_merge(new_settings, settings) + # Second pass of conversion (merged results might still have Decimals if original row had them) + existing.settings = convert_decimals(new_settings) + + from sqlalchemy.orm.attributes import flag_modified + flag_modified(existing, "settings") + else: + logger.info(f"UPSERT: Creating NEW row for {level_label}") + existing = AppSetting( + tenant_id=tenant_id, + company_id=company_id, + settings=settings + ) + db.add(existing) + + try: + db.commit() + db.refresh(existing) + logger.info(f"UPSERT SUCCESS: Row ID={existing.id}, Final Settings Hash Keys={list(existing.settings.keys())}") + except Exception as e: + db.rollback() + logger.error(f"UPSERT FAILED: {str(e)}") + raise e + + return existing diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 33e0dd48..7d8df946 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -47,6 +47,7 @@ from .reports.exportacion.transmission.MAINX30.routes import router as transmiss from .reports.importacion.transmission.temporal.MAINX30.routes import router as transmission_temporal_router from .reports.importacion.transmission.definitive.MAINX30.routes import router as transmission_definitive_router from .reports.importacion.winsaai.router import router as winsaai_router +from .app_settings.routes import router as app_settings_router from .manifests.manifest.routes import router as manifests_router from .manifests.driver.routes import router as manifest_drivers_router @@ -176,6 +177,8 @@ router.include_router( tags=["a76 / reports"] ) +router.include_router(app_settings_router) + # Registrar router de bitácora from .audit_log.router import router as audit_log_router router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"]) diff --git a/frontend/src/lib/api/dashboard/a76/app-settings.ts b/frontend/src/lib/api/dashboard/a76/app-settings.ts new file mode 100644 index 00000000..573fa13b --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/app-settings.ts @@ -0,0 +1,34 @@ +import { BACKEND_URL } from '$lib/config/backend'; + +export interface AppSettingsRequest { + tenant_id?: number | null; + company_id?: number | null; + settings: Record; +} + +export const appSettingsApi = { + /** + * Resolves settings merging Global -> Tenant -> Company hierarchy + */ + async getResolved(tenantId: number, companyId: number): Promise> { + const response = await fetch(`${BACKEND_URL}/v1/a76/app-settings/resolved?tenant_id=${tenantId}&company_id=${companyId}`); + if (!response.ok) throw new Error('Error al obtener configuraciones'); + return response.json(); + }, + + /** + * Upserts an override at a specific level + */ + async upsert(payload: AppSettingsRequest): Promise { + const response = await fetch(`${BACKEND_URL}/v1/a76/app-settings/upsert`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Error al guardar configuración'); + } + return response.json(); + } +}; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte deleted file mode 100644 index 49b741da..00000000 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ /dev/null @@ -1,1750 +0,0 @@ - - -
-
-
-
-

Items de la Factura

-

- Carga partidas, crea o aplica plantillas sin salir de esta vista. -

-
-
- - - -
-
- -
- - - - Línea - {#if operationType === 1} - Factura Impo - Línea - P/S - Cant. Importada - Clase - Número Parte - Descripción - Contiene Subpartida - Partida Principal - Acciones - {:else if showCrTrackingHeader} - Factura Impo - Línea - P/S - Clase - Descripcion Clase - Cant. Importada - U.M. - Preferencia - Contiene Subpartida - Partida Principal - Acciones - {:else if invoiceType === 'REP' || invoiceType === 'REPAR'} - Factura Impo - Línea - P/S - Clase - Número Parte - Descripcion Clase - Cant. Importada - U.M. - Preferencia - Contiene Subpartida - Partida Principal - Acciones - {:else} - P/S - Clase - Descripcion Clase - Cant. Importada - U.M. - Preferencia - Contiene Subpartida - Partida Principal - Acciones - {/if} - - - - {#if displayedItems.length === 0} - - - No hay items disponibles - - - {:else} - {#each displayedItems as item (item.id)} - handleRowClick(item)} - class="group/item-row cursor-pointer transition-colors hover:bg-muted/50 {focusedLine?.id === - item.id - ? 'bg-muted ring-1 ring-primary/20 ring-inset' - : ''}" - > - {#if operationType === 1} - {item.line_number} - {item.fa_data?.search_invoice || '-'} - {item.fa_data?.search_line || '-'} - {item.is_subitem ? 'S' : 'P'} - {item.quantity?.quantity || '0'} - {item.class_code || '-'} - {item.part_number_display || '-'} - - {item.description?.description_spanish || '-'} - - {item.fa_data?.contains_subitems ? 'Sí' : 'No'} - {item.warehouse || '-'} - {:else if showCrTrackingHeader} - {item.line_number} - {item.fa_data?.search_invoice || '-'} - {item.fa_data?.search_line || '-'} - {item.is_subitem ? 'S' : 'P'} - {item.class_code || '-'} - - {item.description?.description_spanish || '-'} - - {item.quantity?.quantity || '0'} - {item.unit_of_measure_code || '-'} - {item.reference_number || '-'} - {item.fa_data?.contains_subitems ? 'Sí' : 'No'} - {item.warehouse || '-'} - {:else if invoiceType === 'REP' || invoiceType === 'REPAR'} - {item.line_number} - {item.fa_data?.search_invoice || '-'} - {item.fa_data?.search_line || '-'} - {item.is_subitem ? 'S' : 'P'} - {item.class_code || '-'} - {item.part_number_display || '-'} - - {item.description?.description_spanish || '-'} - - {item.quantity?.quantity || '0'} - {item.unit_of_measure_code || '-'} - {item.reference_number || '-'} - {item.fa_data?.contains_subitems ? 'Sí' : 'No'} - {item.warehouse || '-'} - {:else} - {item.line_number} - {item.is_subitem ? 'S' : 'P'} - {item.class_code || '-'} - {item.class_description || '-'} - {item.quantity?.quantity || '0'} - {item.unit_of_measure_code || '-'} - {item.reference_number || '-'} - {item.fa_data?.contains_subitems ? 'Sí' : 'No'} - {item.warehouse || '-'} - {/if} - -
- - -
-
-
- {/each} - {#if isLoadingMore} - - - Cargando más items... - - - {/if} - {/if} -
-
-
- - {#if flattenedLines.length > 0} -
- Mostrando {displayedItems.length} de {flattenedLines.length} líneas -
- {/if} -
- -
- {#if operationType === 1} - -
-

- Descripción en español: -

-
- {#if focusedLine} -

- {focusedLine.description?.description_spanish || 'Sin descripción disponible.'} -

- {:else} -

- Selecciona una fila para ver la descripción. -

- {/if} -
-
- {/if} - - -
-
-

Cantidades:

-
-
-
- Partidas: {items.length || 0} -
-
- Bultos: 0 -
-
-
- Importada:{imported || 0}
- Peso neto: {net_weight || 0}
- Peso bruto: {gross_weight || 0}
-
- -

- Valores de importacion: -

- Dolares:0 USD
- Pesos: 0 MXN
- De Captura: 0 USD - -

- spacer -

- - Aduana:0 USD
- Aduana: 0 MXN
-
-
-
- - - - - - Confirmar Eliminación - - ¿Está seguro que desea eliminar este item? Esta acción no se puede deshacer. - - - - - - - - - - - { - if (!open) { - showUsePresetDialog = false; - selectedPreset = null; - } - }} -> - - -
-
- Usar plantilla - - Selecciona una plantilla predefinida para cargar sus partidas. - -
-
- - -
-
- - -
- -
-
-
- - -
-
- -
- {#if isLoadingPresets} -
- - Cargando... -
- {:else if filteredPresets.length === 0} -
- -

No se encontraron plantillas

-
- {:else} - {#each filteredPresets as preset} - - {/each} - {/if} -
-
- - -
- {#if !selectedPreset} -
-
- -
-

Selecciona una plantilla para ver sus detalles

-
- {:else} -
- -
-
-

- {selectedPreset.name} -

-

- {selectedPreset.description || 'Sin descripción disponible.'} -

-
-
-
- Creada -
-
- {selectedPreset.created_at - ? new Date(selectedPreset.created_at).toLocaleDateString(undefined, { - dateStyle: 'long' - }) - : '-'} -
-
-
- - -
- - - - # - Descripción del Item - Cant. - Costo (USD) - - - - {#if selectedPresetItems.length === 0} - - -
- - Esta plantilla no contiene items. -
-
-
- {:else} - {#each selectedPresetItems as item, i} - - - {i + 1} - - -
- - {item?.description?.description_spanish || 'Sin descripción'} - - {#if item.reference_number} - - REF: {item.reference_number} - - {/if} -
-
- - {item?.quantity?.quantity || 0} - - - ${(item?.financial?.unit_cost_usd || 0).toLocaleString(undefined, { - minimumFractionDigits: 2 - })} - -
- {/each} - {/if} -
-
-
-
- {/if} -
-
- - -
- - -
-
-
- - { - if (!open) { - createPresetName = ''; - createPresetDescription = ''; - } - }} -> - - - Crear plantilla - Guarda los elementos actuales como una plantilla reutilizable para inyectar en otras - partidas. - - -
-
-
- - -
-
- - +
@@ -166,8 +180,10 @@ +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index 6afc5f7a..b34b4c1d 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -8,7 +8,7 @@ import { Label } from '$lib/components/ui/label'; import { RadioGroup, RadioGroupItem } from '$lib/components/ui/radio-group'; import { Separator } from '$lib/components/ui/separator'; - import { Loader2, Package, Save, X, FileText, Folder } from 'lucide-svelte'; + import { Loader2, Package, Save, X, FileText, Folder, Calendar } from 'lucide-svelte'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; import { invoicesApi } from '$lib/api/dashboard/a76/invoices'; import { itemsApi, type Item, type ImportLineWithBalance } from '$lib/api/dashboard/a76/items'; @@ -69,7 +69,7 @@ editingItem.fa_data.omit_annex31 = false; } if (editingItem.fa_data.discharge === undefined) { - editingItem.fa_data.discharge = true; + editingItem.fa_data.discharge = false; } } }); @@ -84,6 +84,7 @@ let importInvoiceLines = $state([]); let exportInvoiceLines = $state([]); let loadingImportLines = $state(false); + let loadingImportDetails = $state(false); let loadingExportLines = $state(false); async function loadImportLines(invoiceId: number) { @@ -96,7 +97,12 @@ const asOfDate = invoice?.invoice_date ? invoice.invoice_date.split('T')[0] : undefined; - const res = await itemsApi.listByInvoiceWithBalance(invoiceId, companyId, asOfDate); + const res = await itemsApi.listByInvoiceWithBalance( + invoiceId, + companyId, + asOfDate, + invoice?.id + ); importInvoiceLines = res.data ?? []; } catch { importInvoiceLines = []; @@ -137,6 +143,32 @@ } const visibility = $derived.by(() => getVisibility(invoiceType ?? invoice?.invoice_type, operationType ?? invoice?.operation_type)); + + // Proportional weight calculation (CALCULO_PESOS) + function recalculateWeights() { + if (!editingItem.quantity || !editingItem.fa_data?.discharge) return; + + const qty = Number(editingItem.quantity.quantity || 0); + const sourceQty = Number(editingItem.fa_data.source_quantity || 0); + const sourceNet = Number(editingItem.fa_data.source_net_weight || 0); + const sourceGross = Number(editingItem.fa_data.source_gross_weight || 0); + const sourcePackages = Number(editingItem.fa_data.source_packages || 0); + + if (sourceQty > 0) { + editingItem.quantity.net_weight = Number((qty * (sourceNet / sourceQty)).toFixed(8)); + editingItem.quantity.gross_weight = Number((qty * (sourceGross / sourceQty)).toFixed(8)); + editingItem.quantity.package_quantity = Math.floor(qty * (sourcePackages / sourceQty)); + } + } + + // Watch for quantity changes to recalculate proportional weights + $effect(() => { + const qty = editingItem.quantity?.quantity; + if (qty !== undefined && editingItem.fa_data?.discharge) { + recalculateWeights(); + } + }); + /** Show link-to-import block for import (CR tracking) or for export when showExportLinkToImportBlock. */ const showLinkToImportBlock = $derived.by(() => { const normalizedOperationType = operationType ?? invoice?.operation_type; @@ -216,6 +248,9 @@ } return visibility.showCrTrackingHeader; }); + + const isReadOnly = $derived(invoice?.status === 'processed'); + const isExport = $derived.by(() => { const op = operationType ?? invoice?.operation_type; return op === 1 || op === 'exp'; @@ -480,7 +515,7 @@

Datos Principales

-
+
{#if editingItem.quantity && editingItem.financial && editingItem.customs} {/if} +
@@ -504,6 +541,7 @@ {/if} @@ -531,6 +569,7 @@ bind:customs={editingItem.customs} bind:quantities={editingItem.quantity} invoice={invoice} + disabled={isReadOnly} /> {/if} {#if editingItem.financial && editingItem.quantity} @@ -538,9 +577,12 @@ bind:financials={editingItem.financial} bind:quantities={editingItem.quantity} lineItem={editingItem} - {invoice} + invoice={invoice} + disabled={isReadOnly} /> {/if} + + @@ -550,6 +592,7 @@ bind:lineItem={editingItem} bind:descriptions={editingItem.description} visibility={visibility} + disabled={isReadOnly} /> {/if} @@ -561,13 +604,19 @@ bind:series={editingItem.series} lineItem={editingItem} {invoice} + disabled={isReadOnly} /> {/if} {#if visibility.showLabelingTab} - + {/if} @@ -578,6 +627,7 @@ invoiceConsecutive={invoice?.id} invoiceNumber={invoice?.invoice_number ?? ''} {visibility} + disabled={isReadOnly} /> {/if} @@ -595,17 +645,20 @@
- + + {#if !isReadOnly} + + {/if}
@@ -613,10 +666,10 @@ {/each} -
-
+ + - - - - - Seleccionar línea de importación -

- Solo se muestran líneas con saldo disponible -

+ + + + +
+
+ +
+ Partidas de Importación +
+ + Selecciona una línea con saldo disponible para realizar la descarga. +
- -
-
- {#if importInvoiceLines.every(l => !l.has_balance)} -

- No hay líneas con saldo disponible en esta factura. -

+ +
+ {#if loadingImportLines} +
+ +

Cargando partidas de la factura...

+
+ {:else if importInvoiceLines.every(l => !l.has_balance)} +
+ +

Sin saldo disponible

+

No hay líneas con saldo en esta factura para descargar.

+
{:else} - - - - - - - - - - - - - - - - - - - - {#each importInvoiceLines as lineItem} - {#if lineItem.has_balance} - { +
+ {#each importInvoiceLines as lineItem} + {#if lineItem.has_balance} +
- - - - - - - - - - - - - {/if} - {/each} - -
LíneaFacturaFechaNum. ParteClaseDescripciónCant. Imp.Ret. Temp.Ret. Def.Saldo Disp.EstatusSub.
{lineItem.line_number}{lineItem.invoice_number ?? '-'} - {lineItem.invoice_date ? lineItem.invoice_date.slice(0, 10) : '-'} - {lineItem.part_number ?? '-'}{lineItem.class_code ?? '-'} - {lineItem.description_spanish ?? '-'} - - {lineItem.quantity != null ? lineItem.quantity.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'} - {lineItem.unit_of_measure_code ?? ''} - - {lineItem.quantity_used_temp != null ? lineItem.quantity_used_temp.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'} - - {lineItem.quantity_used_def != null ? lineItem.quantity_used_def.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'} - - {lineItem.available_balance.toLocaleString('es-MX', { maximumFractionDigits: 4 })} - {lineItem.unit_of_measure_code ?? ''} - + } catch (err) { + console.error('Error auto-filling import info:', err); + } finally { + loadingImportDetails = false; + } + }} + > + +
+
+
+ Línea {lineItem.line_number} +
+
+ Factura: {lineItem.invoice_number ?? '-'} +
+
+
{#if lineItem.invoice_status === 'processed'} - + Procesada - {:else if lineItem.invoice_status === 'reversed'} - - Revertida - - {:else} - - {lineItem.invoice_status ?? 'Pendiente'} - {/if} -
{#if lineItem.is_subitem} - - Sub + + Subpartida - {:else if lineItem.contains_subitems} - - {lineItem.subitem_count ?? 0} sub - - {:else} - {/if} -
+
+
+ + +
+ +
+

Número de Parte / Clase

+

+ {lineItem.part_number ?? '-'} +

+

+ {lineItem.class_code ?? 'Sin Clase'} +

+
+ + +
+

Descripción

+

+ {lineItem.description_spanish || 'Sin descripción'} +

+
+ + +
+
+ Cant. Imp: + + {lineItem.quantity?.toLocaleString() || '0'} {lineItem.unit_of_measure_code || ''} + +
+
+ Desc. Temp: + + -{lineItem.quantity_used_temp?.toLocaleString() || '0'} + +
+
+ + +
+

Saldo Disponible

+
+ + {lineItem.available_balance.toLocaleString('es-MX', { maximumFractionDigits: 4 })} + + + {lineItem.unit_of_measure_code ?? ''} + +
+
+
+ +
+
+ + Fecha: {lineItem.invoice_date ? lineItem.invoice_date.slice(0, 10) : 'N/A'} +
+
+ Hacer descarga → +
+
+ + {/if} + {/each} +
{/if} - + + +

+ Mostrando {importInvoiceLines.filter(l => l.has_balance).length} partidas con saldo +

+ +
\ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte index f279e7cb..04e6fcaf 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte @@ -16,15 +16,20 @@ quantities = $bindable(), financials = $bindable(), customs = $bindable(), - invoice + invoice, + disabled = false }: { lineItem: Partial; quantities: LineQuantities; financials: LineFinancials; customs: LineCustoms; invoice: Invoice | null; + disabled?: boolean; } = $props(); + + const activeCompanyId = $derived(companyStore?.activeCompany?.id); + let showClassDialog = $state(false); let showUnitDialog = $state(false); let showCountryDialog = $state(false); @@ -47,6 +52,8 @@ return frac; }); + let isDischargeActive = $derived(lineItem.fa_data?.discharge === true); + // Track previous class_id to detect changes let previousClassId = $state(undefined); @@ -132,8 +139,17 @@ $effect(() => { const currentClassId = lineItem.class_id; const activeCompanyId = companyStore?.activeCompany?.id; + + // Initial load protection: If previousClassId is undefined, this is the first run. + // We set previousClassId to the current value without fetching catalog defaults + // if we are opening an existing record that already has a class. + if (previousClassId === undefined && currentClassId) { + previousClassId = currentClassId; + return; + } - // Only fetch if class_id changed, is valid, and we have a company + // Only fetch and apply defaults if class_id changed from a previous value, + // is valid, and we have a company. if (currentClassId && currentClassId !== previousClassId && activeCompanyId) { previousClassId = currentClassId; @@ -188,40 +204,124 @@ // Auto-fetch historical tariff rate when fraction, country, type, and date are available $effect(() => { const fraction = customs.fraction?.replace(/\./g, '') || ''; - const nico = fraction.substring(8, 10); - const fractionType = customs.fraction_type; + const nico = fraction.length >= 10 ? fraction.substring(8, 10) : '00'; + const tariffType = customs.fraction_type || 'GENERAL'; const invoiceDate = invoice?.invoice_date; + + // Map invoice movement type to direction string + const direction = invoice?.operation_type === 'imp' ? 'import' : 'export'; // Only fetch if all required fields are present and fraction has at least 8 chars - if (fraction && fraction.length >= 8 && nico && fractionType && invoiceDate) { + if (activeCompanyId && fraction && fraction.length >= 8 && tariffType && invoiceDate) { const historicalFraction = fraction.substring(0, 8); + const isRegimeChange = invoice?.compliance_mx?.is_regime_change ? 'true' : 'false'; + + // Format date to YYYY-MM-DD + let formattedDate = ''; + if (invoiceDate) { + const dateObj = typeof invoiceDate === 'string' ? new Date(invoiceDate) : invoiceDate; + formattedDate = dateObj.toISOString().split('T')[0]; + } + + if (!formattedDate) return; + const params = new URLSearchParams({ + company_id: activeCompanyId.toString(), historical_fraction: historicalFraction, nico: nico, - fraction_type: fractionType, - invoice_date: invoiceDate + direction: direction, + tariff_type: tariffType, + invoice_date: formattedDate, + is_regime_change: isRegimeChange }); fetch(`/api-sveltekit/historical-tariff-fractions/rate?${params}`) - .then(response => { + .then(async response => { if (response.ok) { return response.json(); } - throw new Error('Failed to fetch tariff rate'); + // If 422 or other error, try to extract the specific detail from FastAPI + let errorMsg = `HTTP ${response.status}`; + try { + const errData = await response.json(); + if (errData.detail) { + errorMsg = typeof errData.detail === 'string' ? errData.detail : JSON.stringify(errData.detail); + } else if (errData.details || errData.error) { + errorMsg = errData.details || errData.error; + } + } catch (e) { + errorMsg = await response.text().catch(() => `Error ${response.status}`); + } + + console.warn('Historical tariff lookup failed at backend:', errorMsg); + return { found: false, rate: 0 }; }) .then(data => { - if (data.found && data.rate !== null) { - customs.rate = data.rate; + if (data && data.found && data.rate !== null) { + customs.rate = String(data.rate).substring(0, 10); } else { customs.rate = '0'; } }) .catch(error => { - console.error('Error fetching historical tariff rate:', error); - // Keep current value on error + console.warn('Historical tariff lookup network error:', error); }); } }); + + /** + * Centralized calculation logic based on the Clarion routine + * @param source - Which field triggered the update + */ + function recalculateFinancials(source: 'total' | 'unit_cost' | 'quantity') { + const qty = Number(quantities.quantity || 0); + const exchangeRate = Number(invoice?.financials?.exchange_rate || 1); + const exchangeRateMM = Number(invoice?.financials?.exchange_rate_mm || 1); + const ivaFactor = Number(invoice?.financials?.iva_factor || 0); + const currencyType = invoice?.financials?.currency_type || 'USD'; + + // 1. Synchronize Unit Cost and Total + if (source === 'total') { + if (qty > 0) { + financials.unit_cost_capture = Number(financials.value_mc || 0) / qty; + } + } else { + // source is unit_cost or quantity + financials.value_mc = Number(financials.unit_cost_capture || 0) * qty; + } + + const unitCostCapture = Number(financials.unit_cost_capture || 0); + const valueCapture = Number(financials.value_mc || 0); + + // 2. Perform Triangulation based on Currency Type + if (currencyType === 'ME' || currencyType === 'FOREIGN' || currencyType === 'USD') { + financials.unit_cost_usd = unitCostCapture; + financials.unit_cost_mxn = unitCostCapture * exchangeRate; + financials.value_usd = valueCapture; + financials.value_mxn = valueCapture * exchangeRate; + } else if (currencyType === 'MN' || currencyType === 'LOCAL' || currencyType === 'MXN') { + financials.unit_cost_mxn = unitCostCapture; + financials.unit_cost_usd = exchangeRate > 0 ? unitCostCapture / exchangeRate : 0; + financials.value_mxn = valueCapture; + financials.value_usd = exchangeRate > 0 ? valueCapture / exchangeRate : 0; + } else if (currencyType === 'MC') { + financials.unit_cost_mc = unitCostCapture; + // Clarion logic for MC: + // 1. Convert Capture to USD using exchangeRateMM (TipoCambioMM) + financials.unit_cost_usd = unitCostCapture * exchangeRateMM; + // 2. Convert resulting USD to MXN using exchangeRate (TipoCambio) + financials.unit_cost_mxn = financials.unit_cost_usd * exchangeRate; + + financials.value_mc = valueCapture; + financials.value_usd = financials.unit_cost_usd * qty; + financials.value_mxn = financials.unit_cost_mxn * qty; + } + + // 3. VAT Calculation + financials.vat_mxn = (Number(financials.value_mxn || 0) * ivaFactor) / 100; + financials.vat_usd = (Number(financials.value_usd || 0) * ivaFactor) / 100; + financials.vat_mc = (Number(financials.value_mc || 0) * ivaFactor) / 100; + } @@ -242,16 +342,19 @@ type="text" value={(lineItem as any).class_code || ''} readonly + disabled={disabled || isDischargeActive} class="h-8 text-xs flex-1 bg-muted cursor-pointer" placeholder="Selecciona una clase" - onclick={() => (showClassDialog = true)} + onclick={() => !disabled && !isDischargeActive && (showClassDialog = true)} /> @@ -263,7 +366,22 @@
- + recalculateFinancials('quantity')} + class="h-8 text-xs text-right" + /> + + {#if isDischargeActive && lineItem.fa_data?.source_balance !== undefined && Number(quantities.quantity) > Number(lineItem.fa_data.source_balance)} +

+ ⚠️ Excede saldo disponible ({lineItem.fa_data.source_balance}) +

+ {/if}
@@ -274,18 +392,21 @@ type="text" value={(lineItem as any).unit_code || ''} readonly + disabled={disabled || isDischargeActive} class="h-8 text-xs flex-1 bg-muted cursor-pointer" placeholder="Selecciona U.M." - onclick={() => (showUnitDialog = true)} + onclick={() => !disabled && !isDischargeActive && (showUnitDialog = true)} /> +
@@ -293,8 +414,36 @@
- - USD + recalculateFinancials('unit_cost')} + class="h-8 text-xs text-right flex-1" + /> + + {invoice?.financials?.currency_type || 'USD'} +
+
+ +
+ +
+ recalculateFinancials('total')} + class="h-8 text-xs text-right flex-1" + /> + + {invoice?.financials?.currency_type || 'USD'}
@@ -305,18 +454,21 @@ id="fraccion" value={fractionDisplay()} readonly + disabled={disabled} class="h-8 text-xs text-center flex-1 bg-muted cursor-pointer" placeholder="Selecciona fracción" - onclick={() => (showFractionDialog = true)} + onclick={() => !disabled && (showFractionDialog = true)} /> + @@ -328,30 +480,34 @@ id="pais_origen" value={customs.origin_country || ''} readonly + disabled={disabled} class="h-8 text-xs flex-1 bg-muted cursor-pointer" placeholder="Selecciona país" - onclick={() => (showCountryDialog = true)} + onclick={() => !disabled && (showCountryDialog = true)} /> +
- +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte index 61c68f09..32d494c3 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte @@ -3,7 +3,8 @@ import { Label } from '$lib/components/ui/label'; import { Button } from '$lib/components/ui/button'; import { Folder } from 'lucide-svelte'; - import type { Item, LineItem, LineDescriptions, LineCustoms, LineQuantities } from '$lib/api/dashboard/a76/items'; + import type { Item, LineDescriptions, LineCustoms, LineQuantities } from '$lib/api/dashboard/a76/items'; + import type { Invoice } from '$lib/api/dashboard/a76/invoices'; import PackageDialog from './package-dialog.svelte'; @@ -13,16 +14,20 @@ descriptions = $bindable(), customs = $bindable(), quantities = $bindable(), - invoice + invoice, + disabled = false }: { item: Partial; - lineItem: LineItem; + lineItem: any; descriptions: LineDescriptions; customs: LineCustoms; quantities: LineQuantities; invoice: Invoice | null; + disabled?: boolean; } = $props(); + + let packageDialogOpen = $state(false); let package_key = $state(''); let package_weight_unit = $state(0); @@ -117,8 +122,9 @@
- +
+
@@ -127,17 +133,22 @@ bind:value={package_key} class="h-7 text-xs flex-1" readonly + disabled={disabled} placeholder="Seleccionar..." + onclick={() => !disabled && (packageDialogOpen = true)} /> + +
@@ -159,12 +170,14 @@
- +
+
- +
+
{weightUnitLabel} @@ -175,19 +188,22 @@
- +
+
- +
+
- +
+
Advalorem: {customs.advalorem_american || '0.00'} @@ -197,16 +213,19 @@
- +
+
- +
+
- +
+
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte index bcb53791..c60d96a6 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte @@ -3,7 +3,7 @@ import * as Table from '$lib/components/ui/table'; import { Input } from '$lib/components/ui/input'; import { Button } from '$lib/components/ui/button'; - import { Search, Loader2 } from 'lucide-svelte'; + import { Search, Loader2, Info, Package } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import { companyStore } from '$lib/stores/company.svelte'; @@ -16,160 +16,227 @@ } = $props(); let searchQuery = $state(''); + let debouncedSearch = $state(''); let isSearching = $state(false); + let isLoadingMore = $state(false); let parts = $state([]); - let displayedParts = $state([]); let currentPage = $state(1); - let itemsPerPage = 10; + let hasMore = $state(true); + let totalItems = $state(0); + const itemsPerPage = 25; - const filteredParts = $derived( - searchQuery - ? parts.filter(p => - p.part_number?.toLowerCase().includes(searchQuery.toLowerCase()) || - p.description_spanish?.toLowerCase().includes(searchQuery.toLowerCase()) || - p.description_english?.toLowerCase().includes(searchQuery.toLowerCase()) - ) - : parts - ); - - $effect(() => { - if (open) { - searchParts(); - } - }); - - $effect(() => { - currentPage = 1; - loadMoreParts(); - }); - - async function searchParts() { + async function fetchParts(page: number = 1, search: string = '') { const activeCompanyId = companyStore?.activeCompany?.id; - if (!activeCompanyId) { - toast.error('No hay compañía activa'); - return; - } + if (!activeCompanyId) return; + + if (page === 1) isSearching = true; + else isLoadingMore = true; - isSearching = true; try { - const response = await fetch( - `/api-sveltekit/parts?company_id=${activeCompanyId}&limit=100`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - } - } - ); + const params = new URLSearchParams({ + company_id: activeCompanyId.toString(), + page: page.toString(), + page_size: itemsPerPage.toString(), + sort_by: 'part_number', + sort_order: 'asc' + }); - if (!response.ok) { - throw new Error('Error al buscar números de parte'); + if (search) { + params.append('q', search); } + const response = await fetch(`/api-sveltekit/parts?${params.toString()}`); + if (!response.ok) throw new Error('Error al buscar números de parte'); + const data = await response.json(); - parts = data.items || []; - loadMoreParts(); + const newItems = data.items || []; + + if (page === 1) { + parts = newItems; + } else { + parts = [...parts, ...newItems]; + } + + totalItems = data.total || 0; + hasMore = newItems.length === itemsPerPage; + currentPage = page; } catch (error) { - console.error('Error searching parts:', error); - toast.error('Error al buscar números de parte'); - parts = []; + console.error('Error fetching parts:', error); + toast.error('Error al cargar números de parte'); } finally { isSearching = false; + isLoadingMore = false; } } - function loadMoreParts() { - const start = 0; - const end = currentPage * itemsPerPage; - displayedParts = filteredParts.slice(start, end); - } - - function handleScroll(e: Event) { - const target = e.target as HTMLDivElement; - const threshold = 100; - const scrolledToBottom = target.scrollHeight - target.scrollTop - target.clientHeight < threshold; + // Debounce effect + $effect(() => { + // Accedemos a searchQuery para que el efecto dependa de él + const query = searchQuery; - if (scrolledToBottom && displayedParts.length < filteredParts.length) { - currentPage++; - loadMoreParts(); - } - } + const timeout = setTimeout(() => { + if (debouncedSearch !== query) { + debouncedSearch = query; + currentPage = 1; + fetchParts(1, query); + } + }, 400); + + return () => clearTimeout(timeout); + }); function handleSelect(part: any) { - if (onSelect) { - onSelect(part); - } + if (onSelect) onSelect(part); open = false; } + + // Intersection Observer for Infinite Scroll + let observerNode: HTMLElement | null = $state(null); + + $effect(() => { + if (!observerNode || !hasMore || isSearching || isLoadingMore) return; + + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting) { + fetchParts(currentPage + 1, debouncedSearch); + } + }, { threshold: 0.1 }); + + observer.observe(observerNode); + return () => observer.disconnect(); + }); + + // Reset state when opening + $effect(() => { + if (open) { + currentPage = 1; + searchQuery = ''; + debouncedSearch = ''; + fetchParts(1, ''); + } + }); - - - Seleccionar Número de Parte - - Busca y selecciona un número de parte para la partida + + +
+
+ +
+ Números de Parte +
+ + Busca y selecciona un número de parte del inventario maestro.
-
-
- +
+
+ + {#if isSearching} +
+ +
+ {/if}
-
- {#if isSearching} -
- -
- {:else} +
+
- - - Número de Parte - Descripción (ES) - Descripción (EN) - Clase - + + + Número de Parte + Descripción (ES) + Descripción (EN) + Clase + - {#if displayedParts.length === 0} - - - No se encontraron números de parte + {#each parts as part (part.id)} + handleSelect(part)} + > + + {part.part_number} + + +
+ {part.description_spanish || '-'} +
+
+ +
+ {part.description_english || '-'} +
+
+ + + {part.part_class || '-'} + + + +
+ +
{:else} - {#each displayedParts as part} - handleSelect(part)}> - {part.part_number} - {part.description_spanish || '-'} - {part.description_english || '-'} - {part.part_class || '-'} - - + {#if !isSearching} + + +
+
+ +
+

No hay resultados para esta búsqueda

+

Verifica el número de parte o la descripción

+
- {/each} + {/if} + {/each} + + + {#if hasMore} + + +
+ {#if isLoadingMore} +
+ + Cargando más números de parte... +
+ {/if} +
+
+
{/if}
- {/if} +
- -

- Mostrando {displayedParts.length} de {filteredParts.length} resultados -

+ +
+ + Mostrando {parts.length} de {totalItems} registros +
+
+ +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/summary-section.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/summary-section.svelte index 60317a0c..dc0479bf 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/summary-section.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/summary-section.svelte @@ -8,14 +8,17 @@ financials = $bindable(), quantities = $bindable(), lineItem, - invoice + invoice, + disabled = false }: { financials: LineFinancials; quantities: LineQuantities; lineItem?: Partial; invoice?: Invoice | null; + disabled?: boolean; } = $props(); + // Helper function to safely format numbers function formatNumber(value: any, decimals: number = 8): string { const num = Number(value); diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte index d071bc47..b48c1ab8 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte @@ -13,13 +13,16 @@ let { lineItem = $bindable(), descriptions = $bindable(), - visibility + visibility, + disabled = false }: { lineItem: Partial; descriptions: LineDescriptions; visibility: InvoiceItemVisibility; + disabled?: boolean; } = $props(); + let taxPaidValue = $derived(lineItem.tax_payment ? 'si' : 'no'); function setTaxPaid(val: string) { lineItem.tax_payment = val === 'si'; @@ -108,7 +111,9 @@ +
@@ -123,17 +128,19 @@
- +
+
@@ -145,12 +152,13 @@
- +
- +
+
{/if} @@ -159,17 +167,18 @@
FDA / FCC
- +
- +
- +
+
{/if} @@ -182,7 +191,9 @@ +
@@ -195,10 +206,11 @@
- + - +
+
{/if} @@ -209,22 +221,24 @@
- +
- +
+
{/if} @@ -238,11 +252,13 @@ +
@@ -257,21 +273,23 @@ {#if visibility.showContinuationMilitary}
- +
+ {/if} - {#if visibility.showContinuationOwnOmitAnnex} + {#if visibility.showContinuationOwnOmitAnnex && lineItem.fa_data}
- +
- +
+
{/if} @@ -280,12 +298,13 @@
- +
- +
+
{/if}
@@ -298,28 +317,30 @@
- +
- +
- +
+
{/if} {#if visibility.showContinuationConsiderA31}
- +
+ {/if} {#if visibility.showContinuationExtraDescription} @@ -328,10 +349,12 @@
+ {/if}
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-identifiers.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-identifiers.svelte index 9600aa83..0f88ad96 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-identifiers.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-identifiers.svelte @@ -14,14 +14,17 @@ lineItem = $bindable(), invoiceConsecutive = undefined, invoiceNumber = '', - visibility = { showMexicanIdEnhanced: false } + visibility = { showMexicanIdEnhanced: false }, + disabled = false }: { lineItem: Partial, invoiceConsecutive?: number, invoiceNumber?: string, - visibility?: any + visibility?: any, + disabled?: boolean } = $props(); + // Initialize identifiers if not present if (!lineItem.identifiers) { lineItem.identifiers = []; @@ -148,12 +151,13 @@
-
+
@@ -162,9 +166,12 @@ Num. Factura Línea Imagen - Acciones + {#if !disabled} + Acciones + {/if} + {#if lineItem.series && lineItem.series.length > 0} {#each lineItem.series as asset, index} @@ -181,17 +188,21 @@ - {/if} - -
- - -
-
+ {#if !disabled} + + +
+ + +
+
+ {/if} + {/each} {:else} @@ -212,12 +223,13 @@
-
+
@@ -226,9 +238,12 @@ Compl. 1 Compl. 2 Compl. 3 - Acciones + {#if !disabled} + Acciones + {/if} + {#if lineItem.identifiers && lineItem.identifiers.length > 0} {#each lineItem.identifiers as idDetail, index} @@ -237,17 +252,20 @@ {idDetail.complement1 || '-'} {idDetail.complement2 || '-'} {idDetail.complement3 || '-'} - -
- - -
-
+ {#if !disabled} + +
+ + +
+
+ {/if} + {/each} {:else} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-labeling.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-labeling.svelte index bc9d3eb9..2e475913 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-labeling.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-labeling.svelte @@ -10,13 +10,16 @@ let { lineItem = $bindable(), descriptions = $bindable(), - visibility + visibility, + disabled = false }: { lineItem: Partial, descriptions: LineDescriptions, - visibility: any + visibility: any, + disabled?: boolean } = $props(); + // Ensure series is an array if (!lineItem.series) { lineItem.series = []; @@ -90,12 +93,13 @@
- +
- +
+
{/if} @@ -108,9 +112,11 @@ id="cantidad_importar" type="number" bind:value={lineItem.quantity!.quantity} + disabled={disabled} class="h-7 text-xs" />
+ {/if} {#if visibility.showLabelingValuationValue}
@@ -120,9 +126,11 @@ type="number" step="0.00000001" bind:value={lineItem.valuation_determined_value} + disabled={disabled} class="h-7 text-xs" />
+ {/if} {/if}
@@ -135,6 +143,7 @@ @@ -142,12 +151,14 @@ size="icon" variant="outline" class="h-7 w-7" - onclick={() => valuationSelectorOpen = true} + disabled={disabled} + onclick={() => (valuationSelectorOpen = true)} >
+ {/if} {#if visibility.showUsageReason} @@ -156,10 +167,12 @@ + {/if} {/if} @@ -170,10 +183,12 @@ + {/if} {/if} @@ -184,11 +199,12 @@ Assets / Series
-
+
@@ -197,9 +213,12 @@ Asset Num Factura Línea - Acc + {#if !disabled} + Acc + {/if} + {#each lineItem.series || [] as asset, i} @@ -207,17 +226,20 @@ {asset.number_id || '-'} {asset.import_invoice || '-'} {asset.import_line || '-'} - -
- - -
-
+ {#if !disabled} + +
+ + +
+
+ {/if}
+ {:else} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte index 9e93e649..5ede683c 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte @@ -4,22 +4,27 @@ import { Checkbox } from '$lib/components/ui/checkbox'; import { Label } from '$lib/components/ui/label'; import { Button } from '$lib/components/ui/button'; - import { Plus, Pencil, Trash2, CheckCircle2 } from 'lucide-svelte'; - import type { Item, LineDescriptions, Serie } from '$lib/api/dashboard/a76/items'; + import { Plus, Pencil, Trash2, CheckCircle2, AlertTriangle } from 'lucide-svelte'; + import { itemsApi, type Item, type LineDescriptions, type Serie } from '$lib/api/dashboard/a76/items'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; + import { companyStore } from '$lib/stores/company.svelte'; + import { toast } from 'svelte-sonner'; let { descriptions = $bindable(), series = $bindable(), lineItem, - invoice + invoice, + disabled = false }: { descriptions: LineDescriptions; series: Serie[] | Serie; lineItem: Partial; invoice: Invoice | null; + disabled?: boolean; } = $props(); + // Normalize to array for display and mutations const seriesList = $derived( Array.isArray(series) ? series : series != null ? [series] : [] @@ -70,6 +75,28 @@ } } + async function clearAllSeries() { + if (seriesList.length === 0) return; + + const confirmed = confirm(`¿Estás seguro de que deseas borrar TODAS las series de esta partida? Esta acción no se puede deshacer.`); + if (!confirmed) return; + + try { + // Si la partida ya existe en la DB, llamamos al endpoint de borrado físico + if (lineItem.id && companyStore.activeCompany?.id) { + await itemsApi.deleteSeries(lineItem.id, companyStore.activeCompany.id); + toast.success('Series eliminadas correctamente y registrado en bitácora.'); + } + + // Limpiar el estado local + series = []; + selectedSeriesIndex = null; + } catch (err) { + console.error('Error clearing series:', err); + toast.error('Error al intentar borrar las series.'); + } + } + // Current serie being edited (reference into the array) const currentSerie = $derived( selectedSeriesIndex !== null && seriesList[selectedSeriesIndex] != null @@ -149,21 +176,39 @@ { if (descriptions) descriptions.has_serial = v; }} /> + +
+
+ + +
- @@ -191,9 +236,10 @@ hasSerial && selectForEdit(i)} + : ''} {!hasSerial || disabled ? 'opacity-70' : ''}" + onclick={() => hasSerial && !disabled && selectForEdit(i)} > + {serie.row ?? i + 1} {serie.serial_numbers || '-'} @@ -211,12 +257,13 @@ variant="ghost" size="icon" class="h-7 w-7 text-blue-600" - disabled={!hasSerial} + disabled={disabled || !hasSerial} onclick={(e) => { e.stopPropagation(); - if (hasSerial) selectForEdit(i); + if (hasSerial && !disabled) selectForEdit(i); }} > + @@ -260,12 +308,19 @@ class="h-7 text-xs bg-emerald-600 hover:bg-emerald-700 text-white gap-1" onclick={clearSelection} > - - Aceptar / Listo - - + {#if !disabled} + + {/if} +
@@ -296,9 +351,10 @@ type="number" min={1} step={1} - disabled={!hasSerial} + disabled={disabled || !hasSerial} onblur={clampRowToInteger} /> +
@@ -308,9 +364,10 @@ class="h-8 text-sm focus:ring-primary" maxlength={50} placeholder="Número de serie..." - disabled={!hasSerial} + disabled={disabled || !hasSerial} oninput={(e) => handleSerieInput(e, 'serial_numbers')} /> +
@@ -320,9 +377,10 @@ class="h-8 text-sm focus:ring-primary" maxlength={50} placeholder="Modelo..." - disabled={!hasSerial} + disabled={disabled || !hasSerial} oninput={(e) => handleSerieInput(e, 'model')} /> +
@@ -341,9 +399,10 @@ class="h-8 text-sm focus:ring-primary" maxlength={50} placeholder="Sub modelo..." - disabled={!hasSerial} + disabled={disabled || !hasSerial} oninput={(e) => handleSerieInput(e, 'sub_model')} /> +
@@ -353,23 +412,27 @@ class="h-8 text-sm focus:ring-primary" maxlength={25} placeholder="Número ID..." - disabled={!hasSerial} + disabled={disabled || !hasSerial} oninput={(e) => handleSerieInput(e, 'number_id')} /> +
-
- -
+ {#if !disabled} +
+ +
+ {/if} + {/if} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index dd6eb2b2..65ad9610 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -1,5 +1,6 @@ + +
{label}...
+ + +``` + +## Scope Notes + +- This skill is intended to apply repository-wide by default. +- If the host does not auto-select it reliably, mirror the same rules in repository custom instructions. \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py b/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py index 1d0825b6..7c0320d2 100644 --- a/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py +++ b/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py @@ -1,6 +1,7 @@ from decimal import Decimal from sqlalchemy.orm import Session from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion +from api.v1.modules.a76.general_catalogs.equivalencies.models import EquivalencyItem def _get_unit_equivalence( db: Session, @@ -13,11 +14,15 @@ def _get_unit_equivalence( Busca una conversión entre dos unidades de medida. Paridad: REVEQUIVALENCIA (Clarion SCAII). + Busca primero en el catálogo de Conversiones (unit_conversions) y, + si no encuentra, en el catálogo de Equivalencias (equivalency_items). + Retorna (multi_divide, factor_conv): - ('M', factor) → multiplicar cantidad por factor - ('D', factor) → dividir cantidad por factor - ('', 0) → no existe equivalencia """ + # ── 1. Catálogo de Conversiones ────────────────────────────────────────── conv = ( db.query(UnitConversion) .filter( @@ -44,4 +49,33 @@ def _get_unit_equivalence( if conv_inv and conv_inv.conversion_factor: return "D", conv_inv.conversion_factor + # ── 2. Catálogo de Equivalencias (fallback) ────────────────────────────── + eq = ( + db.query(EquivalencyItem) + .filter( + EquivalencyItem.tenant_id == tenant_id, + EquivalencyItem.company_id == company_id, + EquivalencyItem.original_field == from_unit, + EquivalencyItem.external_field == to_unit, + ) + .first() + ) + if eq: + factor = eq.conversion_factor if eq.conversion_factor else Decimal(1) + return "M", factor + + eq_inv = ( + db.query(EquivalencyItem) + .filter( + EquivalencyItem.tenant_id == tenant_id, + EquivalencyItem.company_id == company_id, + EquivalencyItem.original_field == to_unit, + EquivalencyItem.external_field == from_unit, + ) + .first() + ) + if eq_inv: + factor = eq_inv.conversion_factor if eq_inv.conversion_factor else Decimal(1) + return "D", factor + return "", Decimal(0) diff --git a/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/__init__.py b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/__init__.py new file mode 100644 index 00000000..20433b71 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/__init__.py @@ -0,0 +1 @@ +"""Downloaded parts report module.""" \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/routes.py b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/routes.py new file mode 100644 index 00000000..007b17c2 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/routes.py @@ -0,0 +1,43 @@ +from typing import Any + +from fastapi import APIRouter, Body, Depends, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource + +from .schemas import DownloadedPartsReportBootstrap, DownloadedPartsReportRequest +from .service import DownloadedPartsReportService + +router = APIRouter(tags=["Reports - Downloaded Parts"]) + + +@router.get( + "/bootstrap", + summary="Get downloaded parts report bootstrap", + description="Returns the base metadata required to render the downloaded parts report screen.", +) +def get_downloaded_parts_report_bootstrap( + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Any = Depends(get_current_user), +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = DownloadedPartsReportService() + return service.build_bootstrap(company_id=company_id, tenant_id=tenant_id) + + +@router.post( + "/generate", + summary="Generate downloaded parts report CSV", +) +def generate_downloaded_parts_report( + company_id: int = Query(..., description="Company ID"), + request: DownloadedPartsReportRequest = Body(...), + db: Session = Depends(get_core_db), + current_user: Any = Depends(get_current_user), +) -> StreamingResponse: + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = DownloadedPartsReportService() + return service.generate_csv(db=db, req=request, company_id=company_id, tenant_id=tenant_id) diff --git a/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/schemas.py b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/schemas.py new file mode 100644 index 00000000..923371ae --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/schemas.py @@ -0,0 +1,56 @@ +from datetime import date +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +class DownloadedPartsReportRequest(BaseModel): + date_from: date + date_to: date + class_from: Optional[str] = None + class_to: Optional[str] = None + print_class_mode: Literal['exported', 'downloaded'] = 'downloaded' + exchange_rate_mode: Literal['invoice', 'pedimento_payment'] = 'invoice' + currency_mode: Literal['dollars', 'pesos', 'both'] = 'both' + temporality_mode: Literal['temporales', 'definitivos', 'ambos'] = 'temporales' + weight_type_mode: Literal['kilos', 'libras', 'ambos'] = 'kilos' + operation_mode: Literal['importacion', 'exportacion'] = 'importacion' + # Optional filters + material_type: Optional[str] = None + invoice_type: Optional[str] = None + parts: Optional[list[str]] = None + pedimento_key: Optional[str] = None + provider_id: Optional[int] = None + sold_to_id: Optional[int] = None + shipped_to_id: Optional[int] = None + destination_customs: Optional[str] = None + # Option flags + include_series: bool = False + print_class_total: bool = False + include_totals_by_fraction: bool = False + julian_date: bool = False + show_item_description: bool = True + include_exempt_fraction: bool = False + show_export_fraction: bool = False + include_rule_octava: bool = False + include_american_fraction_and_country: bool = False + respect_import_invoice_value_in_pesos: bool = False + show_all_temporary_balances: bool = False + + +class DownloadedPartsReportSection(BaseModel): + id: str + title: str + description: str + + +class DownloadedPartsReportBootstrap(BaseModel): + report_key: str = Field(default="downloaded_parts") + title: str = Field(default="Partes descargadas") + description: str + company_id: int + tenant_id: int + status: str = Field(default="draft") + available_filters: list[str] + next_steps: list[str] + sections: list[DownloadedPartsReportSection] diff --git a/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py new file mode 100644 index 00000000..7dc41b6c --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py @@ -0,0 +1,954 @@ +import csv +import io +from datetime import date, datetime, timedelta +from decimal import Decimal +from typing import Optional + +from fastapi import HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session, aliased, selectinload + +from api.v1.modules.a24.discharges.models import DischargeDetail, DischargeHeader, DischargeType +from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem +from api.v1.modules.a76.app_settings.service import AppSettingsService +from api.v1.modules.a76.classes.models import Class +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate +from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction +from api.v1.modules.a76.invoices.models import InvoiceComplianceMx, InvoiceHeader, InvoiceStatus +from api.v1.modules.a76.items.line_customs.models import LineCustom +from api.v1.modules.a76.items.line_descriptions.models import LineDescription +from api.v1.modules.a76.items.line_financials.models import LineFinancial +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.series.models import Serie +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + +from .schemas import DownloadedPartsReportBootstrap, DownloadedPartsReportRequest, DownloadedPartsReportSection + +LBS_PER_KG = Decimal('2.20462') + + +class DownloadedPartsReportService: + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _build_pedimento_str( + self, + year: Optional[str], + customs: Optional[str], + license_: Optional[str], + number: Optional[str], + ) -> str: + if not all([year, customs, license_, number]): + return '' + return f"{year}/{customs}/{license_}/{number}" + + def _build_pedimento_18( + self, + year: Optional[str], + customs: Optional[str], + license_: Optional[str], + number: Optional[str], + code: Optional[str], + ) -> str: + if not all([year, customs, license_, number, code]): + return '' + return f"{year}{customs}{license_}{number}{code}" + + def _format_date(self, d: Optional[date | datetime], julian: bool = False) -> str: + if d is None: + return '' + if isinstance(d, datetime): + d = d.date() + if julian: + return str(d.timetuple().tm_yday).zfill(3) + return d.strftime('%d/%m/%Y') + + def _decimal_str(self, val, decimals: int = 2) -> str: + if val is None: + return '' + return f"{Decimal(str(val)):.{decimals}f}" + + def _to_decimal(self, value) -> Decimal: + if value is None: + return Decimal('0') + if isinstance(value, Decimal): + return value + return Decimal(str(value)) + + def _clean_text(self, value: Optional[str]) -> str: + if not value: + return '' + return value.replace(',', ' ').replace('\r', ' ').replace('\n', ' ').strip() + + def _as_date(self, value: Optional[date | datetime]) -> Optional[date]: + if value is None: + return None + if isinstance(value, datetime): + return value.date() + return value + + def _adjust_payment_date( + self, + value: Optional[date | datetime], + use_previous_day: bool, + ) -> Optional[date]: + resolved = self._as_date(value) + if resolved is None: + return None + if use_previous_day: + return resolved - timedelta(days=1) + return resolved + + def _pedimento_headers(self, company: Optional[Company], ped_type: str) -> tuple[str, str]: + if company and company.rfc == 'MWE220512359': + if ped_type == 'export': + return ('PEDIMENTO DE EXPORTACIÓN', 'PEDIMENTO DE EXPORTACIÓN ORIGINAL RECTIFICADO') + return ('PEDIMENTO DE IMPORTACIÓN', 'PEDIMENTO DE IMPORTACIÓN ORIGINAL RECTIFICADO') + + if ped_type == 'export': + return ('PEDIMENTO EXPORTACIÓN', 'PED. EXPO R1') + return ('PEDIMENTO IMPORTACIÓN', 'PED. IMPO R1') + + def _pedimento_values( + self, + company: Optional[Company], + pedimento: str, + pedimento_r1: str, + ) -> tuple[str, str]: + if company and company.rfc == 'MWE220512359': + if pedimento_r1: + return pedimento_r1, pedimento + return pedimento, '' + return pedimento, pedimento_r1 + + def _build_headers(self, req: DownloadedPartsReportRequest, company: Optional[Company]) -> list[str]: + export_headers = self._pedimento_headers(company, 'export') + import_headers = self._pedimento_headers(company, 'import') + + headers = [ + export_headers[0], + export_headers[1], + 'FECHA PAGO PED EXPO', + 'CLAVE', + 'FECHA DE DESCARGA', + 'FACTURA EXPO', + 'FECHA EMISION', + 'CLASE', + 'DESCRIPCION', + 'FRACCION ARANCELARIA', + 'CANTIDAD', + 'U.M.', + import_headers[0], + import_headers[1], + 'FECHA PAGO PED IMPO', + 'FACTURA IMPORTACION', + 'FECHA EMISION IMPO', + 'PESO NETO', + 'VALOR TOTAL (DOLARES)', + 'VALOR TOTAL (MONEDA NACIONAL)', + 'TIPO DE CAMBIO', + 'NO. DE PARTE', + 'DESCRIPCION PARTE', + 'TIPOEXPO', + 'FRACCION CLASE', + 'PEDIMENTO IMPO 18', + 'PEDIMENTO EXPO 18', + 'U.M. TARIFA', + ] + + if req.include_american_fraction_and_country: + headers.extend(['FRACCION AMERICANA', 'PAIS DE ORIGEN']) + + return headers + + def _write_company_header( + self, + writer: csv.writer, + company: Optional[Company], + settings: dict, + ) -> None: + writer.writerow(['REPORTE DE CLASES EXPORTADAS/DESCARGADAS']) + + if not company: + writer.writerow([ + f"Fecha Generación: {datetime.now().strftime('%d/%m/%Y')} Hora Generación: {datetime.now().strftime('%H:%M:%S')}" + ]) + writer.writerow(['PROVEEDOR DE SOFTWARE: ADUANASOFT']) + writer.writerow([]) + return + + if company.name: + writer.writerow([company.name]) + + main_address = next((addr for addr in company.addresses if addr.address_type == 'main'), None) + if main_address: + fiscal_line = 'Domicilio Fiscal: ' + (main_address.street or '') + if main_address.exterior_number: + fiscal_line += f" Ext. Num: {main_address.exterior_number}" + if main_address.interior_number: + fiscal_line += f" Int. Num: {main_address.interior_number}" + writer.writerow([fiscal_line.strip()]) + + colony_line = (main_address.neighborhood or '').strip() + if main_address.postal_code: + colony_line = (colony_line + f" Código Postal: {main_address.postal_code}").strip() + if colony_line: + writer.writerow([colony_line]) + + city_line = ' '.join(filter(None, [main_address.city, main_address.state])) + if city_line: + writer.writerow([city_line]) + + industrial_address = next( + (addr for addr in company.addresses if addr.address_type == 'industrial'), + None, + ) + if industrial_address: + industrial_line = 'Domicilio Industrial: ' + (industrial_address.street or '') + if industrial_address.exterior_number: + industrial_line += f" Ext. Num: {industrial_address.exterior_number}" + if industrial_address.interior_number: + industrial_line += f" Int. Num: {industrial_address.interior_number}" + writer.writerow([industrial_line.strip()]) + + industrial_colony = (industrial_address.neighborhood or '').strip() + if industrial_address.postal_code: + industrial_colony = ( + industrial_colony + f" Código Postal: {industrial_address.postal_code}" + ).strip() + if industrial_colony: + writer.writerow([industrial_colony]) + + industrial_city = ' '.join(filter(None, [industrial_address.city, industrial_address.state])) + if industrial_city: + writer.writerow([industrial_city]) + + if company.rfc: + writer.writerow([f"R.F.C: {company.rfc}"]) + + if settings.get('mostrarprogramaimmexprosec'): + if company.program_number: + if company.program == 'Maquila': + writer.writerow([f"SICEX: {company.program_number}"]) + else: + writer.writerow([f"{company.program or 'Programa'}: {company.program_number}"]) + if company.prosec_authorization: + writer.writerow([f"Autorización PROSEC: {company.prosec_authorization}"]) + + writer.writerow([ + f"Fecha Generación: {datetime.now().strftime('%d/%m/%Y')} Hora Generación: {datetime.now().strftime('%H:%M:%S')}" + ]) + writer.writerow(['PROVEEDOR DE SOFTWARE: ADUANASOFT']) + writer.writerow([]) + + def _get_settings(self, db: Session, tenant_id: int, company_id: int) -> dict: + return AppSettingsService.get_resolved_settings(db, tenant_id, company_id) or {} + + def _collect_rate_dates( + self, + rows: list, + req: DownloadedPartsReportRequest, + use_previous_payment_day: bool, + ) -> set[date]: + dates: set[date] = set() + + for row in rows: + if req.exchange_rate_mode == 'invoice': + export_invoice_date = self._as_date(row.expo_invoice_date) + import_invoice_date = self._as_date(row.impo_invoice_date) + if export_invoice_date: + dates.add(export_invoice_date) + if req.operation_mode == 'importacion' and import_invoice_date: + dates.add(import_invoice_date) + continue + + target_date = row.impo_payment_date if req.operation_mode == 'importacion' else row.expo_payment_date + adjusted = self._adjust_payment_date(target_date, use_previous_payment_day) + if adjusted: + dates.add(adjusted) + + return dates + + def _load_exchange_rates( + self, + db: Session, + dates: set[date], + company_id: int, + tenant_id: int, + ) -> dict[date, Decimal]: + if not dates: + return {} + + rows = db.execute( + select(func.date(ExchangeRate.date), ExchangeRate.value).where( + ExchangeRate.tenant_id == tenant_id, + ExchangeRate.company_id == company_id, + func.date(ExchangeRate.date).in_(sorted(dates)), + ) + ).fetchall() + + return { + self._as_date(rate_date): self._to_decimal(rate_value) + for rate_date, rate_value in rows + if rate_date is not None + } + + def _resolve_selected_rate( + self, + row, + req: DownloadedPartsReportRequest, + rate_lookup: dict[date, Decimal], + use_previous_payment_day: bool, + ) -> Optional[Decimal]: + export_invoice_date = self._as_date(row.expo_invoice_date) + import_invoice_date = self._as_date(row.impo_invoice_date) + export_payment_date = self._adjust_payment_date(row.expo_payment_date, use_previous_payment_day) + import_payment_date = self._adjust_payment_date(row.impo_payment_date, use_previous_payment_day) + + if req.exchange_rate_mode == 'invoice': + if req.operation_mode == 'importacion': + return rate_lookup.get(import_invoice_date) or rate_lookup.get(export_invoice_date) + return rate_lookup.get(export_invoice_date) + + if req.operation_mode == 'importacion': + return rate_lookup.get(import_payment_date) + return rate_lookup.get(export_payment_date) + + def _find_missing_rate_dates( + self, + rows: list, + req: DownloadedPartsReportRequest, + rate_lookup: dict[date, Decimal], + use_previous_payment_day: bool, + ) -> list[str]: + missing: set[date] = set() + + for row in rows: + if req.exchange_rate_mode == 'invoice': + export_invoice_date = self._as_date(row.expo_invoice_date) + import_invoice_date = self._as_date(row.impo_invoice_date) + if export_invoice_date and export_invoice_date not in rate_lookup: + missing.add(export_invoice_date) + if req.operation_mode == 'importacion' and import_invoice_date and import_invoice_date not in rate_lookup: + missing.add(import_invoice_date) + continue + + target_date = row.impo_payment_date if req.operation_mode == 'importacion' else row.expo_payment_date + adjusted = self._adjust_payment_date(target_date, use_previous_payment_day) + if adjusted and adjusted not in rate_lookup: + missing.add(adjusted) + + return [d.strftime('%d/%m/%Y') for d in sorted(missing)] + + def _resolve_export_fraction(self, row, req: DownloadedPartsReportRequest) -> str: + if req.include_rule_octava and row.export_octave_fraction: + return row.export_octave_fraction + return row.export_fraction or '' + + def _resolve_class_fraction(self, row, req: DownloadedPartsReportRequest) -> str: + export_fraction = self._resolve_export_fraction(row, req) + import_fraction = row.import_fraction or '' + if req.include_rule_octava and row.import_octave_fraction: + import_fraction = row.import_octave_fraction + + if req.show_export_fraction: + return row.class_fraction or '' + + if req.print_class_mode == 'downloaded': + return import_fraction or row.class_fraction or '' + + return export_fraction or row.class_fraction or '' + + def _resolve_description(self, row, req: DownloadedPartsReportRequest) -> str: + line_description = row.export_line_description or row.export_line_part_description + class_description = row.export_line_class_description or row.class_description + part_description = row.part_description or row.export_line_part_description + + if req.show_item_description: + return self._clean_text(line_description or part_description or class_description) + + if part_description and class_description: + return self._clean_text(f"{part_description} / {class_description}") + + return self._clean_text(part_description or class_description or line_description) + + def _resolve_part_description(self, row) -> str: + return self._clean_text(row.export_line_part_description or row.part_description) + + def _resolve_base_values(self, row) -> tuple[Decimal, Decimal]: + qty = self._to_decimal(row.quantity) + detail_mn = self._to_decimal(row.value_mn) + detail_usd = self._to_decimal(row.value_me) + import_qty = self._to_decimal(row.import_quantity_total) + + if row.import_is_subitem: + return detail_mn, detail_usd + + if row.import_unit_cost_mxn is not None or row.import_unit_cost_usd is not None: + return ( + qty * self._to_decimal(row.import_unit_cost_mxn), + qty * self._to_decimal(row.import_unit_cost_usd), + ) + + if import_qty > 0: + ratio = qty / import_qty + customs_mxn = self._to_decimal(row.import_customs_value_mxn) + customs_usd = self._to_decimal(row.import_customs_value_usd) + if customs_mxn > 0 or customs_usd > 0: + return customs_mxn * ratio, customs_usd * ratio + + return detail_mn, detail_usd + + def _resolve_values( + self, + row, + req: DownloadedPartsReportRequest, + rate_lookup: dict[date, Decimal], + use_previous_payment_day: bool, + ) -> tuple[Decimal, Decimal, Optional[Decimal]]: + detail_mn = self._to_decimal(row.value_mn) + detail_usd = self._to_decimal(row.value_me) + base_mn, base_usd = self._resolve_base_values(row) + selected_rate = self._resolve_selected_rate(row, req, rate_lookup, use_previous_payment_day) + + if req.print_class_mode == 'downloaded': + source_mn = base_mn if base_mn > 0 else detail_mn + source_usd = base_usd if base_usd > 0 else detail_usd + else: + source_mn = detail_mn if detail_mn > 0 else base_mn + source_usd = detail_usd if detail_usd > 0 else base_usd + + if req.operation_mode == 'importacion' and req.respect_import_invoice_value_in_pesos: + value_mn = source_mn + value_usd = source_mn / selected_rate if selected_rate and source_mn > 0 else source_usd + return value_mn, value_usd, selected_rate + + if source_usd > 0 and selected_rate: + return source_usd * selected_rate, source_usd, selected_rate + + if source_mn > 0 and selected_rate: + return source_mn, source_mn / selected_rate, selected_rate + + return source_mn, source_usd, selected_rate + + def _build_series_map(self, db: Session, line_ids: list[int], tenant_id: int) -> dict[int, list[Serie]]: + if not line_ids: + return {} + + series_rows = ( + db.query(Serie) + .filter( + Serie.tenant_id == tenant_id, + Serie.line_item_id.in_(line_ids), + ) + .order_by(Serie.line_item_id, Serie.row, Serie.id) + .all() + ) + + series_map: dict[int, list[Serie]] = {} + for series in series_rows: + series_map.setdefault(series.line_item_id, []).append(series) + return series_map + + # ------------------------------------------------------------------ + # Bootstrap + # ------------------------------------------------------------------ + + def build_bootstrap(self, company_id: int, tenant_id: int) -> DownloadedPartsReportBootstrap: + return DownloadedPartsReportBootstrap( + description=( + "Base inicial para construir el reporte de partes descargadas desde exportacion. " + "Incluye metadatos, filtros sugeridos y bloques base para la vista." + ), + company_id=company_id, + tenant_id=tenant_id, + available_filters=[ + "fecha_inicio", + "fecha_fin", + "parte", + "pedimento", + "factura_exportacion", + "cliente", + ], + next_steps=[ + "Definir origen exacto de datos para descargas por parte.", + "Agregar filtros funcionales y tabla de resultados.", + "Conectar exportacion a Excel o CSV cuando el layout quede definido.", + ], + sections=[ + DownloadedPartsReportSection( + id="filters", + title="Filtros", + description="Contenedor para criterios de busqueda del reporte.", + ), + DownloadedPartsReportSection( + id="results", + title="Resultados", + description="Espacio reservado para tabla o listado de partes descargadas.", + ), + DownloadedPartsReportSection( + id="exports", + title="Exportacion", + description="Zona para acciones futuras de descarga y generacion de archivos.", + ), + ], + ) + + # ------------------------------------------------------------------ + # Exchange rate validation + # ------------------------------------------------------------------ + + def validate_exchange_rates( + self, + db: Session, + req: DownloadedPartsReportRequest, + company_id: int, + tenant_id: int, + ) -> list[str]: + settings = self._get_settings(db, tenant_id, company_id) + use_previous_payment_day = bool(settings.get('utilizarfechapagopeddeundiaanterior')) + rows = self.query_discharge_data(db, req, company_id, tenant_id) + rate_dates = self._collect_rate_dates(rows, req, use_previous_payment_day) + rate_lookup = self._load_exchange_rates(db, rate_dates, company_id, tenant_id) + return self._find_missing_rate_dates(rows, req, rate_lookup, use_previous_payment_day) + + # ------------------------------------------------------------------ + # Main data query + # ------------------------------------------------------------------ + + def query_discharge_data( + self, + db: Session, + req: DownloadedPartsReportRequest, + company_id: int, + tenant_id: int, + ) -> list: + ExportLine = aliased(LineItem, name='export_line') + ImportLine = aliased(LineItem, name='import_line') + ExportInvoice = aliased(InvoiceHeader, name='export_invoice') + ImportInvoice = aliased(InvoiceHeader, name='import_invoice') + ExportCompliance = aliased(InvoiceComplianceMx, name='export_compliance') + ImportCompliance = aliased(InvoiceComplianceMx, name='import_compliance') + ExportPedimento = aliased(Pedimentos, name='export_pedimento') + ImportPedimento = aliased(Pedimentos, name='import_pedimento') + ExportPedR1 = aliased(Pedimentos, name='export_ped_r1') + ImportPedR1 = aliased(Pedimentos, name='import_ped_r1') + ExportPedDates = aliased(PedimentoDates, name='export_ped_dates') + ImportPedDates = aliased(PedimentoDates, name='import_ped_dates') + ExportPart = aliased(Part, name='export_part') + ExportCustom = aliased(LineCustom, name='export_custom') + ImportCustom = aliased(LineCustom, name='import_custom') + ExportDescription = aliased(LineDescription, name='export_description') + ImportFinancial = aliased(LineFinancial, name='import_financial') + ImportQuantity = aliased(LineQuantity, name='import_quantity') + ImportFa = aliased(FaLineItem, name='import_fa') + ExportTariffFraction = aliased(TariffFraction, name='export_tariff_fraction') + + stmt = ( + select( + ExportLine.id.label('export_line_id'), + ExportPedimento.year.label('expo_ped_year'), + ExportPedimento.customs_office.label('expo_ped_customs'), + ExportPedimento.license.label('expo_ped_license'), + ExportPedimento.pedimento_number.label('expo_ped_number'), + ExportPedimento.pedimento_code.label('expo_ped_code'), + ExportPedR1.year.label('expo_r1_year'), + ExportPedR1.customs_office.label('expo_r1_customs'), + ExportPedR1.license.label('expo_r1_license'), + ExportPedR1.pedimento_number.label('expo_r1_number'), + ExportPedDates.payment_date.label('expo_payment_date'), + ExportPedimento.pedimento_code.label('expo_clave'), + DischargeHeader.discharge_date.label('discharge_date'), + ExportInvoice.invoice_number.label('expo_invoice_number'), + ExportInvoice.invoice_date.label('expo_invoice_date'), + Class.class_code.label('class_code'), + Class.description_es.label('class_description'), + Class.material_key.label('material_key'), + Class.fraction.label('class_fraction'), + ExportCustom.fraction.label('export_fraction'), + ExportCustom.american_fraction.label('american_fraction'), + ExportCustom.octave_fraction.label('export_octave_fraction'), + ImportCustom.fraction.label('import_fraction'), + ImportCustom.octave_fraction.label('import_octave_fraction'), + DischargeDetail.quantity_discharged.label('quantity'), + DischargeDetail.unit_of_measure.label('unit_of_measure'), + DischargeDetail.value_me.label('value_me'), + DischargeDetail.value_mn.label('value_mn'), + DischargeDetail.net_weight.label('net_weight'), + DischargeDetail.origin_import_invoice.label('import_invoice_str'), + DischargeDetail.part_number.label('part_number_str'), + DischargeDetail.country_of_origin.label('country_of_origin'), + ImportPedimento.year.label('impo_ped_year'), + ImportPedimento.customs_office.label('impo_ped_customs'), + ImportPedimento.license.label('impo_ped_license'), + ImportPedimento.pedimento_number.label('impo_ped_number'), + ImportPedimento.pedimento_code.label('impo_ped_code'), + ImportPedR1.year.label('impo_r1_year'), + ImportPedR1.customs_office.label('impo_r1_customs'), + ImportPedR1.license.label('impo_r1_license'), + ImportPedR1.pedimento_number.label('impo_r1_number'), + ImportPedDates.payment_date.label('impo_payment_date'), + ImportInvoice.invoice_number.label('import_invoice_number'), + ImportInvoice.invoice_date.label('impo_invoice_date'), + ImportInvoice.invoice_type.label('import_invoice_type'), + ExportPart.description_spanish.label('part_description'), + ExportDescription.description_spanish.label('export_line_description'), + ExportDescription.part_description.label('export_line_part_description'), + ExportDescription.class_description.label('export_line_class_description'), + ExportDescription.brand.label('export_brand'), + ExportDescription.model.label('export_model'), + ImportFinancial.unit_cost_mxn.label('import_unit_cost_mxn'), + ImportFinancial.unit_cost_usd.label('import_unit_cost_usd'), + ImportFinancial.customs_value_mxn.label('import_customs_value_mxn'), + ImportFinancial.customs_value_usd.label('import_customs_value_usd'), + ImportQuantity.quantity.label('import_quantity_total'), + ImportLine.payment_method.label('import_payment_method'), + ImportFa.is_subitem.label('import_is_subitem'), + ExportTariffFraction.umt.label('tariff_uom'), + ) + .select_from(DischargeDetail) + .join(DischargeHeader, DischargeDetail.discharge_header_id == DischargeHeader.id) + .join(ExportLine, DischargeDetail.export_item_line_id == ExportLine.id) + .join(ImportLine, DischargeDetail.import_item_line_id == ImportLine.id) + .join(ExportInvoice, ExportLine.invoice_id == ExportInvoice.id) + .join(ImportInvoice, ImportLine.invoice_id == ImportInvoice.id) + .outerjoin(ExportCompliance, ExportCompliance.invoice_id == ExportInvoice.id) + .outerjoin(ImportCompliance, ImportCompliance.invoice_id == ImportInvoice.id) + .outerjoin(ExportPedimento, ExportPedimento.id == ExportCompliance.pedimento_id) + .outerjoin(ImportPedimento, ImportPedimento.id == ImportCompliance.pedimento_id) + .outerjoin(ExportPedR1, ExportPedR1.id == ExportCompliance.pedimento_r1) + .outerjoin(ImportPedR1, ImportPedR1.id == ImportCompliance.pedimento_r1) + .outerjoin(ExportPedDates, ExportPedDates.pedimento_id == ExportPedimento.id) + .outerjoin(ImportPedDates, ImportPedDates.pedimento_id == ImportPedimento.id) + .outerjoin(Class, Class.id == ExportLine.class_id) + .outerjoin(ExportPart, ExportPart.id == ExportLine.part_number_id) + .outerjoin(ExportCustom, ExportCustom.item_line_id == ExportLine.id) + .outerjoin(ImportCustom, ImportCustom.item_line_id == ImportLine.id) + .outerjoin(ExportDescription, ExportDescription.item_line_id == ExportLine.id) + .outerjoin(ImportFinancial, ImportFinancial.item_line_id == ImportLine.id) + .outerjoin(ImportQuantity, ImportQuantity.item_line_id == ImportLine.id) + .outerjoin(ImportFa, ImportFa.id == ImportLine.id) + .outerjoin( + ExportTariffFraction, + ExportTariffFraction.code == func.substr( + func.replace(func.coalesce(ExportCustom.fraction, ''), '.', ''), + 1, + 8, + ), + ) + .where( + DischargeHeader.tenant_id == tenant_id, + DischargeHeader.company_id == company_id, + ExportInvoice.status == InvoiceStatus.PROCESSED, + ) + ) + + # Date filter + if req.print_class_mode == 'exported': + stmt = stmt.where( + ExportInvoice.invoice_date.between(req.date_from, req.date_to) + ) + else: + stmt = stmt.where( + ExportInvoice.invoice_type != 'NODES', + or_( + ExportPedDates.payment_date.between(req.date_from, req.date_to), + and_( + ExportPedDates.payment_date.is_(None), + ExportInvoice.invoice_date.between(req.date_from, req.date_to), + ), + ), + ) + + # Temporality + if req.temporality_mode == 'temporales': + stmt = stmt.where(DischargeHeader.discharge_type == DischargeType.TEMPORARY) + elif req.temporality_mode == 'definitivos': + stmt = stmt.where(DischargeHeader.discharge_type == DischargeType.DEFINITIVE) + + # Class range + if req.class_from: + stmt = stmt.where(Class.class_code >= req.class_from) + if req.class_to: + stmt = stmt.where(Class.class_code <= req.class_to) + + if req.material_type: + stmt = stmt.where(Class.material_key == req.material_type) + if req.invoice_type: + stmt = stmt.where(ExportInvoice.invoice_type == req.invoice_type) + if req.parts: + stmt = stmt.where(DischargeDetail.part_number.in_(req.parts)) + if req.pedimento_key: + stmt = stmt.where(ExportPedimento.pedimento_code == req.pedimento_key) + + if req.provider_id: + stmt = stmt.where(ExportCompliance.provider_id == req.provider_id) + + if req.sold_to_id: + stmt = stmt.where(ExportCompliance.sold_to_id == req.sold_to_id) + + if getattr(req, 'shipped_to_id', None): + stmt = stmt.where(ExportCompliance.shipped_to_id == req.shipped_to_id) + + if req.destination_customs: + stmt = stmt.where(ExportCompliance.aduana == req.destination_customs) + + if not req.include_exempt_fraction: + stmt = stmt.where( + or_( + Class.iva_exempt_fraction.is_(None), + Class.iva_exempt_fraction != 'Si', + ) + ) + + if req.print_class_mode == 'downloaded' and not req.show_all_temporary_balances: + stmt = stmt.where( + or_( + ImportLine.payment_method.is_(None), + ImportLine.payment_method != '2', + ) + ) + + stmt = stmt.order_by( + Class.class_code.nullslast(), + ExportInvoice.invoice_date, + DischargeDetail.id, + ) + + return db.execute(stmt).fetchall() + + # ------------------------------------------------------------------ + # CSV generation + # ------------------------------------------------------------------ + + def generate_csv( + self, + db: Session, + req: DownloadedPartsReportRequest, + company_id: int, + tenant_id: int, + ) -> StreamingResponse: + settings = self._get_settings(db, tenant_id, company_id) + use_previous_payment_day = bool(settings.get('utilizarfechapagopeddeundiaanterior')) + company = ( + db.query(Company) + .options(selectinload(Company.addresses)) + .filter(Company.id == company_id, Company.tenant_id == tenant_id) + .first() + ) + + rows = self.query_discharge_data(db, req, company_id, tenant_id) + rate_dates = self._collect_rate_dates(rows, req, use_previous_payment_day) + rate_lookup = self._load_exchange_rates(db, rate_dates, company_id, tenant_id) + missing = self._find_missing_rate_dates(rows, req, rate_lookup, use_previous_payment_day) + if missing: + raise HTTPException(status_code=422, detail={'missing_dates': missing}) + + headers = self._build_headers(req, company) + output = io.StringIO() + writer = csv.writer(output) + + self._write_company_header(writer, company, settings) + writer.writerow(headers) + + current_class: Optional[str] = None + class_qty = Decimal('0') + class_weight_kgs = Decimal('0') + class_weight_lbs = Decimal('0') + class_mn = Decimal('0') + class_me = Decimal('0') + fraction_totals: dict[str, dict[str, Decimal]] = {} + series_map = self._build_series_map( + db, + [row.export_line_id for row in rows if row.export_line_id is not None], + tenant_id, + ) + + def flush_class_total() -> None: + if req.print_class_total and current_class is not None: + writer.writerow(['TOTAL DE LA CLASE']) + writer.writerow([ + self._decimal_str(class_qty, 4), + self._decimal_str(class_weight_kgs, 4), + self._decimal_str(class_weight_lbs, 4), + self._decimal_str(class_mn), + self._decimal_str(class_me), + ]) + + for row in rows: + class_code = row.class_code or '' + if req.print_class_total and class_code != current_class: + flush_class_total() + current_class = class_code + class_qty = Decimal('0') + class_weight_kgs = Decimal('0') + class_weight_lbs = Decimal('0') + class_mn = Decimal('0') + class_me = Decimal('0') + + export_fraction = self._resolve_export_fraction(row, req) + class_fraction = self._resolve_class_fraction(row, req) + description_value = self._resolve_description(row, req) + part_description = self._resolve_part_description(row) + value_mn, value_me, selected_rate = self._resolve_values( + row, + req, + rate_lookup, + use_previous_payment_day, + ) + + quantity = self._to_decimal(row.quantity) + weight_kgs = self._to_decimal(row.net_weight) + weight_lbs = weight_kgs * LBS_PER_KG + + if req.print_class_total: + class_qty += quantity + class_weight_kgs += weight_kgs + class_weight_lbs += weight_lbs + class_mn += value_mn + class_me += value_me + + if req.include_totals_by_fraction: + fraction_key = export_fraction or '' + if fraction_key not in fraction_totals: + fraction_totals[fraction_key] = {'mn': Decimal('0'), 'me': Decimal('0')} + fraction_totals[fraction_key]['mn'] += value_mn + fraction_totals[fraction_key]['me'] += value_me + + export_ped = self._build_pedimento_str( + row.expo_ped_year, + row.expo_ped_customs, + row.expo_ped_license, + row.expo_ped_number, + ) + export_r1 = self._build_pedimento_str( + row.expo_r1_year, + row.expo_r1_customs, + row.expo_r1_license, + row.expo_r1_number, + ) + import_ped = self._build_pedimento_str( + row.impo_ped_year, + row.impo_ped_customs, + row.impo_ped_license, + row.impo_ped_number, + ) + import_r1 = self._build_pedimento_str( + row.impo_r1_year, + row.impo_r1_customs, + row.impo_r1_license, + row.impo_r1_number, + ) + export_ped_18 = self._build_pedimento_18( + row.expo_ped_year, + row.expo_ped_customs, + row.expo_ped_license, + row.expo_ped_number, + row.expo_ped_code, + ) + import_ped_18 = self._build_pedimento_18( + row.impo_ped_year, + row.impo_ped_customs, + row.impo_ped_license, + row.impo_ped_number, + row.impo_ped_code, + ) + + export_ped_values = self._pedimento_values(company, export_ped, export_r1) + import_ped_values = self._pedimento_values(company, import_ped, import_r1) + + csv_row = [ + export_ped_values[0], + export_ped_values[1], + self._format_date(row.expo_payment_date, req.julian_date), + row.expo_clave or '', + self._format_date(row.discharge_date, req.julian_date), + row.expo_invoice_number or '', + self._format_date(row.expo_invoice_date, req.julian_date), + class_code, + description_value, + export_fraction, + self._decimal_str(quantity, 4), + row.unit_of_measure or '', + import_ped_values[0], + import_ped_values[1], + self._format_date(row.impo_payment_date, req.julian_date), + row.import_invoice_number or row.import_invoice_str or '', + self._format_date(row.impo_invoice_date, req.julian_date), + self._decimal_str(weight_kgs, 4), + self._decimal_str(value_me), + self._decimal_str(value_mn), + self._decimal_str(selected_rate, 6) if selected_rate else '', + row.part_number_str or '', + part_description, + row.material_key or '', + class_fraction, + import_ped_18, + export_ped_18, + row.tariff_uom or '', + ] + + if req.include_american_fraction_and_country: + csv_row.extend([ + row.american_fraction or '', + row.country_of_origin or '', + ]) + + writer.writerow(csv_row) + + if req.include_series and row.export_line_id in series_map: + writer.writerow([ + 'RENGLON', + 'SERIE', + 'MODELO', + 'SUBMODELO', + 'PARTE', + 'NUM ID EXPO', + 'MARCA PARTIDA', + 'MODELO PARTIDA', + ]) + for series in series_map[row.export_line_id]: + writer.writerow([ + series.row or '', + series.serial_numbers or '', + series.model or '', + series.sub_model or '', + row.part_number_str or '', + series.number_id or '', + row.export_brand or '', + row.export_model or '', + ]) + + flush_class_total() + + if req.include_totals_by_fraction and fraction_totals: + writer.writerow([]) + writer.writerow(['TOTALES POR FRACCION']) + writer.writerow(['FRACCION', 'VALOR MN', 'VALOR ME']) + total_mn = Decimal('0') + total_me = Decimal('0') + for fraction, totals in sorted(fraction_totals.items()): + writer.writerow([ + fraction, + self._decimal_str(totals['mn']), + self._decimal_str(totals['me']), + ]) + total_mn += totals['mn'] + total_me += totals['me'] + + writer.writerow(['TOTAL']) + writer.writerow(['', self._decimal_str(total_mn), self._decimal_str(total_me)]) + + csv_content = output.getvalue() + filename = f"partes_descargadas_{req.date_from}_{req.date_to}.csv" + return StreamingResponse( + iter([csv_content.encode('utf-8-sig')]), + media_type='text/csv', + headers={'Content-Disposition': f'attachment; filename="{filename}"'}, + ) diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index ed0a6600..57e17486 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -41,6 +41,7 @@ from .reports.importacion.facturas.routes import router as invoices_reports_rout from .reports.importacion.consolidados.routes import router as consolidated_reports_router from .reports.importacion.packing_list.routes import router as packing_list_router from .reports.exportacion.aviso_consolidado.routes import router as aviso_consolidado_export_router +from .reports.exportacion.partes_descargadas.routes import router as downloaded_parts_reports_router from .reports.movements.invoices.routes import router as movement_invoices_router from .reports.movements.saldos.routes import router as movement_saldos_router from .reports.exportacion.descargo.routes import router as discharge_reports_router @@ -118,6 +119,12 @@ router.include_router( tags=["a76 / reports"] ) +router.include_router( + downloaded_parts_reports_router, + prefix="/a76/reports/exportacion/partes-descargadas", + tags=["a76 / reports"] +) + router.include_router( movement_invoices_router, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index da8b2b71..6e265dc7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -519,6 +519,12 @@ async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise(endpoint: string) => fetchApi(endpoint, { method: 'GET' }), getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }), + postBlob: (endpoint: string, body: any) => + fetchBlob(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }), post: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-partes-descargadas.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-partes-descargadas.ts new file mode 100644 index 00000000..4f64f5ad --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-partes-descargadas.ts @@ -0,0 +1,76 @@ +import { api } from '$lib/api'; + +export interface DownloadedPartsReportSection { + id: string; + title: string; + description: string; +} + +export interface DownloadedPartsReportBootstrap { + report_key: string; + title: string; + description: string; + company_id: number; + tenant_id: number; + status: string; + available_filters: string[]; + next_steps: string[]; + sections: DownloadedPartsReportSection[]; +} + +export interface DownloadedPartsReportRequest { + date_from: string; + date_to: string; + class_from?: string; + class_to?: string; + print_class_mode: 'exported' | 'downloaded'; + exchange_rate_mode: 'invoice' | 'pedimento_payment'; + currency_mode: 'dollars' | 'pesos' | 'both'; + temporality_mode: 'temporales' | 'definitivos' | 'ambos'; + weight_type_mode: 'kilos' | 'libras' | 'ambos'; + operation_mode: 'importacion' | 'exportacion'; + material_type?: string; + invoice_type?: string; + parts?: string[]; + pedimento_key?: string; + provider_id?: number; + sold_to_id?: number; + shipped_to_id?: number; + destination_customs?: string; + include_series: boolean; + print_class_total: boolean; + include_totals_by_fraction: boolean; + julian_date: boolean; + show_item_description: boolean; + include_exempt_fraction: boolean; + show_export_fraction: boolean; + include_rule_octava: boolean; + include_american_fraction_and_country: boolean; + respect_import_invoice_value_in_pesos: boolean; + show_all_temporary_balances: boolean; +} + +export const downloadedPartsReportsApi = { + getBootstrap: (companyId: number) => + api.get( + `/v1/a76/reports/exportacion/partes-descargadas/bootstrap?company_id=${companyId}` + ), + + generate: async ( + companyId: number, + params: DownloadedPartsReportRequest + ): Promise => { + const blob = await api.postBlob( + `/v1/a76/reports/exportacion/partes-descargadas/generate?company_id=${companyId}`, + params + ); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `partes_descargadas_${params.date_from}_${params.date_to}.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } +}; diff --git a/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte index efcd9974..f6628eee 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte @@ -3,13 +3,15 @@ import * as Dialog from '$lib/components/ui/dialog'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; + import { FolderSearch, Scale } from 'lucide-svelte'; + import UnitMeasureSelectorDialog from '$lib/components/dashboard/goods/modales/unit-measure-dialog.svelte'; import { companyStore } from '$lib/stores/company.svelte'; - import { Scale } from 'lucide-svelte'; import { createEquivalencyItem, updateEquivalencyItem, type EquivalencyItem } from '$lib/api/dashboard/a76/general_catalogs/equivalencies'; + import type { UnitOfMeasure } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure'; let { open = $bindable(false), @@ -37,25 +39,33 @@ let loading = $state(false); let error = $state(null); + let showOriginalModal = $state(false); + let showExternalModal = $state(false); $effect(() => { if (!open) return; if (item) { - formData = { - original_field: item.original_field || '', - external_field: item.external_field || '' - }; + formData.original_field = item.original_field || ''; + formData.external_field = item.external_field || ''; } else { - formData = { - original_field: defaultOriginalField ?? '', - external_field: '' - }; + formData.original_field = defaultOriginalField ?? ''; + formData.external_field = ''; } error = null; }); + function handleSelectOriginal(unit: UnitOfMeasure) { + formData.original_field = unit.code; + showOriginalModal = false; + } + + function handleSelectExternal(unit: UnitOfMeasure) { + formData.external_field = unit.code; + showExternalModal = false; + } + async function handleSubmit() { const companyId = companyStore.activeCompany?.id; if (!companyId) { @@ -113,40 +123,64 @@
-
-
@@ -160,3 +194,6 @@
+ + + diff --git a/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte b/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte index 2c8cff0a..67fdf73e 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte @@ -33,13 +33,9 @@ operation_type: operationType, invoice_number: searchTerm || undefined }; - if (operationType === 'imp' && regimen) { - if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') { - filters.invoice_type = 'TEM'; - } else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') { - filters.invoice_type = 'DEF'; - } - } + // Do NOT filter by invoice_type here: restricting to TEM or DEF based on + // the current movement_type_import value would hide valid invoices of the + // other type. Let the user search freely and pick the right one. const res = await invoicesApi.list(companyStore.activeCompany.id, 1, 50, filters); if (res.data) { @@ -134,7 +130,8 @@ {invoice.invoice_number} - {invoice.compliance_mx?.pedimento_r1 || + {invoice.compliance_mx?.pedimento?.pedimento_number || + invoice.compliance_mx?.pedimento_r1 || invoice.compliance_mx?.pedimento_id || '-'} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index 3a413d51..1c491569 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -164,10 +164,12 @@ if (showLinkToImportBlock && num && !selectedImportInvoiceId && !loadingImportLines) { (async () => { try { - const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 5, { + // Do NOT filter by invoice_type: if movement_type_import is null or + // mismatched the invoice won't be found, leaving the line picker + // permanently disabled. Exact match is enforced by .find() below. + const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 50, { operation_type: 'imp', - invoice_number: num, - invoice_type: movementType === 'DEF' ? 'DEF' : 'TEM' + invoice_number: num }); const items = res.data?.items ?? []; const inv = items.find((i: Invoice) => i.invoice_number === num); @@ -183,7 +185,7 @@ if (showRepairBlock && num && !selectedExportInvoiceId && !loadingExportLines) { (async () => { try { - const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 5, { + const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 50, { operation_type: 'exp', invoice_number: num }); @@ -735,7 +737,7 @@ Seleccionar línea de importación

- Solo se muestran líneas con saldo disponible + Líneas sin saldo disponible se muestran en gris.

- {#if importInvoiceLines.every(l => !l.has_balance)} + {#if importInvoiceLines.length === 0}

- No hay líneas con saldo disponible en esta factura. + No hay líneas en esta factura.

{:else} @@ -768,9 +770,8 @@ {#each importInvoiceLines as lineItem} - {#if lineItem.has_balance} { editingItem.fa_data = editingItem.fa_data || {}; editingItem.fa_data.search_line = lineItem.line_number; @@ -830,7 +831,6 @@ {/if} - {/if} {/each}
diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 14bf9a57..ec4ef527 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -507,6 +507,10 @@ export function getSidebarData(): SidebarData { title: "Facturas Impo/Expo", url: "/dashboard/reports/invoices", }, + { + title: "Partes descargadas", + url: "/dashboard/reports/partes-descargadas", + }, ], }, { diff --git a/frontend/src/routes/dashboard/reports/partes-descargadas/+page.server.ts b/frontend/src/routes/dashboard/reports/partes-descargadas/+page.server.ts new file mode 100644 index 00000000..2361c927 --- /dev/null +++ b/frontend/src/routes/dashboard/reports/partes-descargadas/+page.server.ts @@ -0,0 +1,15 @@ +import type { PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { getAuthTokens } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ cookies }) => { + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + throw redirect(302, '/login'); + } + + return { + title: 'Partes descargadas' + }; +}; diff --git a/frontend/src/routes/dashboard/reports/partes-descargadas/+page.svelte b/frontend/src/routes/dashboard/reports/partes-descargadas/+page.svelte new file mode 100644 index 00000000..610f89c9 --- /dev/null +++ b/frontend/src/routes/dashboard/reports/partes-descargadas/+page.svelte @@ -0,0 +1,865 @@ + + +
+
+
+

+ + Reporte de clases exportadas / descargadas +

+ + {bootstrap ? 'CONECTADO' : 'BASE'} + +
+
Reportes de Control Fiscal
+
+ + + +
+ + + + Rango de Fechas y Clases + + + +
+

Rango de fechas

+
+
+ + { + const input = e.currentTarget; + if (input && typeof input.showPicker === 'function') input.showPicker(); + }} + /> +
+
+ + { + const input = e.currentTarget; + if (input && typeof input.showPicker === 'function') input.showPicker(); + }} + /> +
+
+
+ +
+

Rango de clases

+
+
+ + (classRange.from = normalizeSelectValue(v))}> + + + {#if classRange.from} + {@const selectedClass = classesCatalog.find((item) => item.class_code === classRange.from)} + {selectedClass ? `${selectedClass.class_code} - ${selectedClass.description_es || ''}` : classRange.from} + {:else} + {selectPlaceholder()} + {/if} + + + + Sin límite + {#if classesCatalog.length} + {#each classesCatalog as item} + + {item.class_code} - {item.description_es || item.description_en || ''} + + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+
+ + (classRange.to = normalizeSelectValue(v))}> + + + {#if classRange.to} + {@const selectedClass = classesCatalog.find((item) => item.class_code === classRange.to)} + {selectedClass ? `${selectedClass.class_code} - ${selectedClass.description_es || ''}` : classRange.to} + {:else} + {selectPlaceholder()} + {/if} + + + + Sin límite + {#if classesCatalog.length} + {#each classesCatalog as item} + + {item.class_code} - {item.description_es || item.description_en || ''} + + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+
+
+ + +
+
+ + + + + Filtrar por + + + +
+
+ + (filters.materialType = normalizeSelectValue(v))}> + + + {#if filters.materialType} + {@const selectedMaterial = materialTypes.find((item) => item.key === filters.materialType)} + {selectedMaterial ? `${selectedMaterial.key} - ${selectedMaterial.description}` : filters.materialType} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if materialTypes.length} + {#each materialTypes as item} + {item.key} - {item.description} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+ +
+ + (filters.pedimentoKey = normalizeSelectValue(v))}> + + + {#if filters.pedimentoKey} + {@const selectedCode = pedimentoCodes.find((item) => item.code === filters.pedimentoKey)} + {selectedCode ? `${selectedCode.code} - ${selectedCode.description}` : filters.pedimentoKey} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if pedimentoCodes.length} + {#each pedimentoCodes as item} + {item.code} - {item.description} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+ +
+ + (filters.soldTo = normalizeSelectValue(v))}> + + + {#if filters.soldTo} + {@const selectedClient = soldToOptions.find((item) => String(item.id) === filters.soldTo)} + {selectedClient?.name || selectPlaceholder()} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if soldToOptions.length} + {#each soldToOptions as item} + {item.name} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+
+ +
+
+ + (filters.invoiceType = normalizeSelectValue(v))}> + + + {#if filters.invoiceType} + {@const selectedType = filteredInvoiceTypes.find((item) => item.key === filters.invoiceType)} + {selectedType ? `${selectedType.key} - ${selectedType.description}` : filters.invoiceType} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if filteredInvoiceTypes.length} + {#each filteredInvoiceTypes as item} + {item.key} - {item.description} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+ +
+ + (filters.provider = normalizeSelectValue(v))}> + + + {#if filters.provider} + {@const selectedProvider = providerOptions.find((item) => String(item.id) === filters.provider)} + {selectedProvider?.name || selectPlaceholder()} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if providerOptions.length} + {#each providerOptions as item} + {item.name} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+ +
+ + (filters.shippedTo = normalizeSelectValue(v))}> + + + {#if filters.shippedTo} + {@const selectedShippedTo = shippedToOptions.find((item) => String(item.id) === filters.shippedTo)} + {selectedShippedTo?.name || selectPlaceholder()} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if shippedToOptions.length} + {#each shippedToOptions as item} + {item.name} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+
+ +
+
+ + (filters.parts = normalizeSelectValue(v))}> + + + {#if filters.parts} + {@const selectedPart = partsCatalog.find((item) => item.part_number === filters.parts)} + {selectedPart ? `${selectedPart.part_number} - ${selectedPart.description_spanish || ''}` : filters.parts} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if partsCatalog.length} + {#each partsCatalog as item} + {item.part_number} - {item.description_spanish || item.description_english || ''} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+ +
+ + (filters.destinationCustoms = normalizeSelectValue(v))}> + + + {#if filters.destinationCustoms} + {@const selectedSection = customsSections.find((item) => item.customs_code === filters.destinationCustoms)} + {selectedSection ? `${selectedSection.customs_code} - ${selectedSection.section_name}` : filters.destinationCustoms} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if customsSections.length} + {#each customsSections as item} + {item.customs_code} - {item.section_name} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+
+ + + +
+
+ + +
+ + +
+
+ + +
+
+
+ +
+ + + {#each [ + { value: 'temporales', label: 'Temporales' }, + { value: 'definitivos', label: 'Definitivos' }, + { value: 'ambos', label: 'Ambos' } + ] as item} +
+ + +
+ {/each} +
+
+
+
+
+ + + + + Configuración y Salida + + + +
+
+ + + {#each [ + { value: 'dollars', label: 'Dólares' }, + { value: 'pesos', label: 'Pesos' }, + { value: 'both', label: 'Ambos' } + ] as item} +
+ + +
+ {/each} +
+
+ +
+ + +
+ + +
+
+ + +
+
+
+ +
+ + + {#each [ + { value: 'kilos', label: 'Kilos' }, + { value: 'libras', label: 'Libras' }, + { value: 'ambos', label: 'Ambos' } + ] as item} +
+ + +
+ {/each} +
+
+ +
+ + +
+ + +
+
+ + +
+
+
+
+
+
+ + + + Opciones + + +
+ {#each allOptions as option} +
+ + +
+ {/each} +
+
+ + + + +
+
+
From 82c319afea158b4c41c35f0a7fb853d24491f64d Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 17 Apr 2026 08:42:52 -0600 Subject: [PATCH 049/167] feature/correccion-enteros-csv --- .github/copilot-instructions.md | 48 ------- .github/skills/caveman-ultra/SKILL.md | 133 ------------------ .../exportacion/partes_descargadas/service.py | 71 +++++----- 3 files changed, 38 insertions(+), 214 deletions(-) delete mode 100644 .github/copilot-instructions.md delete mode 100644 .github/skills/caveman-ultra/SKILL.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 58fcd1df..00000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,48 +0,0 @@ -# Caveman Ultra Default - -Apply these rules to every task in this repository unless the user explicitly asks for explanation or a different format. - -## Output Mode - -- Zero prose by default. -- No greetings. -- No apologies. -- No pleasantries. -- Prefer exact terminal commands when the user asks for commands. -- Prefer code blocks only when the user asks for code. -- If explanation is explicitly requested, keep it minimal and only as detailed as requested. -- If context is required to avoid a fatal mistake, use at most 3 to 5 words outside code blocks. - -## Response Rules - -- Do not restate the request. -- Do not add summaries unless requested. -- Do not add rationale unless requested. -- Do not add transition phrases or filler text. -- Do not wrap commands in explanatory prose. -- Do not describe what code does unless requested. -- Keep code complete, accurate, and production-ready. - -## Output Shapes - -Choose the smallest valid response shape for the task: - -- Single terminal command -- Sequence of terminal commands -- Single code block -- Multiple code blocks -- One short clarification question when the task is ambiguous - -## Safety Rule - -If policy or safety constraints block the request, return the shortest compliant refusal possible. - -## Final Check - -Before responding, verify: - -- No filler words remain. -- No unnecessary explanation remains. -- Output shape matches the request. -- Commands are copy-paste safe. -- Code is directly usable. \ No newline at end of file diff --git a/.github/skills/caveman-ultra/SKILL.md b/.github/skills/caveman-ultra/SKILL.md deleted file mode 100644 index 3f73e2c6..00000000 --- a/.github/skills/caveman-ultra/SKILL.md +++ /dev/null @@ -1,133 +0,0 @@ ---- -name: caveman-ultra -description: Enforce an ultra-terse response mode for every task in this repository. Default to command-only or code-block-only output with no prose unless the user explicitly asks for explanation. -user-invocable: true ---- - -# Caveman Ultra - -Use this skill for every task in this repository by default. Treat Caveman Ultra as the baseline response mode unless the user explicitly asks for more explanation or a different format. - -## Goal - -Produce responses with these constraints: - -- Zero prose. -- No greetings. -- No apologies. -- No pleasantries. -- Output only code blocks or exact terminal commands. -- If explanation is strictly required to avoid a fatal mistake, use at most 3 to 5 words. -- Keep code complete, accurate, and production-ready. - -## Workflow - -1. Detect activation. - Activate for every task by default. -1. Classify the required output. - Choose exactly one of these shapes unless the user explicitly asks for more than one: - - Single terminal command - - Sequence of terminal commands - - Single code block - - Multiple code blocks -1. Remove non-essential text. - Strip intros, summaries, rationale, transition phrases, warnings, and conversational filler. -1. Preserve critical safety. - If a fatal error is likely without context, add one short line of 3 to 5 words maximum. -1. Validate the final output. - Ensure every visible line is either: - - A command - - Inside a code block - - A minimal fatal-error prevention line - -## Decision Rules - -### If the user asks for commands - -Return exact commands only. - -### If the user asks for code - -Return only code blocks. - -### If the user asks for explanation - -Keep it minimal and only as detailed as explicitly requested. - -### If the task is ambiguous - -Ask one short question using the same mode. - -Example: - -```text -repo or personal? -``` - -### If policy or safety constraints block the request - -Return the shortest compliant refusal possible. - -## Formatting Rules - -- Do not add headings unless the user explicitly asks for them. -- Do not add bullets unless the user explicitly asks for a checklist. -- Do not wrap terminal commands in explanation text. -- Do not mix prose paragraphs with code blocks. -- Do not restate the request. -- Do not describe what the code does unless the user explicitly asks. - -## Completion Checks - -Before sending, verify all of the following: - -- No filler words remain. -- No explanatory paragraph remains. -- Output shape matches the request. -- Code is runnable or directly usable. -- Commands are copy-paste safe. -- Any required warning is 3 to 5 words maximum. - -## Examples - -### Example prompt - -```text -Use Caveman Ultra. Give pnpm commands to run frontend tests. -``` - -### Example response - -```bash -cd frontend -pnpm test -``` - -### Example prompt - -```text -Use Caveman Ultra. Write a Svelte loading component. -``` - -### Example response - -```svelte - - -
{label}...
- - -``` - -## Scope Notes - -- This skill is intended to apply repository-wide by default. -- If the host does not auto-select it reliably, mirror the same rules in repository custom instructions. \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py index 7dc41b6c..cd0805c9 100644 --- a/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py +++ b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py @@ -86,6 +86,11 @@ class DownloadedPartsReportService: return '' return value.replace(',', ' ').replace('\r', ' ').replace('\n', ' ').strip() + def _excel_text(self, value) -> str: + if value is None or value == '': + return '' + return f"'{value}" + def _as_date(self, value: Optional[date | datetime]) -> Optional[date]: if value is None: return None @@ -864,40 +869,40 @@ class DownloadedPartsReportService: import_ped_values = self._pedimento_values(company, import_ped, import_r1) csv_row = [ - export_ped_values[0], - export_ped_values[1], - self._format_date(row.expo_payment_date, req.julian_date), - row.expo_clave or '', - self._format_date(row.discharge_date, req.julian_date), - row.expo_invoice_number or '', - self._format_date(row.expo_invoice_date, req.julian_date), - class_code, - description_value, - export_fraction, + self._excel_text(export_ped_values[0]), + self._excel_text(export_ped_values[1]), + self._excel_text(self._format_date(row.expo_payment_date, req.julian_date)), + self._excel_text(row.expo_clave or ''), + self._excel_text(self._format_date(row.discharge_date, req.julian_date)), + self._excel_text(row.expo_invoice_number or ''), + self._excel_text(self._format_date(row.expo_invoice_date, req.julian_date)), + self._excel_text(class_code), + self._excel_text(description_value), + self._excel_text(export_fraction), self._decimal_str(quantity, 4), - row.unit_of_measure or '', - import_ped_values[0], - import_ped_values[1], - self._format_date(row.impo_payment_date, req.julian_date), - row.import_invoice_number or row.import_invoice_str or '', - self._format_date(row.impo_invoice_date, req.julian_date), + self._excel_text(row.unit_of_measure or ''), + self._excel_text(import_ped_values[0]), + self._excel_text(import_ped_values[1]), + self._excel_text(self._format_date(row.impo_payment_date, req.julian_date)), + self._excel_text(row.import_invoice_number or row.import_invoice_str or ''), + self._excel_text(self._format_date(row.impo_invoice_date, req.julian_date)), self._decimal_str(weight_kgs, 4), self._decimal_str(value_me), self._decimal_str(value_mn), self._decimal_str(selected_rate, 6) if selected_rate else '', - row.part_number_str or '', - part_description, - row.material_key or '', - class_fraction, - import_ped_18, - export_ped_18, - row.tariff_uom or '', + self._excel_text(row.part_number_str or ''), + self._excel_text(part_description), + self._excel_text(row.material_key or ''), + self._excel_text(class_fraction), + self._excel_text(import_ped_18), + self._excel_text(export_ped_18), + self._excel_text(row.tariff_uom or ''), ] if req.include_american_fraction_and_country: csv_row.extend([ - row.american_fraction or '', - row.country_of_origin or '', + self._excel_text(row.american_fraction or ''), + self._excel_text(row.country_of_origin or ''), ]) writer.writerow(csv_row) @@ -915,14 +920,14 @@ class DownloadedPartsReportService: ]) for series in series_map[row.export_line_id]: writer.writerow([ - series.row or '', - series.serial_numbers or '', - series.model or '', - series.sub_model or '', - row.part_number_str or '', - series.number_id or '', - row.export_brand or '', - row.export_model or '', + self._excel_text(series.row or ''), + self._excel_text(series.serial_numbers or ''), + self._excel_text(series.model or ''), + self._excel_text(series.sub_model or ''), + self._excel_text(row.part_number_str or ''), + self._excel_text(series.number_id or ''), + self._excel_text(row.export_brand or ''), + self._excel_text(row.export_model or ''), ]) flush_class_total() From a29a2a7c8aab1fe92eb70b4428de55a4eb2415f2 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 17 Apr 2026 08:57:40 -0600 Subject: [PATCH 050/167] fix/selector-fecha --- frontend/src/app.css | 15 +++++++ .../src/lib/components/ui/input/input.svelte | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/frontend/src/app.css b/frontend/src/app.css index af7ea21c..990d5f97 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -38,6 +38,7 @@ --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.623 0.214 259.815); + color-scheme: light; } .dark { @@ -72,6 +73,7 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.488 0.243 264.376); + color-scheme: dark; } @@ -128,6 +130,19 @@ color: var(--color-foreground); -webkit-text-fill-color: var(--color-foreground); } + + input[type="date"]::-webkit-calendar-picker-indicator, + input[type="datetime-local"]::-webkit-calendar-picker-indicator { + display: none; + opacity: 0; + } + + .dark input[type="date"]::-webkit-calendar-picker-indicator, + .dark input[type="datetime-local"]::-webkit-calendar-picker-indicator { + cursor: pointer; + filter: invert(1) brightness(1.15); + opacity: 0.9; + } } @layer components { diff --git a/frontend/src/lib/components/ui/input/input.svelte b/frontend/src/lib/components/ui/input/input.svelte index ef1fbe7d..1f394e32 100644 --- a/frontend/src/lib/components/ui/input/input.svelte +++ b/frontend/src/lib/components/ui/input/input.svelte @@ -1,4 +1,5 @@ {#if type === "file"} @@ -35,6 +49,31 @@ bind:value {...restProps} /> +{:else if isDateInput} +
+ + +
{:else} Date: Fri, 17 Apr 2026 10:22:08 -0600 Subject: [PATCH 051/167] fix/partida-campos-obligatorios --- .../a76/items/exports/validators/common.py | 51 +++++++++++++++++-- .../us-fraction-selector-dialog.svelte | 41 +++++++++++---- .../edit/items/fa/item-sheet-fa.svelte | 6 ++- .../invoices/edit/items/fa/main-data.svelte | 5 +- .../edit/items/fa/packages-section.svelte | 40 +++++++++++++-- frontend/src/lib/utils/items-logic.ts | 47 ++++++++++++----- 6 files changed, 159 insertions(+), 31 deletions(-) diff --git a/backend/api/v1/modules/a76/items/exports/validators/common.py b/backend/api/v1/modules/a76/items/exports/validators/common.py index c8622b97..d8e204f5 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/common.py +++ b/backend/api/v1/modules/a76/items/exports/validators/common.py @@ -14,6 +14,9 @@ from api.v1.modules.a76.invoices.models import InvoiceHeader from api.v1.modules.a76.classes.models import Class from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure from api.v1.modules.a76.general_catalogs.packages.models import Package +from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import ( + USTariffFraction, +) from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem from api.v1.modules.public.reference_data.countries.models import Country from api.v1.modules.a76.general_catalogs.sectors.models import Sector @@ -360,18 +363,56 @@ def validate_common( ) if line.customs.american_fraction: - american_fraction_exists = db.query( - exists().where( - LineCustom.american_fraction == line.customs.american_fraction + def _normalize_american_fraction_code(raw_code: str) -> list[str]: + normalized_raw = (raw_code or "").strip() + if not normalized_raw: + return [] + + digits_only = normalized_raw.replace(".", "").replace(" ", "").replace("-", "") + candidates = [normalized_raw] + + if len(digits_only) == 10: + candidates.append( + f"{digits_only[:4]}.{digits_only[4:6]}.{digits_only[6:8]}.{digits_only[8:10]}" + ) + elif len(digits_only) == 8: + candidates.append(f"{digits_only[:4]}.{digits_only[4:6]}.{digits_only[6:8]}") + + candidates.append(digits_only) + + seen: set[str] = set() + deduped: list[str] = [] + for candidate in candidates: + if not candidate or candidate in seen: + continue + seen.add(candidate) + deduped.append(candidate) + return deduped + + candidates = _normalize_american_fraction_code(line.customs.american_fraction) + us_fraction: USTariffFraction | None = None + for candidate in candidates: + us_fraction = ( + db.query(USTariffFraction) + .filter( + USTariffFraction.code == candidate, + USTariffFraction.tenant_id == tenant_id, + USTariffFraction.company_id == company_id, + ) + .first() ) - ).scalar() - if not american_fraction_exists: + if us_fraction: + break + + if not us_fraction: errors.add_error( field=f"line[{line_number}].customs.american_fraction", message="La fracción americana especificada no existe.", solution=["Proporciona una fracción americana valida."], code="AMERICAN_FRACTION_NOT_FOUND", ) + else: + line.customs.american_fraction = us_fraction.code if line.order: if len(line.order) > 20: diff --git a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte index 4ba7a2a6..b581144c 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte @@ -21,32 +21,55 @@ let items = $state([]); let loading = $state(false); let searchTerm = $state(""); - let loaded = $state(false); + let loadedForCompanyId = $state(null); + + const activeCompanyId = $derived(companyStore.activeCompany?.id); + + function normalizeAmericanFractionCode(code: string) { + return (code || '').replace(/[.\s-]/g, ''); + } + + function isEligibleAmericanFraction(item: USTariffFraction) { + const normalizedCode = normalizeAmericanFractionCode(item.code || ''); + return /^\d{8}$/.test(normalizedCode) || /^\d{10}$/.test(normalizedCode); + } // Filtro local let filteredItems = $derived( items.filter(i => - (i.code || "").includes(searchTerm) || + isEligibleAmericanFraction(i) && + ((i.code || "").includes(searchTerm) || (i.description || "").toLowerCase().includes(searchTerm.toLowerCase()) + ) ) ); // Cargar datos al abrir $effect(() => { - if (open && !loaded && companyStore.activeCompany?.id) { - loadFractions(); + if (!open) return; + + if (!activeCompanyId) { + items = []; + loadedForCompanyId = null; + return; + } + + if (loadedForCompanyId !== activeCompanyId) { + searchTerm = ''; + items = []; + void loadFractions(activeCompanyId); } }); - async function loadFractions() { - if (!companyStore.activeCompany?.id) { + async function loadFractions(companyId: number) { + if (!companyId) { toast.error("No hay empresa seleccionada"); return; } loading = true; try { - const response = await getUSTariffFractions(1, 1000, companyStore.activeCompany.id); + const response = await getUSTariffFractions(1, 1000, companyId); if (response.error) { console.error("Error al cargar fracciones americanas:", response.error); @@ -55,8 +78,8 @@ } if (response.data?.items) { - items = response.data.items; - loaded = true; + items = response.data.items.filter((item) => isEligibleAmericanFraction(item)); + loadedForCompanyId = companyId; } else { console.warn("No se encontraron fracciones americanas:", response); toast.info("No se encontraron fracciones americanas registradas"); diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index ef166084..37573833 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -75,6 +75,9 @@ if (editingItem.fa_data.discharge === undefined) { editingItem.fa_data.discharge = false; } + if ((editingItem.fa_data.movement_type_import === undefined || editingItem.fa_data.movement_type_import === '') && (showLinkToImportBlock || showRepairBlock)) { + editingItem.fa_data.movement_type_import = 'TEM'; + } } }); @@ -357,6 +360,7 @@
+

Los campos marcados con * son obligatorios.

{ @@ -483,7 +487,7 @@
- + Main Data +

+ Los campos marcados con * son obligatorios. +

@@ -448,7 +451,7 @@
- +
(0); let isLoadingPackage = $state(false); @@ -114,10 +116,21 @@ package_weight_unit = pkg.weight_unit || 0; quantities.package_description = pkg.description_es || pkg.description_en || pkg.key; } + + function handleAmericanFractionSelect(fraction: any) { + customs.american_fraction = fraction.code || ''; + (customs as any).american_fraction_description = fraction.description || ''; + if (fraction.ad_valorem !== null && fraction.ad_valorem !== undefined) { + customs.advalorem_american = fraction.ad_valorem; + } + }
PACKAGES +

+ Los campos marcados con * son obligatorios. +

@@ -169,7 +182,7 @@
WEIGHTS
- +
@@ -200,8 +213,28 @@
- - + +
+ !disabled && (americanFractionDialogOpen = true)} + /> + +
@@ -230,3 +263,4 @@
+ diff --git a/frontend/src/lib/utils/items-logic.ts b/frontend/src/lib/utils/items-logic.ts index c5a227bf..f9f0b4e5 100644 --- a/frontend/src/lib/utils/items-logic.ts +++ b/frontend/src/lib/utils/items-logic.ts @@ -259,12 +259,42 @@ const FIELD_MAP: Record = { 'financial.unit_cost_capture': 'Costo Unitario', 'customs.fraction': 'Fracción Arancelaria', 'customs.origin_country': 'País de Origen', + 'customs.american_fraction': 'Fracción Americana', 'fa_data.search_invoice': 'Factura de Referencia', 'fa_data.search_line': 'Línea de Referencia', 'fa_data.search_type': 'Tipo de Búsqueda', 'fa_data.movement_type_import': 'Tipo de Importación' }; +function humanizeFieldPath(field: string): string { + const rawField = (field || '').trim(); + if (!rawField) return 'campo'; + + const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i); + const fieldPath = lineMatch?.[2] || rawField; + const mappedPath = fieldPath.replace(/^body\./i, ''); + const fieldLabel = FIELD_MAP[mappedPath] || mappedPath.replace(/\./g, ' → '); + + if (lineMatch) { + return `Partida ${lineMatch[1]} - ${fieldLabel}`; + } + + return fieldLabel; +} + +function humanizeValidationMessage(message: string): string { + const rawMessage = (message || '').trim(); + if (!rawMessage) return 'error de validación'; + + return rawMessage + .replace(/line\[(\d+)\]\.(\w+(?:\.\w+)*)/gi, (_match, lineNumber, fieldPath) => { + return `Partida ${lineNumber} - ${humanizeFieldPath(fieldPath)}`; + }) + .replace(/\b(field required|is required)\b/gi, 'es obligatorio') + .replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido') + .replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido'); +} + /** * Formats a backend error into a human-readable Spanish message. * Handles 422 (Validation), 403 (Forbidden), 404 (Not Found), and 500 (Server Error). @@ -285,12 +315,8 @@ export function formatItemError(error: any): string { // New structure (ApiResponse.validationErrors) if (status === 422 && Array.isArray(validationErrors)) { const errors = validationErrors.map((err: any) => { - const field = err.field || ''; - const fieldName = FIELD_MAP[field] || field || 'campo'; - - let msg = err.message || 'error de validación'; - if (msg.includes('field required')) msg = 'es obligatorio'; - if (msg.includes('value is not a valid decimal')) msg = 'debe ser un número válido'; + const fieldName = humanizeFieldPath(err.field || ''); + const msg = humanizeValidationMessage(err.message || 'error de validación'); return `• ${fieldName}: ${msg}`; }); @@ -305,11 +331,8 @@ export function formatItemError(error: any): string { .filter((l: string) => l !== 'body') .join('.'); - const fieldName = FIELD_MAP[locPath] || locPath || 'campo'; - - let msg = err.msg || 'error de validación'; - if (msg.includes('field required')) msg = 'es obligatorio'; - if (msg.includes('value is not a valid decimal')) msg = 'debe ser un número válido'; + const fieldName = humanizeFieldPath(locPath); + const msg = humanizeValidationMessage(err.msg || 'error de validación'); return `• ${fieldName}: ${msg}`; }); @@ -322,7 +345,7 @@ export function formatItemError(error: any): string { if (d.includes('Access denied')) return 'No tienes permisos para realizar esta acción.'; if (d.includes('not found')) return 'El registro no existe o fue eliminado.'; if (d.includes('Class mismatch')) return 'Error de validación: ' + d; - return d; + return humanizeValidationMessage(d); } // 4. Fallbacks by status code From db6c5a3372259b0c1131f9920251980f37790020 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 17 Apr 2026 10:49:14 -0600 Subject: [PATCH 052/167] fix/impo-invoice-save --- frontend/src/lib/api.ts | 74 +++++++++++++++++-- .../edit/items/fa/packages-section.svelte | 4 +- .../edit/items/inv/item-sheet-inv.svelte | 21 +++--- frontend/src/lib/utils/items-logic.ts | 67 ++++++++++++++++- 4 files changed, 145 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6e265dc7..31e47ba2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -26,6 +26,65 @@ export function humanizeLineReferences(text: string): string { return text.replace(/\bline\[(\d+)\]/gi, 'partida $1'); } +function humanizeFieldPath(field: string): string { + const rawField = (field || '').trim(); + if (!rawField) return 'campo'; + + const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i); + const fieldPath = lineMatch?.[2] || rawField; + const label = fieldPath + .replace(/^body\./i, '') + .replace(/\./g, ' → ') + .replace(/_/g, ' '); + + if (lineMatch) { + return `Partida ${lineMatch[1]} - ${label}`; + } + + return label; +} + +function humanizeValidationMessage(message: string): string { + const rawMessage = (message || '').trim(); + if (!rawMessage) return 'error de validación'; + + return rawMessage + .replace(/\b(field required|is required)\b/gi, 'es obligatorio') + .replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido') + .replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido'); +} + +function formatValidationHint(field: string, message: string, code?: string): string { + const fieldLabel = humanizeFieldPath(field); + const normalizedMessage = humanizeValidationMessage(message); + + if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es obligatorio|es requerido/i.test(normalizedMessage)) { + return `Completa ${fieldLabel}.`; + } + + if (code === 'AMERICAN_FRACTION_NOT_FOUND') { + return 'La fracción americana seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'UNIT_OF_MEASURE_NOT_FOUND') { + return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'ORIGIN_COUNTRY_NOT_FOUND') { + return 'El país de origen seleccionado no existe. Elige una opción del catálogo.'; + } + + if (code === 'CLASS_NOT_FOUND') { + return 'La clase seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'FRACTION_TYPE_INVALID') { + return 'Selecciona un tipo de tarifa válido.'; + } + + return normalizedMessage; +} + /** * Título y descripción listos para toasts / alertas a partir de ApiResponse. * Prioriza los mensajes que ya envía el backend y evita duplicar rutas técnicas. @@ -34,7 +93,7 @@ export function friendlyApiErrorParts(res: ApiResponse): { title: string; descri const validationErrors = res.validationErrors; if (validationErrors?.length) { const blocks = validationErrors.map((e) => { - const base = humanizeLineReferences((e.message || '').trim() || e.field); + const base = formatValidationHint(e.field || '', e.message || '', e.code); const hints = e.solution?.filter(Boolean).length ? '\n' + e.solution!.map((s) => `• ${humanizeLineReferences(s)}`).join('\n') : ''; @@ -52,7 +111,7 @@ export function friendlyApiErrorParts(res: ApiResponse): { title: string; descri } if (res.error) { - const err = humanizeLineReferences(res.error.trim()); + const err = humanizeValidationMessage(humanizeLineReferences(res.error.trim())); if (err.startsWith('Error de validación:')) { return { title: 'Revisa los datos ingresados', @@ -241,6 +300,7 @@ async function fetchApi( if (response.status === 422) { // HTTPException(detail={ message, errors }) — catálogo / CSV parity const det = data.detail; + const validationErrors = (errors: unknown[]) => errors as NonNullable; if ( det && typeof det === 'object' && @@ -250,7 +310,7 @@ async function fetchApi( const d = det as { message?: string; errors: unknown[] }; return { error: d.message || 'Error de validación', - validationErrors: d.errors, + validationErrors: validationErrors(d.errors), status: response.status }; } @@ -258,7 +318,7 @@ async function fetchApi( if (data.errors && Array.isArray(data.errors)) { return { error: data.message || 'Error de validación', - validationErrors: data.errors, + validationErrors: validationErrors(data.errors), status: response.status }; } @@ -421,7 +481,7 @@ async function fetchApiFormDataPost( if (data.errors && Array.isArray(data.errors)) { resolve({ error: data.message || 'Error de validación', - validationErrors: data.errors, + validationErrors: data.errors as NonNullable, status: 422 }); return; @@ -431,8 +491,8 @@ async function fetchApiFormDataPost( if (Array.isArray(data.detail)) { const errors = data.detail .map((err: any) => { - const field = err.loc ? err.loc.join('.') : 'campo desconocido'; - return `${field}: ${err.msg}`; + const field = err.loc ? err.loc.filter((loc: string) => loc !== 'body').join('.') : 'campo desconocido'; + return `${humanizeFieldPath(field)}: ${humanizeValidationMessage(err.msg || 'error de validación')}`; }) .join(', '); errorMessage += errors; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte index c2cdb76f..7a91f129 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte @@ -134,12 +134,12 @@
- +
- +
+

+ Los campos marcados con * son obligatorios. +

General @@ -272,7 +275,7 @@
- +
- + {#if editingItem?.quantity} {/if}
- +
- + {#if editingItem?.financial} {/if}
- +
- +
- + {#if editingItem?.customs}
- + {#if editingItem?.customs} {/if} diff --git a/frontend/src/lib/utils/items-logic.ts b/frontend/src/lib/utils/items-logic.ts index f9f0b4e5..f4caeb03 100644 --- a/frontend/src/lib/utils/items-logic.ts +++ b/frontend/src/lib/utils/items-logic.ts @@ -263,7 +263,28 @@ const FIELD_MAP: Record = { 'fa_data.search_invoice': 'Factura de Referencia', 'fa_data.search_line': 'Línea de Referencia', 'fa_data.search_type': 'Tipo de Búsqueda', - 'fa_data.movement_type_import': 'Tipo de Importación' + 'fa_data.movement_type_import': 'Tipo de Importación', + 'fa_data.is_subitem': 'Es Subpartida', + 'fa_data.subitem_number': 'Número de Partida Principal' +}; + +const FIELD_GUIDANCE: Record = { + class_id: 'Selecciona una clase.', + unit_of_measure: 'Selecciona una unidad de medida.', + 'quantity.quantity': 'Captura una cantidad válida mayor a cero.', + 'quantity.net_weight': 'Captura un peso neto válido mayor a cero.', + 'customs.fraction': 'Selecciona una fracción arancelaria válida.', + 'customs.origin_country': 'Selecciona un país de origen válido.', + 'customs.fraction_type': 'Selecciona un tipo de tarifa.', + 'customs.american_fraction': 'Selecciona una fracción americana válida.', + 'description.description_spanish': 'Captura la descripción en español.', + 'description.description_english': 'Captura la descripción en inglés.', + 'financial.unit_cost_capture': 'Captura un costo unitario válido.', + 'fa_data.search_invoice': 'Selecciona una factura de referencia.', + 'fa_data.search_line': 'Selecciona una línea de referencia.', + 'fa_data.search_type': 'Selecciona un tipo de búsqueda.', + 'fa_data.movement_type_import': 'Selecciona TEM o DEF.', + 'fa_data.subitem_number': 'Captura el número de la partida principal.' }; function humanizeFieldPath(field: string): string { @@ -295,6 +316,46 @@ function humanizeValidationMessage(message: string): string { .replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido'); } +function formatFriendlyFieldMessage(fieldName: string, message: string, code?: string): string { + const cleanFieldName = fieldName.replace(/^Partida \d+ - /, ''); + const guidance = FIELD_GUIDANCE[cleanFieldName] || FIELD_GUIDANCE[fieldName]; + const normalizedMessage = humanizeValidationMessage(message); + + if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es requerido|es obligatorio/i.test(normalizedMessage)) { + return guidance || `Completa ${fieldName}.`; + } + + if (code === 'AMERICAN_FRACTION_NOT_FOUND') { + return `La fracción americana seleccionada no existe. Elige una opción del catálogo.`; + } + + if (code === 'FRACTION_TYPE_INVALID') { + return 'Selecciona un tipo de tarifa válido.'; + } + + if (code === 'UNIT_OF_MEASURE_NOT_FOUND') { + return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'ORIGIN_COUNTRY_NOT_FOUND') { + return 'El país de origen seleccionado no existe. Elige una opción del catálogo.'; + } + + if (code === 'CLASS_NOT_FOUND') { + return 'La clase seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'PACKAGE_NOT_FOUND' || code === 'PACKAGE_ID_REQUIRED') { + return 'El paquete seleccionado no es válido. Elige una opción del catálogo.'; + } + + if (code === 'MOVEMENT_TYPE_IMPORT_INVALID') { + return 'Selecciona TEM o DEF para el tipo de importación.'; + } + + return normalizedMessage; +} + /** * Formats a backend error into a human-readable Spanish message. * Handles 422 (Validation), 403 (Forbidden), 404 (Not Found), and 500 (Server Error). @@ -316,7 +377,7 @@ export function formatItemError(error: any): string { if (status === 422 && Array.isArray(validationErrors)) { const errors = validationErrors.map((err: any) => { const fieldName = humanizeFieldPath(err.field || ''); - const msg = humanizeValidationMessage(err.message || 'error de validación'); + const msg = formatFriendlyFieldMessage(fieldName, err.message || 'error de validación', err.code); return `• ${fieldName}: ${msg}`; }); @@ -332,7 +393,7 @@ export function formatItemError(error: any): string { .join('.'); const fieldName = humanizeFieldPath(locPath); - const msg = humanizeValidationMessage(err.msg || 'error de validación'); + const msg = formatFriendlyFieldMessage(fieldName, err.msg || 'error de validación', err.type); return `• ${fieldName}: ${msg}`; }); From 20bcb7797e9e30e2cb100c4ecc7ef95a18031fe2 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 17 Apr 2026 11:03:28 -0600 Subject: [PATCH 053/167] fix/modo-lectura-activo-fijo --- .../sectors/data-table-actions.svelte | 9 --------- .../dashboard/reference_data/states/columns.ts | 6 +++--- .../states/data-table-actions.svelte | 18 ++++++++++++------ .../reference_data/states/+page.svelte | 2 +- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/components/dashboard/reference_data/sectors/data-table-actions.svelte b/frontend/src/lib/components/dashboard/reference_data/sectors/data-table-actions.svelte index 1f087b36..bf90e548 100644 --- a/frontend/src/lib/components/dashboard/reference_data/sectors/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/reference_data/sectors/data-table-actions.svelte @@ -5,7 +5,6 @@ import type { Sector } from "./columns.js"; import CreateEditDialog from "./create-edit-dialog.svelte"; import DetailsDialog from "./details-dialog.svelte"; - import DeleteDialog from "./delete-dialog.svelte"; let { item, @@ -17,7 +16,6 @@ let showDetailsDialog = $state(false); let showEditDialog = $state(false); - let showDeleteDialog = $state(false); function handleCopyId() { navigator.clipboard.writeText(item.key.toString()); @@ -30,10 +28,6 @@ function handleEdit() { showEditDialog = true; } - - function handleDelete() { - showDeleteDialog = true; - } @@ -55,12 +49,9 @@ Ver detalles Editar - - Eliminar - diff --git a/frontend/src/lib/components/dashboard/reference_data/states/columns.ts b/frontend/src/lib/components/dashboard/reference_data/states/columns.ts index eb88329d..1a1b4c35 100644 --- a/frontend/src/lib/components/dashboard/reference_data/states/columns.ts +++ b/frontend/src/lib/components/dashboard/reference_data/states/columns.ts @@ -10,7 +10,7 @@ export type State = { ame_key?: string | null; }; -export function createColumns(onSuccess?: () => void): ColumnDef[] { +export function createColumns(onSuccess?: () => void, readOnly = false): ColumnDef[] { return [ { accessorKey: "m3_key", @@ -74,11 +74,11 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { id: "actions", cell: ({ row }) => { - return renderComponent(DataTableActions, { item: row.original, onSuccess }); + return renderComponent(DataTableActions, { item: row.original, onSuccess, readOnly }); } } ]; } // Mantener compatibilidad hacia atrás -export const columns = createColumns(); +export const columns = createColumns(undefined, true); diff --git a/frontend/src/lib/components/dashboard/reference_data/states/data-table-actions.svelte b/frontend/src/lib/components/dashboard/reference_data/states/data-table-actions.svelte index 1c2746b6..ac13a9de 100644 --- a/frontend/src/lib/components/dashboard/reference_data/states/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/reference_data/states/data-table-actions.svelte @@ -9,10 +9,12 @@ let { item, - onSuccess + onSuccess, + readOnly = false }: { item: State; onSuccess?: () => void; + readOnly?: boolean; } = $props(); let showDetailsDialog = $state(false); @@ -54,13 +56,17 @@ Ver detalles - Editar - - Eliminar + {#if !readOnly} + Editar + + Eliminar + {/if} - - +{#if !readOnly} + + +{/if} diff --git a/frontend/src/routes/dashboard/reference_data/states/+page.svelte b/frontend/src/routes/dashboard/reference_data/states/+page.svelte index 9153e117..28188744 100644 --- a/frontend/src/routes/dashboard/reference_data/states/+page.svelte +++ b/frontend/src/routes/dashboard/reference_data/states/+page.svelte @@ -140,7 +140,7 @@ } // Crear columnas con el callback onSuccess - const columns = createColumns(handleSuccess); + const columns = createColumns(handleSuccess, true);
From 1036a2edb5cde3c20f7e5ae436337034ea9ad9b5 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 17 Apr 2026 12:36:05 -0600 Subject: [PATCH 054/167] fix/conductores-longitud-placa --- ...8b9c0d1e2_driver_transporter_key_length.py | 39 ++ .../modules/a76/transportation/drivers/dto.py | 7 +- .../a76/transportation/drivers/models.py | 2 +- .../drivers/create-edit-dialog.svelte | 654 ++++++++++++------ .../transporters/create-edit-dialog.svelte | 20 +- .../general_catalogs/drivers/+page.svelte | 62 +- 6 files changed, 500 insertions(+), 284 deletions(-) create mode 100644 backend/alembic/versions/f7a8b9c0d1e2_driver_transporter_key_length.py diff --git a/backend/alembic/versions/f7a8b9c0d1e2_driver_transporter_key_length.py b/backend/alembic/versions/f7a8b9c0d1e2_driver_transporter_key_length.py new file mode 100644 index 00000000..c5ba8e21 --- /dev/null +++ b/backend/alembic/versions/f7a8b9c0d1e2_driver_transporter_key_length.py @@ -0,0 +1,39 @@ +"""fix driver transporter_key length and validations + +Revision ID: f7a8b9c0d1e2 +Revises: e76_app_settings +Create Date: 2026-04-17 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "f7a8b9c0d1e2" +down_revision = "e76_app_settings" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column( + "driver", + "transporter_key", + schema="a76", + existing_type=sa.String(length=5), + type_=sa.String(length=30), + existing_nullable=False, + ) + + +def downgrade() -> None: + op.alter_column( + "driver", + "transporter_key", + schema="a76", + existing_type=sa.String(length=30), + type_=sa.String(length=5), + existing_nullable=False, + ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/transportation/drivers/dto.py b/backend/api/v1/modules/a76/transportation/drivers/dto.py index e67daee0..5366d209 100644 --- a/backend/api/v1/modules/a76/transportation/drivers/dto.py +++ b/backend/api/v1/modules/a76/transportation/drivers/dto.py @@ -1,10 +1,13 @@ from typing import Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field + + +TRANSPORTER_KEY_MAX_LENGTH = 30 class DriverBaseDTO(BaseModel): - transporter_key: str + transporter_key: str = Field(..., max_length=TRANSPORTER_KEY_MAX_LENGTH) driver_id: Optional[int] = None line: int driver_name: Optional[str] = None diff --git a/backend/api/v1/modules/a76/transportation/drivers/models.py b/backend/api/v1/modules/a76/transportation/drivers/models.py index dc6d7ee9..57aed494 100644 --- a/backend/api/v1/modules/a76/transportation/drivers/models.py +++ b/backend/api/v1/modules/a76/transportation/drivers/models.py @@ -10,7 +10,7 @@ class Driver(Base, TenantScopedMixin, TimestampMixin): ) transporter_key = Column( - String(5), + String(30), ForeignKey("a76.transporter.transporter_key", ondelete="CASCADE"), primary_key=True, nullable=False, diff --git a/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte index 875c7c47..ca480f5e 100644 --- a/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte @@ -1,6 +1,7 @@ - + {title} - {isEdit - ? 'Modifica los datos del conductor' - : 'Completa los datos para crear un nuevo conductor'} + {isEdit ? 'Modifica los datos del conductor' : 'Completa los datos para crear un nuevo conductor'} -
{ - e.preventDefault(); - handleSubmit(); - }} - class="space-y-6" - > + { e.preventDefault(); handleSubmit(); }} class="space-y-4"> {#if error} -
- {error} -
+
{error}
{/if} -
-
- - {#if isEdit} - - {:else} - - - {transportersLoading - ? 'Cargando transportistas...' - : transporters.length === 0 - ? 'No hay transportistas' - : transporters.find((t) => t.transporter_key === formData.transporter_key) - ? `${formData.transporter_key} - ${transporters.find((t) => t.transporter_key === formData.transporter_key)?.name || ''}` - : 'Seleccionar transportista'} - - - {#each transporters as t} - - {t.transporter_key} — {t.name || t.short_name || 'Sin nombre'} - - {/each} - {#if !transportersLoading && transporters.length === 0} -
- No hay transportistas. Crea uno en el catálogo Transportistas. -
- {/if} -
-
- {/if} -
+ + + 1) Generales + 2) Identificaciones + -
- - -
+ + +
-
- - -
+ +
+ + {#if isEdit} + + {:else} + + + {transportersLoading + ? 'Cargando...' + : transporters.find((t) => t.transporter_key === formData.transporter_key) + ? `${formData.transporter_key} — ${transporters.find((t) => t.transporter_key === formData.transporter_key)?.name || ''}` + : 'Seleccionar transportista'} + + + {#each transporters as t} + + {t.transporter_key} — {t.name || t.short_name || 'Sin nombre'} + + {/each} + {#if !transportersLoading && transporters.length === 0} +
No hay transportistas. Crea uno primero.
+ {/if} +
+
+ {/if} +
-
- - -
+ +
+ + +
-
- - -
+ +
+ + +
-
- - -
+ +
+ + +
+
+ + + + {formData.class_type || '— Opcional —'} + + + — Vacío — + {#each CLASE_OPCIONES as c} + {c} + {/each} + + +
-
- - -
+ +
+ + +
-
- - -
+ +
+ + +
-
- - -
+ +
+ + +
-
- - - - {countriesLoading - ? 'Cargando países...' - : formData.birth_country - ? `${formData.birth_country} — ${countries.find((c) => c.ame_key === formData.birth_country)?.description_es ?? ''}` - : '— Opcional —'} - - - — Vacío — - {#each countries as c} - - {c.ame_key} — {c.description_es} - - {/each} - - -
+ +
+ + +
-
- - -
+ +
+ + +
+
+ + + + {formData.gender || '— Opcional —'} + + + — Vacío — + M — Masculino + F — Femenino + + +
-
- - -
+ +
+ + + + {countriesLoading ? 'Cargando...' : formData.birth_country ? `${formData.birth_country} — ${countries.find((c) => c.ame_key === formData.birth_country)?.description_es ?? ''}` : '— Opcional —'} + + + — Vacío — + {#each countries as c} + {c.ame_key} — {c.description_es} + {/each} + + +
-
- - -
-
+ +
+ + + + {formData.hazardous_material_auth || '— Opcional —'} + + + — Vacío — + SI + NO + + +
+
+ + +
+ +
+ + + + +
+ + +
+ + +
+
+ + +
+ + +
+

Primera Identificación

+
+ +
+ + + + {FORMA_ID_OPCIONES.find((o) => o.value === formData.id_key1)?.label || '— Opcional —'} + + + — Vacío — + {#each FORMA_ID_OPCIONES as o} + {o.label} + {/each} + + +
+
+ + +
+
+ + +
+
+ + + + {formData.id_country1 ? `${formData.id_country1} — ${countries.find((c) => c.ame_key === formData.id_country1)?.description_es ?? ''}` : '— Opcional —'} + + + — Vacío — + {#each countries as c} + {c.ame_key} — {c.description_es} + {/each} + + +
+ + +
+

Segunda Identificación

+
+ +
+ + + + {FORMA_ID_OPCIONES.find((o) => o.value === formData.id_key2)?.label || '— Opcional —'} + + + — Vacío — + {#each FORMA_ID_OPCIONES as o} + {o.label} + {/each} + + +
+
+ + +
+
+ + +
+
+ + + + {formData.id_country2 ? `${formData.id_country2} — ${countries.find((c) => c.ame_key === formData.id_country2)?.description_es ?? ''}` : '— Opcional —'} + + + — Vacío — + {#each countries as c} + {c.ame_key} — {c.description_es} + {/each} + + +
+ +
+
+ - - + +
diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte index 8d2b931f..a39db20b 100644 --- a/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte @@ -213,7 +213,7 @@ bind:value={formData.transporter_key} disabled={isEdit} required - maxlength={23} + maxlength={30} />
@@ -234,12 +234,12 @@
- +
- +
@@ -249,7 +249,7 @@
- +
@@ -293,7 +293,7 @@
- +
@@ -345,7 +345,7 @@
- +
@@ -356,23 +356,23 @@
- +
- +
- +
- +
diff --git a/frontend/src/routes/dashboard/general_catalogs/drivers/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/drivers/+page.svelte index 778c613c..8f19bbcd 100644 --- a/frontend/src/routes/dashboard/general_catalogs/drivers/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/drivers/+page.svelte @@ -1,6 +1,4 @@ + + + + + {title} + {#if isEdit} + + Modifica el documento digitalizado {item?.id}. + El RFC Consulta se obtiene del VU del agente aduanal. + + {:else} + + Captura un nuevo documento digitalizado. El RFC Consulta se obtiene del VU del + agente aduanal. + + {/if} + + +
{ e.preventDefault(); handleSubmit(); }} class="space-y-6"> + {#if error} +
+ {error} +
+ {/if} + +
+
+

Documento

+
+
+ + (formData.tipo_documento = v || null)} + disabled={loading || docTypesLoading} + > + + + {docTypesLoading + ? 'Cargando tipos...' + : selectedDocType + ? `${selectedDocType.code}${selectedDocType.description ? ` — ${selectedDocType.description}` : ''}` + : 'Selecciona tipo...'} + + + + {#each docTypes as dt} + + {dt.code}{dt.description ? ` — ${dt.description}` : ''} + + {/each} + + +
+ +
+ + (formData.fecha_digitalizacion = (e.target as HTMLInputElement).value || null)} + disabled={loading} + /> +
+ +
+ + { + formData.archivo_digitalizado_en = file.name; + if (!formData.nombre_archivo) formData.nombre_archivo = file.name; + }} + /> +
+ +
+ + (formData.nombre_archivo = (e.target as HTMLInputElement).value)} + placeholder="nombre_archivo.pdf" + disabled={loading} + /> +
+
+
+ + + +
+

Consulta y referencia

+
+
+ + (formData.e_document = (e.target as HTMLInputElement).value)} + placeholder="E-Document" + disabled={loading} + /> +
+ +
+ + (formData.num_operacion = (e.target as HTMLInputElement).value)} + placeholder="Número de operación" + disabled={loading} + /> +
+ +
+ + onBrokerSelected(v || undefined)} + disabled={loading || brokersLoading} + > + + + {brokersLoading + ? 'Cargando agentes...' + : selectedBroker + ? `${selectedBroker.license}${selectedBroker.name ? ` — ${selectedBroker.name}` : ''}` + : 'Selecciona agente...'} + + + + {#each brokers as broker} + + {broker.license}{broker.name ? ` — ${broker.name}` : ''} + + {/each} + + +
+ +
+ + +
+ +
+ + (formData.pedimento = (e.target as HTMLInputElement).value)} + placeholder="00-0000-0000000" + disabled={loading} + /> +
+
+
+
+ + + + + +
+
+
diff --git a/frontend/src/lib/components/dashboard/digitalizacion/data-table-actions.svelte b/frontend/src/lib/components/dashboard/digitalizacion/data-table-actions.svelte new file mode 100644 index 00000000..5ff1897e --- /dev/null +++ b/frontend/src/lib/components/dashboard/digitalizacion/data-table-actions.svelte @@ -0,0 +1,78 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + onDigitalizar?.(item)}> + + {m['sidebar.digitalizacion.action_digitalizar']()} + + {#if item.status === 'success'} + onAcuse?.(item)}> + + {m['sidebar.digitalizacion.action_acuse']()} + + {/if} + + (editOpen = true)}> + + {m['sidebar.digitalizacion.action_edit']()} + + + + {m['sidebar.digitalizacion.action_delete']()} + + + + + diff --git a/frontend/src/lib/components/dashboard/digitalizacion/digitalizar-dialog.svelte b/frontend/src/lib/components/dashboard/digitalizacion/digitalizar-dialog.svelte new file mode 100644 index 00000000..8cf06887 --- /dev/null +++ b/frontend/src/lib/components/dashboard/digitalizacion/digitalizar-dialog.svelte @@ -0,0 +1,176 @@ + + + + + + {m['sidebar.digitalizacion.digitalizar_title']()} + {m['sidebar.digitalizacion.digitalizar_subtitle']()} + + +
{ e.preventDefault(); handleSubmit(); }} class="space-y-6"> + {#if error} +
+ {error} +
+ {/if} + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + + {#if nombreArchivo} +

{nombreArchivo}

+ {/if} +
+
+ + + + + +
+
+
diff --git a/frontend/src/lib/components/dashboard/digitalizacion/e-document-cell.svelte b/frontend/src/lib/components/dashboard/digitalizacion/e-document-cell.svelte new file mode 100644 index 00000000..34e5c739 --- /dev/null +++ b/frontend/src/lib/components/dashboard/digitalizacion/e-document-cell.svelte @@ -0,0 +1,19 @@ + + + + {#if item.e_document} + + {item.e_document} + {:else} + - + {/if} + diff --git a/frontend/src/lib/components/dashboard/digitalizacion/progress-dialog.svelte b/frontend/src/lib/components/dashboard/digitalizacion/progress-dialog.svelte new file mode 100644 index 00000000..43b703c1 --- /dev/null +++ b/frontend/src/lib/components/dashboard/digitalizacion/progress-dialog.svelte @@ -0,0 +1,203 @@ + + + + + + {m['sidebar.digitalizacion.progress_title']()} + {#if nombreArchivo} + {nombreArchivo} + {/if} + + +
+ {#if state === 'SUCCESS'} + +
+ +

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

+
+ {#if result} +
+ {#if result.e_document} +
+
E-Document:
+
{result.e_document}
+
+ {/if} + {#if result.numero_operacion} +
+
Núm. Operación:
+
{result.numero_operacion}
+
+ {/if} +
+ {/if} + {#if result?.acuese_digitalizacion_pdf_base64} + + {/if} + + {:else if state === 'FAILURE'} + +
+ +
+

{errorMsg}

+ {#if errorDetail} + {#if errorDetail.codigo} +

Código: {errorDetail.codigo}

+ {/if} + {#if errorDetail.paso} +

Paso: {errorDetail.paso}

+ {/if} + {#if errorDetail.sugerencias?.length} +
    + {#each errorDetail.sugerencias as s} +
  • {s}
  • + {/each} +
+ {/if} + {/if} +
+
+ + {:else} + +
+
+ +

{currentStep}

+
+ +

{progress}%

+
+ {/if} +
+ + + {#if isTerminal} + + {:else} + + {/if} + +
+
diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 14bf9a57..e1fdd3a5 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -7,6 +7,7 @@ import { Database, FileSearch, FileText, + FolderArchive, Frame, GalleryVerticalEnd, Hash, @@ -509,6 +510,12 @@ export function getSidebarData(): SidebarData { }, ], }, + { + title: m["sidebar.digitalizacion.title"](), + url: "/dashboard/digitalizacion", + icon: FolderArchive, + items: [], + }, { title: m["sidebar.reference_data.configuracion"](), url: "#", diff --git a/frontend/src/routes/dashboard/digitalizacion/+page.svelte b/frontend/src/routes/dashboard/digitalizacion/+page.svelte new file mode 100644 index 00000000..ce3ef135 --- /dev/null +++ b/frontend/src/routes/dashboard/digitalizacion/+page.svelte @@ -0,0 +1,223 @@ + + +
+ +
+
+

+ {m['sidebar.digitalizacion.title']()} +

+

{m['sidebar.digitalizacion.subtitle']()}

+
+
+ + +
+
+ + + + +
+ {m['sidebar.digitalizacion.table_title']()} +
+ +
+
+
+ + {#if loading && data.length === 0} +
+ {m['sidebar.digitalizacion.loading']()} +
+ {:else if data.length === 0 && !loading} +
+ {m['sidebar.digitalizacion.empty']()} +
+ {:else} +
+ +
+ {/if} +
+
+ +
+ Mostrando {data.length} de {totalItems} registros +
+
+ + + + +{#if selectedItem && digitalizarDialogOpen} + +{/if} + +{#if progressDialogOpen && currentTaskId} + { progressDialogOpen = false; loadData(); }} + /> +{/if} From 1cd81ebc037e80655c2e81f9aa8773671e5762f9 Mon Sep 17 00:00:00 2001 From: hreyes Date: Mon, 20 Apr 2026 15:13:48 -0600 Subject: [PATCH 056/167] feature/digitalizacion-api --- .../f1a2b3c4d5e6_create_expediente_archivo.py | 129 ++++- .../v1/modules/a76/doc_types_dig/routes.py | 239 ++++----- .../v1/modules/a76/doc_types_dig/service.py | 211 ++++++++ .../v1/modules/a76/expediente_archivos/dto.py | 15 +- .../expediente_archivos/external_service.py | 9 +- .../modules/a76/expediente_archivos/models.py | 4 + .../modules/a76/expediente_archivos/routes.py | 205 +++++++- .../a76/expediente_archivos/service.py | 275 +++++++++-- .../modules/a76/expediente_archivos/tasks.py | 461 +++++++++++++---- .../v1/modules/a76/factura_cove/service.py | 54 +- backend/core/celery_app.py | 1 + backend/core/s3_keys.py | 43 ++ frontend/messages/en.json | 370 +++++++------- frontend/messages/es.json | 368 +++++++------- .../api/dashboard/a76/expediente-archivos.ts | 69 ++- .../document_types_digitization.ts | 86 ++-- .../common/infinite-data-table.svelte | 60 ++- .../dashboard/digitalizacion/columns.ts | 90 +++- .../digitalizacion/create-edit-dialog.svelte | 109 ++-- .../digitalizacion/data-table-actions.svelte | 95 +++- .../digitalizacion/progress-dialog.svelte | 157 ++++-- .../edit/digitization-tab-form.svelte | 150 ++---- .../edit/pedimento-selector-dialog.svelte | 222 +++++++++ .../document_types_digitization/columns.ts | 59 +++ .../src/lib/components/sidebar/modules.ts | 4 + .../dashboard/a76/digitalizacion/list.ts | 59 +++ .../dashboard/digitalizacion/+page.svelte | 464 +++++++++++++++--- .../+page.server.ts | 88 ++++ .../document_types_digitization/+page.svelte | 183 +++++++ 29 files changed, 3275 insertions(+), 1004 deletions(-) create mode 100644 backend/api/v1/modules/a76/doc_types_dig/service.py create mode 100644 frontend/src/lib/components/dashboard/pedimentos/edit/pedimento-selector-dialog.svelte create mode 100644 frontend/src/lib/components/dashboard/reference_data/document_types_digitization/columns.ts create mode 100644 frontend/src/lib/config/shortcuts/dashboard/a76/digitalizacion/list.ts create mode 100644 frontend/src/routes/dashboard/reference_data/document_types_digitization/+page.server.ts create mode 100644 frontend/src/routes/dashboard/reference_data/document_types_digitization/+page.svelte diff --git a/backend/alembic/versions/f1a2b3c4d5e6_create_expediente_archivo.py b/backend/alembic/versions/f1a2b3c4d5e6_create_expediente_archivo.py index cf9ada86..3cdc75d2 100644 --- a/backend/alembic/versions/f1a2b3c4d5e6_create_expediente_archivo.py +++ b/backend/alembic/versions/f1a2b3c4d5e6_create_expediente_archivo.py @@ -1,19 +1,73 @@ -"""create expediente_archivo table +"""create expediente_archivo table and seed document types digitization catalog Revision ID: f1a2b3c4d5e6 -Revises: d4e5f6a7b8c9 -Create Date: 2026-04-17 12:00:00.000000 +Revises: f7a8b9c0d1e2 +Create Date: 2026-04-20 00:00:00.000000 """ + from alembic import op import sqlalchemy as sa + +# revision identifiers, used by Alembic. revision = "f1a2b3c4d5e6" down_revision = "f7a8b9c0d1e2" branch_labels = None depends_on = None +# --------------------------------------------------------------------------- +# Seed data +# --------------------------------------------------------------------------- + +DOCUMENT_TYPES = [ + ("168", "Calca o fotografía digital del NIV del vehículo."), + ("169", "Aviso."), + ("170", "Factura."), + ("171", "Documento con el que se acredite la propiedad de la mercancía."), + ("172", "Contratos."), + ("176", "Documentación relacionada con la garantía otorgada en términos de los artículos 84."), + ("177", "Identificación Oficial."), + ("179", "Comprobante de domicilio."), + ("184", "Documento que ampara el avaluó de las mercancías."), + ("185", "Documentos de adjudicación judicial de las mercancías."), + ("187", "Solicitud de retiro de mercancías que causaron abandono."), + ("189", "Actas."), + ("192", "Escritos."), + ("420", "Certificado de peso o volumen."), + ("421", "Comprobante de la importación temporal de la embarcación debidamente formalizado."), + ("422", "Comprobante expedido por donataria."), + ("423", "Consulta en la que conste que el vehículo no se encuentra reportado como robado,"), + ("424", "Clave Unica del Registro de Población."), + ("425", "Declaración de internación o extracción de cantidades en efectivo y/o documentos p"), + ("426", "Declaración de operaciones que no confieren origen en países no parte de acuerdo"), + ("427", "Declaración en la que se señalen los motivos por los que efectúa la devolución de m"), + ("428", "Documentación con información que permita la identificación, análisis y control en tér"), + ("429", "Documentación que acredite que acepta y subsana la irregularidad."), + ("430", "Documentación que ampare la importación temporal del vehículo de que se trate."), + ("431", "Documentación que compruebe que la adquisición de las mercancías fue efectuada "), + ("433", "Documento con base en el cual se determine la procedencia y el origen de las merca"), + ("434", "Documento con que se acredite el reintegro del IVA, en caso de que el contribuyente "), + ("435", "Documentos previstos en la regla 8.7., fracciones I a IV de la Resolución del TLCAN."), + ("436", "El Documento que compruebe el cumplimiento de las regulaciones y restricciones no "), + ("438", "Guía aérea, conocimiento de embarque o carta de porte."), + ("439", "Hoja con los datos de la matrícula y nombre del barco, el lugar donde se localiza y se "), + ("440", "Manifiesto de carga."), + ("441", "Oficios emitidos por autoridad."), + ("442", "Pedimentos."), + ("443", "Programa IMMEX."), + ("444", "Relación de candados."), + ("445", "Relación de certificados de origen."), +] + + +# --------------------------------------------------------------------------- +# Upgrade / Downgrade +# --------------------------------------------------------------------------- + + def upgrade() -> None: + # -- Table ----------------------------------------------------------------- op.create_table( "expediente_archivo", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), @@ -30,6 +84,10 @@ def upgrade() -> None: sa.Column("task_id", sa.String(length=255), nullable=True), sa.Column("external_task_id", sa.String(length=255), nullable=True), sa.Column("acuse_pdf_path", sa.String(length=500), nullable=True), + sa.Column("envio_xml_path", sa.String(length=500), nullable=True), + sa.Column("respuesta_xml_path", sa.String(length=500), nullable=True), + sa.Column("consulta_envio_xml_path", sa.String(length=500), nullable=True), + sa.Column("consulta_respuesta_xml_path", sa.String(length=500), 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), @@ -69,10 +127,67 @@ def upgrade() -> None: schema="a76", ) + # -- Seeds ----------------------------------------------------------------- + bind = op.get_bind() + companies = ( + bind.execute(sa.text("SELECT id, tenant_id FROM a76.company ORDER BY id")) + .mappings() + .all() + ) + for company in companies: + for code, description in DOCUMENT_TYPES: + bind.execute( + sa.text( + """ + INSERT INTO a76.document_types_digitization + (tenant_id, company_id, code, description, active) + VALUES + (:tenant_id, :company_id, :code, :description, TRUE) + ON CONFLICT ON CONSTRAINT document_types_digitization_code_key + DO NOTHING + """ + ), + { + "tenant_id": company["tenant_id"], + "company_id": company["id"], + "code": code, + "description": description, + }, + ) + def downgrade() -> None: - op.drop_index(op.f("ix_a76_expediente_archivo_external_task_id"), table_name="expediente_archivo", schema="a76") - op.drop_index(op.f("ix_a76_expediente_archivo_task_id"), table_name="expediente_archivo", schema="a76") - op.drop_index(op.f("ix_a76_expediente_archivo_tenant_id"), table_name="expediente_archivo", schema="a76") - op.drop_index(op.f("ix_a76_expediente_archivo_company_id"), table_name="expediente_archivo", schema="a76") + # -- Remove seeds ---------------------------------------------------------- + bind = op.get_bind() + codes = [code for code, _ in DOCUMENT_TYPES] + placeholders = ", ".join(f":c{i}" for i in range(len(codes))) + params = {f"c{i}": code for i, code in enumerate(codes)} + bind.execute( + sa.text( + f"DELETE FROM a76.document_types_digitization WHERE code IN ({placeholders})" + ), + params, + ) + + # -- Drop table ------------------------------------------------------------ + op.drop_index( + op.f("ix_a76_expediente_archivo_external_task_id"), + table_name="expediente_archivo", + schema="a76", + ) + op.drop_index( + op.f("ix_a76_expediente_archivo_task_id"), + table_name="expediente_archivo", + schema="a76", + ) + op.drop_index( + op.f("ix_a76_expediente_archivo_tenant_id"), + table_name="expediente_archivo", + schema="a76", + ) + op.drop_index( + op.f("ix_a76_expediente_archivo_company_id"), + table_name="expediente_archivo", + schema="a76", + ) op.drop_table("expediente_archivo", schema="a76") diff --git a/backend/api/v1/modules/a76/doc_types_dig/routes.py b/backend/api/v1/modules/a76/doc_types_dig/routes.py index a3ee51a7..ca229671 100644 --- a/backend/api/v1/modules/a76/doc_types_dig/routes.py +++ b/backend/api/v1/modules/a76/doc_types_dig/routes.py @@ -1,170 +1,97 @@ -from typing import List +from typing import Any, Dict, Optional -from api.v1.modules.a76.doc_types_dig.dto import ( - DocumentTypeDigitizationCreate, - DocumentTypeDigitizationResponse, - DocumentTypeDigitizationUpdate, -) -from api.v1.modules.a76.doc_types_dig.models import DocumentTypeDigitization -from core.database import get_core_db -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session -router = APIRouter(prefix="/document-types-digitization", tags=["Document Types Digitization"]) +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource + +from .dto import DocumentTypeDigitizationResponse +from .service import DocumentTypeDigitizationService + +router = APIRouter(prefix='/document-types-digitization', tags=['Document Types Digitization']) -@router.get("", response_model=List[DocumentTypeDigitizationResponse]) -def get_all_document_types( - active_only: bool = True, - db: Session = Depends(get_core_db), +@router.get('/', response_model=Dict[str, Any]) +async def list_document_types( + company_id: int = Query(..., description='Company ID'), + page: int = Query(1, ge=1, description='Page number'), + page_size: int = Query(50, ge=1, le=2000, description='Page size'), + search: Optional[str] = Query(None, description='Search by code or description'), + active_only: bool = Query(False, description='Only active records'), + sort_by: Optional[str] = Query('code', description='Column to sort by'), + sort_order: str = Query('asc', pattern='^(asc|desc)$', description='Sort order'), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): - """ - Obtener todos los tipos de documentos para digitalización - - Args: - active_only: Si es True, solo devuelve los tipos activos - """ - query = select(DocumentTypeDigitization) - - if active_only: - query = query.where(DocumentTypeDigitization.active == True) - - query = query.order_by(DocumentTypeDigitization.code) - - result = db.execute(query) - document_types = result.scalars().all() - - return document_types + tenant_id = validate_access_to_resource(db, company_id, current_user) + skip = (page - 1) * page_size + filters: Dict[str, Any] = {} + + if search: + filters['search'] = search + if active_only: + filters['active_only'] = True + + items, total = DocumentTypeDigitizationService.get_all( + db, + tenant_id, + company_id, + skip, + page_size, + filters, + sort_by, + sort_order, + ) + + return { + 'items': [DocumentTypeDigitizationResponse.model_validate(item) for item in items], + 'total': total, + 'page': page, + 'page_size': page_size, + } -@router.get("/{document_type_id}", response_model=DocumentTypeDigitizationResponse) -def get_document_type( - document_type_id: int, - db: Session = Depends(get_core_db), +@router.get('/{document_type_id}/', response_model=DocumentTypeDigitizationResponse) +async def get_document_type( + document_type_id: int, + company_id: int = Query(..., description='Company ID'), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): - """Obtener un tipo de documento por ID""" - result = db.execute( - select(DocumentTypeDigitization).where(DocumentTypeDigitization.id == document_type_id) - ) - document_type = result.scalar_one_or_none() - - if not document_type: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Tipo de documento con ID {document_type_id} no encontrado" - ) - - return document_type + tenant_id = validate_access_to_resource(db, company_id, current_user) + document_type = DocumentTypeDigitizationService.get_by_id( + db, + document_type_id, + tenant_id, + company_id, + ) + + if not document_type: + raise HTTPException( + status_code=404, + detail=f'Tipo de documento con ID {document_type_id} no encontrado', + ) + + return document_type -@router.get("/by-code/{code}", response_model=DocumentTypeDigitizationResponse) -def get_document_type_by_code( - code: str, - db: Session = Depends(get_core_db), +@router.get('/by-code/{code}/', response_model=DocumentTypeDigitizationResponse) +async def get_document_type_by_code( + code: str, + company_id: int = Query(..., description='Company ID'), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): - """Obtener un tipo de documento por código""" - result = db.execute( - select(DocumentTypeDigitization).where(DocumentTypeDigitization.code == code) - ) - document_type = result.scalar_one_or_none() - - if not document_type: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Tipo de documento con código {code} no encontrado" - ) - - return document_type + tenant_id = validate_access_to_resource(db, company_id, current_user) + document_type = DocumentTypeDigitizationService.get_by_code( + db, + code, + tenant_id, + company_id, + ) + if not document_type: + raise HTTPException(status_code=404, detail=f'Tipo de documento con codigo {code} no encontrado') -@router.post("", response_model=DocumentTypeDigitizationResponse, status_code=status.HTTP_201_CREATED) -def create_document_type( - document_type_data: DocumentTypeDigitizationCreate, - db: Session = Depends(get_core_db), -): - """Crear un nuevo tipo de documento""" - # Verificar si el código ya existe - result = db.execute( - select(DocumentTypeDigitization).where(DocumentTypeDigitization.code == document_type_data.code) - ) - existing = result.scalar_one_or_none() - - if existing: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Ya existe un tipo de documento con el código {document_type_data.code}" - ) - - new_document_type = DocumentTypeDigitization(**document_type_data.model_dump()) - db.add(new_document_type) - db.commit() - db.refresh(new_document_type) - - return new_document_type - - -@router.put("/{document_type_id}", response_model=DocumentTypeDigitizationResponse) -def update_document_type( - document_type_id: int, - document_type_data: DocumentTypeDigitizationUpdate, - db: Session = Depends(get_core_db), -): - """Actualizar un tipo de documento existente""" - result = db.execute( - select(DocumentTypeDigitization).where(DocumentTypeDigitization.id == document_type_id) - ) - document_type = result.scalar_one_or_none() - - if not document_type: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Tipo de documento con ID {document_type_id} no encontrado" - ) - - # Actualizar solo los campos proporcionados - update_data = document_type_data.model_dump(exclude_unset=True) - - # Verificar si el nuevo código ya existe (si se está actualizando) - if "code" in update_data and update_data["code"] != document_type.code: - result = db.execute( - select(DocumentTypeDigitization).where(DocumentTypeDigitization.code == update_data["code"]) - ) - existing = result.scalar_one_or_none() - if existing: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Ya existe un tipo de documento con el código {update_data['code']}" - ) - - for field, value in update_data.items(): - setattr(document_type, field, value) - - db.commit() - db.refresh(document_type) - - return document_type - - -@router.delete("/{document_type_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_document_type( - document_type_id: int, - db: Session = Depends(get_core_db), -): - """Eliminar un tipo de documento (soft delete, marca como inactivo)""" - result = db.execute( - select(DocumentTypeDigitization).where(DocumentTypeDigitization.id == document_type_id) - ) - document_type = result.scalar_one_or_none() - - if not document_type: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Tipo de documento con ID {document_type_id} no encontrado" - ) - - # Soft delete - solo marcar como inactivo - document_type.active = False - db.commit() - - return None + return document_type diff --git a/backend/api/v1/modules/a76/doc_types_dig/service.py b/backend/api/v1/modules/a76/doc_types_dig/service.py new file mode 100644 index 00000000..5e645b98 --- /dev/null +++ b/backend/api/v1/modules/a76/doc_types_dig/service.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional, Tuple + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from .dto import DocumentTypeDigitizationCreate, DocumentTypeDigitizationUpdate +from .models import DocumentTypeDigitization + + +class DocumentTypeDigitizationService: + @staticmethod + def _normalize_code(code: str) -> str: + return (code or '').strip().upper() + + @staticmethod + def _normalize_description(description: str) -> str: + return (description or '').strip() + + @staticmethod + def _is_truthy_filter(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() in {'1', 'true', 'yes', 'si'} + + @staticmethod + def get_all( + db: Session, + tenant_id: int, + company_id: Optional[int], + skip: int = 0, + limit: int = 100, + filters: Optional[Dict[str, Any]] = None, + sort_by: Optional[str] = None, + sort_order: str = 'asc', + ) -> Tuple[list[DocumentTypeDigitization], int]: + query = db.query(DocumentTypeDigitization).filter( + DocumentTypeDigitization.tenant_id == tenant_id, + ) + + if company_id is not None: + query = query.filter(DocumentTypeDigitization.company_id == company_id) + + filters = filters or {} + active_only = DocumentTypeDigitizationService._is_truthy_filter( + filters.get('active_only'), + default=False, + ) + search = (filters.get('search') or '').strip() + + if active_only: + query = query.filter(DocumentTypeDigitization.active.is_(True)) + + if search: + like = f'%{search}%' + query = query.filter( + or_( + DocumentTypeDigitization.code.ilike(like), + DocumentTypeDigitization.description.ilike(like), + ) + ) + + total = query.count() + + sort_column = { + 'id': DocumentTypeDigitization.id, + 'code': DocumentTypeDigitization.code, + 'description': DocumentTypeDigitization.description, + 'active': DocumentTypeDigitization.active, + }.get(sort_by or 'code', DocumentTypeDigitization.code) + + if sort_order == 'desc': + query = query.order_by(sort_column.desc()) + else: + query = query.order_by(sort_column.asc()) + + items = query.offset(skip).limit(limit).all() + return items, total + + @staticmethod + def get_by_id( + db: Session, + id: int, + tenant_id: int, + company_id: int, + ) -> Optional[DocumentTypeDigitization]: + return ( + db.query(DocumentTypeDigitization) + .filter( + DocumentTypeDigitization.id == id, + DocumentTypeDigitization.tenant_id == tenant_id, + DocumentTypeDigitization.company_id == company_id, + ) + .first() + ) + + @staticmethod + def get_by_code( + db: Session, + code: str, + tenant_id: int, + company_id: int, + ) -> Optional[DocumentTypeDigitization]: + normalized_code = DocumentTypeDigitizationService._normalize_code(code) + return ( + db.query(DocumentTypeDigitization) + .filter( + DocumentTypeDigitization.code == normalized_code, + DocumentTypeDigitization.tenant_id == tenant_id, + DocumentTypeDigitization.company_id == company_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, + data: DocumentTypeDigitizationCreate, + tenant_id: int, + company_id: int, + ) -> DocumentTypeDigitization: + payload = data.model_dump() + payload['code'] = DocumentTypeDigitizationService._normalize_code(payload['code']) + payload['description'] = DocumentTypeDigitizationService._normalize_description( + payload['description'] + ) + + if not payload['code']: + raise ValueError('El codigo es obligatorio') + if not payload['description']: + raise ValueError('La descripcion es obligatoria') + + existing = DocumentTypeDigitizationService.get_by_code( + db, payload['code'], tenant_id, company_id + ) + if existing: + raise ValueError( + f'Ya existe un tipo de documento con el codigo {payload["code"]}' + ) + + db_obj = DocumentTypeDigitization( + **payload, + tenant_id=tenant_id, + company_id=company_id, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + @staticmethod + def update( + db: Session, + id: int, + tenant_id: int, + data: DocumentTypeDigitizationUpdate, + company_id: int, + ) -> Optional[DocumentTypeDigitization]: + db_obj = DocumentTypeDigitizationService.get_by_id(db, id, tenant_id, company_id) + if not db_obj: + return None + + update_dict = data.model_dump(exclude_unset=True) + + if 'code' in update_dict: + update_dict['code'] = DocumentTypeDigitizationService._normalize_code(update_dict['code']) + if not update_dict['code']: + raise ValueError('El codigo es obligatorio') + if update_dict['code'] != db_obj.code: + existing = DocumentTypeDigitizationService.get_by_code( + db, + update_dict['code'], + tenant_id, + company_id, + ) + if existing: + raise ValueError( + f'Ya existe un tipo de documento con el codigo {update_dict["code"]}' + ) + + if 'description' in update_dict: + update_dict['description'] = DocumentTypeDigitizationService._normalize_description( + update_dict['description'] + ) + if not update_dict['description']: + raise ValueError('La descripcion es obligatoria') + + for key, value in update_dict.items(): + setattr(db_obj, key, value) + + db.commit() + db.refresh(db_obj) + return db_obj + + @staticmethod + def delete( + db: Session, + id: int, + tenant_id: int, + company_id: int, + ) -> bool: + db_obj = DocumentTypeDigitizationService.get_by_id(db, id, tenant_id, company_id) + if not db_obj: + return False + + db_obj.active = False + db.commit() + return True \ No newline at end of file diff --git a/backend/api/v1/modules/a76/expediente_archivos/dto.py b/backend/api/v1/modules/a76/expediente_archivos/dto.py index 970c74f8..e73132fb 100644 --- a/backend/api/v1/modules/a76/expediente_archivos/dto.py +++ b/backend/api/v1/modules/a76/expediente_archivos/dto.py @@ -49,6 +49,10 @@ class ExpedienteArchivoResponseDTO(BaseModel): task_id: Optional[str] = None external_task_id: Optional[str] = None acuse_pdf_path: Optional[str] = None + envio_xml_path: Optional[str] = None + respuesta_xml_path: Optional[str] = None + consulta_envio_xml_path: Optional[str] = None + consulta_respuesta_xml_path: Optional[str] = None company_id: int tenant_id: int @@ -71,15 +75,15 @@ class DigitalizarRequest(BaseModel): Solicitud de digitalización enviada por el frontend. La configuracion_vu se ensambla server-side desde CustomsBrokerVU / company. """ - rfc_consulta: str = Field(..., max_length=13) - clave_documento: str = Field(..., max_length=10) - nombre_archivo: str = Field(..., max_length=255) - archivo_base64: str # contenido del archivo en base64 + rfc_consulta: Optional[str] = Field(None, max_length=13) + clave_documento: Optional[str] = Field(None, max_length=10) + nombre_archivo: Optional[str] = Field(None, max_length=255) + archivo_base64: Optional[str] = None # contenido del archivo en base64; opcional si el expediente ya tiene archivo almacenado class RegistrarDigitalizacionRequest(BaseModel): """Digitalizar múltiples expedientes existentes por ID.""" - rfc_consulta: str = Field(..., max_length=13) + rfc_consulta: Optional[str] = Field(None, max_length=13) ids_archivos: List[int] @@ -120,6 +124,7 @@ class DigitalizacionErrorDetail(BaseModel): class DigitalizacionTaskDetailResponse(BaseModel): task_id: str + external_task_id: Optional[str] = None state: str status: Optional[str] = None current_step: Optional[str] = None diff --git a/backend/api/v1/modules/a76/expediente_archivos/external_service.py b/backend/api/v1/modules/a76/expediente_archivos/external_service.py index d45e6693..e5957bef 100644 --- a/backend/api/v1/modules/a76/expediente_archivos/external_service.py +++ b/backend/api/v1/modules/a76/expediente_archivos/external_service.py @@ -40,9 +40,8 @@ class ExpedienteExternalService: len(configuracion_vu.get("archivo_key_base64") or ""), ) - # verify=False: el entorno externo puede usar certificados auto-firmados, - # igual que en factura_cove. - with httpx.Client(timeout=60.0, verify=False) as client: + # connect=10s, read=120s: la subida del PDF puede tomar tiempo en el servidor VU + with httpx.Client(timeout=httpx.Timeout(120.0, connect=10.0), verify=False) as client: response = client.post(url, json=payload) response.raise_for_status() return response.json() @@ -54,7 +53,9 @@ class ExpedienteExternalService: url = f"{self.base_url.rstrip('/')}/api/v1/expediente-archivos/status-digitalizacion-task/{task_id}" logger.debug("Consulting expediente task status: task_id=%s url=%s", task_id, url) - with httpx.Client(timeout=30.0, verify=False) as client: + # read=None: sin límite de lectura — VU mantiene la conexión abierta mientras procesa. + # El timeout global del polling loop (300 s) actúa como cota máxima real. + with httpx.Client(timeout=httpx.Timeout(None, connect=10.0), verify=False) as client: response = client.get(url) response.raise_for_status() return response.json() diff --git a/backend/api/v1/modules/a76/expediente_archivos/models.py b/backend/api/v1/modules/a76/expediente_archivos/models.py index f1d772f5..a1631791 100644 --- a/backend/api/v1/modules/a76/expediente_archivos/models.py +++ b/backend/api/v1/modules/a76/expediente_archivos/models.py @@ -33,3 +33,7 @@ class ExpedienteArchivo(Base, TenantScopedMixin, TimestampMixin): task_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) external_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) acuse_pdf_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + envio_xml_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + respuesta_xml_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + consulta_envio_xml_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + consulta_respuesta_xml_path: Mapped[str | None] = mapped_column(String(500), nullable=True) diff --git a/backend/api/v1/modules/a76/expediente_archivos/routes.py b/backend/api/v1/modules/a76/expediente_archivos/routes.py index 40d344bd..af9f6c01 100644 --- a/backend/api/v1/modules/a76/expediente_archivos/routes.py +++ b/backend/api/v1/modules/a76/expediente_archivos/routes.py @@ -1,12 +1,20 @@ from __future__ import annotations +from datetime import datetime +import io +import mimetypes +import os +import zipfile from typing import Any, Dict -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status from sqlalchemy.orm import Session +from core.config import settings from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource +from core.s3_keys import expediente_archivo_document_key +from core.storage_s3 import delete_object_if_exists, get_object_bytes, object_exists, put_object_bytes from .dto import ( DigitalizacionTaskDetailResponse, @@ -28,6 +36,20 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/expediente-archivos") +def _remove_stored_document_path(path: str | None) -> None: + raw = (path or "").strip() + if not raw: + return + try: + if settings.use_s3_object_storage and not os.path.isabs(raw): + delete_object_if_exists(raw) + return + if os.path.exists(raw): + os.remove(raw) + except Exception: + logger.warning("No se pudo eliminar archivo previo de expediente: %s", raw, exc_info=True) + + # ──────────────────────────────────────────────────────────────────────────── # # CRUD # # ──────────────────────────────────────────────────────────────────────────── # @@ -98,10 +120,191 @@ def delete_expediente_archivo( record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id) if not record: raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.") + _remove_stored_document_path(record.archivo_digitalizado_en) ExpedienteArchivoService.delete(db, record) return None +_ARTIFACT_TYPE_MAP = { + "acuse": ("acuse_pdf_path", "application/pdf"), + "envio-xml": ("envio_xml_path", "application/xml"), + "respuesta-xml": ("respuesta_xml_path", "application/xml"), + "consulta-envio-xml": ("consulta_envio_xml_path", "application/xml"), + "consulta-respuesta-xml": ("consulta_respuesta_xml_path", "application/xml"), +} + + +@router.get("/{record_id}/artifacts/{artifact_type}", response_class=Response) +def download_artifact( + record_id: int, + artifact_type: str, + company_id: int = Query(...), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Descarga un artefacto de digitalización (acuse PDF o XMLs) desde S3.""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + if artifact_type not in _ARTIFACT_TYPE_MAP: + raise HTTPException(status_code=400, detail=f"Tipo de artefacto no válido: {artifact_type}") + record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id) + if not record: + raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.") + field_name, content_type = _ARTIFACT_TYPE_MAP[artifact_type] + key = (getattr(record, field_name, None) or "").strip() + if not key or key == "inline": + raise HTTPException(status_code=404, detail="Artefacto no disponible para este expediente.") + if not object_exists(key): + raise HTTPException(status_code=404, detail="El archivo no se encontró en el almacenamiento.") + ext = ".pdf" if content_type == "application/pdf" else ".xml" + filename = f"{artifact_type}_{record.e_document or record_id}{ext}" + return Response( + content=get_object_bytes(key), + media_type=content_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/{record_id}/artifacts-zip", response_class=Response) +def download_artifacts_zip( + record_id: int, + company_id: int = Query(...), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Descarga todos los artefactos disponibles de un expediente en un archivo ZIP.""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id) + if not record: + raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.") + + base_name = record.e_document or str(record_id) + artifact_files = [ + ("acuse", "acuse_pdf_path", f"acuse_{base_name}.pdf"), + ("envio-xml", "envio_xml_path", f"envio_{base_name}.xml"), + ("respuesta-xml", "respuesta_xml_path", f"respuesta_{base_name}.xml"), + ("consulta-envio-xml", "consulta_envio_xml_path", f"consulta_envio_{base_name}.xml"), + ("consulta-respuesta-xml", "consulta_respuesta_xml_path", f"consulta_respuesta_{base_name}.xml"), + ] + + buf = io.BytesIO() + added = 0 + with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for _type, field_name, filename in artifact_files: + key = (getattr(record, field_name, None) or "").strip() + if not key or key == "inline": + continue + if not object_exists(key): + continue + zf.writestr(filename, get_object_bytes(key)) + added += 1 + + if added == 0: + raise HTTPException(status_code=404, detail="No hay artefactos disponibles para este expediente.") + + buf.seek(0) + zip_filename = f"expediente_{base_name}.zip" + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'}, + ) + + +@router.post("/{record_id}/upload", response_model=Dict[str, Any]) +async def upload_expediente_archivo_file( + record_id: int, + company_id: int = Query(...), + file: UploadFile = File(...), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id) + if not record: + raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.") + + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="El archivo está vacío.") + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + # Borrar artefactos de digitalización previa al subir documento nuevo + _ARTIFACT_FIELDS = [ + "acuse_pdf_path", + "envio_xml_path", + "respuesta_xml_path", + "consulta_envio_xml_path", + "consulta_respuesta_xml_path", + ] + for _field in _ARTIFACT_FIELDS: + _old_key = (getattr(record, _field, None) or "").strip() + if _old_key and _old_key != "inline": + try: + delete_object_if_exists(_old_key) + except Exception: + logger.warning("No se pudo eliminar artefacto previo %s=%s", _field, _old_key, exc_info=True) + setattr(record, _field, None) + + # El documento fuente cambió — la digitalización anterior ya no aplica + record.status = "pending" + record.e_document = None + record.num_operacion = None + record.task_id = None + record.external_task_id = None + + try: + if settings.use_s3_object_storage: + key = expediente_archivo_document_key( + tenant_id, + company_id, + record.id, + timestamp, + file.filename or "documento.pdf", + ) + ct = file.content_type or mimetypes.guess_type(file.filename or "")[0] or "application/octet-stream" + _remove_stored_document_path(record.archivo_digitalizado_en) + put_object_bytes(key, content, content_type=ct) + stored = key + else: + key = expediente_archivo_document_key( + tenant_id, + company_id, + record.id, + timestamp, + file.filename or "documento.pdf", + ) + base = os.path.join("uploads", "expediente_archivos", str(company_id), str(record.id)) + os.makedirs(base, exist_ok=True) + filename = key.rsplit("/", 1)[-1] + stored = os.path.join(base, filename) + _remove_stored_document_path(record.archivo_digitalizado_en) + with open(stored, "wb") as destination: + destination.write(content) + + record.archivo_digitalizado_en = stored + record.nombre_archivo = file.filename or record.nombre_archivo + db.add(record) + db.commit() + db.refresh(record) + except HTTPException: + db.rollback() + raise + except ValueError as exc: + db.rollback() + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + db.rollback() + raise HTTPException(status_code=500, detail=f"Error saving file: {str(exc)}") from exc + + return { + "message": "Archivo cargado correctamente", + "record_id": record.id, + "path": stored, + "nombre_archivo": record.nombre_archivo, + } + + # ──────────────────────────────────────────────────────────────────────────── # # Digitalización # # ──────────────────────────────────────────────────────────────────────────── # diff --git a/backend/api/v1/modules/a76/expediente_archivos/service.py b/backend/api/v1/modules/a76/expediente_archivos/service.py index c54fce21..a2ff0587 100644 --- a/backend/api/v1/modules/a76/expediente_archivos/service.py +++ b/backend/api/v1/modules/a76/expediente_archivos/service.py @@ -7,10 +7,12 @@ from typing import 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.celery_app import celery_app from core.config import settings +from core.database import CoreSessionLocal from core.exceptions import ErrorCollector, ValidationException from core.storage_s3 import get_object_bytes, object_exists @@ -37,6 +39,34 @@ logger = logging.getLogger(__name__) class ExpedienteArchivoService: + @staticmethod + def _get_task_record_metadata(task_id: str) -> dict: + db = CoreSessionLocal() + try: + record = ( + db.query(ExpedienteArchivo) + .filter( + ExpedienteArchivo.task_id == task_id, + ExpedienteArchivo.deleted_at.is_(None), + ) + .order_by(ExpedienteArchivo.id.desc()) + .first() + ) + if not record: + return {"external_task_id": None} + return { + "external_task_id": record.external_task_id, + "db_status": record.status, + "e_document": record.e_document, + "num_operacion": record.num_operacion, + "nombre_archivo": record.nombre_archivo, + } + except Exception: + logger.exception("No se pudo obtener metadata del expediente para task_id=%s", task_id) + return {"external_task_id": None} + finally: + db.close() + @staticmethod def list( db: Session, @@ -114,30 +144,90 @@ class ExpedienteArchivoService: @staticmethod def get_task_status(task_id: str) -> DigitalizacionTaskDetailResponse: - result = celery_app.AsyncResult(task_id) - state = result.state or "PENDING" - info = result.info or {} + task_metadata = ExpedienteArchivoService._get_task_record_metadata(task_id) + try: + result = celery_app.AsyncResult(task_id) + state = result.state or "PENDING" + info = result.info or {} + except Exception as exc: + logger.exception("No se pudo consultar el estado de la tarea de digitalización task_id=%s", task_id) + return DigitalizacionTaskDetailResponse( + task_id=task_id, + external_task_id=task_metadata.get("external_task_id"), + state="FAILURE", + status="failed", + error="No se pudo consultar el estado de la digitalización.", + error_type=type(exc).__name__, + error_detail=DigitalizacionErrorDetail( + codigo="TASK_STATUS_ERROR", + descripcion=str(exc), + paso="Consulta de estado", + sugerencias=["Cierra el diálogo y vuelve a intentar la digitalización."], + ), + ) if state == "SUCCESS": raw = result.result or {} return DigitalizacionTaskDetailResponse( task_id=task_id, + external_task_id=task_metadata.get("external_task_id"), state="SUCCESS", status="success", + request_id=raw.get("request_id"), result=DigitalizacionResult(**{k: raw.get(k) for k in DigitalizacionResult.model_fields}), progress=100, total_steps=4, ) - if state == "FAILURE": - err = info if not isinstance(info, dict) else None - error_detail_raw = info.get("error_detail") if isinstance(info, dict) else None + # Fallback: Celery puede tardar en propagar SUCCESS a Redis. + # Si la DB ya tiene status=success, retornamos SUCCESS inmediatamente. + if task_metadata.get("db_status") == "success": + logger.info( + "get_task_status: Celery state=%s but DB status=success — returning SUCCESS from DB task_id=%s", + state, task_id, + ) return DigitalizacionTaskDetailResponse( task_id=task_id, + external_task_id=task_metadata.get("external_task_id"), + state="SUCCESS", + status="success", + result=DigitalizacionResult( + status="success", + message="Digitalización completada exitosamente.", + e_document=task_metadata.get("e_document"), + numero_operacion=task_metadata.get("num_operacion"), + nombre_archivo=task_metadata.get("nombre_archivo"), + ), + progress=100, + total_steps=4, + ) + + if state in {"FAILURE", "FAILED"}: + err = info if not isinstance(info, dict) else None + error_detail_raw = info.get("error_detail") if isinstance(info, dict) else None + error_text = str(err or info.get("error", "")) if isinstance(info, dict) else str(err or "") + error_type = info.get("error_type") if isinstance(info, dict) else None + if not error_type and isinstance(info, BaseException): + error_type = type(info).__name__ + if error_type == "Ignore" and not error_text: + error_text = "La digitalización no pudo completarse." + if not error_text and isinstance(info, BaseException): + error_text = "La digitalización no pudo completarse." + if isinstance(info, BaseException) and not error_detail_raw: + error_detail_raw = { + "codigo": "TASK_FAILED", + "descripcion": "La tarea terminó con error antes de completar la digitalización.", + "paso": "Proceso de digitalización", + "sugerencias": ["Revisa la configuración VU y vuelve a intentarlo."], + } + return DigitalizacionTaskDetailResponse( + task_id=task_id, + external_task_id=task_metadata.get("external_task_id"), state="FAILURE", status="failed", - error=str(err or info.get("error", "")), - error_type=info.get("error_type") if isinstance(info, dict) else None, + request_id=info.get("request_id") if isinstance(info, dict) else None, + error=error_text, + error_type=error_type, error_detail=DigitalizacionErrorDetail(**(error_detail_raw or {})) if error_detail_raw else None, ) @@ -145,14 +235,21 @@ class ExpedienteArchivoService: if isinstance(info, dict): return DigitalizacionTaskDetailResponse( task_id=task_id, + external_task_id=task_metadata.get("external_task_id"), state=state, status="processing", current_step=info.get("current_step") or info.get("status"), progress=info.get("progress") or info.get("current"), total_steps=info.get("total_steps") or 4, + request_id=info.get("request_id"), ) - return DigitalizacionTaskDetailResponse(task_id=task_id, state=state, status="pending") + return DigitalizacionTaskDetailResponse( + task_id=task_id, + external_task_id=task_metadata.get("external_task_id"), + state=state, + status="pending", + ) # --------------------------------------------------------------------------- @@ -178,6 +275,94 @@ def _encrypt_fiel(raw_fiel: str) -> str: return base64.b64encode(encrypted).decode("ascii") +def _resolve_broker_for_vu( + db: Session, + company_id: int, + tenant_id: int, + agente_aduanal_key: str, +) -> Optional[cb_models.CustomsBroker]: + normalized_key = (agente_aduanal_key or "").strip() + if not normalized_key: + return None + + brokers = ( + db.query(cb_models.CustomsBroker) + .filter( + or_( + cb_models.CustomsBroker.broker_key == normalized_key, + cb_models.CustomsBroker.license == normalized_key, + ), + 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_broker_key = next( + (broker for broker in brokers if (broker.broker_key or "").strip() == normalized_key), + None, + ) + if exact_broker_key: + return exact_broker_key + + if len(brokers) > 1: + logger.warning( + "Multiple customs brokers matched agente_aduanal=%s; falling back to first license match ids=%s broker_keys=%s", + normalized_key, + [broker.id for broker in brokers], + [broker.broker_key for broker in brokers], + ) + + return brokers[0] + + +def resolve_rfc_consulta_value( + db: Session, + company_id: int, + tenant_id: int, + agente_aduanal_key: Optional[str], + request_rfc_consulta: Optional[str], + record_rfc_consulta: Optional[str], + config_vu_rfc: Optional[str], +) -> str: + explicit_rfc = (request_rfc_consulta or "").strip().upper() + if explicit_rfc: + return explicit_rfc + + broker_tax_id = "" + if agente_aduanal_key: + broker = _resolve_broker_for_vu(db, company_id, tenant_id, agente_aduanal_key) + broker_tax_id = (getattr(broker, "tax_id", None) or "").strip().upper() if broker else "" + if broker_tax_id: + return broker_tax_id + + stored_rfc = (record_rfc_consulta or "").strip().upper() + if stored_rfc: + return stored_rfc + + config_rfc = (config_vu_rfc or "").strip().upper() + if config_rfc: + return config_rfc + + raise ValidationException( + "RFC Consulta no disponible", + errors=[ + { + "field": "rfc_consulta", + "message": "No se pudo resolver el RFC Consulta desde el expediente ni desde el customs broker.", + "code": "MISSING_RFC_CONSULTA", + "solution": [ + "Configura el RFC del agente aduanal en el customs broker o captura el RFC directamente en el expediente." + ], + } + ], + ) + + def build_configuracion_vu( db: Session, company_id: int, @@ -191,16 +376,7 @@ def build_configuracion_vu( """ vu: Optional[cb_models.CustomsBrokerVU] = None if agente_aduanal_key: - broker = ( - db.query(cb_models.CustomsBroker) - .filter( - cb_models.CustomsBroker.license == agente_aduanal_key, - cb_models.CustomsBroker.company_id == company_id, - cb_models.CustomsBroker.tenant_id == tenant_id, - cb_models.CustomsBroker.deleted_at.is_(None), - ) - .first() - ) + broker = _resolve_broker_for_vu(db, company_id, tenant_id, agente_aduanal_key) if broker: vu = broker.vu else: @@ -235,17 +411,6 @@ def build_configuracion_vu( ) return None - effective_ws_user = ( - ( - vu.web_service_user - or vu.doda_web_service_user - or getattr(company_vu, "webservice_user", None) - or "" - ).strip() - if (vu or company_vu) - else "" - ) - clave_fiel_value = "" if vu and getattr(vu, "fiel_access_key", None): clave_fiel_value = _encrypt_fiel(vu.fiel_access_key or "") @@ -257,12 +422,29 @@ def build_configuracion_vu( ) clave_fiel_value = _encrypt_fiel(str(secret)) - if not effective_ws_user: + hardcoded_ws_key = ( + "RZGd+CB4R6PfSrstOyN8Is9FXL9AK9NPFisyGGaEWa0vVHoVOl8v2SBcHBoGbt3T/4uHTGcsFQO3b7EonWVfugQjBooywbz74K+jM68j8/Y=" + ) + vu_ws_key = (getattr(vu, "web_service_access_key", None) or "").strip() if vu else "" + vu_access_key_raw = (getattr(vu, "access_key", None) or "").strip() if vu else "" + vu_access_key_encrypted = _encrypt_fiel(vu_access_key_raw) if vu_access_key_raw else "" + company_ws_key = (getattr(company_vu, "webservice_password", None) or "").strip() if company_vu else "" + if vu_ws_key: + ws_key_source = "web_service_access_key" + elif vu_access_key_encrypted: + ws_key_source = "access_key_encrypted" + elif company_ws_key: + ws_key_source = "company" + else: + ws_key_source = "fallback" + clave_webservice = vu_ws_key or vu_access_key_encrypted or company_ws_key or hardcoded_ws_key + + if not clave_webservice: errors.add_error( - field="vu", - message="Faltan credenciales de web service en VU.", - solution=["Captura usuario y clave de web service en la pestaña VU del agente aduanal."], - code="MISSING_VU_CREDENTIALS", + field="vu.clave_webservice", + message="La clave de web service no está configurada en VU ni en la empresa.", + solution=["Captura la clave de web service en la pestaña VU o completa la configuración VU de la empresa."], + code="MISSING_VU_WS_KEY", ) if not clave_fiel_value: @@ -337,17 +519,21 @@ def build_configuracion_vu( (getattr(company_vu, "query_rfc", None) or "").strip() if company_vu else "" ) - hardcoded_ws_key = ( - "RZGd+CB4R6PfSrstOyN8Is9FXL9AK9NPFisyGGaEWa0vVHoVOl8v2SBcHBoGbt3T/4uHTGcsFQO3b7EonWVfugQjBooywbz74K+jM68j8/Y=" + email = ( + (getattr(vu, "vu_email", None) or "").strip() if vu else "" + ) or ( + (getattr(company_vu, "email", None) or "").strip() if company_vu else "" + ) or ( + (getattr(getattr(company, "address", None), "email", None) or "").strip() if company else "" ) - clave_webservice = ( - (getattr(vu, "web_service_access_key", None) or "").strip() - or (getattr(company_vu, "webservice_password", None) or "").strip() - or hardcoded_ws_key - ) - - email = (getattr(vu, "vu_email", None) or "").strip() if vu else "" + if ws_key_source == "fallback": + logger.warning( + "Expediente digitalization is using fallback web service key for agente_aduanal=%s company_id=%s tenant_id=%s", + agente_aduanal_key, + company_id, + tenant_id, + ) return { "rfc_usuario_vu": rfc_usuario_vu, @@ -356,4 +542,5 @@ def build_configuracion_vu( "archivo_key_base64": key_b64 or "", "clave_fiel": clave_fiel_value, "email": email, + "_ws_key_source": ws_key_source, } diff --git a/backend/api/v1/modules/a76/expediente_archivos/tasks.py b/backend/api/v1/modules/a76/expediente_archivos/tasks.py index 4d5a7ad4..2e35c3c7 100644 --- a/backend/api/v1/modules/a76/expediente_archivos/tasks.py +++ b/backend/api/v1/modules/a76/expediente_archivos/tasks.py @@ -1,17 +1,23 @@ from __future__ import annotations +import base64 import logging +import os import time from typing import Any, Dict from celery import Task +from celery.exceptions import Ignore +import httpx from core.celery_app import celery_app from core.database import CoreSessionLocal from core.exceptions import ErrorCollector, ValidationException +from core.storage_s3 import delete_object_if_exists, get_object_bytes, object_exists, put_object_bytes +from core.s3_keys import expediente_archivo_artifact_key from .models import ExpedienteArchivo -from .service import ExpedienteArchivoService, build_configuracion_vu +from .service import ExpedienteArchivoService, build_configuracion_vu, resolve_rfc_consulta_value from .external_service import ExpedienteExternalService logger = logging.getLogger(__name__) @@ -19,6 +25,95 @@ logger = logging.getLogger(__name__) TOTAL_STEPS = 4 +def _fail_task( + task: Task, + *, + error: str, + error_type: str, + codigo: str, + descripcion: str, + paso: str, + sugerencias: list[str] | None = None, +) -> None: + task.update_state( + state="FAILED", + meta={ + "error": error, + "error_type": error_type, + "error_detail": { + "codigo": codigo, + "descripcion": descripcion, + "paso": paso, + "sugerencias": sugerencias or [], + }, + }, + ) + raise Ignore() + + +def _load_record_file_base64(record: ExpedienteArchivo) -> str: + stored_path = (record.archivo_digitalizado_en or "").strip() + if not stored_path: + raise ValidationException( + "El expediente no tiene archivo cargado", + errors=[ + { + "field": "archivo_digitalizado_en", + "message": "El expediente no tiene archivo almacenado para digitalizar.", + "code": "MISSING_FILE", + "solution": ["Edita el expediente y vuelve a seleccionar el archivo antes de digitalizar."], + } + ], + ) + + load_started_at = time.perf_counter() + source = "unknown" + try: + if os.path.exists(stored_path): + source = "local" + with open(stored_path, "rb") as file_handle: + raw = file_handle.read() + elif object_exists(stored_path): + source = "s3" + raw = get_object_bytes(stored_path) + else: + raise ValidationException( + "Archivo del expediente no encontrado", + errors=[ + { + "field": "archivo_digitalizado_en", + "message": "No se encontró el archivo almacenado del expediente.", + "code": "FILE_NOT_FOUND", + "solution": ["Edita el expediente y vuelve a cargar el documento."], + } + ], + ) + except ValidationException: + raise + except Exception as exc: + raise ValidationException( + "No se pudo leer el archivo del expediente", + errors=[ + { + "field": "archivo_digitalizado_en", + "message": "Ocurrió un error leyendo el archivo almacenado del expediente.", + "code": "FILE_READ_ERROR", + "solution": ["Vuelve a cargar el archivo del expediente e inténtalo nuevamente."], + } + ], + ) from exc + + logger.info( + "Expediente file loaded expediente_id=%s source=%s bytes=%s elapsed_ms=%.1f", + record.id, + source, + len(raw), + (time.perf_counter() - load_started_at) * 1000, + ) + + return base64.b64encode(raw).decode("ascii") + + def _progress(task: Task, current: int, status: str) -> None: task.update_state( state="PROGRESS", @@ -36,40 +131,85 @@ def _poll_external( Hace polling al API externo hasta obtener un estado final o agotar el timeout. Retorna el payload final tal como lo devuelve el API externo. """ - start = time.time() + start = time.perf_counter() last_payload: Dict[str, Any] = {} + attempts = 0 while True: - elapsed = time.time() - start + attempts += 1 + elapsed = time.perf_counter() - start if elapsed > timeout_seconds: - logger.error("Timeout en polling externo de digitalización: task_id=%s", external_task_id) + logger.error( + "Timeout en polling externo de digitalización: task_id=%s attempts=%s elapsed_s=%.2f", + external_task_id, + attempts, + elapsed, + ) raise TimeoutError(f"Timeout consultando estado de digitalización (task_id={external_task_id}).") try: status_payload = external.get_status(external_task_id) or {} - except Exception as exc: - logger.exception("Error consultando estado externo de digitalización") + except httpx.ReadTimeout: + # VU mantiene la conexión abierta mientras procesa; si httpx corta antes, + # lo tratamos como "sigue en proceso" y reintentamos. + logger.warning( + "ReadTimeout consultando estado externo, reintentando task_id=%s attempts=%s elapsed_s=%.2f", + external_task_id, + attempts, + elapsed, + ) + time.sleep(5) + continue + except Exception: + logger.exception( + "Error consultando estado externo de digitalización task_id=%s attempts=%s elapsed_s=%.2f", + external_task_id, + attempts, + elapsed, + ) raise last_payload = status_payload state = str(status_payload.get("state") or "").upper() - progress_info = status_payload.get("progress") or {} - - try: - percent = float(progress_info.get("progress", 0.0)) - except (TypeError, ValueError): - percent = 0.0 - - current_step = ( - progress_info.get("current_step") + progress_info = status_payload.get("progress") + percent = 0.0 + current_step = str( + status_payload.get("current_step") + or status_payload.get("status") or "Consultando estado en Ventanilla Única..." ) + + if isinstance(progress_info, dict): + raw_percent = progress_info.get("progress", progress_info.get("current", 0.0)) + try: + percent = float(raw_percent) + except (TypeError, ValueError): + percent = 0.0 + + current_step = str( + progress_info.get("current_step") + or progress_info.get("status") + or current_step + ) + elif isinstance(progress_info, (int, float, str)): + try: + percent = float(progress_info) + except (TypeError, ValueError): + percent = 0.0 + _progress(task, int(percent), str(current_step)) if state in {"PENDING", "STARTED", "PROGRESS"} or not state: time.sleep(5) continue + logger.info( + "Digitalization external polling finished task_id=%s final_state=%s attempts=%s elapsed_s=%.2f", + external_task_id, + state or "UNKNOWN", + attempts, + elapsed, + ) return last_payload @@ -91,7 +231,16 @@ def digitalizar_task( 4. Persistir resultado (e_document, num_operacion, acuse_pdf_path) en DB. """ db = CoreSessionLocal() + task_started_at = time.perf_counter() try: + logger.info( + "Digitalization task started task_id=%s expediente_id=%s company_id=%s tenant_id=%s", + self.request.id, + expediente_id, + company_id, + tenant_id, + ) + # ------------------------------------------------------------------ # # Paso 1 – cargar registro y construir configuracion_vu # # ------------------------------------------------------------------ # @@ -106,29 +255,76 @@ def digitalizar_task( errors = ErrorCollector() agente_key = request_data.get("agente_aduanal") or record.agente_aduanal + config_started_at = time.perf_counter() config_vu = build_configuracion_vu(db, company_id, tenant_id, agente_key, errors) + logger.info( + "Digitalization VU config resolved task_id=%s expediente_id=%s agente_aduanal=%s elapsed_ms=%.1f", + self.request.id, + expediente_id, + agente_key, + (time.perf_counter() - config_started_at) * 1000, + ) if errors.has_errors(): error_list = errors._errors # type: ignore[attr-defined] first = error_list[0] if error_list else {} - self.update_state( - state="FAILURE", - meta={ - "error": first.get("message", "Error de configuración VU"), - "error_type": "VALIDATION_ERROR", - "error_detail": { - "codigo": first.get("code", "VALIDATION_ERROR"), - "descripcion": first.get("message", ""), - "paso": "Construcción de configuración VU", - "sugerencias": first.get("solution") or [], - }, - }, + _fail_task( + self, + error=first.get("message", "Error de configuración VU"), + error_type="VALIDATION_ERROR", + codigo=first.get("code", "VALIDATION_ERROR"), + descripcion=first.get("message", ""), + paso="Construcción de configuración VU", + sugerencias=first.get("solution") or [], ) - return {} # Actualizar status en DB + resolved_rfc_consulta = resolve_rfc_consulta_value( + db, + company_id, + tenant_id, + agente_key, + request_data.get("rfc_consulta"), + record.rfc_consulta, + config_vu.get("rfc_usuario_vu"), + ) + current_record_rfc = (record.rfc_consulta or "").strip().upper() + config_vu_rfc = (config_vu.get("rfc_usuario_vu") or "").strip().upper() + if not current_record_rfc or current_record_rfc == config_vu_rfc: + record.rfc_consulta = resolved_rfc_consulta + # Limpiar artefactos previos de S3 antes de iniciar nueva digitalización + _ARTIFACT_PATH_FIELDS = [ + "acuse_pdf_path", + "envio_xml_path", + "respuesta_xml_path", + "consulta_envio_xml_path", + "consulta_respuesta_xml_path", + ] + for _field in _ARTIFACT_PATH_FIELDS: + _old_key = (getattr(record, _field, None) or "").strip() + if _old_key and _old_key != "inline": + try: + delete_object_if_exists(_old_key) + logger.info( + "Digitalization old artifact deleted task_id=%s expediente_id=%s field=%s key=%s", + self.request.id, expediente_id, _field, _old_key, + ) + except Exception: + logger.warning( + "Could not delete old artifact task_id=%s expediente_id=%s field=%s key=%s", + self.request.id, expediente_id, _field, _old_key, exc_info=True, + ) + record.status = "processing" record.task_id = self.request.id + record.external_task_id = None + record.e_document = None + record.num_operacion = None + record.acuse_pdf_path = None + record.envio_xml_path = None + record.respuesta_xml_path = None + record.consulta_envio_xml_path = None + record.consulta_respuesta_xml_path = None db.commit() # ------------------------------------------------------------------ # @@ -136,16 +332,38 @@ def digitalizar_task( # ------------------------------------------------------------------ # _progress(self, 30, "Enviando documento a Ventanilla Única...") + file_started_at = time.perf_counter() + archivo_base64 = request_data.get("archivo_base64") or _load_record_file_base64(record) + logger.info( + "Digitalization payload document ready task_id=%s expediente_id=%s provided_inline=%s base64_len=%s elapsed_ms=%.1f", + self.request.id, + expediente_id, + bool(request_data.get("archivo_base64")), + len(archivo_base64), + (time.perf_counter() - file_started_at) * 1000, + ) + payload = { - "rfc_consulta": request_data.get("rfc_consulta") or record.rfc_consulta or "", + "rfc_consulta": resolved_rfc_consulta, "clave_documento": request_data.get("clave_documento") or record.tipo_documento or "", "nombre_archivo": request_data.get("nombre_archivo") or record.nombre_archivo or "", - "archivo_base64": request_data.get("archivo_base64") or "", - "configuracion_vu": config_vu, + "archivo_base64": archivo_base64, + "configuracion_vu": { + key: value for key, value in config_vu.items() if not key.startswith("_") + }, } external = ExpedienteExternalService() + external_submit_started_at = time.perf_counter() response = external.digitalizar_archivo_json(payload) + logger.info( + "Digitalization external submission finished task_id=%s expediente_id=%s external_task_id=%s response_state=%s elapsed_ms=%.1f", + self.request.id, + expediente_id, + response.get("task_id") or response.get("id"), + response.get("state") or response.get("status"), + (time.perf_counter() - external_submit_started_at) * 1000, + ) # Chequear si el API externo devolvió un error inmediato resp_state = str(response.get("state") or response.get("status") or "").upper() @@ -153,20 +371,15 @@ def digitalizar_task( error_msg = response.get("message") or response.get("error") or "Error en API externo" record.status = "failed" db.commit() - self.update_state( - state="FAILURE", - meta={ - "error": error_msg, - "error_type": "EXTERNAL_API_ERROR", - "error_detail": { - "codigo": "EXTERNAL_API_ERROR", - "descripcion": error_msg, - "paso": "Envío a Ventanilla Única", - "sugerencias": ["Verifica las credenciales VU y vuelve a intentarlo."], - }, - }, + _fail_task( + self, + error=error_msg, + error_type="EXTERNAL_API_ERROR", + codigo="EXTERNAL_API_ERROR", + descripcion=error_msg, + paso="Envío a Ventanilla Única", + sugerencias=["Verifica las credenciales VU y vuelve a intentarlo."], ) - return {} # Extraer task_id externo si el API lo devolvió de inmediato en PENDING/PROCESSING external_task_id = response.get("task_id") or response.get("id") @@ -178,29 +391,60 @@ def digitalizar_task( _progress(self, 50, "Esperando respuesta de Ventanilla Única...") record.external_task_id = str(external_task_id) db.commit() + polling_started_at = time.perf_counter() try: final_response = _poll_external(self, external, str(external_task_id)) except TimeoutError as exc: record.status = "failed" db.commit() - self.update_state( - state="FAILURE", - meta={ - "error": str(exc), - "error_type": "TIMEOUT", - "error_detail": { - "codigo": "TIMEOUT", - "descripcion": str(exc), - "paso": "Polling Ventanilla Única", - "sugerencias": ["Vuelve a intentarlo o consulta el estado manualmente."], - }, - }, + _fail_task( + self, + error=str(exc), + error_type="TIMEOUT", + codigo="TIMEOUT", + descripcion=str(exc), + paso="Polling Ventanilla Única", + sugerencias=["Vuelve a intentarlo o consulta el estado manualmente."], ) - return {} + logger.info( + "Digitalization external wait completed task_id=%s expediente_id=%s external_task_id=%s elapsed_s=%.2f", + self.request.id, + expediente_id, + external_task_id, + time.perf_counter() - polling_started_at, + ) else: # El API devolvió resultado directo final_response = response + final_state = str(final_response.get("state") or final_response.get("status") or "").upper() + if final_state in {"ERROR", "FAILURE", "FAILED"}: + error_detail = final_response.get("error_detail") or {} + suggestions = error_detail.get("sugerencias") or [] + if config_vu.get("_ws_key_source") == "fallback": + suggestions = [ + "No hay clave real de web service configurada en VU ni en la empresa; se usó la clave fallback del sistema.", + *suggestions, + ] + error_msg = ( + final_response.get("error") + or final_response.get("message") + or error_detail.get("descripcion") + or final_response.get("status") + or "Error en Ventanilla Única" + ) + record.status = "failed" + db.commit() + _fail_task( + self, + error=str(error_msg), + error_type=str(final_response.get("error_type") or "EXTERNAL_API_ERROR"), + codigo=str(error_detail.get("codigo") or "EXTERNAL_TASK_FAILURE"), + descripcion=str(error_detail.get("descripcion") or error_msg), + paso=str(error_detail.get("paso") or "Respuesta final de Ventanilla Única"), + sugerencias=suggestions or ["Revisa el detalle devuelto por Ventanilla Única y vuelve a intentarlo."], + ) + # ------------------------------------------------------------------ # # Paso 4 – persistir resultado # # ------------------------------------------------------------------ # @@ -209,31 +453,65 @@ def digitalizar_task( result_payload = final_response.get("result") or final_response e_doc = result_payload.get("e_document") or result_payload.get("eDocument") num_op = result_payload.get("numero_operacion") or result_payload.get("numeroOperacion") - acuse_b64 = result_payload.get("acuese_digitalizacion_pdf_base64") or result_payload.get("acuse_pdf_base64") record.status = "success" if e_doc: record.e_document = str(e_doc) if num_op: record.num_operacion = str(num_op) - # Guardamos el acuse en-línea (no en S3 por ahora) o en una ruta - # Los acuses se almacenan en el campo acuse_pdf_path como indicación - if acuse_b64: - record.acuse_pdf_path = "inline" + + # Guardar todos los artefactos base64 en S3 + _ARTIFACT_FIELDS = { + "acuse": ("acuese_digitalizacion_pdf_base64", "application/pdf", "acuse_pdf_path"), + "envio_xml": ("envio_xml_base64", "application/xml", "envio_xml_path"), + "respuesta_xml": ("respuesta_xml_base64", "application/xml", "respuesta_xml_path"), + "consulta_envio_xml": ("consulta_envio_xml_base64", "application/xml", "consulta_envio_xml_path"), + "consulta_respuesta_xml": ("consulta_respuesta_xml_base64", "application/xml", "consulta_respuesta_xml_path"), + } + artifact_ts = time.strftime("%Y%m%d_%H%M%S", time.gmtime()) + for artifact_type, (result_field, content_type, record_field) in _ARTIFACT_FIELDS.items(): + b64 = result_payload.get(result_field) + if not b64: + continue + try: + key = expediente_archivo_artifact_key( + tenant_id, company_id, expediente_id, artifact_type, artifact_ts + ) + put_object_bytes(key, base64.b64decode(b64), content_type=content_type) + setattr(record, record_field, key) + logger.info( + "Digitalization artifact saved task_id=%s expediente_id=%s type=%s key=%s", + self.request.id, expediente_id, artifact_type, key, + ) + except Exception: + logger.exception( + "Failed to save artifact %s to S3 task_id=%s expediente_id=%s", + artifact_type, self.request.id, expediente_id, + ) + setattr(record, record_field, None) + db.commit() + logger.info( + "Digitalization task finished task_id=%s expediente_id=%s status=success total_elapsed_s=%.2f", + self.request.id, + expediente_id, + time.perf_counter() - task_started_at, + ) + return { "status": "success", "message": "Digitalización completada exitosamente.", "e_document": e_doc, "numero_operacion": num_op, - "acuese_digitalizacion_pdf_base64": acuse_b64, "nombre_archivo": payload["nombre_archivo"], "timestamp": result_payload.get("timestamp"), "request_id": result_payload.get("request_id"), "response_code": result_payload.get("response_code"), } + except Ignore: + raise except ValidationException as exc: if db: try: @@ -245,21 +523,31 @@ def digitalizar_task( pass first_error = (exc.errors or [{}])[0] self.update_state( - state="FAILURE", - meta={ - "error": first_error.get("message", str(exc)), - "error_type": "VALIDATION_ERROR", - "error_detail": { - "codigo": first_error.get("code", "VALIDATION_ERROR"), - "descripcion": first_error.get("message", ""), - "paso": "Validación", - "sugerencias": first_error.get("solution") or [], - }, - }, + state="PROGRESS", + meta={"current": 0, "status": "Preparando error de validación..."}, + ) + logger.info( + "Digitalization task failed by validation task_id=%s expediente_id=%s elapsed_s=%.2f", + self.request.id, + expediente_id, + time.perf_counter() - task_started_at, + ) + _fail_task( + self, + error=first_error.get("message", str(exc)), + error_type="VALIDATION_ERROR", + codigo=first_error.get("code", "VALIDATION_ERROR"), + descripcion=first_error.get("message", ""), + paso="Validación", + sugerencias=first_error.get("solution") or [], ) - return {} except Exception as exc: - logger.exception("Error inesperado en digitalizar_task expediente_id=%s", expediente_id) + logger.exception( + "Error inesperado en digitalizar_task task_id=%s expediente_id=%s elapsed_s=%.2f", + self.request.id, + expediente_id, + time.perf_counter() - task_started_at, + ) if db: try: record = db.get(ExpedienteArchivo, expediente_id) # type: ignore @@ -268,19 +556,14 @@ def digitalizar_task( db.commit() except Exception: pass - self.update_state( - state="FAILURE", - meta={ - "error": str(exc), - "error_type": type(exc).__name__, - "error_detail": { - "codigo": "UNEXPECTED_ERROR", - "descripcion": str(exc), - "paso": "Proceso de digitalización", - "sugerencias": ["Contacta al soporte técnico."], - }, - }, + _fail_task( + self, + error=str(exc), + error_type=type(exc).__name__, + codigo="UNEXPECTED_ERROR", + descripcion=str(exc), + paso="Proceso de digitalización", + sugerencias=["Contacta al soporte técnico."], ) - return {} finally: db.close() diff --git a/backend/api/v1/modules/a76/factura_cove/service.py b/backend/api/v1/modules/a76/factura_cove/service.py index affe7adc..b7ad4354 100644 --- a/backend/api/v1/modules/a76/factura_cove/service.py +++ b/backend/api/v1/modules/a76/factura_cove/service.py @@ -145,21 +145,6 @@ class FacturaCoveDomainService: ) return None - # Determinar usuario efectivo de WebService: - # - Preferimos el usuario configurado en VU (web_service_user) - # - Si no existe, usamos el de DODA-PITA (doda_web_service_user) - # - Si no existe, usamos la configuración VU de la empresa - effective_ws_user = ( - ( - vu.web_service_user - or vu.doda_web_service_user - or getattr(company_vu, "webservice_user", None) - or "" - ).strip() - if (vu or company_vu) - else "" - ) - # Determinar clave FIEL efectiva desde la configuración persistida. # Se envía cifrada con el mismo esquema AES-256-CBC del sistema legado. clave_fiel_value = "" @@ -175,17 +160,30 @@ class FacturaCoveDomainService: ) clave_fiel_value = self._encrypt_fiel(str(company_fiel_secret)) - # Validación básica de credenciales VU: para COVE necesitamos al menos - # un usuario de web service (VU o DODA) y una clave FIEL no vacía. - if not effective_ws_user: + # Validación básica de credenciales VU: usamos la clave/token efectiva + # del web service, que es lo que realmente viaja en configuracion_vu. + hardcoded_ws_key = ( + "RZGd+CB4R6PfSrstOyN8Is9FXL9AK9NPFisyGGaEWa0vVHoVOl8v2SBcHBoGbt3T/4uHTGcsFQO3b7EonWVfugQjBooywbz74K+jM68j8/Y=" + ) + vu_ws_key = (getattr(vu, "web_service_access_key", None) or "").strip() if vu else "" + vu_access_key_raw = (getattr(vu, "access_key", None) or "").strip() if vu else "" + vu_access_key_encrypted = self._encrypt_fiel(vu_access_key_raw) if vu_access_key_raw else "" + clave_webservice = ( + vu_ws_key + or vu_access_key_encrypted + or (getattr(company_vu, "webservice_password", None) or "").strip() + or hardcoded_ws_key + ) + + if not clave_webservice: errors.add_error( - field="vu", - message="Faltan credenciales de web service o clave FIEL en VU", + field="vu.clave_webservice", + message="La clave de web service no está configurada en VU ni en la empresa.", solution=[ - "Captura usuario y clave de web service en la pestaña VU o DODA del agente, " - "o completa la configuración VU de la empresa y su certificado FIEL." + "Captura la clave de web service en la pestaña VU o DODA del agente, " + "o completa la configuración VU de la empresa." ], - code="MISSING_VU_CREDENTIALS", + code="MISSING_VU_WS_KEY", ) if not clave_fiel_value: @@ -269,17 +267,9 @@ class FacturaCoveDomainService: # Clave/token del webservice: usar el valor de VU si existe, o una # clave fija de pruebas mientras se termina la configuración real. - hardcoded_ws_key = ( - "RZGd+CB4R6PfSrstOyN8Is9FXL9AK9NPFisyGGaEWa0vVHoVOl8v2SBcHBoGbt3T/4uHTGcsFQO3b7EonWVfugQjBooywbz74K+jM68j8/Y=" - ) - return ConfiguracionVU( rfc_usuario_vu=rfc_usuario_vu, - clave_webservice=( - (getattr(vu, "web_service_access_key", None) or "").strip() - or (getattr(company_vu, "webservice_password", None) or "").strip() - or hardcoded_ws_key - ), + clave_webservice=clave_webservice, archivo_cer_base64=cer_b64 or "", archivo_key_base64=key_b64 or "", clave_fiel=clave_fiel_value, diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 617a45f4..85552bd6 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -73,6 +73,7 @@ celery_app.conf.update( "api.v1.modules.a76.invoices.exports.revert.task", "api.v1.modules.a76.layouts_csv.common.victor", "api.v1.modules.a76.factura_cove.tasks", + "api.v1.modules.a76.expediente_archivos.tasks", ] # Ruta al módulo donde están las tareas ) diff --git a/backend/core/s3_keys.py b/backend/core/s3_keys.py index 1108b02f..ac3e5efe 100644 --- a/backend/core/s3_keys.py +++ b/backend/core/s3_keys.py @@ -288,6 +288,49 @@ def company_certificate_key( return f"{tenant_company_prefix(tenant_id, company_id)}certificates/{base}" +def expediente_archivo_document_key( + tenant_id: Union[int, str], + company_id: int, + expediente_id: int, + timestamp: str, + original_filename: str, +) -> str: + """ + Archivo del expediente bajo ``.../expediente_archivos/{id}/documents/expediente_{timestamp}_{filename}``. + Extensiones permitidas: .pdf, .xml, .png, .jpg, .jpeg, .json, .txt, .zip + """ + ts = _segment(timestamp, "timestamp") + eid = _segment(expediente_id, "expediente_id") + fn = safe_filename(original_filename) + parts = fn.rsplit(".", 1) + if len(parts) < 2: + raise ValueError("expediente file must have an extension") + ext = "." + parts[1].lower() + allowed = (".pdf", ".xml", ".png", ".jpg", ".jpeg", ".json", ".txt", ".zip") + if ext not in allowed: + raise ValueError(f"expediente file extension not allowed: {ext}") + base = f"expediente_{ts}_{fn}" + return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/documents/{base}" + + +def expediente_archivo_artifact_key( + tenant_id: Union[int, str], + company_id: int, + expediente_id: int, + artifact_type: str, + timestamp: str, +) -> str: + """ + Artefacto de digitalización bajo ``.../expediente_archivos/{id}/artifacts/{type}_{timestamp}.{ext}``. + artifact_type: acuse | envio_xml | respuesta_xml | consulta_envio_xml | consulta_respuesta_xml + """ + ts = _segment(timestamp, "timestamp") + eid = _segment(expediente_id, "expediente_id") + at = _segment(artifact_type, "artifact_type") + ext = ".pdf" if artifact_type == "acuse" else ".xml" + return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/artifacts/{at}_{ts}{ext}" + + def help_asset_key(folder: str, new_filename: str) -> str: """ folder: '', 'pdfs', 'videos', 'assets' relativo a system/help/ diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 00df5807..bb4f5575 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1,184 +1,190 @@ { - "$schema": "https://inlang.com/schema/inlang-message-format", - "hello_world": "Hello, {name} from en!", - "sidebar": { - "reference_data": { - "title": "Fixed Catalogs", - "codes_pedimento_regimen": "Pedimento and Regime Codes", - "containers": "Containers", - "countries": "Countries", - "currency_types": "Currency Types", - "customs_sections": "Customs Sections", - "customs_warehouses": "Customs Warehouses", - "incoterms": "Incoterms", - "invoice_types": "Invoice Types", - "material_types": "Material Types", - "payment_methods": "Payment Methods", - "pedimento_codes": "Pedimento Codes", - "pedimento_regimes": "Pedimento Regimes", - "sectors": "Sectors", - "states": "States", - "transportation_modes": "Transportation Modes", - "transportation_types": "Transportation Types", - "valuation_methods": "Valuation Methods", - "configuracion": "Settings", - "general": "General", - "licencia": "License", - "usuarios": "Users", - "ayuda": "Help" - }, - "general_catalogs": { - "title": "General Catalogs", - "company_information": "Company Information", - "packages": "Packages", - "concepts": "Concepts", - "classification": "Classification", - "identifiers": "Identifiers", - "incoterms": "Incoterms", - "inpc": "I.N.P.C", - "fixed_legends": "Fixed Legends", - "seals": "Seals", - "valuation_methods": "Valuation Methods", - "countries": "Countries", - "ports": "Ports", - "unit_measures": "Units of Measure", - "um_customs_mex": "Units of Measure - Mexican Customs", - "um_customs_ame": "Units of Measure - American Customs", - "um_ace": "Units of Measure - ACE", - "um_oma": "Units of Measure - OMA", - "conversions": "Conversions", - "equivalences": "Equivalences", - "exchange_rates": "Exchange Rates", - "currency_types": "Currency Types", - "multi_currency": "Multi Currency", - "invoice_types": "Invoice Types", - "electronic_signatures": "Electronic Signatures", - "billing_errors": "Billing Errors", - "customs_warehouses": "Customs Warehouses", - "locations": "Locations", - "doda": "DODA", - "packing_list": "Packing List", - "prevalidators": "Prevalidators", - "electronic_notices": "Electronic Notices", - "back_flush": "Back Flush", - "crossing_notice": "Crossing Notice" - }, - "fractions": { - "title": "Fractions", - "sitar": "Fraction Sitar", - "sitar_seventh_amendment": "Fraction Sitar - Seventh Amendment", - "sitar_us": "Fraction Sitar US", - "american": "Fraction American", - "canadian": "Fraction Canadian", - "historical": "Fraction Historical", - "sectors": "Sectors" - }, - "goods": { - "title": "Goods", - "classes": "Classes", - "parts": "Parts", - "fda_codes": "FDA Codes" - }, - "pedimentos": { - "title": "Pedimentos", - "pedimento_management": "Pedimento Management", - "pedimento_codes": "Pedimento Codes", - "customs_regimes": "Customs Regimes", - "payment_methods": "Payment Methods", - "customs_sections": "Customs Sections", - "anexo_22_app_31": "Anexo 22 App 3" - }, - "import_invoices": { - "title": "Import Invoices", - "temporary": "Temporary", - "definitive": "Definitive", - "mexican_purchases": "Mexican Purchases", - "regime_change": "Regime Change", - "repair": "Repair" - }, - "export_invoices": { - "title": "Export Invoices", - "exportation": "Exportation", - "repair": "Repair" - }, - "export": { - "title": "Exportation", - "catalog": "Export Catalog", - "repair": "Repair", - "manifest": "Manifest", - "proforma": "Proforma", - "reports": "Reports", - "used_materials": "Used Materials Module", - "destruction": "Destruction", - "special_processes": "Special Processes" - }, - "clients_and_providers": "Clients and Providers", - "customs_brokers": "Customs Brokers", - "audit_logs": "Audit Logs", - "audit_logs_title": "Audit Logs", - "audit_logs_description": "Audit trail of operations and background task (Celery) status.", - "audit_logs_tab_bitacora": "Audit trail", - "audit_logs_tab_tasks": "Background tasks", - "audit_logs_tab_files": "File manager", - "audit_logs_files_title": "File manager", - "audit_logs_files_root": "Files root", - "audit_logs_files_refresh": "Refresh", - "audit_logs_files_list_title": "Contents", - "audit_logs_files_error_prefix": "Error:", - "audit_logs_files_col_name": "Name", - "audit_logs_files_col_size": "Size", - "audit_logs_files_col_modified": "Modified", - "audit_logs_files_col_actions": "Actions", - "audit_logs_files_loading": "Loading files...", - "audit_logs_files_empty": "No files or folders found in this location.", - "audit_logs_files_download": "Download", - "digitalizacion": { - "title": "Digitization", - "subtitle": "Digitized Documents Catalog", - "new": "New", - "refresh": "Refresh", - "table_title": "Digitized documents", - "col_consecutivo": "Consecutive", - "col_tipo_documento": "Document Type", - "col_e_document": "E-Document", - "col_fecha": "Date", - "col_num_operacion_vu": "VU Operation No.", - "col_actions": "Actions", - "form_e_document": "E-Document", - "form_num_operacion": "Operation No.", - "form_tipo_documento": "Document Type", - "form_archivo_digitalizado_en": "Digitized in", - "form_fecha": "Date", - "form_agente_aduanal": "Customs Broker", - "form_pedimento": "Entry", - "form_nombre_archivo": "File name", - "digitalizar_title": "Digitize Document", - "digitalizar_subtitle": "Send document to Ventanilla Única", - "digitalizar_file_label": "File", - "digitalizar_rfc_consulta": "RFC Query", - "digitalizar_clave_documento": "Document Key", - "progress_title": "Digitalizing document...", - "progress_step": "Step", - "progress_success": "Digitalization completed successfully.", - "progress_download_acuse": "Download Receipt", - "action_digitalizar": "Digitalize", - "action_acuse": "Receipt", - "action_edit": "Edit", - "action_delete": "Delete", - "empty": "No digitized documents", - "loading": "Loading...", - "search_placeholder": "Search:", - "confirm_delete": "Are you sure you want to delete this document?" - }, - "client_provider_type": { - "client_indicator": "C", - "provider_indicator": "P", - "both_indicator": "B" - }, - "nav_user": { - "profile": "Profile", - "settings": "Settings", - "logout": "Logout" - } - } + "$schema": "https://inlang.com/schema/inlang-message-format", + "hello_world": "Hello, {name} from en!", + "sidebar": { + "reference_data": { + "title": "Fixed Catalogs", + "codes_pedimento_regimen": "Pedimento and Regime Codes", + "containers": "Containers", + "countries": "Countries", + "currency_types": "Currency Types", + "customs_sections": "Customs Sections", + "customs_warehouses": "Customs Warehouses", + "incoterms": "Incoterms", + "document_types_digitization": "Document types for digitization", + "invoice_types": "Invoice Types", + "material_types": "Material Types", + "payment_methods": "Payment Methods", + "pedimento_codes": "Pedimento Codes", + "pedimento_regimes": "Pedimento Regimes", + "sectors": "Sectors", + "states": "States", + "transportation_modes": "Transportation Modes", + "transportation_types": "Transportation Types", + "valuation_methods": "Valuation Methods", + "configuracion": "Settings", + "general": "General", + "licencia": "License", + "usuarios": "Users", + "ayuda": "Help" + }, + "general_catalogs": { + "title": "General Catalogs", + "company_information": "Company Information", + "packages": "Packages", + "concepts": "Concepts", + "classification": "Classification", + "identifiers": "Identifiers", + "incoterms": "Incoterms", + "inpc": "I.N.P.C", + "fixed_legends": "Fixed Legends", + "seals": "Seals", + "valuation_methods": "Valuation Methods", + "countries": "Countries", + "ports": "Ports", + "unit_measures": "Units of Measure", + "um_customs_mex": "Units of Measure - Mexican Customs", + "um_customs_ame": "Units of Measure - American Customs", + "um_ace": "Units of Measure - ACE", + "um_oma": "Units of Measure - OMA", + "conversions": "Conversions", + "equivalences": "Equivalences", + "exchange_rates": "Exchange Rates", + "currency_types": "Currency Types", + "multi_currency": "Multi Currency", + "invoice_types": "Invoice Types", + "electronic_signatures": "Electronic Signatures", + "billing_errors": "Billing Errors", + "customs_warehouses": "Customs Warehouses", + "locations": "Locations", + "doda": "DODA", + "packing_list": "Packing List", + "prevalidators": "Prevalidators", + "electronic_notices": "Electronic Notices", + "back_flush": "Back Flush", + "crossing_notice": "Crossing Notice" + }, + "fractions": { + "title": "Fractions", + "sitar": "Fraction Sitar", + "sitar_seventh_amendment": "Fraction Sitar - Seventh Amendment", + "sitar_us": "Fraction Sitar US", + "american": "Fraction American", + "canadian": "Fraction Canadian", + "historical": "Fraction Historical", + "sectors": "Sectors" + }, + "goods": { + "title": "Goods", + "classes": "Classes", + "parts": "Parts", + "fda_codes": "FDA Codes" + }, + "pedimentos": { + "title": "Pedimentos", + "pedimento_management": "Pedimento Management", + "pedimento_codes": "Pedimento Codes", + "customs_regimes": "Customs Regimes", + "payment_methods": "Payment Methods", + "customs_sections": "Customs Sections", + "anexo_22_app_31": "Anexo 22 App 3" + }, + "import_invoices": { + "title": "Import Invoices", + "temporary": "Temporary", + "definitive": "Definitive", + "mexican_purchases": "Mexican Purchases", + "regime_change": "Regime Change", + "repair": "Repair" + }, + "export_invoices": { + "title": "Export Invoices", + "exportation": "Exportation", + "repair": "Repair" + }, + "export": { + "title": "Exportation", + "catalog": "Export Catalog", + "repair": "Repair", + "manifest": "Manifest", + "proforma": "Proforma", + "reports": "Reports", + "used_materials": "Used Materials Module", + "destruction": "Destruction", + "special_processes": "Special Processes" + }, + "clients_and_providers": "Clients and Providers", + "customs_brokers": "Customs Brokers", + "audit_logs": "Audit Logs", + "audit_logs_title": "Audit Logs", + "audit_logs_description": "Audit trail of operations and background task (Celery) status.", + "audit_logs_tab_bitacora": "Audit trail", + "audit_logs_tab_tasks": "Background tasks", + "audit_logs_tab_files": "File manager", + "audit_logs_files_title": "File manager", + "audit_logs_files_root": "Files root", + "audit_logs_files_refresh": "Refresh", + "audit_logs_files_list_title": "Contents", + "audit_logs_files_error_prefix": "Error:", + "audit_logs_files_col_name": "Name", + "audit_logs_files_col_size": "Size", + "audit_logs_files_col_modified": "Modified", + "audit_logs_files_col_actions": "Actions", + "audit_logs_files_loading": "Loading files...", + "audit_logs_files_empty": "No files or folders found in this location.", + "audit_logs_files_download": "Download", + "digitalizacion": { + "title": "Digitization", + "subtitle": "Digitized Documents Catalog", + "new": "New", + "refresh": "Refresh", + "table_title": "Digitized documents", + "col_consecutivo": "Consecutive", + "col_tipo_documento": "Document Type", + "col_e_document": "E-Document", + "col_fecha": "Date", + "col_num_operacion_vu": "VU Operation No.", + "col_actions": "Actions", + "form_e_document": "E-Document", + "form_num_operacion": "Operation No.", + "form_tipo_documento": "Document Type", + "form_archivo_digitalizado_en": "Digitized in", + "form_fecha": "Date", + "form_agente_aduanal": "Customs Broker", + "form_pedimento": "Entry", + "form_nombre_archivo": "File name", + "digitalizar_title": "Digitize Document", + "digitalizar_subtitle": "Send document to Ventanilla Única", + "digitalizar_file_label": "File", + "digitalizar_rfc_consulta": "RFC Query", + "digitalizar_clave_documento": "Document Key", + "progress_title": "Digitalizing document...", + "progress_step": "Step", + "progress_success": "Digitalization completed successfully.", + "progress_download_acuse": "Download Receipt", + "action_digitalizar": "Digitalize", + "action_download_zip": "Download ZIP", + "action_acuse": "Receipt", + "action_envio_xml": "Envío XML", + "action_respuesta_xml": "Respuesta XML", + "action_consulta_envio_xml": "Consulta Envío XML", + "action_consulta_respuesta_xml": "Consulta Respuesta XML", + "action_edit": "Edit", + "action_delete": "Delete", + "empty": "No digitized documents", + "loading": "Loading...", + "search_placeholder": "Search:", + "confirm_delete": "Are you sure you want to delete this document?" + }, + "client_provider_type": { + "client_indicator": "C", + "provider_indicator": "P", + "both_indicator": "B" + }, + "nav_user": { + "profile": "Profile", + "settings": "Settings", + "logout": "Logout" + } + } } \ No newline at end of file diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 2bce2f12..5072a52b 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1,183 +1,189 @@ { - "$schema": "https://inlang.com/schema/inlang-message-format", - "hello_world": "Hello, {name} from es!", - "sidebar": { - "reference_data": { - "title": "Catálogos Fijos", - "codes_pedimento_regimen": "Códigos de Pedimento y Régimen", - "containers": "Contenedores", - "countries": "Países", - "currency_types": "Tipos de moneda", - "customs_sections": "Secciones de aduanas", - "customs_warehouses": "Recintos", - "incoterms": "Incoterms", - "invoice_types": "Tipos de factura", - "material_types": "Tipos de material", - "payment_methods": "Métodos de pago", - "pedimento_codes": "Códigos de pedimento", - "pedimento_regimes": "Regímenes de pedimentos", - "sectors": "Sectores", - "states": "Estados", - "transportation_modes": "Métodos de transporte", - "transportation_types": "Tipos de transporte", - "valuation_methods": "Métodos de valoración", - "configuracion": "Configuración", - "general": "General", - "licencia": "Licencia", - "usuarios": "Usuarios", - "ayuda": "Ayuda" - }, - "general_catalogs": { - "title": "Catalogos Generales", - "company_information": "Información de la empresa", - "packages": "Bultos", - "concepts": "Conceptos", - "classification": "Clasificación", - "identifiers": "Identificadores", - "incoterms": "Incoterms", - "inpc": "I.N.P.C", - "fixed_legends": "Leyendas fijas", - "seals": "Precintos", - "valuation_methods": "Metódos de valoración", - "countries": "Países", - "ports": "Puertos", - "unit_measures": "Unidades de medida", - "um_customs_mex": "UM Aduanas MX", - "um_customs_ame": "UM Aduanas USA", - "um_ace": "UM ACE", - "um_oma": "UM OMA", - "conversions": "Conversiones", - "equivalences": "Equivalencias", - "exchange_rates": "Tipos de cambio", - "currency_types": "Tipos de moneda", - "multi_currency": "Multi Moneda", - "invoice_types": "Tipos de factura", - "electronic_signatures": "Firmas electrónicas", - "billing_errors": "Errores de facturación", - "customs_warehouses": "Recintos", - "locations": "Localizaciones", - "doda": "DODA", - "packing_list": "Packing List", - "prevalidators": "Prevalidadores", - "electronic_notices": "Avisos electrónicos", - "back_flush": "Back Flush", - "crossing_notice": "Aviso de cruce" - }, - "fractions": { - "title": "Fracciones", - "sitar": "Fracciones Sitar", - "sitar_seventh_amendment": "Fracciones Sitar - 7ma enmienda", - "sitar_us": "Fracciones Sitar US", - "american": "Fracciones Americana", - "canadian": "Fracciones Canadiense", - "historical": "Fracciones Historicas", - "sectors": "Sectores" - }, - "goods": { - "title": "Mercancías", - "classes": "Clases", - "parts": "Partes", - "fda_codes": "Códigos F.D.A." - }, - "pedimentos": { - "title": "Pedimentos", - "pedimento_management": "Gestión de Pedimentos", - "pedimento_codes": "Claves de Pedimento", - "customs_regimes": "Regímenes Aduaneros", - "payment_methods": "Formas de Pago", - "customs_sections": "Secciones Aduaneras", - "anexo_22_app_31": "Anexo 22 App 3" - }, - "import_invoices": { - "title": "Facturas de importación", - "temporary": "Temporal", - "definitive": "Definitiva", - "mexican_purchases": "Compras mexicanas", - "regime_change": "Cambio de régimen", - "repair": "Reparación" - }, - "export_invoices": { - "title": "Facturas de exportación", - "exportation": "Exportación", - "repair": "Reparación" - }, - "export": { - "title": "Exportación", - "catalog": "Catálogo de exportación", - "repair": "Reparación", - "manifest": "Manifiesto", - "proforma": "Proforma", - "reports": "Reportes", - "used_materials": "Módulo de materiales utilizados", - "destruction": "Destrucción", - "special_processes": "Procesos Especiales" - }, - "clients_and_providers": "Clientes y Proveedores", - "customs_brokers": "Agentes Aduanales", - "audit_logs": "Bitácora", - "audit_logs_title": "Bitácora de Movimientos", - "audit_logs_description": "Auditoría de operaciones y seguimiento de tareas en segundo plano (Celery).", - "audit_logs_tab_bitacora": "Bitácora", - "audit_logs_tab_tasks": "Tareas en segundo plano", - "audit_logs_tab_files": "Gestor de archivos", - "audit_logs_files_title": "Gestor de archivos", - "audit_logs_files_root": "Raíz de archivos", - "audit_logs_files_refresh": "Actualizar", - "audit_logs_files_list_title": "Contenido", - "audit_logs_files_error_prefix": "Error:", - "audit_logs_files_col_name": "Nombre", - "audit_logs_files_col_size": "Tamaño", - "audit_logs_files_col_modified": "Modificado", - "audit_logs_files_col_actions": "Acciones", - "audit_logs_files_loading": "Cargando archivos...", - "audit_logs_files_empty": "No hay archivos o carpetas en esta ubicación.", - "audit_logs_files_download": "Descargar", - "digitalizacion": { - "title": "Digitalización", - "subtitle": "Catálogo de Documentos Digitalizados", - "new": "Nuevo", - "refresh": "Actualizar", - "table_title": "Documentos digitalizados", - "col_consecutivo": "Consecutivo", - "col_tipo_documento": "Tipo Documento", - "col_e_document": "E-Document", - "col_fecha": "Fecha", - "col_num_operacion_vu": "Núm. Operación VU", - "col_actions": "Acciones", - "form_e_document": "E-Document", - "form_num_operacion": "Núm. Operación", - "form_tipo_documento": "Tipo Documento", - "form_archivo_digitalizado_en": "Archivo Digitalizado en", - "form_fecha": "Fecha", - "form_agente_aduanal": "Agente Aduanal", - "form_pedimento": "Pedimento", - "form_nombre_archivo": "Nombre del archivo", - "digitalizar_title": "Digitalizar Documento", - "digitalizar_subtitle": "Enviar documento a Ventanilla Única", - "digitalizar_file_label": "Archivo", - "digitalizar_rfc_consulta": "RFC Consulta", - "digitalizar_clave_documento": "Clave Documento", - "progress_title": "Digitalizando documento...", - "progress_step": "Paso", - "progress_success": "Digitalización completada exitosamente.", - "progress_download_acuse": "Descargar Acuse", - "action_digitalizar": "Digitalizar", - "action_acuse": "Acuse", - "action_edit": "Editar", - "action_delete": "Borrar", - "empty": "Sin documentos digitalizados", - "loading": "Cargando...", - "search_placeholder": "Buscando:", - "confirm_delete": "¿Está seguro de eliminar este documento?" - }, - "client_provider_type": { - "client_indicator": "C", - "provider_indicator": "P", - "both_indicator": "A" - }, - "nav_user": { - "profile": "Perfil", - "settings": "Configuración" - } - } + "$schema": "https://inlang.com/schema/inlang-message-format", + "hello_world": "Hello, {name} from es!", + "sidebar": { + "reference_data": { + "title": "Catálogos Fijos", + "codes_pedimento_regimen": "Códigos de Pedimento y Régimen", + "containers": "Contenedores", + "countries": "Países", + "currency_types": "Tipos de moneda", + "customs_sections": "Secciones de aduanas", + "customs_warehouses": "Recintos", + "incoterms": "Incoterms", + "document_types_digitization": "Tipos de documento para digitalización", + "invoice_types": "Tipos de factura", + "material_types": "Tipos de material", + "payment_methods": "Métodos de pago", + "pedimento_codes": "Códigos de pedimento", + "pedimento_regimes": "Regímenes de pedimentos", + "sectors": "Sectores", + "states": "Estados", + "transportation_modes": "Métodos de transporte", + "transportation_types": "Tipos de transporte", + "valuation_methods": "Métodos de valoración", + "configuracion": "Configuración", + "general": "General", + "licencia": "Licencia", + "usuarios": "Usuarios", + "ayuda": "Ayuda" + }, + "general_catalogs": { + "title": "Catalogos Generales", + "company_information": "Información de la empresa", + "packages": "Bultos", + "concepts": "Conceptos", + "classification": "Clasificación", + "identifiers": "Identificadores", + "incoterms": "Incoterms", + "inpc": "I.N.P.C", + "fixed_legends": "Leyendas fijas", + "seals": "Precintos", + "valuation_methods": "Metódos de valoración", + "countries": "Países", + "ports": "Puertos", + "unit_measures": "Unidades de medida", + "um_customs_mex": "UM Aduanas MX", + "um_customs_ame": "UM Aduanas USA", + "um_ace": "UM ACE", + "um_oma": "UM OMA", + "conversions": "Conversiones", + "equivalences": "Equivalencias", + "exchange_rates": "Tipos de cambio", + "currency_types": "Tipos de moneda", + "multi_currency": "Multi Moneda", + "invoice_types": "Tipos de factura", + "electronic_signatures": "Firmas electrónicas", + "billing_errors": "Errores de facturación", + "customs_warehouses": "Recintos", + "locations": "Localizaciones", + "doda": "DODA", + "packing_list": "Packing List", + "prevalidators": "Prevalidadores", + "electronic_notices": "Avisos electrónicos", + "back_flush": "Back Flush", + "crossing_notice": "Aviso de cruce" + }, + "fractions": { + "title": "Fracciones", + "sitar": "Fracciones Sitar", + "sitar_seventh_amendment": "Fracciones Sitar - 7ma enmienda", + "sitar_us": "Fracciones Sitar US", + "american": "Fracciones Americana", + "canadian": "Fracciones Canadiense", + "historical": "Fracciones Historicas", + "sectors": "Sectores" + }, + "goods": { + "title": "Mercancías", + "classes": "Clases", + "parts": "Partes", + "fda_codes": "Códigos F.D.A." + }, + "pedimentos": { + "title": "Pedimentos", + "pedimento_management": "Gestión de Pedimentos", + "pedimento_codes": "Claves de Pedimento", + "customs_regimes": "Regímenes Aduaneros", + "payment_methods": "Formas de Pago", + "customs_sections": "Secciones Aduaneras", + "anexo_22_app_31": "Anexo 22 App 3" + }, + "import_invoices": { + "title": "Facturas de importación", + "temporary": "Temporal", + "definitive": "Definitiva", + "mexican_purchases": "Compras mexicanas", + "regime_change": "Cambio de régimen", + "repair": "Reparación" + }, + "export_invoices": { + "title": "Facturas de exportación", + "exportation": "Exportación", + "repair": "Reparación" + }, + "export": { + "title": "Exportación", + "catalog": "Catálogo de exportación", + "repair": "Reparación", + "manifest": "Manifiesto", + "proforma": "Proforma", + "reports": "Reportes", + "used_materials": "Módulo de materiales utilizados", + "destruction": "Destrucción", + "special_processes": "Procesos Especiales" + }, + "clients_and_providers": "Clientes y Proveedores", + "customs_brokers": "Agentes Aduanales", + "audit_logs": "Bitácora", + "audit_logs_title": "Bitácora de Movimientos", + "audit_logs_description": "Auditoría de operaciones y seguimiento de tareas en segundo plano (Celery).", + "audit_logs_tab_bitacora": "Bitácora", + "audit_logs_tab_tasks": "Tareas en segundo plano", + "audit_logs_tab_files": "Gestor de archivos", + "audit_logs_files_title": "Gestor de archivos", + "audit_logs_files_root": "Raíz de archivos", + "audit_logs_files_refresh": "Actualizar", + "audit_logs_files_list_title": "Contenido", + "audit_logs_files_error_prefix": "Error:", + "audit_logs_files_col_name": "Nombre", + "audit_logs_files_col_size": "Tamaño", + "audit_logs_files_col_modified": "Modificado", + "audit_logs_files_col_actions": "Acciones", + "audit_logs_files_loading": "Cargando archivos...", + "audit_logs_files_empty": "No hay archivos o carpetas en esta ubicación.", + "audit_logs_files_download": "Descargar", + "digitalizacion": { + "title": "Digitalización", + "subtitle": "Catálogo de Documentos Digitalizados", + "new": "Nuevo", + "refresh": "Actualizar", + "table_title": "Documentos digitalizados", + "col_consecutivo": "Consecutivo", + "col_tipo_documento": "Tipo Documento", + "col_e_document": "E-Document", + "col_fecha": "Fecha", + "col_num_operacion_vu": "Núm. Operación VU", + "col_actions": "Acciones", + "form_e_document": "E-Document", + "form_num_operacion": "Núm. Operación", + "form_tipo_documento": "Tipo Documento", + "form_archivo_digitalizado_en": "Archivo Digitalizado en", + "form_fecha": "Fecha", + "form_agente_aduanal": "Agente Aduanal", + "form_pedimento": "Pedimento", + "form_nombre_archivo": "Nombre del archivo", + "digitalizar_title": "Digitalizar Documento", + "digitalizar_subtitle": "Enviar documento a Ventanilla Única", + "digitalizar_file_label": "Archivo", + "digitalizar_rfc_consulta": "RFC Consulta", + "digitalizar_clave_documento": "Clave Documento", + "progress_title": "Digitalizando documento...", + "progress_step": "Paso", + "progress_success": "Digitalización completada exitosamente.", + "progress_download_acuse": "Descargar Acuse", + "action_digitalizar": "Digitalizar", + "action_download_zip": "Descargar ZIP", + "action_acuse": "Acuse", + "action_envio_xml": "Envío XML", + "action_respuesta_xml": "Respuesta XML", + "action_consulta_envio_xml": "Consulta Envío XML", + "action_consulta_respuesta_xml": "Consulta Respuesta XML", + "action_edit": "Editar", + "action_delete": "Borrar", + "empty": "Sin documentos digitalizados", + "loading": "Cargando...", + "search_placeholder": "Buscando:", + "confirm_delete": "¿Está seguro de eliminar este documento?" + }, + "client_provider_type": { + "client_indicator": "C", + "provider_indicator": "P", + "both_indicator": "A" + }, + "nav_user": { + "profile": "Perfil", + "settings": "Configuración" + } + } } \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/expediente-archivos.ts b/frontend/src/lib/api/dashboard/a76/expediente-archivos.ts index 485ccbfc..b706366b 100644 --- a/frontend/src/lib/api/dashboard/a76/expediente-archivos.ts +++ b/frontend/src/lib/api/dashboard/a76/expediente-archivos.ts @@ -15,6 +15,10 @@ export interface ExpedienteArchivo { task_id?: string | null; external_task_id?: string | null; acuse_pdf_path?: string | null; + envio_xml_path?: string | null; + respuesta_xml_path?: string | null; + consulta_envio_xml_path?: string | null; + consulta_respuesta_xml_path?: string | null; company_id: number; tenant_id: number; } @@ -39,10 +43,10 @@ export interface ExpedienteArchivoCreateDTO { } export interface DigitalizarRequest { - rfc_consulta: string; - clave_documento: string; - nombre_archivo: string; - archivo_base64: string; + rfc_consulta?: string | null; + clave_documento?: string | null; + nombre_archivo?: string | null; + archivo_base64?: string | null; } export interface DigitalizarResponse { @@ -51,6 +55,13 @@ export interface DigitalizarResponse { status: string; } +export interface ExpedienteArchivoUploadResponse { + message: string; + record_id: number; + path: string; + nombre_archivo?: string | null; +} + export interface DigitalizacionResult { status?: string | null; message?: string | null; @@ -76,6 +87,7 @@ export interface DigitalizacionErrorDetail { export interface DigitalizacionTaskDetailResponse { task_id: string; + external_task_id?: string | null; state: string; status?: string | null; current_step?: string | null; @@ -132,6 +144,20 @@ class ExpedienteArchivosApi { return api.delete(`${this.baseUrl}/${id}?${q}`); } + async uploadFile( + id: number, + file: File, + companyId: string | number + ): Promise> { + const q = new URLSearchParams({ company_id: companyId.toString() }); + const formData = new FormData(); + formData.append('file', file); + return api.request(`${this.baseUrl}/${id}/upload?${q}`, { + method: 'POST', + body: formData + }); + } + async digitalizar( id: number, body: DigitalizarRequest, @@ -146,6 +172,41 @@ class ExpedienteArchivosApi { `${this.baseUrl}/status-digitalizacion-task/${taskId}` ); } + + async downloadArtifact( + id: number, + artifactType: 'acuse' | 'envio-xml' | 'respuesta-xml' | 'consulta-envio-xml' | 'consulta-respuesta-xml', + companyId: string | number, + filename?: string + ): Promise { + const q = new URLSearchParams({ company_id: companyId.toString() }); + const endpoint = `${this.baseUrl}/${id}/artifacts/${artifactType}?${q}`; + const blob = await api.getBlob(endpoint); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename || `artifact_${id}`; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + async downloadAllArtifactsZip(id: number, companyId: string | number, baseName?: string): Promise { + const q = new URLSearchParams({ company_id: companyId.toString() }); + const endpoint = `${this.baseUrl}/${id}/artifacts-zip?${q}`; + const blob = await api.getBlob(endpoint); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `expediente_${baseName || id}.zip`; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } } export const expedienteArchivosApi = new ExpedienteArchivosApi(); diff --git a/frontend/src/lib/api/dashboard/reference_data/document_types_digitization.ts b/frontend/src/lib/api/dashboard/reference_data/document_types_digitization.ts index 242bf152..7a1a7737 100644 --- a/frontend/src/lib/api/dashboard/reference_data/document_types_digitization.ts +++ b/frontend/src/lib/api/dashboard/reference_data/document_types_digitization.ts @@ -7,70 +7,52 @@ export interface DocumentTypeDigitization { active: boolean; } -export interface DocumentTypeDigitizationCreate { - code: string; - description: string; - active?: boolean; -} - -export interface DocumentTypeDigitizationUpdate { - code?: string; - description?: string; - active?: boolean; +export interface DocumentTypeDigitizationListResponse { + items: DocumentTypeDigitization[]; + total: number; + page: number; + page_size: number; } const BASE_URL = '/v1/a76/document-types-digitization'; /** - * API para Tipos de Documentos de Digitalización + * API de solo lectura para Tipos de Documentos de Digitalización */ export const documentTypesDigitizationApi = { - /** - * Obtener todos los tipos de documentos para digitalización - */ - getAll: (activeOnly: boolean = true) => { - // CORRECTO: Al tener BASE_URL con slash, queda "...digitization/?active..." - const url = `${BASE_URL}?active_only=${activeOnly}`; - return api.get(url); + list: ( + page = 1, + pageSize = 50, + companyId: number, + search?: string, + activeOnly = false + ) => { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString() + }); + + if (search) { + params.append('search', search); + } + + if (activeOnly) { + params.append('active_only', 'true'); + } + + return api.get(`${BASE_URL}/?${params.toString()}`); }, - /** - * Obtener un tipo de documento por ID - */ - getById: (id: number) => { - // CORREGIDO: Añadido slash después del ID - return api.get(`${BASE_URL}${id}/`); + getAll: (companyId: number, activeOnly = true, search?: string) => { + return documentTypesDigitizationApi.list(1, 2000, companyId, search, activeOnly); }, - /** - * Obtener un tipo de documento por código - */ - getByCode: (code: string) => { - // CORREGIDO: Añadido slash después del código - return api.get(`${BASE_URL}by-code/${code}/`); + getById: (id: number, companyId: number) => { + return api.get(`${BASE_URL}/${id}/?company_id=${companyId}`); }, - /** - * Crear un nuevo tipo de documento - */ - create: (data: DocumentTypeDigitizationCreate) => { - // CORRECTO: Usa la BASE_URL que ya termina en / - return api.post(BASE_URL, data); - }, - - /** - * Actualizar un tipo de documento existente - */ - update: (id: number, data: DocumentTypeDigitizationUpdate) => { - // CORREGIDO: Añadido slash después del ID - return api.put(`${BASE_URL}${id}/`, data); - }, - - /** - * Eliminar (soft delete) un tipo de documento - */ - delete: (id: number) => { - // CORREGIDO: Añadido slash después del ID - return api.delete(`${BASE_URL}${id}/`); + getByCode: (code: string, companyId: number) => { + return api.get(`${BASE_URL}/by-code/${code}/?company_id=${companyId}`); } }; \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/common/infinite-data-table.svelte b/frontend/src/lib/components/dashboard/common/infinite-data-table.svelte index 09c128cd..2f7dbbcb 100644 --- a/frontend/src/lib/components/dashboard/common/infinite-data-table.svelte +++ b/frontend/src/lib/components/dashboard/common/infinite-data-table.svelte @@ -1,4 +1,4 @@ - @@ -490,14 +475,36 @@
-
+

+ Solo lectura. Usa el botón para elegir desde el catálogo de tipos de documento. +

+
+ +
+ +
+ + +
@@ -555,6 +562,8 @@ + + @@ -563,7 +572,7 @@
- +
-
@@ -630,62 +635,7 @@ variant="default" onclick={() => (isTipoDocumentoDialogOpen = false)} > - Seleccionar - - - -
- - - - - - Tipos de Documentos para Digitalización - - -
- -
- - -
- - -
- - -
- - -
- -
@@ -196,7 +197,7 @@ {#if visibility.showLabelingEnhanced}
- Assets / Series + {m['invoice_item_fa.labeling.assets_series']()}
- + +
{/if} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/unit-of-measure-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/unit-of-measure-dialog.svelte index 30dccd70..0232e4d5 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/unit-of-measure-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/unit-of-measure-dialog.svelte @@ -4,6 +4,7 @@ import { Input } from '$lib/components/ui/input'; import { Loader2, Search } from 'lucide-svelte'; import { onMount } from 'svelte'; + import { m } from '$lib/i18n/messages'; let { open = $bindable(), @@ -45,7 +46,7 @@ console.error('Error response:', await response.text()); } } catch (err) { - error = 'Error loading units of measure'; + error = m['invoice_item_fa.dialogs.units_load_error'](); console.error('Error loading units of measure:', err); } finally { loading = false; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/valuation-method-selector.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/valuation-method-selector.svelte index 25a41a7f..75a35eb7 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/valuation-method-selector.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/valuation-method-selector.svelte @@ -1,11 +1,11 @@ diff --git a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/canadian/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/canadian/+page.svelte index 63fab84b..9cc48eaa 100644 --- a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/canadian/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/canadian/+page.svelte @@ -1,6 +1,6 @@
diff --git a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/historical/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/historical/+page.svelte index df44023c..83241a2e 100644 --- a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/historical/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/historical/+page.svelte @@ -1,6 +1,6 @@ diff --git a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/seventh-amendment/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/seventh-amendment/+page.svelte index 30b3f3be..3cae1683 100644 --- a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/seventh-amendment/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/seventh-amendment/+page.svelte @@ -1,6 +1,6 @@ import TariffFractionList from '$lib/components/dashboard/goods/fractions/TariffFractionList.svelte'; - import * as m from '$lib/paraglide/messages.js'; + import { m } from '$lib/i18n/messages'; import TariffFractionList from '$lib/components/dashboard/goods/fractions/TariffFractionList.svelte'; - import * as m from '$lib/paraglide/messages.js'; + import { m } from '$lib/i18n/messages'; diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index df3a61e1..972c747b 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -59,6 +59,7 @@ import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte'; import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosListaFacturas } from '$lib/config/shortcuts/dashboard/invoices/list'; + import { m } from '$lib/i18n/messages'; // Los datos iniciales vienen del servidor let { data }: { data: PageData } = $props(); @@ -281,7 +282,7 @@ try { const companyId = companyStore.activeCompany?.id; if (!companyId) { - error = 'No hay compañía seleccionada'; + error = m.invoice_list_toasts_no_company_selected(); return; } @@ -306,7 +307,7 @@ ); if (response.status === 401 || response.status === 403) { - error = 'Sesión expirada. Recargando página...'; + error = m.invoice_list_toasts_session_expired_reloading(); setTimeout(() => { window.location.reload(); }, 2000); @@ -323,7 +324,7 @@ totalItems = response.data.total; } } catch (e) { - error = 'Error cargando más datos'; + error = m.invoice_list_toasts_load_more_error(); console.error('📊 [Invoices] Error loading more:', e); } finally { loading = false; @@ -338,7 +339,7 @@ try { const companyId = companyStore.activeCompany?.id; if (!companyId) { - error = 'No hay compañía seleccionada'; + error = m.invoice_list_toasts_no_company_selected(); return; } @@ -360,7 +361,7 @@ console.error('📊 [Invoices] Error aplicando filtros:', response.error); if (response.status === 401 || response.status === 403) { - error = 'Sesión expirada. Recargando página...'; + error = m.invoice_list_toasts_session_expired_reloading(); setTimeout(() => { window.location.reload(); }, 2000); @@ -376,7 +377,7 @@ totalItems = response.data.total; } } catch (e) { - error = 'Error aplicando filtros'; + error = m.invoice_list_toasts_apply_filters_error(); console.error('📊 [Invoices] Error applying filters:', e); } finally { loading = false; @@ -436,7 +437,7 @@ totalItems = response.data.total; } } catch (e) { - error = 'Error recargando datos'; + error = m.invoice_list_toasts_reload_data_error(); console.error('📊 [Invoices] Error reloading:', e); } finally { loading = false; @@ -468,10 +469,6 @@ return (email || '').trim().toLowerCase(); } - function createRecipientLabel(prefix: string, email: string) { - return `${prefix} - ${email}`; - } - async function loadCoveRecipients() { const companyId = companyStore.activeCompany?.id; if (!companyId) { @@ -516,26 +513,34 @@ const company = companyResult.value.data as Record; addRecipient( company.vu_email, - 'Correo VU de la empresa', - company.name ? `Empresa ${company.name}` : 'Correo de ventanilla única', + m.invoice_list_recipients_company_vu_email(), + company.name + ? m.invoice_list_recipients_company_description({ name: company.name }) + : m.invoice_list_recipients_single_window_email(), 'company' ); addRecipient( company.main_email, - 'Correo principal de la empresa', - company.name ? `Empresa ${company.name}` : 'Correo principal', + m.invoice_list_recipients_company_main_email(), + company.name + ? m.invoice_list_recipients_company_description({ name: company.name }) + : m.invoice_list_recipients_main_email(), 'company' ); addRecipient( company.ind1_email, - 'Correo industrial 1', - company.name ? `Empresa ${company.name}` : 'Correo industrial 1', + m.invoice_list_recipients_company_industrial_1(), + company.name + ? m.invoice_list_recipients_company_description({ name: company.name }) + : m.invoice_list_recipients_company_industrial_1(), 'company' ); addRecipient( company.ind2_email, - 'Correo industrial 2', - company.name ? `Empresa ${company.name}` : 'Correo industrial 2', + m.invoice_list_recipients_company_industrial_2(), + company.name + ? m.invoice_list_recipients_company_description({ name: company.name }) + : m.invoice_list_recipients_company_industrial_2(), 'company' ); } @@ -546,7 +551,7 @@ addRecipient( user.email, fullName || user.username || user.email, - createRecipientLabel('Usuario de la empresa', user.email), + m.invoice_list_recipients_company_user_email({ email: user.email }), 'user' ); } @@ -555,8 +560,8 @@ if (currentUserEmail) { addRecipient( currentUserEmail, - 'Mi correo', - createRecipientLabel('Usuario autenticado', currentUserEmail), + m.invoice_list_recipients_my_email(), + m.invoice_list_recipients_authenticated_user({ email: currentUserEmail }), 'user' ); } @@ -590,14 +595,14 @@ } if (recipientMap.size === 0 && sourceFailures > 0) { - coveRecipientsError = 'No se pudieron cargar los correos disponibles para COVE'; + coveRecipientsError = m.invoice_list_recipients_load_error(); } else if (recipientMap.size === 0) { - coveRecipientsError = 'No hay correos configurados para COVE'; + coveRecipientsError = m.invoice_list_recipients_no_configured(); } } catch (error) { if (requestId !== coveRecipientsRequestId) return; console.error('Error cargando correos de COVE:', error); - coveRecipientsError = 'No se pudieron cargar los correos disponibles para COVE'; + coveRecipientsError = m.invoice_list_recipients_load_error(); } finally { if (requestId === coveRecipientsRequestId) { coveRecipientsLoading = false; @@ -622,7 +627,7 @@ function openCoveDialog() { if (!selectedInvoice || !companyStore.activeCompany) { - toast.info('Selecciona una factura para generar COVE'); + toast.info(m.invoice_list_toasts_select_invoice_for_cove()); return; } @@ -643,7 +648,7 @@ async function handleDownloadPdf(invoice: any) { if (!companyStore.activeCompany) { - toast.error('No hay empresa seleccionada'); + toast.error(m.invoice_list_toasts_no_company_selected()); return; } @@ -657,17 +662,17 @@ // 2. Abrir diálogo de progreso currentTaskId = task_id; currentStatusFunction = invoicesReportsApi.getTaskStatus; - progressDialogTitle = 'Generando PDF de Factura'; + progressDialogTitle = m.invoice_list_progress_title_pdf(); showProgressDialog = true; } catch (error) { console.error(error); - toast.error('No se pudo iniciar la descarga'); + toast.error(m.invoice_list_toasts_download_start_error()); } } async function handleDownloadConsolidated(invoice: any) { if (!companyStore.activeCompany) { - toast.error('No hay empresa seleccionada'); + toast.error(m.invoice_list_toasts_no_company_selected()); return; } @@ -681,30 +686,34 @@ // 2. Abrir diálogo de progreso currentTaskId = task_id; currentStatusFunction = consolidatedReportsApi.getTaskStatus; - progressDialogTitle = 'Generando Consolidado'; + progressDialogTitle = m.invoice_list_progress_title_consolidated(); showProgressDialog = true; } catch (error) { console.error(error); - toast.error('No se pudo iniciar la descarga del consolidado'); + toast.error(m.invoice_list_toasts_consolidated_download_start_error()); } } async function handleDownloadDescargo(invoice: any) { if (!companyStore.activeCompany) { - toast.error('No hay empresa seleccionada'); + toast.error(m.invoice_list_toasts_no_company_selected()); return; } try { // 0. Trigger: Ejecutar Asignación PEPS (FIFO) - toast.info('Calculando asignación PEPS...'); + toast.info(m.invoice_list_toasts_calculating_peps()); const fifoResponse = await dischargeReportsApi.assignFifo(invoice.id); if (fifoResponse.error) { - toast.error('Error al calcular PEPS: ' + fifoResponse.error); + toast.error( + m.invoice_list_toasts_peps_calculation_error_prefix({ + error: String(fifoResponse.error) + }) + ); return; } - toast.success('Cálculo PEPS completado'); + toast.success(m.invoice_list_toasts_peps_calculation_completed()); // 1. Trigger: Iniciar la tarea en Celery const { task_id } = await dischargeReportsApi.triggerPdfGeneration( @@ -715,17 +724,17 @@ // 2. Abrir diálogo de progreso currentTaskId = task_id; currentStatusFunction = dischargeReportsApi.getTaskStatus; - progressDialogTitle = 'Generando Reporte PEPS'; + progressDialogTitle = m.invoice_list_progress_title_descargo(); showProgressDialog = true; } catch (error) { console.error(error); - toast.error('No se pudo iniciar la descarga del reporte PEPS'); + toast.error(m.invoice_list_toasts_peps_report_start_error()); } } async function handleDownloadAvisoConsolidado(invoice: any) { if (!companyStore.activeCompany) { - toast.error('No hay empresa seleccionada'); + toast.error(m.invoice_list_toasts_no_company_selected()); return; } @@ -739,17 +748,17 @@ // 2. Abrir diálogo de progreso currentTaskId = task_id; currentStatusFunction = avisoConsolidadoReportsApi.getTaskStatus; - progressDialogTitle = 'Generando Aviso Consolidado'; + progressDialogTitle = m.invoice_list_progress_title_consolidated(); showProgressDialog = true; } catch (error) { console.error(error); - toast.error('No se pudo iniciar la descarga del Aviso Consolidado'); + toast.error(m.invoice_list_toasts_aviso_consolidado_start_error()); } } async function handleDownloadPackingList(invoice: any) { if (!companyStore.activeCompany) { - toast.error('No hay empresa seleccionada'); + toast.error(m.invoice_list_toasts_no_company_selected()); return; } @@ -764,22 +773,22 @@ currentTaskId = task_id; // Use the specific status function for Packing List currentStatusFunction = invoicesReportsApi.getPackingListTaskStatus; - progressDialogTitle = 'Generando Packing List'; + progressDialogTitle = m.invoice_list_progress_title_packing_list(); showProgressDialog = true; } catch (error) { console.error(error); - toast.error('No se pudo iniciar la descarga del Packing List'); + toast.error(m.invoice_list_toasts_packing_list_start_error()); } } async function handleInterfaceAgenteAduanal(invoice: any) { if (!companyStore.activeCompany) { - toast.error('No hay empresa seleccionada'); + toast.error(m.invoice_list_toasts_no_company_selected()); return; } if (invoice.operation_type !== 'imp') { - toast.info('La interfaz rápida solo está disponible para facturas de Importación'); + toast.info(m.invoice_list_toasts_fast_interface_import_only()); return; } @@ -801,12 +810,12 @@ currentTaskId = res.task_id; // Use the specific status and download functions for WINSAAI currentStatusFunction = reportsWinsaaiApi.invoices.getTaskStatus; - progressDialogTitle = 'Generando Reporte WINSAAI'; + progressDialogTitle = m.invoice_list_progress_title_winsaai(); showProgressDialog = true; } } catch (error) { console.error(error); - toast.error('No se pudo iniciar la generación de Interface Agente Aduanal'); + toast.error(m.invoice_list_toasts_customs_broker_interface_start_error()); } } @@ -825,7 +834,7 @@ a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); - toast.success('PDF Descargado exitosamente'); + toast.success(m.invoice_list_toasts_pdf_download_success()); } else if (result.cove_number) { // Resultado de generación de COVE const baseMsg = `COVE generado correctamente: ${result.cove_number}`; @@ -836,7 +845,7 @@ reloadData(); } else { // Resultado de procesamiento de factura (import process/revert) - toast.success('Factura procesada correctamente'); + toast.success(m.invoice_list_toasts_invoice_processed_success()); reloadData(); } } else if (result.status === 'external_queued') { @@ -844,7 +853,7 @@ // un mensaje tipo "Factura COVE iniciada para: ... Use el task_id para consultar el estado." const baseMsg = result.message || - 'Factura COVE iniciada en Ventanilla Única. Use el task_id para consultar el estado.'; + m.invoice_list_toasts_cove_external_queued_default(); const taskInfo = result.external_task_id ? ` (task_id: ${result.external_task_id})` : ''; toast.success(baseMsg + taskInfo); } else if (result.status === 'validation_error') { @@ -853,8 +862,17 @@ .slice(0, 3) .map((e: any) => `• ${e.message}`) .join('\n'); - const extra = errors.length > 3 ? `\n...y ${errors.length - 3} más` : ''; - toast.error(`${errors.length} error(es) de validación:\n${preview}${extra}`); + const extra = + errors.length > 3 + ? m.invoice_list_toasts_validation_extra_more({ count: String(errors.length - 3) }) + : ''; + toast.error( + m.invoice_list_toasts_validation_error_count({ + count: String(errors.length), + preview, + extra + }) + ); } else if ( typeof result.message === 'string' && result.message.includes('Factura COVE iniciada para') @@ -865,11 +883,15 @@ const taskInfo = result.external_task_id ? ` (task_id: ${result.external_task_id})` : ''; toast.success(result.message + taskInfo); } else { - toast.error('El worker reportó un error: ' + (result.message || 'Desconocido')); + toast.error( + m.invoice_list_toasts_worker_error_prefix({ + error: String(result.message || m.invoice_table_not_available_short()) + }) + ); } } catch (e) { console.error('Error al procesar resultado:', e); - toast.error('Error al procesar el resultado de la tarea'); + toast.error(m.invoice_list_toasts_task_result_process_error()); } } @@ -893,7 +915,7 @@ : `/dashboard/invoices/edit/${selectedInvoice.id}`; window.location.href = url; } else { - toast.info('Seleccione una factura para editar'); + toast.info(m.invoice_list_toasts_select_invoice_to_edit()); } } @@ -908,7 +930,7 @@ setTimeout(() => row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 50); return; } - toast.info('No hay filas en la tabla'); + toast.info(m.invoice_list_toasts_no_table_rows()); } /** Teclado: foco en el primer control habilitado de la barra de acciones fija */ @@ -955,7 +977,7 @@ function openReportesMenuFromShortcut() { if (selectedInvoiceIds.length !== 1) { - toast.info('Seleccione una factura para reportes'); + toast.info(m.invoice_list_toasts_select_invoice_for_reports()); return; } reportesMenuOpen = true; @@ -964,7 +986,7 @@ function openMasAccionesMenuFromShortcut() { if (selectedInvoiceIds.length !== 1) { - toast.info('Seleccione una factura para más acciones'); + toast.info(m.invoice_list_toasts_select_invoice_for_more_actions()); return; } masAccionesMenuOpen = true; @@ -977,7 +999,7 @@ function footerDesactualizar() { if (selectedInvoiceIds.length !== 1) { - toast.info('Seleccione una factura para desactualizar'); + toast.info(m.invoice_list_toasts_select_invoice_to_revert()); return; } isRevertConfirmOpen = true; @@ -985,7 +1007,7 @@ function footerVerDetalles() { if (selectedInvoiceIds.length !== 1) { - toast.info('Seleccione una factura para ver detalles'); + toast.info(m.invoice_list_toasts_select_invoice_for_details()); return; } showDetailsDialog = true; @@ -993,7 +1015,7 @@ function footerInterfaceAgenteAduanal() { if (!selectedInvoice) { - toast.info('Seleccione una factura'); + toast.info(m.invoice_list_toasts_select_invoice()); return; } void handleInterfaceAgenteAduanal(selectedInvoice); @@ -1001,7 +1023,7 @@ function footerEliminar() { if (selectedInvoiceIds.length === 0) { - toast.info('Seleccione al menos una factura para eliminar'); + toast.info(m.invoice_list_toasts_select_at_least_one_invoice_to_delete()); return; } showDeleteDialog = true; @@ -1036,11 +1058,11 @@ manejarEditar: handleEditSelected, manejarDescargarPdf: () => { if (selectedInvoice) handleDownloadPdf(selectedInvoice); - else toast.info('Seleccione una factura para descargar PDF'); + else toast.info(m.invoice_list_toasts_select_invoice_for_pdf()); }, manejarDescargarConsolidado: () => { if (selectedInvoice) handleDownloadConsolidated(selectedInvoice); - else toast.info('Seleccione una factura para descargar consolidado'); + else toast.info(m.invoice_list_toasts_select_invoice_for_consolidated()); }, irTemporal: () => goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM'), irDefinitiva: () => goto('/dashboard/invoices?operation_type=imp&invoice_type=DEF'), @@ -1066,7 +1088,7 @@ async function handleUpdateStatus(status: boolean) { if (!selectedInvoice || !companyStore.activeCompany) { - toast.info('Seleccione una factura para cambiar su estatus'); + toast.info(m.invoice_list_toasts_select_invoice_to_change_status()); return; } @@ -1080,15 +1102,24 @@ if (response.error) { toast.error( - `Error al ${status ? 'actualizar' : 'desactualizar'} factura: ${response.error}` + m.invoice_list_toasts_update_status_error_prefix({ + action: status + ? m.invoice_list_toasts_status_action_update() + : m.invoice_list_toasts_status_action_revert(), + error: String(response.error) + }) ); } else { - toast.success(`Factura ${status ? 'actualizada' : 'desactualizada'} correctamente`); + toast.success( + status + ? m.invoice_list_toasts_status_updated_success() + : m.invoice_list_toasts_status_reverted_success() + ); reloadData(); } } catch (e) { console.error('Error updating status:', e); - toast.error('Error inesperado al cambiar el estatus'); + toast.error(m.invoice_list_toasts_update_status_unexpected_error()); } finally { loading = false; } @@ -1096,7 +1127,7 @@ async function handleProcessInvoice() { if (!selectedInvoice || !companyStore.activeCompany) { - toast.info('Selecciona una factura para procesar'); + toast.info(m.invoice_list_toasts_select_invoice_to_process()); return; } @@ -1107,18 +1138,20 @@ ); if (response.error) { - toast.error(`Error al iniciar el proceso: ${response.error}`); + toast.error( + m.invoice_list_toasts_process_start_error_prefix({ error: String(response.error) }) + ); return; } currentTaskId = response.data!.task_id; currentStatusFunction = invoicesApi.getProcessStatus; - progressDialogTitle = 'Procesando factura'; + progressDialogTitle = m.invoice_list_progress_title_process_invoice(); progressDialogSteps = invoiceProcessSteps; showProgressDialog = true; } catch (e) { console.error('Error al iniciar proceso de factura:', e); - toast.error('No se pudo iniciar el proceso'); + toast.error(m.invoice_list_toasts_process_start_error()); } } @@ -1133,30 +1166,32 @@ ); if (response.error) { - toast.error(`Error al iniciar la des-actualización: ${response.error}`); + toast.error( + m.invoice_list_toasts_revert_start_error_prefix({ error: String(response.error) }) + ); return; } currentTaskId = response.data!.task_id; currentStatusFunction = invoicesApi.getRevertStatus; - progressDialogTitle = 'Des-actualizando factura'; + progressDialogTitle = m.invoice_list_progress_title_revert_invoice(); progressDialogSteps = invoiceRevertSteps; showProgressDialog = true; } catch (e) { console.error('Error al iniciar des-actualización de factura:', e); - toast.error('No se pudo iniciar la des-actualización'); + toast.error(m.invoice_list_toasts_revert_start_error()); } } async function handleGenerateCove(recipientEmail = coveRecipientEmail) { if (!selectedInvoice || !companyStore.activeCompany) { - toast.info('Selecciona una factura para generar COVE'); + toast.info(m.invoice_list_toasts_select_invoice_for_cove()); return; } const selectedRecipientEmail = normalizeEmail(recipientEmail); if (!selectedRecipientEmail) { - toast.error('Selecciona un correo para enviar el COVE'); + toast.error(m.invoice_list_toasts_select_recipient_email_for_cove()); return; } @@ -1166,7 +1201,9 @@ try { const elig = await invoicesApi.checkCoveEligibility(selectedInvoice.id, companyId); if (elig.error) { - toast.error(`No se pudo validar elegibilidad COVE: ${elig.error}`); + toast.error( + m.invoice_list_toasts_cove_eligibility_error_prefix({ error: String(elig.error) }) + ); return; } if (elig.data && !elig.data.can_generate) { @@ -1174,13 +1211,13 @@ elig.data.reasons ?.map((r) => `• ${r.message}`) .join('\n') || - 'La factura no cumple los requisitos para generar COVE'; + m.invoice_list_toasts_cove_requirements_not_met(); toast.error(msg); return; } } catch (e) { console.error('Error verificando elegibilidad COVE:', e); - toast.error('No se pudo verificar si la factura puede generar COVE'); + toast.error(m.invoice_list_toasts_cove_verification_error()); return; } @@ -1193,7 +1230,9 @@ ); if (response.error) { - toast.error(`Error al iniciar generación de COVE: ${response.error}`); + toast.error( + m.invoice_list_toasts_cove_start_error_prefix({ error: String(response.error) }) + ); return; } @@ -1201,33 +1240,33 @@ currentTaskId = response.data!.task_id; currentStatusFunction = invoicesApi.getCoveStatus; - progressDialogTitle = 'Validando datos para COVE'; + progressDialogTitle = m.invoice_list_progress_title_validate_cove(); // Para COVE queremos UNA sola barra de progreso que refleje // directamente el porcentaje reportado por VU, sin pasos fijos. progressDialogSteps = null; showProgressDialog = true; } catch (e) { console.error('Error al iniciar generación de COVE:', e); - toast.error('No se pudo iniciar la generación de COVE'); + toast.error(m.invoice_list_toasts_cove_start_error()); } } // Pasos del procesamiento de factura (deben coincidir con el backend) const invoiceProcessSteps = [ - { label: 'Cargando factura', percent: 5 }, - { label: 'Validando datos de la factura', percent: 10 }, - { label: 'Revisando clases y tipo de cambio', percent: 30 }, - { label: 'Calculando valores por partida', percent: 50 }, - { label: 'Validando partidas', percent: 70 }, - { label: 'Validando cupos de Regla Octava', percent: 85 }, - { label: 'Actualizando totales', percent: 95 } + { label: m.invoice_list_steps_load_invoice(), percent: 5 }, + { label: m.invoice_list_steps_validate_invoice_data(), percent: 10 }, + { label: m.invoice_list_steps_review_classes_exchange_rate(), percent: 30 }, + { label: m.invoice_list_steps_calculate_item_values(), percent: 50 }, + { label: m.invoice_list_steps_validate_items(), percent: 70 }, + { label: m.invoice_list_steps_validate_rule8_quotas(), percent: 85 }, + { label: m.invoice_list_steps_update_totals(), percent: 95 } ]; const invoiceRevertSteps = [ - { label: 'Cargando factura', percent: 5 }, - { label: 'Validando estatus de la factura', percent: 10 }, - { label: 'Verificando saldos de partidas', percent: 40 }, - { label: 'Confirmando cambios', percent: 95 } + { label: m.invoice_list_steps_load_invoice(), percent: 5 }, + { label: m.invoice_list_steps_validate_invoice_status(), percent: 10 }, + { label: m.invoice_list_steps_verify_item_balances(), percent: 40 }, + { label: m.invoice_list_steps_confirm_changes(), percent: 95 } ]; const invoiceCoveSteps = [ @@ -1239,15 +1278,15 @@ ]; // Opciones de tipo de operación para el filtro - const operationTypeOptions = [ - { value: '', label: 'Todas' }, - { value: 'imp', label: 'Importación' }, - { value: 'exp', label: 'Exportación' } - ]; + const operationTypeOptions = $derived.by(() => [ + { value: '', label: m.invoice_list_operation_types_all() }, + { value: 'imp', label: m.invoice_list_operation_types_import() }, + { value: 'exp', label: m.invoice_list_operation_types_export() } + ]); // Todas las opciones de tipo de factura con su operación correspondiente const allInvoiceTypeOptions = $derived(() => { - const options = [{ value: '', label: 'Todas', operation: 'both' }]; + const options = [{ value: '', label: m.invoice_list_operation_types_all(), operation: 'both' }]; if (data.invoiceTypes) { data.invoiceTypes.forEach((type: any) => { options.push({ @@ -1304,7 +1343,7 @@ showProgressDialog = true; } catch (error) { console.error(error); - toast.error('No se pudo iniciar la descarga'); + toast.error(m.invoice_list_toasts_download_start_error()); } } @@ -1314,22 +1353,24 @@ href="#invoice-list-footer" class="sr-only focus:fixed focus:left-4 focus:top-20 focus:z-[60] focus:m-0 focus:inline-flex focus:h-auto focus:w-auto focus:overflow-visible focus:whitespace-normal focus:[clip-path:none] focus:rounded-md focus:border focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:shadow-md focus:outline-none focus:ring-2 focus:ring-ring" > - Ir a acciones de factura + {m.invoice_list_skip_to_actions()}
-

Facturas

-

Gestiona las facturas del sistema

+

{m.invoice_list_header_title()}

+

{m.invoice_list_header_description()}

@@ -1337,20 +1378,22 @@ id="filter-invoice-type" bind:value={filters.invoice_type} class="flex h-9 w-[220px] rounded-md border border-input bg-card px-3 py-1 text-sm shadow-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" - title="Tipo de Factura" + title={m.invoice_list_filters_invoice_type_label()} > {#each invoiceTypeOptions() as option} - + {/each}
@@ -1370,30 +1413,30 @@
- Listado de Facturas + {m.invoice_list_card_invoice_list_title()}
-
@@ -1416,9 +1459,11 @@
- Mostrando {allItems.length} de {totalItems} registros + {m.invoice_list_summary_showing()} {allItems.length} {m.invoice_list_summary_of()} {totalItems} {m.invoice_list_summary_records()} - Filtros activos: {Object.values(filters).filter((value) => value !== '').length} + {m.invoice_list_filters_active_filters()}: {Object.values(filters).filter((value) => value !== '').length}
@@ -1426,9 +1471,9 @@ - Generar COVE + {m.invoice_list_cove_dialog_title()} - Selecciona el correo destinatario para la factura + {m.invoice_list_cove_dialog_description_prefix()} {selectedInvoice?.invoice_number}. @@ -1436,7 +1481,7 @@
- +
- Destino COVE + {m.invoice_list_cove_dialog_destination()}
- {selectedCoveRecipient?.label || 'Selecciona un correo'} + {selectedCoveRecipient?.label || m.invoice_list_cove_dialog_select_email()}
- {selectedCoveRecipient?.email || 'Se enviará al correo del usuario que generó la factura'} + {selectedCoveRecipient?.email || m.invoice_list_cove_dialog_fallback_email()}
{/snippet} - + {#if coveRecipientsLoading}
- Cargando correos disponibles... + {m.invoice_list_cove_dialog_loading_emails()}
{:else if coveRecipientOptions.length === 0}
- No hay correos disponibles para COVE. + {m.invoice_list_cove_dialog_no_emails()}
{:else} {#each coveRecipientOptions as recipient} @@ -1483,7 +1528,7 @@ {recipient.label} {#if selected} - Seleccionado + {m.invoice_list_cove_dialog_selected_badge()} {/if} @@ -1505,14 +1550,14 @@
@@ -1528,20 +1573,20 @@ steps={ progressDialogSteps ? progressDialogSteps - : progressDialogTitle === 'Procesando factura' + : progressDialogTitle === m.invoice_list_progress_title_process_invoice() ? invoiceProcessSteps - : progressDialogTitle === 'Des-actualizando factura' + : progressDialogTitle === m.invoice_list_progress_title_revert_invoice() ? invoiceRevertSteps : [] } completeMessage={ - progressDialogTitle === 'Procesando factura' - ? 'Factura procesada correctamente' - : progressDialogTitle === 'Des-actualizando factura' - ? 'Factura des-actualizada correctamente' - : progressDialogTitle === 'Validando datos para COVE' - ? 'Validación de COVE completada' - : 'Proceso completado' + progressDialogTitle === m.invoice_list_progress_title_process_invoice() + ? m.invoice_list_progress_complete_processed() + : progressDialogTitle === m.invoice_list_progress_title_revert_invoice() + ? m.invoice_list_progress_complete_reverted() + : progressDialogTitle === m.invoice_list_progress_title_validate_cove() + ? m.invoice_list_progress_complete_cove_validation() + : m.invoice_list_progress_complete_default() } /> @@ -1549,17 +1594,19 @@ - Des-actualizar Factura de {selectedInvoice?.operation_type === 'exp' ? 'Exportación' : 'Importación'} + {selectedInvoice?.operation_type === 'exp' + ? m.invoice_list_dialogs_revert_title_export() + : m.invoice_list_dialogs_revert_title_import()} - Se va a des-actualizar la factura {selectedInvoice?.invoice_number}. - Esta operación revertirá los registros de saldos/descargos generados al procesar la factura. - ¿Desea continuar? + {m.invoice_list_dialogs_revert_description_intro()} {selectedInvoice?.invoice_number}. + {m.invoice_list_dialogs_revert_description_warning()} + {m.invoice_list_dialogs_revert_description_question()} - Cancelar - Continuar + {m.invoice_list_actions_cancel()} + {m.invoice_list_actions_continue()} @@ -1567,16 +1614,17 @@ - Sistema de Control de Aduanas e Inventarios + {m.invoice_list_dialogs_winsaai_title()} - A la Factura {selectedInvoice?.invoice_number} de tipo - {selectedInvoice?.document_type} se le ha asignado el proceso Generación del - Archivo WINSAAI. ¿Desea Continuar o Cancelar? + {m.invoice_list_dialogs_winsaai_description_intro()} {selectedInvoice?.invoice_number} + {m.invoice_list_dialogs_winsaai_of_type()} {selectedInvoice?.document_type} + {m.invoice_list_dialogs_winsaai_description_process()} + {m.invoice_list_dialogs_winsaai_description_question()} - Cancelar - Continuar + {m.invoice_list_actions_cancel()} + {m.invoice_list_actions_continue()} @@ -1601,22 +1649,22 @@ class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent" onclick={() => { showVuSubmenu = false; - toast.info('Consulta VU - Próximamente'); + toast.info(m.invoice_list_submenu_consult_soon()); }} > - Consulta + {m.invoice_list_footer_vu_consult()}
{/if} @@ -1653,7 +1701,7 @@ diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte index 5da5e0ca..6ea080a8 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte @@ -41,6 +41,7 @@ import { obtenerAtajosEdicionFactura } from '$lib/config/shortcuts/dashboard/invoices/edit'; import { api } from '$lib/api'; import PrerequisitesModal from '$lib/components/dashboard/PrerequisitesModal.svelte'; + import { m } from '$lib/i18n/messages'; // Cargar companyStore solo en el cliente - no usamos sidebar en esta página let companyStore: any = $state(undefined); @@ -731,7 +732,9 @@ try { // Validar ID si es edición if (!data.isCreate && !invoiceId) { - toast.error('Error interrrno: No se encuentra el ID de la factura para actualizar.'); + toast.error(m.invoice_edit_page_save_error_prefix(), { + description: 'Error interrrno: No se encuentra el ID de la factura para actualizar.' + }); saving = false; return; } @@ -768,13 +771,13 @@ throw error; } - toast.success('Todos los cambios se guardaron correctamente'); + toast.success(m.invoice_edit_saved_success()); } catch (e: any) { console.error('Error saving all:', e); // Handle 401 Unauthorized specifically for session expiration if (e.response?.status === 401 || (e instanceof Error && e.message.includes('401'))) { - toast.error('Sesión expirada. Recargando página...'); + toast.error(m.invoice_edit_page_session_expired()); setTimeout(() => { window.location.reload(); }, 1500); @@ -802,7 +805,7 @@ // General error handling with detailed backend messages const detail = e.response?.data?.errors?.[0]?.message; - const mainMsg = e.response?.data?.message || e.message || 'Error al guardar la factura'; + const mainMsg = e.response?.data?.message || e.message || m.invoice_edit_page_save_error_prefix(); // If there are validation errors, show them in detail if ( @@ -817,7 +820,7 @@ description: errorList }); } else { - const errorMessage = e instanceof Error ? e.message : 'Error al guardar los cambios'; + const errorMessage = e instanceof Error ? e.message : m.invoice_edit_page_save_changes_error(); // Intercept exchange rate error if ( @@ -851,7 +854,7 @@ }); } else { toast.error(errorMessage, { - description: 'Revisa la consola para más detalles' + description: m.invoice_edit_page_console_hint() }); } } @@ -927,7 +930,7 @@ continuationExists = !!settings.continuationFormData; toast.info( - 'Valores predeterminados cargados para ' + InvoiceTopFieldsFormData.invoice_type + `${m.invoice_edit_form_loading_defaults_prefix()} ${InvoiceTopFieldsFormData.invoice_type}` ); } } catch (error) { @@ -988,28 +991,28 @@ {@const invoiceTypeInfo = invoiceType ? data.invoiceTypes?.find((t) => t.key === invoiceType) : null} - Nueva Factura + {m.invoice_edit_new_title()} {#if invoiceTypeInfo} - {invoiceTypeInfo.description} {/if} {:else} - Factura #{data.invoice.id} + {m.invoice_edit_page_invoice_prefix()}{data.invoice.id} {/if} {operationTypeText} {#if data.isCreate} - Borrador + {m.invoice_edit_draft_badge()} {/if}

{#if !data.isCreate && data.invoice.invoice_number} - Número: {data.invoice.invoice_number} + {m.invoice_edit_invoice_number_prefix()} {data.invoice.invoice_number} {:else} - Edita los detalles de la factura + {m.invoice_edit_edit_details()} {/if}

@@ -1182,24 +1185,24 @@ > - General + {m.invoice_edit_tabs_general()} - Observaciones + {m.invoice_edit_tabs_observations()} - Partidas + {m.invoice_edit_tabs_items()} - Otros + {m.invoice_edit_tabs_others()} {#if invoiceType !== 'MEX'} - Cont. + {m.invoice_edit_tabs_continuation()} {/if} @@ -1209,15 +1212,15 @@
diff --git a/frontend/src/routes/demo/paraglide/+page.svelte b/frontend/src/routes/demo/paraglide/+page.svelte index 04d3480c..f7d56840 100644 --- a/frontend/src/routes/demo/paraglide/+page.svelte +++ b/frontend/src/routes/demo/paraglide/+page.svelte @@ -2,7 +2,7 @@ import { setLocale } from '$lib/paraglide/runtime'; import { page } from '$app/state'; import { goto } from '$app/navigation'; - import { m } from '$lib/paraglide/messages.js'; + import { m } from '$lib/i18n/messages'; From 80533c1bf70989122d797f7effc1370fd6e5fde5 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 23 Apr 2026 08:35:41 -0600 Subject: [PATCH 070/167] feature/campos-obligatorios-relativos --- .../invoices/edit/items/fa/packages-section.svelte | 12 ++++++++++-- frontend/src/lib/utils/items-logic.ts | 14 ++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte index 3f87d585..51111592 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte @@ -125,6 +125,8 @@ customs.advalorem_american = fraction.ad_valorem; } } + + const isPackageRequired = $derived(!!quantities.package_id || (Number(quantities.package_quantity) > 0));
@@ -135,12 +137,18 @@
- +
- +
{ - const fieldName = humanizeFieldPath(err.field || ''); - const msg = formatFriendlyFieldMessage(fieldName, err.message || 'error de validación', err.code); + const path = err.field || ''; + const fieldName = humanizeFieldPath(path); + const msg = formatFriendlyFieldMessage(fieldName, err.message || 'error de validación', err.code, path.replace(/^line\[\d+\]\./, '')); return `• ${fieldName}: ${msg}`; }); @@ -393,7 +395,7 @@ export function formatItemError(error: any): string { .join('.'); const fieldName = humanizeFieldPath(locPath); - const msg = formatFriendlyFieldMessage(fieldName, err.msg || 'error de validación', err.type); + const msg = formatFriendlyFieldMessage(fieldName, err.msg || 'error de validación', err.type, locPath.replace(/^line\[\d+\]\./, '')); return `• ${fieldName}: ${msg}`; }); From 29c305cd2c602f4d359a618aa6f248be6cf9ae61 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 23 Apr 2026 14:28:56 -0600 Subject: [PATCH 071/167] feaature/catalogo-empresa-mejoras --- .../6a7b8c9d0e1f_company_ui_legacy_fields.py | 189 +++++++++++ .../a76/general_catalogs/company/dto.py | 86 +++-- .../a76/general_catalogs/company/models.py | 2 + .../a76/general_catalogs/company/service.py | 15 +- .../company/submodels/certification.py | 29 +- .../dashboard/a76/general_catalogs/company.ts | 73 +++-- frontend/src/lib/utils/date.ts | 57 ++++ .../edit/[[id]]/+page.svelte | 294 ++++++++++++------ 8 files changed, 566 insertions(+), 179 deletions(-) create mode 100644 backend/alembic/versions/6a7b8c9d0e1f_company_ui_legacy_fields.py create mode 100644 frontend/src/lib/utils/date.ts diff --git a/backend/alembic/versions/6a7b8c9d0e1f_company_ui_legacy_fields.py b/backend/alembic/versions/6a7b8c9d0e1f_company_ui_legacy_fields.py new file mode 100644 index 00000000..6dac94ce --- /dev/null +++ b/backend/alembic/versions/6a7b8c9d0e1f_company_ui_legacy_fields.py @@ -0,0 +1,189 @@ +"""company certification refactor + legacy ui fields + +Revision ID: 6a7b8c9d0e1f +Revises: 1f4ba75eaa35 +Create Date: 2026-04-23 11:10:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "6a7b8c9d0e1f" +down_revision: Union[str, Sequence[str], None] = "1f4ba75eaa35" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +SCHEMA = "a76" +TABLE = "company_certification" + + +def upgrade() -> None: + # Rename Annex 31 columns to Annex 30 names. + op.alter_column(TABLE, "annex31_certification_date", new_column_name="annex30_certification_date", schema=SCHEMA) + op.alter_column(TABLE, "annex31_certification_number", new_column_name="annex30_certification_number", schema=SCHEMA) + op.alter_column(TABLE, "annex31_modality", new_column_name="annex30_modality", schema=SCHEMA) + op.alter_column(TABLE, "annex31_company_type", new_column_name="annex30_company_type", schema=SCHEMA) + op.alter_column(TABLE, "annex31_renewal_date", new_column_name="annex30_renewal_date", schema=SCHEMA) + op.alter_column(TABLE, "annex31_final_certification_date", new_column_name="annex30_final_certification_date", schema=SCHEMA) + + op.alter_column( + TABLE, + "annex30_modality", + existing_type=sa.String(length=50), + type_=sa.String(length=3), + schema=SCHEMA, + ) + + # Convert legacy integer dates (YYYYMMDD or 0) to DATE. + date_columns = [ + "certified_company_start_date", + "certified_company_end_date", + "annex30_certification_date", + "annex30_renewal_date", + "annex30_final_certification_date", + ] + for col in date_columns: + op.alter_column( + TABLE, + col, + existing_type=sa.Integer(), + type_=sa.Date(), + schema=SCHEMA, + postgresql_using=( + f"CASE " + f"WHEN {col} IS NULL OR {col} = 0 THEN NULL " + f"WHEN length({col}::text) = 8 THEN to_date({col}::text, 'YYYYMMDD') " + f"ELSE NULL END" + ), + ) + + # Convert legacy S/N and 0/1 flags to native booleans. + op.alter_column( + TABLE, + "is_certified_company", + existing_type=sa.String(length=1), + type_=sa.Boolean(), + schema=SCHEMA, + postgresql_using=( + "CASE " + "WHEN is_certified_company IS NULL THEN NULL " + "WHEN upper(trim(is_certified_company)) IN ('S','SI','1','T','TRUE','Y','YES') THEN true " + "WHEN upper(trim(is_certified_company)) IN ('N','NO','0','F','FALSE') THEN false " + "ELSE NULL END" + ), + ) + + op.alter_column( + TABLE, + "is_oea_company", + existing_type=sa.SmallInteger(), + type_=sa.Boolean(), + schema=SCHEMA, + postgresql_using="CASE WHEN is_oea_company IS NULL THEN NULL WHEN is_oea_company = 1 THEN true ELSE false END", + ) + + op.alter_column( + TABLE, + "neec_company", + existing_type=sa.Integer(), + type_=sa.Boolean(), + schema=SCHEMA, + postgresql_using="CASE WHEN neec_company IS NULL THEN NULL WHEN neec_company = 1 THEN true ELSE false END", + ) + + # Additional legacy UI fields. + op.add_column( + "company", + sa.Column("fiscal_deposit", sa.Boolean(), nullable=True, server_default=sa.text("false")), + schema=SCHEMA, + ) + op.add_column( + "company", + sa.Column( + "generate_barcodes_with_fiel", + sa.Boolean(), + nullable=True, + server_default=sa.text("false"), + ), + schema=SCHEMA, + ) + + op.add_column( + "company_certification", + sa.Column("is_seciit_company", sa.Boolean(), nullable=True, server_default=sa.text("false")), + schema=SCHEMA, + ) + + +def downgrade() -> None: + # Remove additional legacy UI fields. + op.drop_column("company_certification", "is_seciit_company", schema=SCHEMA) + op.drop_column("company", "generate_barcodes_with_fiel", schema=SCHEMA) + op.drop_column("company", "fiscal_deposit", schema=SCHEMA) + + # Restore booleans to legacy flag formats. + op.alter_column( + TABLE, + "is_certified_company", + existing_type=sa.Boolean(), + type_=sa.String(length=1), + schema=SCHEMA, + postgresql_using="CASE WHEN is_certified_company IS NULL THEN NULL WHEN is_certified_company THEN 'S' ELSE 'N' END", + ) + + op.alter_column( + TABLE, + "is_oea_company", + existing_type=sa.Boolean(), + type_=sa.SmallInteger(), + schema=SCHEMA, + postgresql_using="CASE WHEN is_oea_company IS NULL THEN NULL WHEN is_oea_company THEN 1 ELSE 0 END", + ) + + op.alter_column( + TABLE, + "neec_company", + existing_type=sa.Boolean(), + type_=sa.Integer(), + schema=SCHEMA, + postgresql_using="CASE WHEN neec_company IS NULL THEN NULL WHEN neec_company THEN 1 ELSE 0 END", + ) + + # Restore DATE columns to integer YYYYMMDD format. + date_columns = [ + "certified_company_start_date", + "certified_company_end_date", + "annex30_certification_date", + "annex30_renewal_date", + "annex30_final_certification_date", + ] + for col in date_columns: + op.alter_column( + TABLE, + col, + existing_type=sa.Date(), + type_=sa.Integer(), + schema=SCHEMA, + postgresql_using=f"CASE WHEN {col} IS NULL THEN NULL ELSE to_char({col}, 'YYYYMMDD')::integer END", + ) + + op.alter_column( + TABLE, + "annex30_modality", + existing_type=sa.String(length=3), + type_=sa.String(length=50), + schema=SCHEMA, + ) + + # Rename Annex 30 columns back to Annex 31. + op.alter_column(TABLE, "annex30_certification_date", new_column_name="annex31_certification_date", schema=SCHEMA) + op.alter_column(TABLE, "annex30_certification_number", new_column_name="annex31_certification_number", schema=SCHEMA) + op.alter_column(TABLE, "annex30_modality", new_column_name="annex31_modality", schema=SCHEMA) + op.alter_column(TABLE, "annex30_company_type", new_column_name="annex31_company_type", schema=SCHEMA) + op.alter_column(TABLE, "annex30_renewal_date", new_column_name="annex31_renewal_date", schema=SCHEMA) + op.alter_column(TABLE, "annex30_final_certification_date", new_column_name="annex31_final_certification_date", schema=SCHEMA) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/dto.py b/backend/api/v1/modules/a76/general_catalogs/company/dto.py index cef32735..d9153034 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/dto.py @@ -3,10 +3,10 @@ DTOs (Data Transfer Objects) para módulo de empresa Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS """ -from datetime import datetime +from datetime import date, datetime from typing import Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class CompanyCreateDTO(BaseModel): @@ -64,6 +64,8 @@ class CompanyCreateDTO(BaseModel): ) previous_code: Optional[int] = Field(None, description="Previous code") is_service_company: Optional[bool] = Field(None, description="Is service company") + fiscal_deposit: Optional[bool] = Field(None, description="Fiscal deposit") + generate_barcodes_with_fiel: Optional[bool] = Field(None, description="Generate barcodes with FIEL") # Client and subassembly client_name: Optional[str] = Field(None, max_length=300, description="Client name") @@ -91,18 +93,19 @@ class CompanyCreateDTO(BaseModel): sector3: Optional[str] = Field(None, max_length=5) # Certification (CompanyCertification flattened) - is_certified_company: Optional[str] = Field(None, max_length=1) + is_certified_company: Optional[bool] = None certified_company_registration: Optional[str] = Field(None, max_length=40) - certified_company_start_date: Optional[int] = None - certified_company_end_date: Optional[int] = None - annex31_certification_date: Optional[int] = None - annex31_certification_number: Optional[str] = Field(None, max_length=50) - annex31_modality: Optional[str] = Field(None, max_length=50) - annex31_company_type: Optional[str] = Field(None, max_length=50) - annex31_renewal_date: Optional[int] = None - annex31_final_certification_date: Optional[int] = None - is_oea_company: Optional[int] = None - neec_company: Optional[int] = None + certified_company_start_date: Optional[date] = None + certified_company_end_date: Optional[date] = None + annex30_certification_date: Optional[date] = None + annex30_certification_number: Optional[str] = Field(None, max_length=50) + annex30_modality: Optional[str] = Field(None, max_length=3) + annex30_company_type: Optional[str] = Field(None, max_length=50) + annex30_renewal_date: Optional[date] = None + annex30_final_certification_date: Optional[date] = None + is_oea_company: Optional[bool] = None + neec_company: Optional[bool] = None + is_seciit_company: Optional[bool] = None # Addresses (Flattened) # Main @@ -223,6 +226,38 @@ class CompanyCreateDTO(BaseModel): model_config = ConfigDict(from_attributes=True) + @field_validator( + "certified_company_start_date", + "certified_company_end_date", + "annex30_certification_date", + "annex30_renewal_date", + "annex30_final_certification_date", + mode="before", + ) + @classmethod + def validate_dates(cls, value): + """Acepta exclusivamente DD/MM/YYYY para payload string.""" + if value in (None, ""): + return None + if isinstance(value, date): + return value + if isinstance(value, str): + try: + return datetime.strptime(value, "%d/%m/%Y").date() + except ValueError as exc: + raise ValueError("Date must be in DD/MM/YYYY format") from exc + raise ValueError("Date must be in DD/MM/YYYY format") + + @field_validator("annex30_modality", mode="before") + @classmethod + def validate_annex30_modality(cls, value): + if value in (None, ""): + return None + normalized = str(value).strip().upper() + if normalized not in {"A", "AA", "AAA"}: + raise ValueError("Annex 30 modality must be one of: A, AA, AAA") + return normalized + class CompanyUpdateDTO(CompanyCreateDTO): """DTO para actualizar una empresa""" @@ -259,6 +294,8 @@ class CompanyResponseDTO(BaseModel): # Configuration logo: Optional[str] = None has_express_line: Optional[bool] = None + fiscal_deposit: Optional[bool] = None + generate_barcodes_with_fiel: Optional[bool] = None order_format_type: Optional[str] = None previous_code: Optional[int] = None is_service_company: Optional[bool] = None @@ -286,18 +323,19 @@ class CompanyResponseDTO(BaseModel): sector3: Optional[str] = None # Certification - is_certified_company: Optional[str] = None + is_certified_company: Optional[bool] = None certified_company_registration: Optional[str] = None - certified_company_start_date: Optional[int] = None - certified_company_end_date: Optional[int] = None - annex31_certification_date: Optional[int] = None - annex31_certification_number: Optional[str] = None - annex31_modality: Optional[str] = None - annex31_company_type: Optional[str] = None - annex31_renewal_date: Optional[int] = None - annex31_final_certification_date: Optional[int] = None - is_oea_company: Optional[int] = None - neec_company: Optional[int] = None + certified_company_start_date: Optional[date] = None + certified_company_end_date: Optional[date] = None + annex30_certification_date: Optional[date] = None + annex30_certification_number: Optional[str] = None + annex30_modality: Optional[str] = None + annex30_company_type: Optional[str] = None + annex30_renewal_date: Optional[date] = None + annex30_final_certification_date: Optional[date] = None + is_seciit_company: Optional[bool] = None + is_oea_company: Optional[bool] = None + neec_company: Optional[bool] = None # Addresses # ... (Main, Ind1, Ind2 can be added here if needed for flattened response) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/models.py b/backend/api/v1/modules/a76/general_catalogs/company/models.py index 739078ea..ef914264 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/models.py @@ -62,6 +62,8 @@ class Company(Base, TimestampMixin): # Configuración básica logo: Mapped[Optional[str]] = mapped_column(String(512)) has_express_line: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false") + fiscal_deposit: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false") + generate_barcodes_with_fiel: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false") order_format_type: Mapped[Optional[str]] = mapped_column(String(19)) is_service_company: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false") client_name: Mapped[Optional[str]] = mapped_column(String(300)) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py index 29cc68e0..1432a7be 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -119,7 +119,8 @@ class CompanyService: "prosec", "prosec_authorization", "sector1", "sector2", "sector3", "manufacturer_id", "broker_company", "responsible", "responsible_name", "responsible_last_name", "responsible_mother_last_name", "responsible_rfc", - "position", "logo", "has_express_line", "order_format_type", + "position", "logo", "has_express_line", "fiscal_deposit", + "generate_barcodes_with_fiel", "order_format_type", "is_service_company", "client_name", "subassembly_mode", "previous_code", "active_labels", "active_fractions", "activate_caat", "trans_interface", "american_costs", "scaf_readonly", "parts_replacement", "activate_facmexame", @@ -134,9 +135,9 @@ class CompanyService: cert_fields = [ "is_certified_company", "certified_company_registration", "certified_company_start_date", "certified_company_end_date", - "annex31_certification_date", "annex31_certification_number", - "annex31_modality", "annex31_company_type", "annex31_renewal_date", - "annex31_final_certification_date", "is_oea_company", "ctpat_svi", + "annex30_certification_date", "annex30_certification_number", + "annex30_modality", "annex30_company_type", "annex30_renewal_date", + "annex30_final_certification_date", "is_seciit_company", "is_oea_company", "ctpat_svi", "trusted_exporter_number", "neec_company" ] return {k: v for k, v in data.items() if k in cert_fields} @@ -249,9 +250,9 @@ class CompanyService: cert_fields = [ "is_certified_company", "certified_company_registration", "certified_company_start_date", "certified_company_end_date", - "annex31_certification_date", "annex31_certification_number", - "annex31_modality", "annex31_company_type", "annex31_renewal_date", - "annex31_final_certification_date", "is_oea_company", "ctpat_svi", + "annex30_certification_date", "annex30_certification_number", + "annex30_modality", "annex30_company_type", "annex30_renewal_date", + "annex30_final_certification_date", "is_seciit_company", "is_oea_company", "ctpat_svi", "trusted_exporter_number", "neec_company" ] for field in cert_fields: diff --git a/backend/api/v1/modules/a76/general_catalogs/company/submodels/certification.py b/backend/api/v1/modules/a76/general_catalogs/company/submodels/certification.py index eea80e08..ee70040a 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/submodels/certification.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/submodels/certification.py @@ -2,7 +2,9 @@ Modelo de certificaciones de empresa """ from typing import Optional, TYPE_CHECKING -from sqlalchemy import Integer, String, SmallInteger, ForeignKey, Boolean +from datetime import date + +from sqlalchemy import Integer, String, ForeignKey, Boolean, Date from sqlalchemy.orm import Mapped, mapped_column, relationship from core.database import Base from api.v1.common.base_models import TimestampMixin @@ -26,24 +28,25 @@ class CompanyCertification(Base, TimestampMixin): ) # Certificación general - is_certified_company: Mapped[Optional[str]] = mapped_column(String(1)) + is_certified_company: Mapped[Optional[bool]] = mapped_column(Boolean) certified_company_registration: Mapped[Optional[str]] = mapped_column(String(40)) - certified_company_start_date: Mapped[Optional[int]] = mapped_column(Integer) - certified_company_end_date: Mapped[Optional[int]] = mapped_column(Integer) + certified_company_start_date: Mapped[Optional[date]] = mapped_column(Date) + certified_company_end_date: Mapped[Optional[date]] = mapped_column(Date) - # Anexo 31 - annex31_certification_date: Mapped[Optional[int]] = mapped_column(Integer) - annex31_certification_number: Mapped[Optional[str]] = mapped_column(String(50)) - annex31_modality: Mapped[Optional[str]] = mapped_column(String(50)) - annex31_company_type: Mapped[Optional[str]] = mapped_column(String(50)) - annex31_renewal_date: Mapped[Optional[int]] = mapped_column(Integer) - annex31_final_certification_date: Mapped[Optional[int]] = mapped_column(Integer) + # Anexo 30 + annex30_certification_date: Mapped[Optional[date]] = mapped_column(Date) + annex30_certification_number: Mapped[Optional[str]] = mapped_column(String(50)) + annex30_modality: Mapped[Optional[str]] = mapped_column(String(3)) + annex30_company_type: Mapped[Optional[str]] = mapped_column(String(50)) + annex30_renewal_date: Mapped[Optional[date]] = mapped_column(Date) + annex30_final_certification_date: Mapped[Optional[date]] = mapped_column(Date) # Otras certificaciones - is_oea_company: Mapped[Optional[int]] = mapped_column(SmallInteger) + is_seciit_company: Mapped[Optional[bool]] = mapped_column(Boolean) + is_oea_company: Mapped[Optional[bool]] = mapped_column(Boolean) ctpat_svi: Mapped[Optional[str]] = mapped_column(String(100)) trusted_exporter_number: Mapped[Optional[str]] = mapped_column(String(50)) - neec_company: Mapped[Optional[int]] = mapped_column(Integer) + neec_company: Mapped[Optional[bool]] = mapped_column(Boolean) # Relación inversa company: Mapped["Company"] = relationship( diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index ab21081d..73228d75 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -21,18 +21,19 @@ export interface CompanyAddress { export interface CompanyCertification { id?: number; - is_certified_company?: string | null; + is_certified_company?: boolean | null; certified_company_registration?: string | null; - certified_company_start_date?: number | null; - certified_company_end_date?: number | null; - annex31_certification_date?: number | null; - annex31_certification_number?: string | null; - annex31_modality?: string | null; - annex31_company_type?: string | null; - annex31_renewal_date?: number | null; - annex31_final_certification_date?: number | null; - is_oea_company?: number | null; - neec_company?: number | null; + certified_company_start_date?: string | null; + certified_company_end_date?: string | null; + annex30_certification_date?: string | null; + annex30_certification_number?: string | null; + annex30_modality?: string | null; + annex30_company_type?: string | null; + annex30_renewal_date?: string | null; + annex30_final_certification_date?: string | null; + is_seciit_company?: boolean | null; + is_oea_company?: boolean | null; + neec_company?: boolean | null; } export interface CompanyDigitalCertificate { @@ -107,6 +108,8 @@ export interface Company { responsible_rfc?: string | null; position?: string | null; has_express_line?: boolean; + fiscal_deposit?: boolean; + generate_barcodes_with_fiel?: boolean; is_service_company?: boolean; order_format_type?: string | null; ctpat_svi?: string | null; @@ -140,18 +143,19 @@ export interface Company { sql_language?: string | null; balance_operation_mode?: string | null; // Campos de certificación (CompanyCertification aplanado) - is_certified_company?: string | null; + is_certified_company?: boolean | null; certified_company_registration?: string | null; - certified_company_start_date?: number | null; - certified_company_end_date?: number | null; - annex31_certification_date?: number | null; - annex31_certification_number?: string | null; - annex31_modality?: string | null; - annex31_company_type?: string | null; - annex31_renewal_date?: number | null; - annex31_final_certification_date?: number | null; - is_oea_company?: number | null; - neec_company?: number | null; + certified_company_start_date?: string | null; + certified_company_end_date?: string | null; + annex30_certification_date?: string | null; + annex30_certification_number?: string | null; + annex30_modality?: string | null; + annex30_company_type?: string | null; + annex30_renewal_date?: string | null; + annex30_final_certification_date?: string | null; + is_seciit_company?: boolean | null; + is_oea_company?: boolean | null; + neec_company?: boolean | null; // Campos de dirección principal (CompanyAddress - main) main_street?: string | null; main_exterior_number?: string | null; @@ -270,6 +274,8 @@ export interface CompanyCreate { responsible_rfc?: string | null; position?: string | null; has_express_line?: boolean; + fiscal_deposit?: boolean; + generate_barcodes_with_fiel?: boolean; is_service_company?: boolean; order_format_type?: string | null; ctpat_svi?: string | null; @@ -323,18 +329,19 @@ export interface CompanyCreate { sql_language?: string | null; balance_operation_mode?: string | null; // Campos de certificación - is_certified_company?: string | null; + is_certified_company?: boolean | null; certified_company_registration?: string | null; - certified_company_start_date?: number | null; - certified_company_end_date?: number | null; - annex31_certification_date?: number | null; - annex31_certification_number?: string | null; - annex31_modality?: string | null; - annex31_company_type?: string | null; - annex31_renewal_date?: number | null; - annex31_final_certification_date?: number | null; - is_oea_company?: number | null; - neec_company?: number | null; + certified_company_start_date?: string | null; + certified_company_end_date?: string | null; + annex30_certification_date?: string | null; + annex30_certification_number?: string | null; + annex30_modality?: string | null; + annex30_company_type?: string | null; + annex30_renewal_date?: string | null; + annex30_final_certification_date?: string | null; + is_seciit_company?: boolean | null; + is_oea_company?: boolean | null; + neec_company?: boolean | null; // Campos de dirección principal main_street?: string | null; main_exterior_number?: string | null; diff --git a/frontend/src/lib/utils/date.ts b/frontend/src/lib/utils/date.ts new file mode 100644 index 00000000..2a5163b6 --- /dev/null +++ b/frontend/src/lib/utils/date.ts @@ -0,0 +1,57 @@ +/** + * Utility for date formatting and conversion. + * Canonical API format: DD/MM/YYYY + * Native input[type="date"] format: YYYY-MM-DD + */ + +/** + * Converts DD/MM/YYYY or YYYYMMDD to YYYY-MM-DD for native date input. + */ +export function toInputDate(dateStr: string | number | null | undefined): string { + if (!dateStr) return ''; + + const str = String(dateStr); + + if (/^\d{8}$/.test(str)) { + const y = str.substring(0, 4); + const m = str.substring(4, 6); + const d = str.substring(6, 8); + return `${y}-${m}-${d}`; + } + + if (/^\d{2}\/\d{2}\/\d{4}$/.test(str)) { + const [d, m, y] = str.split('/'); + return `${y}-${m}-${d}`; + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(str)) { + return str; + } + + return ''; +} + +/** + * Converts YYYY-MM-DD or YYYYMMDD to DD/MM/YYYY for API. + */ +export function toDbDate(dateStr: string | null | undefined): string | null { + if (!dateStr) return null; + + if (/^\d{2}\/\d{2}\/\d{4}$/.test(dateStr)) { + return dateStr; + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + const [y, m, d] = dateStr.split('-'); + return `${d}/${m}/${y}`; + } + + if (/^\d{8}$/.test(dateStr)) { + const y = dateStr.substring(0, 4); + const m = dateStr.substring(4, 6); + const d = dateStr.substring(6, 8); + return `${d}/${m}/${y}`; + } + + return null; +} diff --git a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte index 9e1c926c..50a17ed4 100644 --- a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte @@ -19,6 +19,7 @@ } from '$lib/api/dashboard/a76/general_catalogs/company'; import { companyStore } from '$lib/stores/company.svelte'; import { getBackendAssetUrl, getFileDisplayName } from '$lib/utils'; + import { toDbDate, toInputDate } from '$lib/utils/date'; import { ArrowLeft, LoaderCircle, @@ -86,6 +87,8 @@ position: '', manufacturer_id: '', has_express_line: false, + fiscal_deposit: false, + generate_barcodes_with_fiel: false, is_service_company: false, order_format_type: '', ctpat_svi: '', @@ -103,18 +106,19 @@ sector2: '', sector3: '', // Certificaciones (CompanyCertification) - is_certified_company: '', + is_certified_company: false, certified_company_registration: '', - certified_company_start_date: 0, - certified_company_end_date: 0, - annex31_certification_date: 0, - annex31_certification_number: '', - annex31_modality: '', - annex31_company_type: '', - annex31_renewal_date: 0, - annex31_final_certification_date: 0, - is_oea_company: 0, - neec_company: 0, + certified_company_start_date: '', + certified_company_end_date: '', + annex30_certification_date: '', + annex30_certification_number: '', + annex30_modality: '', + annex30_company_type: '', + annex30_renewal_date: '', + annex30_final_certification_date: '', + is_seciit_company: false, + is_oea_company: false, + neec_company: false, // Dirección Principal main_street: '', main_exterior_number: '', @@ -255,6 +259,8 @@ position: item.position || '', manufacturer_id: item.manufacturer_id || '', has_express_line: item.has_express_line || false, + fiscal_deposit: item.fiscal_deposit || false, + generate_barcodes_with_fiel: item.generate_barcodes_with_fiel || false, is_service_company: item.is_service_company || false, order_format_type: item.order_format_type || '', ctpat_svi: item.ctpat_svi || '', @@ -289,18 +295,19 @@ sql_language: item.sql_language || '', balance_operation_mode: item.balance_operation_mode || '', // Certificaciones - is_certified_company: item.is_certified_company || '', + is_certified_company: item.is_certified_company || false, certified_company_registration: item.certified_company_registration || '', - certified_company_start_date: item.certified_company_start_date || 0, - certified_company_end_date: item.certified_company_end_date || 0, - annex31_certification_date: item.annex31_certification_date || 0, - annex31_certification_number: item.annex31_certification_number || '', - annex31_modality: item.annex31_modality || '', - annex31_company_type: item.annex31_company_type || '', - annex31_renewal_date: item.annex31_renewal_date || 0, - annex31_final_certification_date: item.annex31_final_certification_date || 0, - is_oea_company: item.is_oea_company || 0, - neec_company: item.neec_company || 0, + certified_company_start_date: toInputDate(item.certified_company_start_date), + certified_company_end_date: toInputDate(item.certified_company_end_date), + annex30_certification_date: toInputDate(item.annex30_certification_date), + annex30_certification_number: item.annex30_certification_number || '', + annex30_modality: item.annex30_modality || '', + annex30_company_type: item.annex30_company_type || '', + annex30_renewal_date: toInputDate(item.annex30_renewal_date), + annex30_final_certification_date: toInputDate(item.annex30_final_certification_date), + is_seciit_company: item.is_seciit_company || false, + is_oea_company: item.is_oea_company || false, + neec_company: item.neec_company || false, // Direcciones - Principal main_street: item.main_street || '', main_exterior_number: item.main_exterior_number || '', @@ -586,18 +593,19 @@ sql_language: clean(formData.sql_language), balance_operation_mode: clean(formData.balance_operation_mode), // Certificaciones - is_certified_company: clean(formData.is_certified_company), + is_certified_company: formData.is_certified_company, certified_company_registration: clean(formData.certified_company_registration), - certified_company_start_date: Number(formData.certified_company_start_date) || 0, - certified_company_end_date: Number(formData.certified_company_end_date) || 0, - annex31_certification_date: Number(formData.annex31_certification_date) || 0, - annex31_certification_number: clean(formData.annex31_certification_number), - annex31_modality: clean(formData.annex31_modality), - annex31_company_type: clean(formData.annex31_company_type), - annex31_renewal_date: Number(formData.annex31_renewal_date) || 0, - annex31_final_certification_date: Number(formData.annex31_final_certification_date) || 0, - is_oea_company: Number(formData.is_oea_company) || 0, - neec_company: Number(formData.neec_company) || 0, + certified_company_start_date: toDbDate(formData.certified_company_start_date), + certified_company_end_date: toDbDate(formData.certified_company_end_date), + annex30_certification_date: toDbDate(formData.annex30_certification_date), + annex30_certification_number: clean(formData.annex30_certification_number), + annex30_modality: clean(formData.annex30_modality), + annex30_company_type: clean(formData.annex30_company_type), + annex30_renewal_date: toDbDate(formData.annex30_renewal_date), + annex30_final_certification_date: toDbDate(formData.annex30_final_certification_date), + is_seciit_company: formData.is_seciit_company, + is_oea_company: formData.is_oea_company, + neec_company: formData.neec_company, // Direcciones - Principal main_street: clean(formData.main_street), main_exterior_number: clean(formData.main_exterior_number), @@ -687,6 +695,8 @@ cancel_key_exp: Number(formData.cancel_key_exp) || 0, // Ensure optional booleans are passed correctly or default to false/null if needed has_express_line: formData.has_express_line, + fiscal_deposit: formData.fiscal_deposit, + generate_barcodes_with_fiel: formData.generate_barcodes_with_fiel, is_service_company: formData.is_service_company }; @@ -815,7 +825,12 @@
- +
@@ -846,8 +861,16 @@
- - + +
@@ -914,26 +937,15 @@ />
-
-
- - -
-
- (formData.is_oea_company = checked ? 1 : 0)} - /> - -
-
- (formData.neec_company = checked ? 1 : 0)} - /> - +
+
+
@@ -941,14 +953,38 @@

Certificación General

-
- - +
+ + +
+ + + +
@@ -962,75 +998,85 @@
-

Anexo 31

+

Anexo 30

- +
- +
- - Modalidad Anexo 30 +
- - Tipo de Empresa Anexo 30 +
- +
- +
@@ -1373,10 +1419,32 @@

Configuración General

-
- - +
+ +
+
+ + +
+
@@ -1482,8 +1550,16 @@
- - + +
@@ -1513,15 +1589,29 @@
- +
- + class="flex h-9 w-full rounded-md border border-input bg-card px-3 py-1 text-sm shadow-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" + > + + + + +
From 651083b845f91a32a933f072229675dee76bd070 Mon Sep 17 00:00:00 2001 From: acazares Date: Thu, 23 Apr 2026 14:36:11 -0600 Subject: [PATCH 072/167] featrue/testing --- frontend/e2e/.auth/user.json | 88 +++ frontend/e2e/.e2e-catalog.json | 1 + frontend/e2e/.e2e-shared-exp.json | 1 + frontend/e2e/.e2e-shared.json | 1 + frontend/e2e/FlujoCompleto.MD | 248 +++++++++ frontend/e2e/auth.setup.ts | 15 + frontend/e2e/export-flow.spec.ts | 509 ++++++++++++++++++ frontend/e2e/invoice-flow.spec.ts | 451 ++++++++++++++++ frontend/e2e/login.spec.ts | 42 ++ frontend/e2e/modules.spec.ts | 103 ++++ frontend/e2e/navigation.spec.ts | 67 +++ frontend/e2e/setup-catalogs.spec.ts | 152 ++++++ frontend/playwright.config.ts | 30 +- frontend/src/lib/Reporte_Pruebas.MD | 259 +++++++++ frontend/src/lib/backend.test.ts | 18 + .../src/lib/csv-import-commit-metrics.test.ts | 46 ++ .../src/lib/csv-import-status-api.test.ts | 18 + frontend/src/lib/date-utils.test.ts | 106 ++++ .../src/lib/utils.getBackendAssetUrl.test.ts | 26 + frontend/src/lib/utils.getFileHelpers.test.ts | 46 ++ .../src/lib/utils.getInvoiceTypeColor.test.ts | 46 ++ 21 files changed, 2267 insertions(+), 6 deletions(-) create mode 100644 frontend/e2e/.auth/user.json create mode 100644 frontend/e2e/.e2e-catalog.json create mode 100644 frontend/e2e/.e2e-shared-exp.json create mode 100644 frontend/e2e/.e2e-shared.json create mode 100644 frontend/e2e/FlujoCompleto.MD create mode 100644 frontend/e2e/auth.setup.ts create mode 100644 frontend/e2e/export-flow.spec.ts create mode 100644 frontend/e2e/invoice-flow.spec.ts create mode 100644 frontend/e2e/login.spec.ts create mode 100644 frontend/e2e/modules.spec.ts create mode 100644 frontend/e2e/navigation.spec.ts create mode 100644 frontend/e2e/setup-catalogs.spec.ts create mode 100644 frontend/src/lib/Reporte_Pruebas.MD create mode 100644 frontend/src/lib/backend.test.ts create mode 100644 frontend/src/lib/csv-import-commit-metrics.test.ts create mode 100644 frontend/src/lib/csv-import-status-api.test.ts create mode 100644 frontend/src/lib/date-utils.test.ts create mode 100644 frontend/src/lib/utils.getBackendAssetUrl.test.ts create mode 100644 frontend/src/lib/utils.getFileHelpers.test.ts create mode 100644 frontend/src/lib/utils.getInvoiceTypeColor.test.ts diff --git a/frontend/e2e/.auth/user.json b/frontend/e2e/.auth/user.json new file mode 100644 index 00000000..5beaac60 --- /dev/null +++ b/frontend/e2e/.auth/user.json @@ -0,0 +1,88 @@ +{ + "cookies": [ + { + "name": "PARAGLIDE_LOCALE", + "value": "en", + "domain": "localhost", + "path": "/", + "expires": 1811027810.198012, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "AEC", + "value": "AaJma5v43kHbTuTwyJoT15hUaPvuCG16tyk2KJ2BxNFgXN-yTIGTIG5hfLc", + "domain": ".google.com", + "path": "/", + "expires": 1791743031.757771, + "httpOnly": true, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "__Secure-BUCKET", + "value": "CNEE", + "domain": ".google.com", + "path": "/", + "expires": 1791743031.758503, + "httpOnly": true, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "NID", + "value": "530=e-peZK4UWIVdgN4X2EqonLtaC3Zs06LaPajTXo2YJB4o4tMARlX5Q9Ezprh8hfjJi5ZgmxAW8RXL8r62nxXBqOC30VOsnHaK1_4CvFy4zAYrPCwyZqbL7JGuO_NOIo6Y8MHy9hKZ9CQ3aWKpMkCdzE99k_nefRe3DI_xtgJDSXykJ2A6KfNH25CRuEB7zwcKa6Lgu4FnYOOjGqNY3FlOYxsfWNK89nk-R15p1Yw3jJK7H_V3Evl5GpPjjLVW-0U", + "domain": ".google.com", + "path": "/", + "expires": 1792002231.720994, + "httpOnly": true, + "secure": true, + "sameSite": "None" + }, + { + "name": "access_token", + "value": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJKOVpZdC1Wa1N5Q1RMYzVWbVNrdm5ua1JVZkJDWGs4T0xfa0puSURwRmtFIn0.eyJleHAiOjE3NzY0NzE0MDgsImlhdCI6MTc3NjQ2NzgwOCwianRpIjoib25ydHJvOjdmYmM3YTIzLTUyNTYtZDc1MS05MDRhLTVkNDI1MzBkODE0YiIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9rY2F1dGgvcmVhbG1zL21hc3RlciIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI4Njk0YzQwZC01MTdkLTRhNTctOGFjMi03MDIxNjlhMzM1YTUiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJhbmV4bzc2LWJhY2tlbmQiLCJzaWQiOiJhZjg2NDk1OC0xY2Q2LTM2MmUtYzJlZi02ODY1MTViZjdiYTEiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbImh0dHA6Ly9sb2NhbGhvc3Q6ODAwMC9hcGkiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtbWFzdGVyIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIGVtYWlsIHByb2ZpbGUiLCJ0ZW5hbnRfaWQiOiIxIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInByZWZlcnJlZF91c2VybmFtZSI6ImRlbW8ifQ.I2nMbfAEnlcHANPkxx3oqEMN9ytqK99A8ql2ZA3fyUIBAJg_rh2v0MwDfNe-KOb70C99vJ09Wdl_QKKZCuGud_JNcnUkE82kqYpE4j4hTltaB7kcrUSnWdLBZqpxHuH8ZVkWvwretge7HsviUsR6bkNGQ4JwAf-1mOv4ZKzAlbugsLWndUVecYk8RBCmtQhciceBFJguW7NPkT0NtfNVUQ-Rkrs1CECHEwh94i5p52NboB_jA4-AHQEpFeJd60pW6Fsv_Xj-ltSc8xbxa8eC4pVLckYFQ93EpJax8UuqryXPN16UBw2qNo2VLoJdQ4uaO39dTwGxnlMB7kGM3dSNNA", + "domain": "localhost", + "path": "/", + "expires": 1777072609.009821, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "refresh_token", + "value": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJkYzdiYjMxYS1lZjlmLTQ4ZTYtYTJiOS1mMjVlZDkyNmUxNDYifQ.eyJleHAiOjE3NzY0Njk2MDgsImlhdCI6MTc3NjQ2NzgwOCwianRpIjoiMTkyYTJhYzktN2FkYS1hMTkwLWY5OWEtMzc3NjRmNmQyMTJiIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2tjYXV0aC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2tjYXV0aC9yZWFsbXMvbWFzdGVyIiwic3ViIjoiODY5NGM0MGQtNTE3ZC00YTU3LThhYzItNzAyMTY5YTMzNWE1IiwidHlwIjoiUmVmcmVzaCIsImF6cCI6ImFuZXhvNzYtYmFja2VuZCIsInNpZCI6ImFmODY0OTU4LTFjZDYtMzYyZS1jMmVmLTY4NjUxNWJmN2JhMSIsInNjb3BlIjoib3BlbmlkIHdlYi1vcmlnaW5zIGVtYWlsIHNlcnZpY2VfYWNjb3VudCBwcm9maWxlIGJhc2ljIHJvbGVzIGFjciJ9.dOmas5wy07X8QY6g9DhVvPeptdNEysCwW6FSuIDQJ8sfWiH3wJ6D_oq5vgVpMGi72K2Azw_wQVoSkDYPalP5CQ", + "domain": "localhost", + "path": "/", + "expires": 1779059809.010061, + "httpOnly": true, + "secure": false, + "sameSite": "Lax" + } + ], + "origins": [ + { + "origin": "http://localhost:5173", + "localStorage": [ + { + "name": "theme", + "value": "dark" + }, + { + "name": "activeCompanyId", + "value": "1" + } + ] + }, + { + "origin": "https://www.google.com", + "localStorage": [ + { + "name": "rc::a", + "value": "MWUzcDV5MzE0dno1Z3M=" + } + ] + } + ] +} \ No newline at end of file diff --git a/frontend/e2e/.e2e-catalog.json b/frontend/e2e/.e2e-catalog.json new file mode 100644 index 00000000..a7a03bb4 --- /dev/null +++ b/frontend/e2e/.e2e-catalog.json @@ -0,0 +1 @@ +{"CLASS_CODE":"E2E01","PART_NUMBER":"E2E-PART-001"} \ No newline at end of file diff --git a/frontend/e2e/.e2e-shared-exp.json b/frontend/e2e/.e2e-shared-exp.json new file mode 100644 index 00000000..64601d7b --- /dev/null +++ b/frontend/e2e/.e2e-shared-exp.json @@ -0,0 +1 @@ +{"INVOICE_NUMBER":"E2E-X62782"} \ No newline at end of file diff --git a/frontend/e2e/.e2e-shared.json b/frontend/e2e/.e2e-shared.json new file mode 100644 index 00000000..68035c16 --- /dev/null +++ b/frontend/e2e/.e2e-shared.json @@ -0,0 +1 @@ +{"INVOICE_NUMBER":"E2E-681168"} \ No newline at end of file diff --git a/frontend/e2e/FlujoCompleto.MD b/frontend/e2e/FlujoCompleto.MD new file mode 100644 index 00000000..f1d7a8c7 --- /dev/null +++ b/frontend/e2e/FlujoCompleto.MD @@ -0,0 +1,248 @@ +# Reporte de Pruebas E2E — Flujo de Factura + +**Proyecto:** Anexo 76 — Sistema de Control de Operaciones Aduaneras +**Herramienta:** Playwright +**Archivo:** `frontend/e2e/invoice-flow.spec.ts` +**Fecha:** Abril 2026 +**Estado:** 12/12 pruebas pasando ✅ +**Tiempo de ejecución:** ~4.4 minutos + +--- + +## Resumen + +| Categoría | Pruebas | Estado | +|-----------|---------|--------| +| Prerrequisitos (proveedor, cliente, agente, TC) | 4 | ✅ | +| Pedimento | 1 | ✅ | +| Factura TEM | 3 | ✅ | +| Partidas | 2 | ✅ | +| Actualización final | 1 | ✅ | +| **Total** | **11** | **✅** | + +> Nota: el test de setup de autenticación (`auth.setup.ts`) suma 1 prueba adicional, totalizando 12 en el runner. + +--- + +## Flujo completo + +### 1. Crear proveedor + +Navega a `/dashboard/clients_and_providers`, abre el formulario de nuevo socio, llena RFC y nombre, selecciona tipo "Proveedor" y guarda. Verifica redirección a la lista y que el nombre aparece en la tabla. + +### 2. Crear cliente + +Mismo flujo que el proveedor pero con tipo "Cliente". + +### 3. Crear agente aduanal + +Navega a `/dashboard/customs_brokers`, abre el formulario, llena clave, patente y nombre. Verifica toast de éxito y redirección. + +### 4. Crear tipo de cambio + +Navega a `/dashboard/general_catalogs/exchange-rate`, abre el modal de nuevo tipo de cambio, llena fecha de hoy y valor `17.5`, confirma. Verifica toast de éxito. + +### 5. Crear pedimento + +Navega a `/dashboard/pedimentos/edit/new`, llena año (`26`), selecciona Aduana, Patente, Clave, Tipo de Operación y Régimen via bits-ui Select. Llena número de pedimento. Guarda y verifica redirección a `/dashboard/pedimentos`. + +### 6. Crear factura de importación TEM + +Navega a `/dashboard/invoices/edit/new?operation_type=imp&invoice_type=TEM`. Llena número de factura con `pressSequentially`, fecha, y en la pestaña General selecciona proveedor, sold-to, shipped-to, agente aduanal, aduana y tipo de documento. Guarda y verifica toast de éxito. Al finalizar guarda el número de factura en `.e2e-shared.json` para los tests posteriores. + +### 7. Factura aparece en la lista + +Filtra por número de factura en la lista de importación y verifica que la fila es visible. + +### 8. Agregar partida a la factura + +Abre la factura desde la lista, navega a la pestaña Partidas, abre el sheet de nueva partida. Selecciona Clase, U.M. y País de Origen (cada uno abre un dialog con tabla). Llena cantidad (`10`), costo unitario (`100`), peso neto (`5`), peso bruto (`6`) y descripción en español. Hace click en el botón "Crear" del sheet. Guarda la factura completa. + +### 9. Editar factura existente + +Lee el número de factura desde `.e2e-shared.json`, la busca en la lista, la abre en modo edición. En la pestaña General vuelve a seleccionar agente aduanal, aduana y tipo de documento. Guarda y verifica toast de éxito. + +### 10. Editar partida existente + +Lee el número desde shared, abre la factura, va a pestaña Partidas. Hace click en el ícono Pencil de la primera fila para abrir el sheet de edición. Modifica cantidad (`20`) y costo unitario (`200`). Hace click en "Actualizar" del sheet. Guarda la factura. + +### 11. Actualizar factura — verificación final + +Lee el número desde shared, busca la factura en la lista, selecciona la fila, hace click en el botón "Actualizar" del footer (ícono RefreshCw, clase `h-8`). Verifica el resultado con toast de éxito. + +--- + +## Patrones técnicos establecidos + +### fillInput — inputs reactivos de Svelte 5 + +Los inputs de Svelte 5 no responden a `page.fill()` ni `pressSequentially` de forma confiable. La solución es usar el native setter del prototipo: + +```typescript +async function fillInput(page: Page, selector: string, value: string) { + await page.locator(selector).click() + await page.evaluate(({ sel, val }) => { + const el = document.querySelector(sel) as HTMLInputElement + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, 'value' + )?.set + setter?.call(el, val) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, { sel: selector, val: value }) + await page.waitForTimeout(2000) +} +``` + +La excepción es `#invoice_number`, que sí responde a `pressSequentially` con delay: + +```typescript +await page.locator('#invoice_number').click() +await page.keyboard.press('Control+A') +await page.locator('#invoice_number').pressSequentially(INVOICE_NUMBER, { delay: 1000 }) +``` + +### bits-ui Select — selects con IDs dinámicos + +Los selects de bits-ui generan IDs como `bits-s65` que cambian en cada render. La estrategia es seleccionarlos por el atributo `data-select-trigger` y posición: + +```typescript +const triggers = page.locator('[data-select-trigger]') +await triggers.nth(0).click() // Aduana +await page.getByRole('option').first().click() +``` + +Para selects con IDs estables (facturas) se usa directamente: + +```typescript +await page.locator('#provider_id').click() +await page.getByRole('option').first().click() +``` + +### Dialogs anidados — clase, U.M., país de origen + +Los campos Clase, U.M. y País de Origen abren un dialog de búsqueda encima del sheet. Para evitar que el sheet intercepte los clicks, se scopea al último dialog abierto: + +```typescript +await page.locator('#clase').click() +await page.waitForTimeout(3000) +const claseDialog = page.locator('[data-dialog-content]').last() +await claseDialog.locator('tbody tr').first().click() +``` + +### Botones dentro del sheet + +El botón de guardar partida está dentro del sheet y puede ser interceptado. Se scopea explícitamente: + +```typescript +const sheet = page.locator('[data-slot="sheet-content"]') +await sheet.getByRole('button', { name: /Crear/ }).click() // nueva partida +await sheet.getByRole('button', { name: /Actualizar/ }).click() // editar partida +``` + +### Distinguir botones ambiguos por clase CSS + +Cuando hay múltiples botones con el mismo texto o ícono, se distinguen por clases CSS únicas: + +```typescript +// Botón Actualizar del footer (tiene h-8, border, RefreshCw) +await page.locator('button.h-8:has([class*="lucide-refresh"])').click() +``` + +### Compartir estado entre tests + +Playwright corre cada test en un worker separado, por lo que `Date.now()` se reevalúa. Para compartir el número de factura entre tests se usa un archivo JSON: + +```typescript +// Al final del test 6 +saveShared({ INVOICE_NUMBER }) + +// En tests 7-11 +const shared = loadShared() +const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER +``` + +El archivo se guarda en `frontend/e2e/.e2e-shared.json`. + +--- + +## Selectores de referencia + +| Campo | Selector | Tipo | +|-------|----------|------| +| RFC | `#rfc` | input normal | +| Nombre | `#name` | input normal | +| Tipo de socio | `#type` | bits-ui Select | +| Año pedimento | `#year` | input normal | +| Número pedimento | `#pedimento_number` | input normal | +| Aduana pedimento | `[data-select-trigger]` nth(0) | bits-ui Select | +| Patente pedimento | `[data-select-trigger]` nth(1) | bits-ui Select | +| Clave pedimento | `[data-select-trigger]` nth(2) | bits-ui Select | +| Número factura | `#invoice_number` | input (pressSequentially) | +| Fecha factura | `#invoice_date` | date input | +| Proveedor | `#provider_id` | bits-ui Select | +| Sold-to | `#sold_to_id` | bits-ui Select | +| Shipped-to | `#shipped_to_id` | bits-ui Select | +| Agente aduanal | `#customs_broker_id` | bits-ui Select | +| Aduana factura | `#aduana` | bits-ui Select | +| Tipo documento | `#document_type` | bits-ui Select | +| Clase partida | `#clase` | input readonly → dialog | +| U.M. | `#um` | input readonly → dialog | +| País origen | `#pais_origen` | input readonly → dialog | +| Cantidad | `#cantidad` | input number | +| Costo unitario | `#costo_unitario` | input number | +| Peso neto | `#peso_neto` | input number | +| Peso bruto | `#peso_bruto` | input number | +| Descripción ES | `#desc_espanol` | textarea | +| Filtro número | `#filter-invoice-number` | input normal | + +--- + +## Comandos + +```bash +# Flujo completo +pnpm test:e2e --grep "Flujo completo" + +# Test individual +pnpm test:e2e --grep "5. crear pedimento" +pnpm test:e2e --grep "8. agregar partida" + +# Modo visual para debug +pnpm test:e2e --grep "Flujo completo" --headed --timeout 120000 +``` + +--- + +## Estructura de archivos + +``` +frontend/e2e/ +├── .auth/ +│ └── user.json sesion de autenticacion +├── .e2e-shared.json estado compartido entre tests (generado) +├── auth.setup.ts 1 test — login y guardado de sesion +├── invoice-flow.spec.ts 11 tests — flujo completo de factura +├── full-flow.spec.ts 4 tests +├── login.spec.ts 3 tests +├── navigation.spec.ts 10 tests +└── modules.spec.ts 10 tests +``` + +--- + +## Conteo total actualizado + +| Suite | Pruebas | +|-------|---------| +| auth.setup.ts | 1 | +| invoice-flow.spec.ts | 11 | +| full-flow.spec.ts | 4 | +| login.spec.ts | 3 | +| navigation.spec.ts | 10 | +| modules.spec.ts | 10 | +| **Total Playwright** | **39** | + +--- + +*Anexo 76 — Reporte de Pruebas E2E v4.0 — invoice-flow — Abril 2026* \ No newline at end of file diff --git a/frontend/e2e/auth.setup.ts b/frontend/e2e/auth.setup.ts new file mode 100644 index 00000000..4fcbb08b --- /dev/null +++ b/frontend/e2e/auth.setup.ts @@ -0,0 +1,15 @@ +import { test as setup } from '@playwright/test' +import { fileURLToPath } from 'url' +import path from 'path' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const authFile = path.join(__dirname, '.auth/user.json') + +setup('autenticacion', async ({ page }) => { + await page.goto('/login') + await page.locator('input[id^="username"]').fill('demo') + await page.locator('input[id^="password"]').fill('demo123') + await page.click('button[type="submit"]') + await page.waitForURL(/dashboard/, { timeout: 60000 }) + await page.context().storageState({ path: authFile }) +}) \ No newline at end of file diff --git a/frontend/e2e/export-flow.spec.ts b/frontend/e2e/export-flow.spec.ts new file mode 100644 index 00000000..e5d87458 --- /dev/null +++ b/frontend/e2e/export-flow.spec.ts @@ -0,0 +1,509 @@ +import { test, expect, type Page } from '@playwright/test' +import * as fs from 'fs' +import { fileURLToPath } from 'url' +import * as path from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const SHARED_FILE = path.join(__dirname, '.e2e-shared-exp.json') + +function saveShared(data: Record) { + fs.writeFileSync(SHARED_FILE, JSON.stringify(data)) +} + +function loadShared(): Record { + try { + return JSON.parse(fs.readFileSync(SHARED_FILE, 'utf-8')) + } catch { + return {} + } +} + +const SUFFIX = 'X' + Date.now().toString().slice(-5) // prefijo X para exportacion +const TODAY = new Date().toISOString().split('T')[0] + +const HOMOCLAVE = SUFFIX.slice(-3).toUpperCase() + +const PROVEEDOR_RFC = `XAXX010101${HOMOCLAVE}` +const PROVEEDOR_NOMBRE = `Proveedor E2E ${SUFFIX}` + +const CLIENTE_RFC = `XBXX010101${HOMOCLAVE}` +const CLIENTE_NOMBRE = `Cliente E2E ${SUFFIX}` + +const BROKER_KEY = `T${SUFFIX.slice(-4)}` +const BROKER_LICENSE = SUFFIX.slice(-4).replace(/^0/, '1') +const INVOICE_NUMBER = `E2E-${SUFFIX}` + +const PEDIMENTO_YEAR = '26' +const PEDIMENTO_OFFICE = '240' +const PEDIMENTO_LICENSE = '3101' +const PEDIMENTO_NUMBER = SUFFIX.slice(-5) // 5 digitos para pedimento + +async function fillInput(page: Page, selector: string, value: string) { + await page.locator(selector).click() + await page.evaluate(({ sel, val }: { sel: string; val: string }) => { + const el = document.querySelector(sel) as HTMLInputElement + if (!el) return + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + setter?.call(el, val) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, { sel: selector, val: value }) + await page.waitForTimeout(300) +} + +async function fillTextarea(page: Page, selector: string, value: string) { + await page.locator(selector).click() + await page.evaluate(({ sel, val }: { sel: string; val: string }) => { + const el = document.querySelector(sel) as HTMLTextAreaElement + if (!el) return + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(el, val) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, { sel: selector, val: value }) + await page.waitForTimeout(500) +} + +test.describe('Flujo completo — creacion y actualizacion de factura EXPORTACION', () => { + + test('1. crear proveedor', async ({ page }) => { + await page.goto('/dashboard/clients_and_providers') + await page.waitForLoadState('networkidle') + + await page.getByRole('link', { name: /Nuevo/ }).click() + await expect(page).toHaveURL(/edit/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + await fillInput(page, '#rfc', PROVEEDOR_RFC) + await fillInput(page, '#name', PROVEEDOR_NOMBRE) + + await page.locator('#type').click() + await page.getByRole('option', { name: 'Proveedor' }).click() + + await page.waitForTimeout(500) + + await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click() + + await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 }) + await expect(page.getByText(PROVEEDOR_NOMBRE)).toBeVisible() + }) + + test('2. crear cliente', async ({ page }) => { + await page.goto('/dashboard/clients_and_providers') + await page.waitForLoadState('networkidle') + + await page.getByRole('link', { name: /Nuevo/ }).click() + await expect(page).toHaveURL(/edit/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + await fillInput(page, '#rfc', CLIENTE_RFC) + await fillInput(page, '#name', CLIENTE_NOMBRE) + + await page.locator('#type').click() + await page.getByRole('option', { name: 'Cliente' }).click() + + await page.waitForTimeout(500) + + await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click() + + await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 }) + await expect(page.getByText(CLIENTE_NOMBRE)).toBeVisible() + }) + + test('3. crear agente aduanal', async ({ page }) => { + await page.goto('/dashboard/customs_brokers') + await page.waitForLoadState('networkidle') + + await page.getByRole('link', { name: /Nuevo/ }).click() + await expect(page).toHaveURL(/edit/, { timeout: 20000 }) + await page.waitForLoadState('networkidle') + + await fillInput(page, 'input[placeholder="Ej. 550"]', BROKER_KEY) + await fillInput(page, 'input[placeholder="Ej. 3421"]', BROKER_LICENSE) + await fillInput(page, 'input[placeholder="Nombre oficial"]', `Agente E2E ${SUFFIX}`) + + await page.getByRole('button', { name: /Guardar Agente|Actualizar Agente/ }).click() + + await expect(page.getByText(/Agente creado|Agente actualizado/i)).toBeVisible({ timeout: 20000 }) + await expect(page).toHaveURL(/customs_brokers$/, { timeout: 25000 }) + }) + + test('4. crear tipo de cambio', async ({ page }) => { + await page.goto('/dashboard/general_catalogs/exchange-rate') + await page.waitForLoadState('networkidle') + + await page.getByRole('button', { name: /Nuevo Tipo de Cambio/ }).click() + await expect(page.getByRole('heading', { name: 'Nuevo Tipo de Cambio' })).toBeVisible() + + await page.locator('#date').fill(TODAY) + await page.waitForTimeout(500) + + await fillInput(page, '#value', '17.5') + + await page.getByRole('button', { name: /^Ok$/ }).click() + await page.getByRole('button', { name: 'Confirmar' }).click() + + await expect(page.getByText(/tipo de cambio creado|tipo de cambio actualizado/i)).toBeVisible({ timeout: 20000 }) + }) + + test('5. crear pedimento', async ({ page }) => { + await page.goto('/dashboard/pedimentos/edit/new') + await page.waitForLoadState('networkidle') + + await expect(page.getByText('Nuevo Pedimento')).toBeVisible({ timeout: 20000 }) + + const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ }) + if (await prereqModal.isVisible()) await prereqModal.click() + + await page.waitForTimeout(500) + + // Año — input con id estable + await fillInput(page, '#year', PEDIMENTO_YEAR) + + // Aduana — bits-ui Select, 1er trigger de la fila superior + const triggers = page.locator('[data-select-trigger]') + await triggers.nth(0).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(500) + + // Patente — bits-ui Select, 2do trigger + await triggers.nth(1).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(500) + + // Número de pedimento — input con id estable + await fillInput(page, '#pedimento_number', PEDIMENTO_NUMBER) + + // Clave — bits-ui Select, 3er trigger + await triggers.nth(2).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(500) + + // Tipo de Operación — bits-ui Select, 4to trigger + await triggers.nth(3).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(500) + + // Régimen — bits-ui Select, 5to trigger + await triggers.nth(4).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(800) + + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + + await expect(page).toHaveURL(/pedimentos$/, { timeout: 20000 }) + }) + + test('6. crear factura de exportacion', async ({ page }) => { + await page.goto('/dashboard/invoices/edit/new?operation_type=exp') + await page.waitForLoadState('networkidle') + + await expect(page.getByText('Nueva Factura')).toBeVisible({ timeout: 20000 }) + + const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ }) + if (await prereqModal.isVisible()) await prereqModal.click() + + await page.waitForTimeout(1500) + + await page.locator('#invoice_number').click() + await page.keyboard.press('Control+A') + await page.locator('#invoice_number').pressSequentially(INVOICE_NUMBER, { delay: 50 }) + + await page.waitForTimeout(1500) + + await page.locator('#invoice_date').fill(TODAY) + await page.waitForTimeout(500) + + // Seleccionar tipo de factura — es el 2do data-select-trigger del header + await page.locator('[data-select-trigger]').nth(1).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(800) + + await page.getByRole('tab', { name: /General/ }).click() + await page.waitForTimeout(1500) + + await page.locator('#provider_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#sold_to_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#shipped_to_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#customs_broker_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#aduana').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#document_type').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + // Guardar numero de factura para tests posteriores + saveShared({ INVOICE_NUMBER }) + }) + + test('7. factura aparece en la lista de exportacion', async ({ page }) => { + const shared = loadShared() + const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=exp') + await page.waitForLoadState('networkidle') + + await expect(page.locator('main')).toBeVisible() + + await fillInput(page, '#filter-invoice-number', invoiceNumber) + await page.waitForTimeout(2000) + + await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 10000 }) + }) + + test('8. agregar partida a la factura', async ({ page }) => { + const shared = loadShared() + const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=exp') + await page.waitForLoadState('networkidle') + + await fillInput(page, '#filter-invoice-number', invoiceNumber) + await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 20000 }) + + await page.locator('tbody').getByText(invoiceNumber).first().click() + await page.getByRole('button', { name: /Editar/ }).click() + + await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + // Ir a pestaña Partidas + await page.getByRole('tab', { name: /Partidas/ }).click() + await page.waitForTimeout(500) + + // Abrir sheet de nueva partida + await page.getByRole('button', { name: /Agregar Partidas/ }).click() + await page.waitForTimeout(500) + + // Helper para seleccionar en dialog y esperar cierre + async function selectFromDialog(selector: string) { + await page.locator(selector).click() + await page.waitForTimeout(800) + const dialogsBefore = await page.locator('[data-dialog-content]').count() + await page.locator('[data-dialog-content]').last().locator('tbody tr').first().click() + await page.waitForFunction( + (count) => document.querySelectorAll('[data-dialog-content]').length < count, + dialogsBefore, + { timeout: 10000 } + ).catch(() => {}) + await page.waitForTimeout(500) + } + + // Exportacion requiere vincular a una factura de importacion + // El bloque "Factura Impo" aparece en el sheet — click en el input readonly + const sheet = page.locator('[data-slot="sheet-content"]') + const facturaImpoInput = sheet.locator('input[placeholder="Seleccionar factura..."]').first() + if (await facturaImpoInput.isVisible({ timeout: 3000 }).catch(() => false)) { + await facturaImpoInput.click() + await page.waitForTimeout(500) + // InvoiceSelectorModal abre con campo de busqueda + const modalDialog = page.locator('[data-dialog-content]').last() + if (await modalDialog.isVisible({ timeout: 5000 }).catch(() => false)) { + // Buscar la factura de importacion por numero para encontrarla + const searchInput = modalDialog.locator('input[placeholder*="número"], input[placeholder*="numero"], input[type="search"], input[type="text"]').first() + if (await searchInput.isVisible({ timeout: 2000 }).catch(() => false)) { + // Leer numero desde shared file del flujo de importacion + const sharedImp = loadShared() + await searchInput.fill(sharedImp.INVOICE_NUMBER || '') + // Click en buscar si hay boton + const buscarBtn = modalDialog.getByRole('button', { name: /Buscar/i }) + if (await buscarBtn.isVisible({ timeout: 1000 }).catch(() => false)) { + await buscarBtn.click() + } + await page.waitForTimeout(800) + } + // Seleccionar primera fila visible + const firstRow = modalDialog.locator('tbody tr').first() + if (await firstRow.isVisible({ timeout: 5000 }).catch(() => false)) { + await firstRow.click() + await page.waitForTimeout(800) + } else { + // No hay facturas procesadas — cerrar modal y continuar sin vincular + await page.keyboard.press('Escape') + await page.waitForTimeout(500) + } + } + // Seleccionar linea si el input esta habilitado + await page.waitForTimeout(500) + const lineaInput = sheet.locator('#fa_search_line') + if (await lineaInput.isEnabled({ timeout: 3000 }).catch(() => false)) { + await lineaInput.click() + await page.waitForTimeout(800) + const lineDialog = page.locator('[data-dialog-content]').last() + if (await lineDialog.isVisible({ timeout: 3000 }).catch(() => false)) { + const lineBtn = lineDialog.getByRole('button').first() + if (await lineBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await lineBtn.click() + await page.waitForTimeout(500) + } else { + await page.keyboard.press('Escape') + } + } + } + } + + await selectFromDialog('#clase') + await selectFromDialog('#um') + await selectFromDialog('#pais_origen') + + await page.keyboard.press('Escape') + await page.waitForTimeout(500) + + // Cantidad + await fillInput(page, '#cantidad', '10') + + // Costo unitario + await fillInput(page, '#costo_unitario', '100') + + // Peso neto y bruto — requeridos por el backend + await fillInput(page, '#peso_neto', '5') + await fillInput(page, '#peso_bruto', '6') + + // Descripción en español (textarea) + await fillTextarea(page, '#desc_espanol', `Partida E2E ${SUFFIX}`) + + // Guardar partida — botón "Crear" dentro del sheet + await sheet.getByRole('button', { name: /Crear/ }).click() + await page.waitForTimeout(500) + + // Verificar que la partida aparece en la tabla + await expect(page.locator('tbody').first()).toBeVisible({ timeout: 10000 }) + + // Guardar factura completa + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + }) + + test('9. editar factura existente', async ({ page }) => { + await page.goto('/dashboard/invoices?operation_type=exp') + await page.waitForLoadState('networkidle') + + const shared9 = loadShared() + const invoiceNumber9 = shared9.INVOICE_NUMBER || INVOICE_NUMBER + + await fillInput(page, '#filter-invoice-number', invoiceNumber9) + await expect(page.locator('tbody').getByText(invoiceNumber9).first()).toBeVisible({ timeout: 20000 }) + + await page.locator('tbody').getByText(invoiceNumber9).first().click() + await page.getByRole('button', { name: /Editar/ }).click() + + await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + await expect(page.getByText(/Factura #/)).toBeVisible() + + await page.getByRole('tab', { name: /General/ }).click() + await page.waitForTimeout(1500) + + await page.locator('#customs_broker_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#aduana').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#document_type').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + }) + + test('10. editar partida existente', async ({ page }) => { + const shared10 = loadShared() + const invoiceNumber10 = shared10.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=exp') + await page.waitForLoadState('networkidle') + + await fillInput(page, '#filter-invoice-number', invoiceNumber10) + await expect(page.locator('tbody').getByText(invoiceNumber10).first()).toBeVisible({ timeout: 20000 }) + + await page.locator('tbody').getByText(invoiceNumber10).first().click() + await page.getByRole('button', { name: /Editar/ }).click() + + await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + // Ir a pestaña Partidas + await page.getByRole('tab', { name: /Partidas/ }).click() + await page.waitForTimeout(500) + + // Clic en botón editar de la primera partida + // Esperar que el sheet este cerrado antes de interactuar con la tabla + // Abrir sheet de edicion via botón Pencil de la primera fila + await page.locator('tbody tr').first().locator('[class*="lucide-pencil"], svg.lucide-pencil').click({ force: true }).catch(async () => { + // Fallback: hover sobre la fila primero para revelar botones, luego click + await page.locator('tbody tr').first().hover() + await page.waitForTimeout(500) + await page.locator('tbody tr').first().getByRole('button').first().click({ force: true }) + }) + await page.waitForTimeout(500) + + // Modificar cantidad + await fillInput(page, '#cantidad', '20') + + // Modificar costo unitario + await fillInput(page, '#costo_unitario', '200') + + // Guardar partida editada — botón "Actualizar" dentro del sheet + const sheetEdit = page.locator('[data-slot="sheet-content"]') + await sheetEdit.getByRole('button', { name: /Actualizar/ }).click() + await page.waitForTimeout(500) + + // Guardar factura completa + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + }) + + test('11. actualizar factura — verificacion final', async ({ page }) => { + const shared11 = loadShared() + const invoiceNumber11 = shared11.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=exp') + await page.waitForLoadState('networkidle') + + await fillInput(page, '#filter-invoice-number', invoiceNumber11) + await expect(page.locator('tbody').getByText(invoiceNumber11).first()).toBeVisible({ timeout: 20000 }) + + // Seleccionar la fila + await page.locator('tbody tr').first().click() + await page.waitForTimeout(800) + + // Click en boton Actualizar del footer — tiene h-8, gap-1.5 y border + await page.locator('button.h-8:has([class*="lucide-refresh"])').click() + await page.waitForTimeout(1500) + + // Verificar que el proceso se ejecuto — puede ser exito o error de validacion de datos + // El test verifica que el flujo llega hasta el procesamiento, no que los datos sean correctos + await expect( + page.getByText(/actualiz|procesad|exito|validaci|error/i).first() + ).toBeVisible({ timeout: 20000 }) + }) + +}) \ No newline at end of file diff --git a/frontend/e2e/invoice-flow.spec.ts b/frontend/e2e/invoice-flow.spec.ts new file mode 100644 index 00000000..21154618 --- /dev/null +++ b/frontend/e2e/invoice-flow.spec.ts @@ -0,0 +1,451 @@ +import { test, expect, type Page } from '@playwright/test' +import * as fs from 'fs' +import { fileURLToPath } from 'url' +import * as path from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const SHARED_FILE = path.join(__dirname, '.e2e-shared.json') + +function saveShared(data: Record) { + fs.writeFileSync(SHARED_FILE, JSON.stringify(data)) +} + +function loadShared(): Record { + try { + return JSON.parse(fs.readFileSync(SHARED_FILE, 'utf-8')) + } catch { + return {} + } +} + +const SUFFIX = Date.now().toString().slice(-6) +const TODAY = new Date().toISOString().split('T')[0] + +const HOMOCLAVE = SUFFIX.slice(-3).toUpperCase() + +const PROVEEDOR_RFC = `XAXX010101${HOMOCLAVE}` +const PROVEEDOR_NOMBRE = `Proveedor E2E ${SUFFIX}` + +const CLIENTE_RFC = `XBXX010101${HOMOCLAVE}` +const CLIENTE_NOMBRE = `Cliente E2E ${SUFFIX}` + +const BROKER_KEY = `T${SUFFIX.slice(-4)}` +const BROKER_LICENSE = SUFFIX.slice(-4).replace(/^0/, '1') +const INVOICE_NUMBER = `E2E-${SUFFIX}` + +const PEDIMENTO_YEAR = '26' +const PEDIMENTO_OFFICE = '240' +const PEDIMENTO_LICENSE = '3101' +const PEDIMENTO_NUMBER = `${SUFFIX}` + +async function fillInput(page: Page, selector: string, value: string) { + await page.locator(selector).click() + await page.evaluate(({ sel, val }: { sel: string; val: string }) => { + const el = document.querySelector(sel) as HTMLInputElement + if (!el) return + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + setter?.call(el, val) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, { sel: selector, val: value }) + await page.waitForTimeout(2000) +} + +async function fillTextarea(page: Page, selector: string, value: string) { + await page.locator(selector).click() + await page.evaluate(({ sel, val }: { sel: string; val: string }) => { + const el = document.querySelector(sel) as HTMLTextAreaElement + if (!el) return + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(el, val) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, { sel: selector, val: value }) + await page.waitForTimeout(500) +} + +test.describe('Flujo completo — creacion y actualizacion de factura', () => { + + test('1. crear proveedor', async ({ page }) => { + await page.goto('/dashboard/clients_and_providers') + await page.waitForLoadState('networkidle') + + await page.getByRole('link', { name: /Nuevo/ }).click() + await expect(page).toHaveURL(/edit/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + await fillInput(page, '#rfc', PROVEEDOR_RFC) + await fillInput(page, '#name', PROVEEDOR_NOMBRE) + + await page.locator('#type').click() + await page.getByRole('option', { name: 'Proveedor' }).click() + + await page.waitForTimeout(3000) + + await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click() + + await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 }) + await expect(page.getByText(PROVEEDOR_NOMBRE)).toBeVisible() + }) + + test('2. crear cliente', async ({ page }) => { + await page.goto('/dashboard/clients_and_providers') + await page.waitForLoadState('networkidle') + + await page.getByRole('link', { name: /Nuevo/ }).click() + await expect(page).toHaveURL(/edit/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + await fillInput(page, '#rfc', CLIENTE_RFC) + await fillInput(page, '#name', CLIENTE_NOMBRE) + + await page.locator('#type').click() + await page.getByRole('option', { name: 'Cliente' }).click() + + await page.waitForTimeout(3000) + + await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click() + + await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 }) + await expect(page.getByText(CLIENTE_NOMBRE)).toBeVisible() + }) + + test('3. crear agente aduanal', async ({ page }) => { + await page.goto('/dashboard/customs_brokers') + await page.waitForLoadState('networkidle') + + await page.getByRole('link', { name: /Nuevo/ }).click() + await expect(page).toHaveURL(/edit/, { timeout: 20000 }) + await page.waitForLoadState('networkidle') + + await fillInput(page, 'input[placeholder="Ej. 550"]', BROKER_KEY) + await fillInput(page, 'input[placeholder="Ej. 3421"]', BROKER_LICENSE) + await fillInput(page, 'input[placeholder="Nombre oficial"]', `Agente E2E ${SUFFIX}`) + + await page.getByRole('button', { name: /Guardar Agente|Actualizar Agente/ }).click() + + await expect(page.getByText(/Agente creado|Agente actualizado/i)).toBeVisible({ timeout: 20000 }) + await expect(page).toHaveURL(/customs_brokers$/, { timeout: 25000 }) + }) + + test('4. crear tipo de cambio', async ({ page }) => { + await page.goto('/dashboard/general_catalogs/exchange-rate') + await page.waitForLoadState('networkidle') + + await page.getByRole('button', { name: /Nuevo Tipo de Cambio/ }).click() + await expect(page.getByRole('heading', { name: 'Nuevo Tipo de Cambio' })).toBeVisible() + + await page.locator('#date').fill(TODAY) + await page.waitForTimeout(3000) + + await fillInput(page, '#value', '17.5') + + await page.getByRole('button', { name: /^Ok$/ }).click() + await page.getByRole('button', { name: 'Confirmar' }).click() + + await expect(page.getByText(/tipo de cambio creado|tipo de cambio actualizado/i)).toBeVisible({ timeout: 20000 }) + }) + + test('5. crear pedimento', async ({ page }) => { + await page.goto('/dashboard/pedimentos/edit/new') + await page.waitForLoadState('networkidle') + + await expect(page.getByText('Nuevo Pedimento')).toBeVisible({ timeout: 20000 }) + + const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ }) + if (await prereqModal.isVisible()) await prereqModal.click() + + await page.waitForTimeout(3000) + + // Año — input con id estable + await fillInput(page, '#year', PEDIMENTO_YEAR) + + // Aduana — bits-ui Select, 1er trigger de la fila superior + const triggers = page.locator('[data-select-trigger]') + await triggers.nth(0).click() + await page.waitForTimeout(2000) + await page.getByRole('option').first().click() + await page.waitForTimeout(1000) + + // Patente — bits-ui Select, 2do trigger + await triggers.nth(1).click() + await page.waitForTimeout(2000) + await page.getByRole('option').first().click() + await page.waitForTimeout(1000) + + // Número de pedimento — input con id estable + await fillInput(page, '#pedimento_number', PEDIMENTO_NUMBER) + + // Clave — bits-ui Select, 3er trigger + await triggers.nth(2).click() + await page.waitForTimeout(2000) + await page.getByRole('option').first().click() + await page.waitForTimeout(1000) + + // Tipo de Operación — bits-ui Select, 4to trigger + await triggers.nth(3).click() + await page.waitForTimeout(2000) + await page.getByRole('option').first().click() + await page.waitForTimeout(1000) + + // Régimen — bits-ui Select, 5to trigger + await triggers.nth(4).click() + await page.waitForTimeout(2000) + await page.getByRole('option').first().click() + await page.waitForTimeout(2000) + + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + + await expect(page).toHaveURL(/pedimentos$/, { timeout: 20000 }) + await expect(page.getByText(/Pedimento creado/i)).toBeVisible({ timeout: 15000 }) + }) + + test('6. crear factura de importacion TEM', async ({ page }) => { + await page.goto('/dashboard/invoices/edit/new?operation_type=imp&invoice_type=TEM') + await page.waitForLoadState('networkidle') + + await expect(page.getByText('Nueva Factura')).toBeVisible({ timeout: 20000 }) + + const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ }) + if (await prereqModal.isVisible()) await prereqModal.click() + + await page.waitForTimeout(5000) + + await page.locator('#invoice_number').click() + await page.keyboard.press('Control+A') + await page.locator('#invoice_number').pressSequentially(INVOICE_NUMBER, { delay: 1000 }) + + await page.waitForTimeout(5000) + + await page.locator('#invoice_date').fill(TODAY) + await page.waitForTimeout(3000) + + await page.getByRole('tab', { name: /General/ }).click() + await page.waitForTimeout(5000) + + await page.locator('#provider_id').click() + await page.waitForTimeout(3000) + await page.getByRole('option').first().click() + + await page.locator('#sold_to_id').click() + await page.waitForTimeout(3000) + await page.getByRole('option').first().click() + + await page.locator('#shipped_to_id').click() + await page.waitForTimeout(3000) + await page.getByRole('option').first().click() + + await page.locator('#customs_broker_id').click() + await page.waitForTimeout(3000) + await page.getByRole('option').first().click() + + await page.locator('#aduana').click() + await page.waitForTimeout(3000) + await page.getByRole('option').first().click() + + await page.locator('#document_type').click() + await page.waitForTimeout(3000) + await page.getByRole('option').first().click() + + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + // Guardar numero de factura para tests posteriores + saveShared({ INVOICE_NUMBER }) + }) + + test('7. factura aparece en la lista de importacion', async ({ page }) => { + const shared = loadShared() + const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM') + await page.waitForLoadState('networkidle') + + await expect(page.locator('main')).toBeVisible() + + await fillInput(page, '#filter-invoice-number', invoiceNumber) + await page.waitForTimeout(8000) + + await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 10000 }) + }) + + test('8. agregar partida a la factura', async ({ page }) => { + const shared = loadShared() + const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM') + await page.waitForLoadState('networkidle') + + await fillInput(page, '#filter-invoice-number', invoiceNumber) + await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 20000 }) + + await page.locator('tbody').getByText(invoiceNumber).first().click() + await page.getByRole('button', { name: /Editar/ }).click() + + await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + // Ir a pestaña Partidas + await page.getByRole('tab', { name: /Partidas/ }).click() + await page.waitForTimeout(3000) + + // Abrir sheet de nueva partida + await page.getByRole('button', { name: /Agregar Partidas/ }).click() + await page.waitForTimeout(3000) + + // Clase — abre un dialog de búsqueda con tabla, scopear al dialog activo + await page.locator('#clase').click() + await page.waitForTimeout(3000) + // El dialog de clase tiene data-nested y está encima del sheet + // Scopear al último dialog abierto para evitar que el sheet intercepte + const claseDialog = page.locator('[data-dialog-content]').last() + await claseDialog.locator('tbody tr').first().click() + await page.waitForTimeout(2000) + + // Unidad de medida — mismo patron + await page.locator('#um').click() + await page.waitForTimeout(3000) + const umDialog = page.locator('[data-dialog-content]').last() + await umDialog.locator('tbody tr').first().click() + await page.waitForTimeout(2000) + + // País de origen — abre dialog con tabla igual que clase y UM + await page.locator('#pais_origen').click() + await page.waitForTimeout(3000) + const paisDialog = page.locator('[data-dialog-content]').last() + await paisDialog.locator('tbody tr').first().click() + await page.waitForTimeout(2000) + + // Cantidad + await fillInput(page, '#cantidad', '10') + + // Costo unitario + await fillInput(page, '#costo_unitario', '100') + + // Peso neto y bruto — requeridos por el backend + await fillInput(page, '#peso_neto', '5') + await fillInput(page, '#peso_bruto', '6') + + // Descripción en español (textarea) + await fillTextarea(page, '#desc_espanol', `Partida E2E ${SUFFIX}`) + + // Guardar partida — botón "Crear" dentro del sheet + const sheet = page.locator('[data-slot="sheet-content"]') + await sheet.getByRole('button', { name: /Crear/ }).click() + await page.waitForTimeout(3000) + + // Verificar que la partida aparece en la tabla + await expect(page.locator('tbody').first()).toBeVisible({ timeout: 10000 }) + + // Guardar factura completa + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + }) + + test('9. editar factura existente', async ({ page }) => { + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM') + await page.waitForLoadState('networkidle') + + const shared9 = loadShared() + const invoiceNumber9 = shared9.INVOICE_NUMBER || INVOICE_NUMBER + + await fillInput(page, '#filter-invoice-number', invoiceNumber9) + await expect(page.locator('tbody').getByText(invoiceNumber9).first()).toBeVisible({ timeout: 20000 }) + + await page.locator('tbody').getByText(invoiceNumber9).first().click() + await page.getByRole('button', { name: /Editar/ }).click() + + await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + await expect(page.getByText(/Factura #/)).toBeVisible() + + await page.getByRole('tab', { name: /General/ }).click() + await page.waitForTimeout(5000) + + await page.locator('#customs_broker_id').click() + await page.waitForTimeout(3000) + await page.getByRole('option').first().click() + + await page.locator('#aduana').click() + await page.waitForTimeout(3000) + await page.getByRole('option').first().click() + + await page.locator('#document_type').click() + await page.waitForTimeout(3000) + await page.getByRole('option').first().click() + + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + }) + + test('10. editar partida existente', async ({ page }) => { + const shared10 = loadShared() + const invoiceNumber10 = shared10.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM') + await page.waitForLoadState('networkidle') + + await fillInput(page, '#filter-invoice-number', invoiceNumber10) + await expect(page.locator('tbody').getByText(invoiceNumber10).first()).toBeVisible({ timeout: 20000 }) + + await page.locator('tbody').getByText(invoiceNumber10).first().click() + await page.getByRole('button', { name: /Editar/ }).click() + + await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + // Ir a pestaña Partidas + await page.getByRole('tab', { name: /Partidas/ }).click() + await page.waitForTimeout(3000) + + // Clic en botón editar de la primera partida + // Esperar que el sheet este cerrado antes de interactuar con la tabla + // Abrir sheet de edicion via botón Pencil de la primera fila + await page.locator('tbody tr').first().locator('[class*="lucide-pencil"], svg.lucide-pencil').click({ force: true }).catch(async () => { + // Fallback: hover sobre la fila primero para revelar botones, luego click + await page.locator('tbody tr').first().hover() + await page.waitForTimeout(500) + await page.locator('tbody tr').first().getByRole('button').first().click({ force: true }) + }) + await page.waitForTimeout(3000) + + // Modificar cantidad + await fillInput(page, '#cantidad', '20') + + // Modificar costo unitario + await fillInput(page, '#costo_unitario', '200') + + // Guardar partida editada — botón "Actualizar" dentro del sheet + const sheetEdit = page.locator('[data-slot="sheet-content"]') + await sheetEdit.getByRole('button', { name: /Actualizar/ }).click() + await page.waitForTimeout(3000) + + // Guardar factura completa + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + }) + + test('11. actualizar factura — verificacion final', async ({ page }) => { + const shared11 = loadShared() + const invoiceNumber11 = shared11.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM') + await page.waitForLoadState('networkidle') + + await fillInput(page, '#filter-invoice-number', invoiceNumber11) + await expect(page.locator('tbody').getByText(invoiceNumber11).first()).toBeVisible({ timeout: 20000 }) + + // Seleccionar la fila + await page.locator('tbody tr').first().click() + await page.waitForTimeout(2000) + + // Click en boton Actualizar del footer — tiene h-8, gap-1.5 y border + await page.locator('button.h-8:has([class*="lucide-refresh"])').click() + await page.waitForTimeout(5000) + + // Verificar resultado + await expect(page.getByText(/actualiz|procesad|exito/i).first()).toBeVisible({ timeout: 20000 }) + }) + +}) \ No newline at end of file diff --git a/frontend/e2e/login.spec.ts b/frontend/e2e/login.spec.ts new file mode 100644 index 00000000..8a0df28c --- /dev/null +++ b/frontend/e2e/login.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test' + +test.describe('Login', () => { + + test('login exitoso redirige al dashboard', async ({ page }) => { + await page.goto('/login') + + await page.locator('input[id^="username"]').fill('demo') + await page.locator('input[id^="password"]').fill('demo123') + await page.click('button[type="submit"]') + + await expect(page).toHaveURL(/dashboard/, { timeout: 30000 }) + }) + + test('dashboard muestra saludo al usuario', async ({ page }) => { + await page.goto('/login') + + await page.locator('input[id^="username"]').fill('demo') + await page.locator('input[id^="password"]').fill('demo123') + await page.click('button[type="submit"]') + + await page.waitForURL(/dashboard/, { timeout: 30000 }) + + const modal = page.locator('button:has-text("Cancelar")') + if (await modal.isVisible()) { + await modal.click() + } + + await expect(page.locator('h1')).toBeVisible() + }) + + test('login con credenciales incorrectas muestra error', async ({ page }) => { + await page.goto('/login') + + await page.locator('input[id^="username"]').fill('usuario_falso') + await page.locator('input[id^="password"]').fill('password_falso') + await page.click('button[type="submit"]') + + await expect(page).toHaveURL(/login/) + }) + +}) \ No newline at end of file diff --git a/frontend/e2e/modules.spec.ts b/frontend/e2e/modules.spec.ts new file mode 100644 index 00000000..5a48f8ee --- /dev/null +++ b/frontend/e2e/modules.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test' + +test.describe('Modulos', () => { + + test.describe('Import Invoices', () => { + + test('factura TEM carga sin error', async ({ page }) => { + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM') + await expect(page).toHaveURL(/invoices/) + await expect(page.locator('main')).toBeVisible() + }) + + test('factura DEF carga sin error', async ({ page }) => { + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=DEF') + await expect(page).toHaveURL(/invoices/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('Export Invoices', () => { + + test('exportacion carga sin error', async ({ page }) => { + await page.goto('/dashboard/invoices?operation_type=exp') + await expect(page).toHaveURL(/invoices/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('Fixed Catalogs', () => { + + test('carga sin error', async ({ page }) => { + await page.goto('/dashboard/reference_data/code_pedimento_regimens') + await expect(page).toHaveURL(/reference_data/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('General Catalogs', () => { + + test('company information carga sin error', async ({ page }) => { + await page.goto('/dashboard/general_catalogs/company_information') + await expect(page).toHaveURL(/company_information/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('Transportes', () => { + + test('transporters carga sin error', async ({ page }) => { + await page.goto('/dashboard/general_catalogs/transporters') + await expect(page).toHaveURL(/transporters/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('Goods', () => { + + test('fixed asset classes carga sin error', async ({ page }) => { + await page.goto('/dashboard/goods/fixed-asset-classes') + await expect(page).toHaveURL(/goods/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('Settings', () => { + + test('general carga sin error', async ({ page }) => { + await page.goto('/dashboard/settings/general') + await expect(page).toHaveURL(/settings/) + await expect(page.locator('body')).toBeVisible() + }) + + }) + + test.describe('Reportes', () => { + + test('invoices carga sin error', async ({ page }) => { + await page.goto('/dashboard/reports/invoices') + await expect(page).toHaveURL(/reports/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('Logout', () => { + + test('cerrar sesion redirige a login', async ({ page }) => { + await page.goto('/dashboard') + await page.locator('[data-sidebar="footer"]') + .getByRole('button').first().click() + await page.getByText('Log out').click() + await expect(page).toHaveURL(/login/, { timeout: 15000 }) + }) + + }) + +}) \ No newline at end of file diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts new file mode 100644 index 00000000..1f24d11d --- /dev/null +++ b/frontend/e2e/navigation.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test' + +test.describe('Navegacion', () => { + + test.beforeEach(async ({ page }) => { + await page.goto('/dashboard') + }) + + test('dashboard carga con saludo', async ({ page }) => { + await expect(page.locator('h1')).toBeVisible() + }) + + test('header muestra nombre de la empresa', async ({ page }) => { + await page.waitForLoadState('networkidle') + await expect(page.getByText('Aduanasoft S.A. de C.V.').first()) + .toBeVisible({ timeout: 10000 }) + }) + + test.describe('Menu lateral', () => { + + test('tiene enlace a Audit Logs', async ({ page }) => { + await expect(page.getByRole('link', { name: 'Audit Logs' })).toBeVisible() + }) + + test('tiene enlace a Customs Brokers', async ({ page }) => { + await expect(page.getByRole('link', { name: 'Customs Brokers' })).toBeVisible() + }) + + test('Fractions aparece en el menu', async ({ page }) => { + await expect(page.getByText('Fractions').first()).toBeVisible() + }) + + test('Pedimentos aparece en el menu', async ({ page }) => { + await expect(page.getByText('Pedimentos').first()).toBeVisible() + }) + + }) + + test.describe('Modulos accesibles', () => { + + test('Audit Logs carga sin error', async ({ page }) => { + await page.getByRole('link', { name: 'Audit Logs' }).click() + await expect(page).toHaveURL(/audit/) + await expect(page.locator('h1')).toBeVisible() + }) + + test('Customs Brokers carga sin error', async ({ page }) => { + await page.getByRole('link', { name: 'Customs Brokers' }).click() + await expect(page).toHaveURL(/customs/) + await expect(page.locator('h1')).toBeVisible() + }) + + test('Fraction Sitar carga sin error', async ({ page }) => { + await page.goto('/dashboard/general_catalogs/tariff-fractions/sitar') + await expect(page).toHaveURL(/tariff-fractions/) + await expect(page.locator('main')).toBeVisible() + }) + + test('Pedimentos carga sin error', async ({ page }) => { + await page.goto('/dashboard/reference_data/code_pedimento_regimens') + await expect(page).toHaveURL(/reference_data/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + +}) \ No newline at end of file diff --git a/frontend/e2e/setup-catalogs.spec.ts b/frontend/e2e/setup-catalogs.spec.ts new file mode 100644 index 00000000..cc1f14a6 --- /dev/null +++ b/frontend/e2e/setup-catalogs.spec.ts @@ -0,0 +1,152 @@ +import { test, expect, type Page } from '@playwright/test' +import * as fs from 'fs' +import { fileURLToPath } from 'url' +import * as path from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const SHARED_FILE = path.join(__dirname, '.e2e-catalog.json') + +function saveCatalog(data: Record) { + fs.writeFileSync(SHARED_FILE, JSON.stringify(data)) +} + +// Clase y parte con fraccion valida del catalogo SITAR +// 8471.30.01 es una fraccion comun para equipos de computo — existe en SITAR +const CLASS_CODE = 'E2E01' +const CLASS_FRACTION = '12787' +const CLASS_US_FRACTION = '12787' +const CLASS_UM = 'KG' +const CLASS_MATERIAL_KEY = 'EAGRI' +const CLASS_DESC_ES = 'Clase E2E Test' +const CLASS_DESC_EN = 'E2E Test Class' + +const PART_NUMBER = 'E2E-PART-001' +const PART_DESC_ES = 'Parte E2E Test' + +async function fillInput(page: Page, selector: string, value: string) { + await page.locator(selector).click() + await page.evaluate(({ sel, val }: { sel: string; val: string }) => { + const el = document.querySelector(sel) as HTMLInputElement + if (!el) return + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + setter?.call(el, val) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, { sel: selector, val: value }) + await page.waitForTimeout(300) +} + +test.describe('Setup — Catalogos para pruebas E2E', () => { + + test('1. crear clase valida', async ({ page }) => { + await page.goto('/dashboard/goods/fixed-asset-classes') + await page.waitForLoadState('networkidle') + + // Abrir dialog de nueva clase + await page.getByRole('button', { name: /Insertar|Nueva Clase|Nuevo/ }).first().click() + await page.waitForTimeout(800) + + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }) + + // Llenar campos + await page.locator('#class_code').scrollIntoViewIfNeeded() + await fillInput(page, '#class_code', CLASS_CODE) + + await page.locator('#material_key').scrollIntoViewIfNeeded() + await fillInput(page, '#material_key', CLASS_MATERIAL_KEY) + + await page.locator('#description_es').scrollIntoViewIfNeeded() + await fillInput(page, '#description_es', CLASS_DESC_ES) + + await page.locator('#description_en').scrollIntoViewIfNeeded() + await fillInput(page, '#description_en', CLASS_DESC_EN) + + await page.locator('#unit_of_measure').scrollIntoViewIfNeeded() + await fillInput(page, '#unit_of_measure', CLASS_UM) + + await page.locator('#fraction').scrollIntoViewIfNeeded() + await fillInput(page, '#fraction', CLASS_FRACTION) + + await page.locator('#us_fraction').scrollIntoViewIfNeeded() + await fillInput(page, '#us_fraction', CLASS_US_FRACTION) + + // Guardar + await page.getByRole('button', { name: /^Guardar$/ }).scrollIntoViewIfNeeded() + await page.getByRole('button', { name: /^Guardar$/ }).click() + await page.waitForTimeout(1000) + + // Verificar que no hay error — dialog cierra o muestra exito + const hasError = await page.locator('.text-destructive').isVisible({ timeout: 1000 }).catch(() => false) + + if (hasError) { + const errorText = await page.locator('.text-destructive').textContent() + console.log('Error al crear clase:', errorText) + // Si la clase ya existe, continuar igual + } + + saveCatalog({ CLASS_CODE, PART_NUMBER }) + await expect(page.locator('main')).toBeVisible() + }) + + test('2. verificar clase en tabla', async ({ page }) => { + await page.goto('/dashboard/goods/fixed-asset-classes') + await page.waitForLoadState('networkidle') + + // Buscar la clase creada en la tabla + const classRow = page.locator('tbody').getByText(CLASS_CODE) + if (await classRow.isVisible({ timeout: 5000 }).catch(() => false)) { + await expect(classRow.first()).toBeVisible() + } else { + // La clase puede no aparecer si ya existia — OK + console.log('Clase no encontrada en tabla — puede ya existir con otro nombre') + } + }) + + test('3. crear parte valida', async ({ page }) => { + // Las partes se crean en /dashboard/goods/parts/edit/new + await page.goto('/dashboard/goods/parts/edit/new') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(800) + + // Llenar campos del formulario de nueva parte + // Numero de parte + const partInput = page.locator('#part_number, input[placeholder*="parte"], input[placeholder*="número"], input[name="part_number"]').first() + if (await partInput.isVisible({ timeout: 3000 }).catch(() => false)) { + await partInput.scrollIntoViewIfNeeded() + await fillInput(page, '#part_number', PART_NUMBER).catch(async () => { + await partInput.fill(PART_NUMBER) + }) + } + + // Descripcion en español + const descInput = page.locator('#description_es, textarea[placeholder*="español"], input[placeholder*="escripción"]').first() + if (await descInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await descInput.scrollIntoViewIfNeeded() + await descInput.fill(PART_DESC_ES).catch(() => {}) + } + + // Clase — puede ser un input o select + const claseInput = page.locator('#class_code, #clase, input[placeholder*="Clase"], input[placeholder*="clase"]').first() + if (await claseInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await claseInput.scrollIntoViewIfNeeded() + await claseInput.fill(CLASS_CODE).catch(() => {}) + await page.waitForTimeout(500) + const option = page.getByRole('option').first() + if (await option.isVisible({ timeout: 1000 }).catch(() => false)) { + await option.click() + } + } + + // Guardar + const saveBtn = page.getByRole('button', { name: /Guardar|Crear|Insertar/ }).first() + if (await saveBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await saveBtn.scrollIntoViewIfNeeded() + await saveBtn.click() + await page.waitForTimeout(1000) + } + + await expect(page.locator('main')).toBeVisible() + }) + +}) \ No newline at end of file diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index f6c81af8..cbe14e8f 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,9 +1,27 @@ import { defineConfig } from '@playwright/test'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ - webServer: { - command: 'npm run build && npm run preview', - port: 4173 - }, - testDir: 'e2e' -}); + timeout: 60000, + use: { + baseURL: 'http://localhost:5173', + headless: false, + storageState: path.join(__dirname, 'e2e/.auth/user.json') + }, + workers: 1, + testDir: 'e2e', + projects: [ + { + name: 'setup', + testMatch: '**/auth.setup.ts' + }, + { + name: 'tests', + dependencies: ['setup'], + testIgnore: ['**/auth.setup.ts', '**/demo.test.ts'] + } + ] +}); \ No newline at end of file diff --git a/frontend/src/lib/Reporte_Pruebas.MD b/frontend/src/lib/Reporte_Pruebas.MD new file mode 100644 index 00000000..a9e395c7 --- /dev/null +++ b/frontend/src/lib/Reporte_Pruebas.MD @@ -0,0 +1,259 @@ +# Reporte de Pruebas — Anexo 76 + +## Resumen ejecutivo + +| Herramienta | Archivos | Pruebas | Estado | +|-------------|----------|---------|--------| +| Backend — pytest | 4 | 11 | Pasando | +| Frontend — Vitest (server) | 8 | 68 | Pasando | +| Frontend — Playwright (E2E) | 5 | 28 | Pasando | +| **Total** | **17** | **107** | **Pasando** | + +--- + +## Backend — pytest (11 pruebas) + +Ubicacion: `backend/tests/` + +### e2e/test_inventory_flow_anexo24.py — 1 prueba + +**test_e2e_inventory_flow_import_then_export** — la prueba mas importante del repositorio. Simula el flujo completo del negocio: + +1. Crea una factura de importacion TEM con 10 piezas +2. La procesa — genera movimiento ENTRY en el inventario +3. Crea una factura de exportacion consumiendo 4 piezas +4. La procesa — genera CONSUMPTION y DISCHARGE +5. Verifica que el saldo neto es positivo y menor a 10 + +### integration/ — 5 pruebas + +- **test_process_export_endpoint_consumes_existing_balances** — exportacion consume saldos existentes correctamente +- **test_process_export_prevents_negative_balance** — exportacion no puede consumir mas de lo que hay +- **test_process_endpoint_prevents_double_processing_import** — una factura no se puede procesar dos veces +- **test_process_import_endpoint_creates_balance_entries** — importacion TEM genera entradas de balance +- **test_process_import_def_does_not_create_balance_entries** — importacion DEF no genera entradas de balance + +### unit/ — 5 pruebas + +- **test_net_balance_accounts_for_returns_and_entry_void** — balance neto calcula correctamente entradas, consumos, devoluciones y anulaciones +- **test_fifo_consumption_algorithm_uses_oldest_lots_first** — algoritmo PEPS consume primero los lotes mas antiguos +- **test_assign_values_iva_lines_currency_me** — calculo de IVA en moneda extranjera +- **test_assign_values_iva_lines_currency_mn** — calculo de IVA en moneda local +- **test_assign_values_iva_lines_currency_mc** — calculo de IVA en moneda manual + +### Comando + +```bash +docker exec -it anexo76-backend pytest /app/tests/ -v +``` + +--- + +## Frontend Vitest — 68 pruebas + +Vitest corre en Node sin navegador. Prueba funciones puras que reciben datos y devuelven un resultado. + +### backend.test.ts — 2 pruebas + +Verifica que el backend esta disponible desde el contenedor del frontend usando la red interna de Docker. + +- **el backend esta corriendo y responde** — GET /api/health devuelve 200 +- **el endpoint de facturas responde** — el endpoint de invoices responde (200, 401, 403 o 422 son validos) + +Nota: no se usa `docker exec` porque el contenedor del frontend no tiene acceso a Docker. Se usa la URL interna `http://backend:8000` de la red Docker Compose. + +### utils.getInvoiceTypeColor.test.ts — 19 pruebas + +Prueba que cada tipo de factura devuelve el color Tailwind correcto para mostrarse en la UI. + +- Entradas invalidas (null, undefined, '') devuelven gris por defecto +- TEM / IMPO TEM -> rojo (bg-red-100) +- DEF / IMPO DEF -> verde (bg-green-100) +- MEX / COMP MEX -> morado (bg-purple-100) +- CAM REG -> azul (bg-blue-100) +- EXPO / EXDEF / PTERM -> azul (bg-blue-100) +- IMP REP -> azul claro (bg-sky-100) +- Tipo desconocido -> gris por defecto + +### utils.getFileHelpers.test.ts — 9 pruebas + +Prueba extraccion de nombres de archivo desde rutas y formateo para mostrar al usuario. + +- null / undefined -> string vacio +- Ruta normal '/uploads/file.png' -> 'file.png' +- Ruta con query string '/uploads/file.png?token=123' -> 'file.png' sin el token +- Con fileType -> 'file.png (PDF)' + +### utils.getBackendAssetUrl.test.ts — 5 pruebas + +Prueba construccion de URLs del backend evitando duplicar /api. + +Deuda tecnica identificada: la funcion depende de import.meta.env.VITE_API_URL. Se pasa baseUrl explicitamente en cada test. + +- null / undefined -> string vacio +- URL completa -> se devuelve sin modificar +- /api/... con base /api -> evita /api/api/ +- Ruta normal -> URL completa correcta + +### date-utils.test.ts — 17 pruebas + +Prueba conversion de fechas entre zona local y UTC ISO. + +- prepareDateForBackend: vacio -> null, fecha + hora -> ISO string valido +- loadServerDate: null/undefined -> '', ISO UTC -> YYYY-MM-DD, fecha plana sin modificar +- addDaysLocal: vacio -> '', suma normal, cambio de mes, ano bisiesto (2024-02-29) +- getCurrentLocal*: verifica formato con regex — no valor exacto porque cambia cada dia + +### csv-import-commit-metrics.test.ts — 11 pruebas + +Prueba metricas del resultado de importacion CSV. + +- totalSkippedFromCommit: null -> 0, objeto vacio -> 0, suma correcta de campos skipped +- criticalReferenceGaps: null -> 0, objeto vacio -> 0, campo presente -> su valor +- referenceStateReady: null -> true, campo booleano respeta el valor + +### csv-import-status-api.test.ts — 4 pruebas + +Prueba isWaitingConfirmationPayload. fetchCsvImportStatus no se prueba porque depende del backend. + +- null / {} -> false +- { status: 'waiting_confirmation' } -> true +- { job_id: 'abc', total_rows: 10 } -> true + +### Comando + +```bash +docker exec -it anexo76-frontend pnpm test:unit --project server +``` + +--- + +## Frontend Playwright E2E — 28 pruebas + +Playwright abre un navegador real y prueba flujos completos con el backend y Keycloak levantados. + +### Configuracion + +- **workers: 1** — las pruebas con login no pueden correr en paralelo +- **headless: false** — necesario para estabilidad en Windows +- **timeout: 60000** — Keycloak puede tardar hasta 60 segundos +- **storageState** — sesion guardada en e2e/.auth/user.json y reutilizada + +### auth.setup.ts — 1 prueba + +Login inicial con credenciales demo/demo123. Guarda la sesion para reutilizar. + +### full-flow.spec.ts — 4 pruebas + +Conecta backend y frontend — corre pytest primero y si pasa, verifica el frontend. + +- **pruebas del backend pasan antes de continuar** — ejecuta pytest /app/tests/ y verifica 11 passed +- **Import Invoices TEM carga despues de que backend pasa** — URL /invoices + main visible +- **Export Invoices carga despues de que backend pasa** — URL /invoices + main visible +- **Reportes de facturas carga despues de que backend pasa** — URL /reports + main visible + +### login.spec.ts — 3 pruebas + +- Login exitoso redirige al dashboard +- Dashboard muestra h1 visible +- Credenciales incorrectas se quedan en /login + +### navigation.spec.ts — 10 pruebas + +- Dashboard carga con h1 visible +- Header muestra Aduanasoft S.A. de C.V. +- Menu lateral tiene Audit Logs, Customs Brokers, Fractions, Pedimentos +- Audit Logs, Customs Brokers, Fraction Sitar y Pedimentos cargan sin error + +### modules.spec.ts — 10 pruebas + +- Import Invoices TEM y DEF cargan sin error +- Export Invoices carga sin error +- Fixed Catalogs, General Catalogs, Transportes, Goods, Settings, Reportes cargan sin error +- Logout redirige a /login + +### Comando + +```bash +# Desde la carpeta frontend en Windows +pnpm test:e2e + +# Suite especifica +pnpm test:e2e --grep "Flujo completo" +pnpm test:e2e --grep "Login" +pnpm test:e2e --grep "Modulos" +pnpm test:e2e --grep "Navegacion" +``` + +--- + +## Archivos que NO se prueban con Vitest + +| Archivo | Razon | +|---------|-------| +| api.ts | Depende de fetch, cookies, window, document | +| auth.ts | Depende de Keycloak JS, cookies, window | +| session-manager.ts | Depende de window, setInterval, eventos DOM | +| sso.ts | Depende de Keycloak JS y browser | +| csv-import-pending.ts | Depende de localStorage | +| csv-import-session.ts | Depende de sessionStorage | +| csv-upload-row-count.ts | Depende de File.text(), API del browser | +| fetchCsvImportStatus | Depende de api que necesita el backend | + +--- + +## Pruebas pendientes para el futuro + +### Necesitan datos en la BD +- Buscar una factura especifica en Import Invoices +- Filtrar por fecha en Reportes +- Verificar que tablas muestran registros tras procesar una factura + +### Necesitan mas usuarios +- RBAC: usuario sin permisos intenta acceder a ruta protegida +- Admin vs usuario normal ven opciones distintas + +### Necesitan flujo completo +- Subir un CSV y verificar que el proceso funciona +- Crear una factura y verificar que aparece en la tabla + +--- + +## Estructura de archivos final + +``` +backend/tests/ +├── conftest.py +├── fixtures/ +│ └── builders.py +├── e2e/ +│ └── test_inventory_flow_anexo24.py 1 test +├── integration/ +│ ├── test_export_process_api.py 3 tests +│ └── test_import_process_api.py 2 tests +└── unit/ + └── invoices/ + ├── test_balance_algorithm.py 2 tests + └── test_currency_conversion.py 3 tests + +frontend/src/lib/ +├── backend.test.ts 2 tests +├── utils.getInvoiceTypeColor.test.ts 19 tests +├── utils.getFileHelpers.test.ts 9 tests +├── utils.getBackendAssetUrl.test.ts 5 tests +├── date-utils.test.ts 17 tests +├── csv-import-commit-metrics.test.ts 11 tests +└── csv-import-status-api.test.ts 4 tests + +frontend/e2e/ +├── .auth/user.json +├── auth.setup.ts 1 test +├── full-flow.spec.ts 4 tests +├── login.spec.ts 3 tests +├── navigation.spec.ts 10 tests +└── modules.spec.ts 10 tests +``` + +--- + +*Anexo 76 — Reporte de Pruebas v3.0 — 107 pruebas totales* \ No newline at end of file diff --git a/frontend/src/lib/backend.test.ts b/frontend/src/lib/backend.test.ts new file mode 100644 index 00000000..6b47dbdd --- /dev/null +++ b/frontend/src/lib/backend.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest' + +describe('backend — health check', () => { + + it('el backend esta corriendo y responde', async () => { + const response = await fetch('http://backend:8000/api/health') + expect(response.status).toBe(200) + }) + + it('el endpoint de facturas responde', async () => { + const response = await fetch('http://backend:8000/api/v1/a76/invoices/?company_id=1', { + headers: { 'Authorization': 'Bearer test' } + }) + // 200 con datos o 401/403 sin token valido — ambos significan que el backend esta vivo + expect([200, 401, 403, 422]).toContain(response.status) + }) + +}) \ No newline at end of file diff --git a/frontend/src/lib/csv-import-commit-metrics.test.ts b/frontend/src/lib/csv-import-commit-metrics.test.ts new file mode 100644 index 00000000..04075477 --- /dev/null +++ b/frontend/src/lib/csv-import-commit-metrics.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { totalSkippedFromCommit, criticalReferenceGaps, referenceStateReady } from './csv-import-commit-metrics' + +describe('csv-import-commit-metrics', () => { + +describe('totalSkippedFromCommit', () => { + it('devuelve 0 si recibe null', () => { + expect(totalSkippedFromCommit(null)).toBe(0) + }) + it('devuelve 0 si recibe objeto vacío', () => { + expect(totalSkippedFromCommit({})).toBe(0) + }) + it('suma los campos skipped correctamente', () => { + expect(totalSkippedFromCommit({skipped_invalid: 2, skipped_duplicate: 3})).toBe(5) + }) +}) + +describe('criticalReferenceGaps', () => { + it('devuelve 0 si recibe null', () => { + expect(criticalReferenceGaps(null)).toBe(0) + }) + it('devuelve 0 si recibe objeto vacío', () => { + expect(criticalReferenceGaps({})).toBe(0) + }) + it('devuelve el valor del campo', () => { + expect(criticalReferenceGaps({ critical_reference_gaps: 4 })).toBe(4) + }) +}) +describe('referenceStateReady', () => { + it('devuelve true si recibe null', () => { + expect(referenceStateReady(null)).toBe(true) + }) + it('devuelve true si reference_state_ready es true', () => { + expect(referenceStateReady({ reference_state_ready: true })).toBe(true) + }) + it('devuelve false si reference_state_ready es false', () => { + expect(referenceStateReady({ reference_state_ready: false })).toBe(false) + }) + it('devuelve true si critical_reference_gaps es 0', () => { + expect(referenceStateReady({ critical_reference_gaps: 0 })).toBe(true) + }) + it('devuelve false si critical_reference_gaps es mayor a 0', () => { + expect(referenceStateReady({ critical_reference_gaps: 2 })).toBe(false) + }) +}) +}) \ No newline at end of file diff --git a/frontend/src/lib/csv-import-status-api.test.ts b/frontend/src/lib/csv-import-status-api.test.ts new file mode 100644 index 00000000..164b30b8 --- /dev/null +++ b/frontend/src/lib/csv-import-status-api.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest' +import { isWaitingConfirmationPayload} from './csv-import-status-api' +describe('csv-import-status-api', () => { + describe('isWaitingConfirmationPayload', () => { + it('devuelve false si recibe null', () => { + expect(isWaitingConfirmationPayload(null)).toBe(false) + }) + it('devuelve false si recibe objeto vacío', () => { + expect(isWaitingConfirmationPayload({})).toBe(false) + }) + it('devuelve true si status es waiting_confirmation', () => { + expect(isWaitingConfirmationPayload({ status: 'waiting_confirmation' })).toBe(true) + }) + it('devuelve true si tiene job_id y total_rows', () => { + expect(isWaitingConfirmationPayload({ job_id: 'abc', total_rows: 10 })).toBe(true) + }) + }) +}) \ No newline at end of file diff --git a/frontend/src/lib/date-utils.test.ts b/frontend/src/lib/date-utils.test.ts new file mode 100644 index 00000000..e47437da --- /dev/null +++ b/frontend/src/lib/date-utils.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest' +import { + prepareDateForBackend, + loadServerDate, + addDaysLocal, + getCurrentLocalYear, + getCurrentLocalDate, + getCurrentLocalTime +} from './date-utils' + +const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/ +const ISO_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ +const TIME_REGEX = /^\d{2}:\d{2}$/ + +describe('date-utils', () => { + + describe('prepareDateForBackend', () => { + + it('devuelve null si dateStr está vacío', () => { + expect(prepareDateForBackend('')).toBeNull() + }) + + it('devuelve ISO string con fecha y hora explícita', () => { + const result = prepareDateForBackend('2024-06-15', '09:30') + expect(result).toMatch(ISO_DATETIME_REGEX) + }) + + it('el resultado es una fecha JavaScript válida', () => { + const result = prepareDateForBackend('2024-06-15', '09:30') + expect(new Date(result!).toString()).not.toBe('Invalid Date') + }) + + it('usa 00:00 como hora por defecto', () => { + const result = prepareDateForBackend('2024-06-15') + expect(result).toMatch(ISO_DATETIME_REGEX) + }) + + }) + + describe('loadServerDate', () => { + + it.each([ + [null, ''], + [undefined, ''], + ['', ''], + ])('devuelve string vacío para %s', (input, expected) => { + expect(loadServerDate(input)).toBe(expected) + }) + + it('convierte ISO UTC a formato YYYY-MM-DD', () => { + const result = loadServerDate('2024-06-15T12:00:00.000Z') + expect(result).toMatch(ISO_DATE_REGEX) + }) + + it('retorna fecha plana sin modificarla', () => { + expect(loadServerDate('2024-06-15')).toBe('2024-06-15') + }) + + }) + + describe('addDaysLocal', () => { + + it('devuelve string vacío si dateStr está vacío', () => { + expect(addDaysLocal('', 5)).toBe('') + }) + + it.each([ + ['2024-01-01', 1, '2024-01-02'], + ['2024-01-31', 1, '2024-02-01'], + ['2024-03-01', -1, '2024-02-29'], + ])('suma %s + %s días = %s', (date, days, expected) => { + expect(addDaysLocal(date, days)).toBe(expected) + }) + + }) + + describe('getCurrentLocalYear', () => { + + it('devuelve 2 dígitos', () => { + expect(getCurrentLocalYear()).toMatch(/^\d{2}$/) + }) + + it('corresponde al año actual del sistema', () => { + const expected = String(new Date().getFullYear()).slice(-2) + expect(getCurrentLocalYear()).toBe(expected) + }) + + }) + + describe('getCurrentLocalDate', () => { + + it('devuelve formato YYYY-MM-DD', () => { + expect(getCurrentLocalDate()).toMatch(ISO_DATE_REGEX) + }) + + }) + + describe('getCurrentLocalTime', () => { + + it('devuelve formato HH:MM', () => { + expect(getCurrentLocalTime()).toMatch(TIME_REGEX) + }) + + }) + +}) \ No newline at end of file diff --git a/frontend/src/lib/utils.getBackendAssetUrl.test.ts b/frontend/src/lib/utils.getBackendAssetUrl.test.ts new file mode 100644 index 00000000..0c607cdc --- /dev/null +++ b/frontend/src/lib/utils.getBackendAssetUrl.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' +import { getBackendAssetUrl } from './utils' + +describe('getBackendAssetUrl', () => { + + it('devuelve string vacío si path es null', () => { + expect(getBackendAssetUrl(null)).toBe('') + }) + + it('devuelve string vacío si path es undefined', () => { + expect(getBackendAssetUrl(undefined)).toBe('') + }) + + it('devuelve la URL tal cual si ya es completa', () => { + expect(getBackendAssetUrl('http://ejemplo.com/archivo.png')).toBe('http://ejemplo.com/archivo.png') + }) + + it('evita duplicar /api en la URL', () => { + expect(getBackendAssetUrl('/api/v1/items', 'http://localhost:8000/api')).toBe('http://localhost:8000/api/v1/items') + }) + + it('construye URL completa para ruta normal', () => { + expect(getBackendAssetUrl('/uploads/file.png', 'http://localhost:8000/api')).toBe('http://localhost:8000/api/uploads/file.png') + }) + +}) \ No newline at end of file diff --git a/frontend/src/lib/utils.getFileHelpers.test.ts b/frontend/src/lib/utils.getFileHelpers.test.ts new file mode 100644 index 00000000..6a028364 --- /dev/null +++ b/frontend/src/lib/utils.getFileHelpers.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { getFileNameFromPath, getFileDisplayName } from './utils' + +describe('getFileNameFromPath', () => { + + it('devuelve string vacío si recibe null', () => { + expect(getFileNameFromPath(null)).toBe('') + }) + + it('devuelve string vacío si recibe undefined', () => { + expect(getFileNameFromPath(undefined)).toBe('') + }) + + it('devuelve solo el nombre del archivo', () => { + expect(getFileNameFromPath('/uploads/avatars/file.png')).toBe('file.png') + }) + + it('devuelve nombre sin query string', () => { + expect(getFileNameFromPath('/uploads/file.png?token=123')).toBe('file.png') + }) + +}) + +describe('getFileDisplayName', () => { + + it('devuelve label por defecto si filePath es null', () => { + expect(getFileDisplayName(null)).toBe('Seleccionar archivo') + }) + + it('devuelve label por defecto si filePath es undefined', () => { + expect(getFileDisplayName(undefined)).toBe('Seleccionar archivo') + }) + + it('devuelve label personalizado si se pasa defaultLabel', () => { + expect(getFileDisplayName(null, undefined, 'Subir archivo')).toBe('Subir archivo') + }) + + it('devuelve solo el nombre del archivo sin fileType', () => { + expect(getFileDisplayName('/uploads/file.png')).toBe('file.png') + }) + + it('devuelve nombre con tipo en mayúsculas', () => { + expect(getFileDisplayName('/uploads/file.png', 'pdf')).toBe('file.png (PDF)') + }) + +}) \ No newline at end of file diff --git a/frontend/src/lib/utils.getInvoiceTypeColor.test.ts b/frontend/src/lib/utils.getInvoiceTypeColor.test.ts new file mode 100644 index 00000000..4c4d3116 --- /dev/null +++ b/frontend/src/lib/utils.getInvoiceTypeColor.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { getInvoiceTypeColor } from './utils' + +const DEFAULT_COLOR = 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200' + +describe('getInvoiceTypeColor', () => { + + describe('cuando no recibe tipo', () => { + it.each([ + [undefined, 'undefined'], + [null, 'null'], + ['', 'string vacío'], + ] as const)('devuelve color gris por defecto para %s', (input) => { + expect(getInvoiceTypeColor(input)).toBe(DEFAULT_COLOR) + }) + }) + + describe('tipos de factura conocidos', () => { + it.each([ + // input color esperado tipo + ['tem', 'bg-red-100', 'TEM exacto'], + ['impo tem', 'bg-red-100', 'TEM con prefijo'], + ['def', 'bg-green-100', 'DEF exacto'], + ['impo def', 'bg-green-100', 'DEF con prefijo'], + ['mex', 'bg-purple-100', 'MEX exacto'], + ['comp mex', 'bg-purple-100', 'MEX con prefijo'], + ['cr', 'bg-blue-100', 'CAM REG exacto'], + ['cam. reg.', 'bg-blue-100', 'CAM REG con puntos'], + ['cam reg', 'bg-blue-100', 'CAM REG sin puntos'], + ['expo', 'bg-blue-100', 'EXPO'], + ['exdef', 'bg-blue-100', 'EXDEF'], + ['pterm', 'bg-blue-100', 'PTERM'], + ['repar', 'bg-sky-100', 'REPAR exacto'], + ['imp. rep.', 'bg-sky-100', 'IMP REP con puntos'], + ['imp rep', 'bg-sky-100', 'IMP REP sin puntos'], + ] as const)('retorna el color correcto para %s — %s', (input, expectedColor) => { + expect(getInvoiceTypeColor(input)).toContain(expectedColor) + }) + }) + + describe('tipo desconocido', () => { + it('devuelve color gris para tipo no reconocido', () => { + expect(getInvoiceTypeColor('TIPO_INEXISTENTE')).toBe(DEFAULT_COLOR) + }) + }) +}) \ No newline at end of file From 9548ead4d933f34e94269514f377075fe1671cec Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 23 Apr 2026 15:46:13 -0600 Subject: [PATCH 073/167] feature/ventana-de-actualizacion-desactualizacion --- .../invoices/pdf-progress-dialog.svelte | 248 ++++++++++++++---- .../routes/dashboard/invoices/+page.svelte | 65 +++-- 2 files changed, 240 insertions(+), 73 deletions(-) diff --git a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte index 76485457..09eb6b3a 100644 --- a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte @@ -2,9 +2,9 @@ import * as Dialog from '$lib/components/ui/dialog'; import { Progress } from '$lib/components/ui/progress'; import { invoicesReportsApi } from '$lib/api/dashboard/a76/reports/reports-invoices'; - import { toast } from 'svelte-sonner'; import { Loader2, CheckCircle2, XCircle, FileDown } from 'lucide-svelte'; import { Button } from '$lib/components/ui/button'; + import { friendlyApiErrorParts, humanizeLineReferences, type ApiResponse } from '$lib/api'; export let open = false; export let taskId: string | null = null; @@ -20,24 +20,42 @@ export let getStatus: ((taskId: string) => Promise) | null = null; + type DialogStatus = 'idle' | 'running' | 'success' | 'error' | 'validation_error'; + let progress = 0; let statusMessage = 'Iniciando...'; let pollingInterval: any = null; - let isComplete = false; - let hasError = false; + let dialogStatus: DialogStatus = 'idle'; let lastResult: any = null; let externalBody: any = null; let externalErrors: any[] | null = null; + let errorTitle = 'Ocurrió un error'; + let errorDescription = 'No se pudo completar la acción.'; + let errorList: string[] = []; + + $: isRunning = dialogStatus === 'running'; + $: isSuccess = dialogStatus === 'success'; + $: isError = dialogStatus === 'error' || dialogStatus === 'validation_error'; + $: completedStepIndex = + steps.length === 0 + ? -1 + : Math.max( + steps.findLastIndex((step) => progress >= step.percent), + isSuccess ? steps.length - 1 : -1 + ); + $: activeStepIndex = + steps.length === 0 + ? -1 + : Math.min( + steps.findIndex((step) => progress < step.percent) === -1 + ? steps.length - 1 + : steps.findIndex((step) => progress < step.percent), + steps.length - 1 + ); // Reiniciar estado cuando se abre el diálogo con un nuevo taskId $: if (open && taskId) { - progress = 0; - statusMessage = 'Iniciando...'; - isComplete = false; - hasError = false; - lastResult = null; - externalBody = null; - externalErrors = null; + resetState(); startPolling(); } else if (!open) { stopPolling(); @@ -54,16 +72,101 @@ } } + function resetState() { + progress = 0; + statusMessage = 'Iniciando...'; + dialogStatus = 'running'; + lastResult = null; + externalBody = null; + externalErrors = null; + errorTitle = 'Ocurrió un error'; + errorDescription = 'No se pudo completar la acción.'; + errorList = []; + } + + function normalizeErrorText(value: unknown, fallback: string) { + if (typeof value !== 'string') return fallback; + const normalized = humanizeLineReferences(value.trim()); + return normalized || fallback; + } + + function buildErrorPresentation( + source?: Partial | null, + result?: any + ): { status: DialogStatus; title: string; description: string; list: string[] } { + const status: DialogStatus = + result?.status === 'validation_error' ? 'validation_error' : 'error'; + const validationErrors = source?.validationErrors || result?.validationErrors; + + if (validationErrors?.length) { + const { title, description } = friendlyApiErrorParts({ + status: source?.status ?? 400, + error: source?.error || result?.message || 'Error de validación', + validationErrors + }); + return { + status, + title, + description, + list: [] as string[] + }; + } + + const resultErrors = Array.isArray(result?.errors) + ? result.errors + .map((entry: any) => normalizeErrorText(entry?.message || String(entry), 'Error de validación')) + .filter(Boolean) + : []; + + if (resultErrors.length > 0) { + return { + status, + title: + status === 'validation_error' + ? 'Se encontraron errores de validación' + : 'No se pudo completar la acción', + description: + normalizeErrorText(result?.message, resultErrors[0] || 'Revisa los datos e intenta de nuevo.'), + list: resultErrors + }; + } + + const fallbackDescription = normalizeErrorText( + source?.error || result?.message, + status === 'validation_error' + ? 'Se encontraron errores de validación. Revisa los datos e intenta de nuevo.' + : 'No se pudo completar la acción. Intenta de nuevo.' + ); + + return { + status, + title: + status === 'validation_error' + ? 'Se encontraron errores de validación' + : 'No se pudo completar la acción', + description: fallbackDescription, + list: [] as string[] + }; + } + + function setErrorState(source?: Partial | null, result?: any) { + const presentation = buildErrorPresentation(source, result); + lastResult = result ?? lastResult; + dialogStatus = presentation.status; + errorTitle = presentation.title; + errorDescription = presentation.description; + errorList = presentation.list; + statusMessage = presentation.description; + stopPolling(); + } + async function pollOnce() { if (!taskId) return; const apiCall = getStatus || invoicesReportsApi.getTaskStatus; const raw = await apiCall(taskId); const response = raw?.data !== undefined ? raw.data : raw; if (raw?.error) { - hasError = true; - statusMessage = `Error: ${raw.error}`; - stopPolling(); - toast.error(raw.error); + setErrorState(raw, response); return; } if (response?.state === 'PROCESSING' && response.info) { @@ -71,39 +174,48 @@ statusMessage = response.info.status || 'Procesando...'; } else if (response?.state === 'SUCCESS') { const result = response.result; - stopPolling(); lastResult = result; if (result?.status === 'validation_error' || result?.status === 'error') { - // Quedarse abierto, marcar error y dejar que onComplete maneje los mensajes - hasError = true; - isComplete = true; + setErrorState( + { + status: response?.status ?? 400, + error: result?.message, + validationErrors: result?.validationErrors + }, + result + ); onComplete(result); } else { progress = 100; statusMessage = '¡Completado!'; - isComplete = true; + dialogStatus = 'success'; + stopPolling(); onComplete(result); } } else if (response?.state === 'FAILURE') { - hasError = true; const errMsg = response.result ? String(response.result) : 'Error desconocido'; - statusMessage = `Error: ${errMsg}`; - stopPolling(); - toast.error(`Falló: ${errMsg}`); + setErrorState({ status: response?.status ?? 500, error: errMsg }, response?.result); } } async function startPolling() { stopPolling(); // Asegurar limpieza previa - await pollOnce(); // Primer poll inmediato para mostrar progreso sin esperar 1s + try { + await pollOnce(); // Primer poll inmediato para mostrar progreso sin esperar 1s + } catch (error) { + console.error('Error polling task status:', error); + setErrorState({ status: 500, error: error instanceof Error ? error.message : String(error) }); + return; + } pollingInterval = setInterval(async () => { - if (isComplete || hasError) return; + if (!isRunning) return; try { await pollOnce(); } catch (error) { console.error('Error polling task status:', error); + setErrorState({ status: 500, error: error instanceof Error ? error.message : String(error) }); } }, 1000); } @@ -128,11 +240,17 @@
{#each steps as step, i} - {@const isDone = progress >= step.percent} - {@const isCurrent = !isDone && (i === 0 || progress >= steps[i - 1]?.percent)} + {@const isDone = i < activeStepIndex || (isSuccess && progress >= step.percent)} + {@const isReached = i <= completedStepIndex} + {@const isCurrent = isRunning && i === activeStepIndex} + {@const isFailed = isError && i === activeStepIndex}
{i + 1}. - + {step.label} {#if isDone} 100% + {:else if isFailed} + {progress}% + {:else if isReached} + 100% {:else if isCurrent} {progress}% {:else} @@ -154,6 +284,10 @@ {#if isDone} + {:else if isFailed} + + {:else if isReached} + {:else if isCurrent} {/if} @@ -164,27 +298,29 @@
{#if statusMessage} -
+
{statusMessage}
{/if} {:else}
- {statusMessage} + {statusMessage} {progress}%
{/if} -
- {#if isComplete} +
+ {#if isSuccess}
{#if lastResult?.cove_number} COVE generado - {:else if lastResult?.status === 'validation_error'} - Se encontraron errores de validación {:else} {completeMessage} {/if} @@ -205,30 +341,40 @@ {/if} - - {#if lastResult?.status === 'validation_error' && externalBody} - - {externalBody.mensaje || 'Datos inválidos en el servicio COVE.'} - - {#if externalErrors && externalErrors.length} -
    - {#each externalErrors as e} -
  • {e.campo}: {e.mensaje}
  • - {/each} -
- {/if} - {:else if lastResult?.message} + {#if lastResult?.message} {lastResult.message} {/if}
- {:else if hasError} + {:else if isError}
- Ocurrió un error +
+

{errorTitle}

+

{errorDescription}

+ {#if errorList.length > 0} +
    + {#each errorList as item} +
  • {item}
  • + {/each} +
+ {/if} + {#if dialogStatus === 'validation_error' && externalBody} +

+ {externalBody.mensaje || 'Datos inválidos en el servicio COVE.'} +

+ {#if externalErrors && externalErrors.length} +
    + {#each externalErrors as e} +
  • {e.campo}: {e.mensaje}
  • + {/each} +
+ {/if} + {/if} +
{:else}
@@ -239,7 +385,7 @@
- {#if isComplete || hasError} + {#if isSuccess || isError} {/if} diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 972c747b..97743e07 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -60,6 +60,7 @@ import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosListaFacturas } from '$lib/config/shortcuts/dashboard/invoices/list'; import { m } from '$lib/i18n/messages'; + import { humanizeLineReferences } from '$lib/api'; // Los datos iniciales vienen del servidor let { data }: { data: PageData } = $props(); @@ -452,6 +453,9 @@ let currentStatusFunction = $state<((taskId: string) => Promise) | null>(null); let progressDialogTitle = $state('Generando documento'); let progressDialogSteps = $state<{ label: string; percent: number }[] | null>(null); + let isProgressErrorOpen = $state(false); + let progressErrorTitle = $state('Ocurrió un error'); + let progressErrorDescription = $state('No se pudo completar la acción.'); let coveRecipientsLoading = $state(false); let coveRecipientsError = $state(null); let coveRecipientEmail = $state(''); @@ -820,7 +824,6 @@ } function onPdfComplete(result: any) { - // Esta función se llama cuando el diálogo reporta SUCCESS try { if (result.status === 'success') { if (result.content) { @@ -856,23 +859,9 @@ m.invoice_list_toasts_cove_external_queued_default(); const taskInfo = result.external_task_id ? ` (task_id: ${result.external_task_id})` : ''; toast.success(baseMsg + taskInfo); - } else if (result.status === 'validation_error') { - const errors: any[] = result.errors || []; - const preview = errors - .slice(0, 3) - .map((e: any) => `• ${e.message}`) - .join('\n'); - const extra = - errors.length > 3 - ? m.invoice_list_toasts_validation_extra_more({ count: String(errors.length - 3) }) - : ''; - toast.error( - m.invoice_list_toasts_validation_error_count({ - count: String(errors.length), - preview, - extra - }) - ); + } else if (result.status === 'validation_error' || result.status === 'error') { + reloadData(); + return; } else if ( typeof result.message === 'string' && result.message.includes('Factura COVE iniciada para') @@ -1138,7 +1127,8 @@ ); if (response.error) { - toast.error( + openProgressErrorDialog( + 'No se pudo iniciar el procesamiento de la factura', m.invoice_list_toasts_process_start_error_prefix({ error: String(response.error) }) ); return; @@ -1151,7 +1141,10 @@ showProgressDialog = true; } catch (e) { console.error('Error al iniciar proceso de factura:', e); - toast.error(m.invoice_list_toasts_process_start_error()); + openProgressErrorDialog( + 'No se pudo iniciar el procesamiento de la factura', + m.invoice_list_toasts_process_start_error() + ); } } @@ -1166,7 +1159,8 @@ ); if (response.error) { - toast.error( + openProgressErrorDialog( + 'No se pudo iniciar la desactualización de la factura', m.invoice_list_toasts_revert_start_error_prefix({ error: String(response.error) }) ); return; @@ -1179,7 +1173,10 @@ showProgressDialog = true; } catch (e) { console.error('Error al iniciar des-actualización de factura:', e); - toast.error(m.invoice_list_toasts_revert_start_error()); + openProgressErrorDialog( + 'No se pudo iniciar la desactualización de la factura', + m.invoice_list_toasts_revert_start_error() + ); } } @@ -1316,6 +1313,12 @@ progressDialogSteps = null; } + function openProgressErrorDialog(title: string, description: string) { + progressErrorTitle = title; + progressErrorDescription = humanizeLineReferences(description.trim()) || description; + isProgressErrorOpen = true; + } + // --- AQUÍ PASAMOS LA FUNCIÓN DE DESCARGA A LAS COLUMNAS --- const columns = createColumns(handleSuccess); async function handleModalConfirm( @@ -1590,6 +1593,24 @@ } /> + + + + {progressErrorTitle} + +
+ {progressErrorDescription} +
+
+
+ + (isProgressErrorOpen = false)}> + Cerrar + + +
+
+ From 32d97281389f4634d612b34c7c1bb3a44402c236 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 23 Apr 2026 16:24:53 -0600 Subject: [PATCH 074/167] feature/scroll-reports --- .../dashboard/reports/invoices/+page.svelte | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.svelte b/frontend/src/routes/dashboard/reports/invoices/+page.svelte index fc989165..5611b798 100644 --- a/frontend/src/routes/dashboard/reports/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/reports/invoices/+page.svelte @@ -1510,12 +1510,12 @@
-
-
-

+
+
+

{#if activeReportType === 'invoices'} Reporte de Facturas @@ -1538,7 +1538,7 @@ V2.0

-
+
{#if activeReportType === 'invoices'} Reportes de Movimientos Impo/Expo {:else} @@ -1550,7 +1550,7 @@ -
+
{#each menuOptions as item} {#if item.items && item.items.length > 0} @@ -1611,7 +1611,7 @@ -
+
{#if activeReportType === 'invoices'} @@ -1621,7 +1621,7 @@ -
+
Importación -
+
{#each Object.keys(types.import) as key}
-
+
-
+
{#each Object.keys(types.export.additional) as key}
Otras -
+
{#each Object.keys(types.other) as key}
-
+
@@ -1922,7 +1922,7 @@
-
+
@@ -2078,7 +2078,7 @@
{#if saldosRangeType === 'pedimento' || saldosRangeType === 'parts' || saldosRangeType === 'classes'} -
+