From e3ad7e7a7e4f7459d70c3d9bae5f2f20fdd51ebd Mon Sep 17 00:00:00 2001 From: hreyes Date: Wed, 6 May 2026 11:13:09 -0600 Subject: [PATCH 1/6] feature/catalogos-generales-permisos --- .../a76/general_catalogs/company/routes.py | 61 ++++++++++++++- .../depreciation_catalog/routes.py | 8 +- .../a76/general_catalogs/doda/routes.py | 74 ++++++++++++++++--- .../general_catalogs/error_catalogs/routes.py | 44 ++++++++--- .../canadian_tariff_fractions/routes.py | 30 ++++---- .../historical_tariff_fractions/routes.py | 36 ++++----- .../fractions/tariff_fractions/routes.py | 15 +++- .../fractions/us_tariff_fractions/routes.py | 8 +- .../a76/general_catalogs/location/routes.py | 5 ++ .../general_catalogs/prevalidators/routes.py | 30 ++++++-- .../a76/general_catalogs/signatures/routes.py | 12 ++- .../v1/modules/core/permissions/seed_v2.py | 11 +-- .../doda/doda-form-modal.svelte | 20 ++++- .../locations/create-edit-dialog.svelte | 14 +++- .../sectors/SectorsList.svelte | 11 +-- .../components/dashboard/locations/columns.ts | 10 ++- .../locations/data-table-actions.svelte | 13 +++- .../locations/locations-catalog.svelte | 33 +++++++-- .../src/lib/components/sidebar/modules.ts | 30 ++++++-- .../catalogs/broker-concepts-permissions.ts | 12 +++ .../classification-concepts-permissions.ts | 12 +++ .../catalogs/company-catalog-permissions.ts | 12 +++ .../catalogs/concepts-permissions.ts | 12 +++ .../depreciation-catalog-permissions.ts | 15 ++++ .../permissions/catalogs/doda-permissions.ts | 12 +++ .../catalogs/drivers-permissions.ts | 11 +++ .../electronic-notices-permissions.ts | 12 +++ .../catalogs/equivalencies-permissions.ts | 12 +++ .../catalogs/error-catalogs-permissions.ts | 12 +++ .../catalogs/exchange-rates-permissions.ts | 12 +++ .../catalogs/identifiers-permissions.ts | 12 +++ .../permissions/catalogs/inpc-permissions.ts | 12 +++ .../catalogs/legends-permissions.ts | 12 +++ .../catalogs/locations-catalog-permissions.ts | 12 +++ .../multi-currency-types-permissions.ts | 12 +++ .../catalogs/packages-permissions.ts | 12 +++ .../permissions/catalogs/ports-permissions.ts | 12 +++ .../catalogs/prevalidators-permissions.ts | 12 +++ .../permissions/catalogs/seals-permissions.ts | 12 +++ .../catalogs/sectors-catalog-permissions.ts | 12 +++ .../catalogs/signatures-permissions.ts | 12 +++ .../catalogs/trailers-permissions.ts | 11 +++ .../catalogs/transporters-permissions.ts | 14 ++++ .../catalogs/um-ace-permissions.ts | 12 +++ .../catalogs/um-american-permissions.ts | 12 +++ .../catalogs/um-customs-permissions.ts | 12 +++ .../catalogs/um-general-permissions.ts | 12 +++ .../catalogs/um-oma-permissions.ts | 12 +++ .../catalogs/unit-conversions-permissions.ts | 12 +++ .../catalogs/vehicles-permissions.ts | 11 +++ .../containers-ref-permissions.ts | 13 ++++ .../countries-ref-permissions.ts | 13 ++++ .../currency-types-ref-permissions.ts | 13 ++++ .../customs-sections-ref-permissions.ts | 13 ++++ .../customs-warehouses-ref-permissions.ts | 13 ++++ .../incoterms-ref-permissions.ts | 13 ++++ .../invoice-types-ref-permissions.ts | 13 ++++ .../material-types-ref-permissions.ts | 13 ++++ .../payment-methods-ref-permissions.ts | 13 ++++ .../pedimento-codes-ref-permissions.ts | 13 ++++ .../pedimento-regimens-ref-permissions.ts | 13 ++++ .../reference-data/sectors-ref-permissions.ts | 13 ++++ .../reference-data/states-ref-permissions.ts | 13 ++++ .../transport-modes-ref-permissions.ts | 13 ++++ .../transport-types-ref-permissions.ts | 13 ++++ .../valuation-methods-ref-permissions.ts | 13 ++++ .../lib/permissions/transport-permissions.ts | 19 ----- .../dashboard/despacho/doda/+page.svelte | 35 +++++++-- .../classification_concepts/+page.svelte | 12 +-- .../company_information/+page.svelte | 18 +++-- .../edit/[[id]]/+page.svelte | 34 ++++++++- .../general_catalogs/concepts/+page.svelte | 12 +-- .../customs_broker_concepts/+page.svelte | 14 ++-- .../general_catalogs/doda/+page.svelte | 12 +-- .../doda/edit/[[id]]/+page.svelte | 33 ++++++++- .../general_catalogs/drivers/+page.svelte | 14 ++-- .../electronic_notices/+page.svelte | 14 ++-- .../equivalencies/+page.svelte | 12 +-- .../error_catalogs/+page.svelte | 14 ++-- .../exchange-rate/+page.svelte | 12 +-- .../exchange-rate/new/+page.svelte | 22 +++++- .../general_catalogs/identifiers/+page.svelte | 12 +-- .../general_catalogs/inpc/+page.svelte | 12 +-- .../general_catalogs/legends/+page.svelte | 12 +-- .../general_catalogs/locations/+page.svelte | 5 ++ .../multi_currency_types/+page.svelte | 12 +-- .../general_catalogs/packages/+page.svelte | 12 +-- .../general_catalogs/ports/+page.svelte | 12 +-- .../prevalidators/+page.svelte | 13 ++-- .../general_catalogs/seal/+page.svelte | 12 +-- .../general_catalogs/signatures/+page.svelte | 12 +-- .../general_catalogs/trailers/+page.svelte | 14 ++-- .../transporters/+page.svelte | 14 ++-- .../unit_conversions/+page.svelte | 12 +-- .../units_of_measure/ace/+page.svelte | 12 +-- .../units_of_measure/american/+page.svelte | 12 +-- .../units_of_measure/customs/+page.svelte | 12 +-- .../units_of_measure/general/+page.svelte | 12 +-- .../units_of_measure/oma/+page.svelte | 12 +-- .../general_catalogs/vehicles/+page.svelte | 14 ++-- .../code_pedimento_regimens/+page.svelte | 5 +- .../reference_data/containers/+page.svelte | 5 +- .../reference_data/countries/+page.svelte | 5 +- .../currency_types/+page.svelte | 5 +- .../customs_sections/+page.svelte | 5 +- .../customs_warehouses/+page.svelte | 5 +- .../document_types_digitization/+page.svelte | 5 +- .../reference_data/incoterms/+page.svelte | 5 +- .../reference_data/invoice_types/+page.svelte | 5 +- .../material_types/+page.svelte | 5 +- .../payment_methods/+page.svelte | 5 +- .../pedimento_codes/+page.svelte | 5 +- .../pedimento_regimens/+page.svelte | 5 +- .../reference_data/sectors/+page.svelte | 5 +- .../reference_data/states/+page.svelte | 5 +- .../transport_modes/+page.svelte | 5 +- .../transport_types/+page.svelte | 5 +- .../valuation_methods/+page.svelte | 5 +- 118 files changed, 1318 insertions(+), 324 deletions(-) create mode 100644 frontend/src/lib/permissions/catalogs/broker-concepts-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/classification-concepts-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/company-catalog-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/concepts-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/depreciation-catalog-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/doda-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/drivers-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/electronic-notices-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/equivalencies-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/error-catalogs-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/exchange-rates-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/identifiers-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/inpc-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/legends-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/locations-catalog-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/multi-currency-types-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/packages-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/ports-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/prevalidators-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/seals-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/sectors-catalog-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/signatures-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/trailers-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/transporters-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/um-ace-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/um-american-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/um-customs-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/um-general-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/um-oma-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/unit-conversions-permissions.ts create mode 100644 frontend/src/lib/permissions/catalogs/vehicles-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/containers-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/countries-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/currency-types-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/customs-sections-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/customs-warehouses-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/incoterms-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/invoice-types-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/material-types-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/payment-methods-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/pedimento-codes-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/pedimento-regimens-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/sectors-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/states-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/transport-modes-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/transport-types-ref-permissions.ts create mode 100644 frontend/src/lib/permissions/reference-data/valuation-methods-ref-permissions.ts delete mode 100644 frontend/src/lib/permissions/transport-permissions.ts create mode 100644 frontend/src/routes/dashboard/general_catalogs/locations/+page.svelte diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py index 10c1d5c6..00191e97 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -19,6 +19,8 @@ from core.database import get_core_db from core.s3_keys import company_certificate_key, company_logo_key from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes from core.security import ( + collect_company_ids_from_app_membership, + collect_user_role_names, get_current_user, get_tenant_from_token, resolve_effective_tenant_id_from_user, @@ -37,6 +39,48 @@ MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB logger = logging.getLogger(__name__) +def _user_is_admin(current_user: dict) -> bool: + return "admin" in collect_user_role_names(current_user) + + +def _assert_permission_any_company( + db: Session, + current_user: dict, + permission_code: str, +) -> None: + """ + Enforces a permission when endpoint has no explicit company_id param. + """ + if _user_is_admin(current_user): + return + + user_id = current_user.get("sub") or current_user.get("id") + if not user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied") + + company_ids = collect_company_ids_from_app_membership(db, str(user_id)) + if not company_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied") + + from api.v1.modules.core.permissions.service import PermissionService + + permission_service = PermissionService(db) + for company_id in company_ids: + if permission_service.has_any_permission(str(user_id), int(company_id), [permission_code]): + return + + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied") + + +def _assert_permission_for_company( + db: Session, + company_id: int, + current_user: dict, + permission_code: str, +) -> int: + return validate_access_to_resource(db, company_id, current_user, [permission_code]) + + def _resolve_tenant_id_int(current_user: dict) -> int: """Misma lógica que validate_access_to_resource: entero estable para BD y claves S3.""" tid = get_tenant_from_token(current_user) @@ -87,6 +131,7 @@ async def create_company( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): + _assert_permission_any_company(db, current_user, "cat_company.create") tenant_id = current_user.get("tenant_id") if not tenant_id: @@ -114,6 +159,8 @@ async def list_companies( current_user: dict = Depends(get_current_user), ): """Get paginated list of companies for current tenant with optional filters""" + _assert_permission_any_company(db, current_user, "cat_company.view") + tenant_id = current_user.get("tenant_id") if not tenant_id: raise HTTPException( @@ -142,7 +189,7 @@ async def list_companies( return { "items": [ - CompanyResponseDTO.model_validate(service.flatten_company_dto(item)) + CompanyResponseDTO.model_validate(service.flatten_company_dto(item)) for item in items ], "total": total, @@ -167,6 +214,8 @@ async def get_my_companies( Un usuario solo con roles de app y sin ``tenant_id`` en /auth/me sigue pudiendo listar sus compañías asignadas. """ + _assert_permission_any_company(db, current_user, "cat_company.view") + user_id = current_user.get("sub") or current_user.get("id") tenant_id = resolve_effective_tenant_id_from_user(current_user) @@ -194,6 +243,8 @@ async def get_company( current_user: dict = Depends(get_current_user), ): """Get a specific company by ID""" + _assert_permission_for_company(db, company_id, current_user, "cat_company.view") + tenant_id = current_user.get("tenant_id") if not tenant_id: raise HTTPException( @@ -224,6 +275,8 @@ async def update_company( current_user: dict = Depends(get_current_user), ): """Update a company""" + _assert_permission_for_company(db, company_id, current_user, "cat_company.edit") + tenant_id = current_user.get("tenant_id") if not tenant_id: raise HTTPException( @@ -299,6 +352,8 @@ async def delete_company( current_user: dict = Depends(get_current_user), ): """Delete a company""" + _assert_permission_for_company(db, company_id, current_user, "cat_company.delete") + tenant_id = current_user.get("tenant_id") if not tenant_id: raise HTTPException( @@ -328,6 +383,8 @@ async def upload_company_logo( current_user: dict = Depends(get_current_user), ): """Upload a logo for a company""" + _assert_permission_for_company(db, company_id, current_user, "cat_company.edit") + tenant_id = _resolve_tenant_id_int(current_user) # Validar que la empresa existe @@ -409,6 +466,8 @@ async def upload_company_certificate( Upload a certificate for a company certificate_type: fiel_cer, fiel_key, cfdi_cert_cer, cfdi_cert_key, cancel_cer, cancel_key """ + _assert_permission_for_company(db, company_id, current_user, "cat_company.edit") + tenant_id = _resolve_tenant_id_int(current_user) # Validar que la empresa existe diff --git a/backend/api/v1/modules/a76/general_catalogs/depreciation_catalog/routes.py b/backend/api/v1/modules/a76/general_catalogs/depreciation_catalog/routes.py index 20f2c9a9..56f5086a 100644 --- a/backend/api/v1/modules/a76/general_catalogs/depreciation_catalog/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/depreciation_catalog/routes.py @@ -30,6 +30,10 @@ base_router = TenantCRUDRoutes( enable_filters=False, default_page_size=100, max_page_size=1000, + get_permissions=["cat_depreciation_catalog.view"], + create_permissions=["cat_depreciation_catalog.create"], + update_permissions=["cat_depreciation_catalog.edit"], + delete_permissions=["cat_depreciation_catalog.delete"], ) router = APIRouter(prefix="/depreciation-catalog", tags=["a76 / general catalogs / depreciation catalog"]) @@ -49,7 +53,9 @@ async def list_depreciation_catalog( 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) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["cat_depreciation_catalog.view"] + ) items, total = DepreciationCatalogService.get_all( db, tenant_id, company_id, page, page_size, search diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py index 05b18223..bec0c7c5 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py @@ -254,7 +254,9 @@ async def print_doda_pdf( Genera o reutiliza el PDF almacenado en S3 cuando el contenido no ha cambiado (huella SHA-256 de DODA + hijos). """ - tenant_id = int(validate_access_to_resource(db, company_id, current_user)) + tenant_id = int( + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) + ) doda = DodaService.get_by_id(db, doda_id, tenant_id, company_id) if not doda: raise HTTPException( @@ -327,9 +329,12 @@ async def print_doda_pdf( ) async def get_doda_containers( doda_id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Get all containers for a specific DODA""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) containers = DodaService.get_containers(db, doda_id) return [DodaContainerResponseDTO.model_validate(c) for c in containers] @@ -343,9 +348,12 @@ async def get_doda_containers( async def add_container( doda_id: int, container_data: DodaContainerCreateDTO, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Add a new container to a DODA""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) container = DodaService.add_container(db, doda_id, container_data) if not container: raise HTTPException( @@ -364,9 +372,12 @@ async def update_container( doda_id: int, container_line: int, container_data: DodaContainerUpdateDTO, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Update a container""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) container = DodaService.update_container( db, doda_id, container_line, container_data ) @@ -386,12 +397,15 @@ async def update_container( async def delete_container( doda_id: int, container_line: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """ Elimina un contenedor del DODA. Devuelve 409 si el contenedor tiene precintos (candados) asignados. """ + validate_access_to_resource(db, company_id, current_user, ["cat_doda.delete"]) DodaService.delete_container(db, doda_id, container_line) return None @@ -415,8 +429,11 @@ def _seal_to_response(seal) -> DodaContainerSealResponseDTO: async def get_container_seals( doda_id: int, container_line: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) seals = DodaService.get_seals_for_container(db, doda_id, container_line) return [_seal_to_response(s) for s in seals] @@ -431,8 +448,11 @@ async def add_container_seal( doda_id: int, container_line: int, seal_data: DodaContainerSealCreateDTO, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): + validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) seal = DodaService.add_seal(db, doda_id, container_line, seal_data) return _seal_to_response(seal) @@ -446,8 +466,11 @@ async def delete_container_seal( doda_id: int, container_line: int, seal_line: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): + validate_access_to_resource(db, company_id, current_user, ["cat_doda.delete"]) DodaService.delete_seal(db, doda_id, container_line, seal_line) return None @@ -460,9 +483,12 @@ async def delete_container_seal( ) async def get_american_pedimentos( doda_id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Get all American pedimentos for a specific DODA""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) pedimentos = DodaService.get_american_pedimentos(db, doda_id) return [DodaAmericanPedimentoResponseDTO.model_validate(p) for p in pedimentos] @@ -476,9 +502,12 @@ async def get_american_pedimentos( async def add_american_pedimento( doda_id: int, pedimento_data: DodaAmericanPedimentoCreateDTO, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Add a new American pedimento to a DODA""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) pedimento = DodaService.add_american_pedimento(db, doda_id, pedimento_data) if not pedimento: raise HTTPException( @@ -497,9 +526,12 @@ async def add_american_pedimento( async def delete_american_pedimento( doda_id: int, pedimento_line: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Elimina un pedimento americano del DODA.""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.delete"]) DodaService.delete_american_pedimento(db, doda_id, pedimento_line) return None @@ -512,9 +544,12 @@ async def delete_american_pedimento( ) async def get_doda_pedimentos( doda_id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Get all pedimentos for a specific DODA""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) pedimentos = DodaService.get_pedimentos(db, doda_id) return [DodaPedimentoResponseDTO.model_validate(p) for p in pedimentos] @@ -528,9 +563,12 @@ async def get_doda_pedimentos( async def add_pedimento( doda_id: int, pedimento_data: DodaPedimentoCreateDTO, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Add a new pedimento to a DODA""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) pedimento = DodaService.add_pedimento(db, doda_id, pedimento_data) if not pedimento: raise HTTPException( @@ -548,9 +586,12 @@ async def add_pedimento( async def delete_pedimento( doda_id: int, pedimento_line: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Elimina un pedimento del DODA.""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.delete"]) DodaService.delete_pedimento(db, doda_id, pedimento_line) return None @@ -575,7 +616,7 @@ async def get_doda_elegibilidad( Porta las validaciones del sistema legacy (campos requeridos, max 4 contenedores, gafete si DODA, patente vs agente, certificados DODA en VU). """ - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) user_email = ( current_user.get("email") or current_user.get("preferred_username") @@ -614,7 +655,7 @@ async def post_doda_alta( Verifica elegibilidad, construye el payload desde los datos del DODA y su VU, y envía el alta al servicio externo. Retorna {task_id, status, message} para polling. """ - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) user_email = ( current_user.get("email") or current_user.get("preferred_username") @@ -694,11 +735,14 @@ async def post_doda_alta( ) async def get_doda_alta_status( task_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ) -> Any: """ Proxy transparente al servicio externo para consultar el estado de una tarea de alta DODA. """ + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) try: ext = DodaExternalService() return ext.get_status(task_id) @@ -727,7 +771,7 @@ async def post_doda_consulta( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ) -> Dict[str, Any]: - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) user_email = ( current_user.get("email") or current_user.get("preferred_username") @@ -788,8 +832,11 @@ async def post_doda_consulta( ) async def get_doda_consulta_status( task_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ) -> Any: + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) try: ext = DodaExternalService() return ext.get_consulta_status(task_id) @@ -818,7 +865,9 @@ async def post_doda_consulta_apply( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ) -> Dict[str, Any]: - tenant_id = int(validate_access_to_resource(db, company_id, current_user)) + tenant_id = int( + validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) + ) doda_record = DodaService.get_by_id(db, doda_id, tenant_id, company_id) if not doda_record: raise HTTPException(status_code=404, detail="DODA no encontrado.") @@ -880,7 +929,7 @@ async def post_doda_eliminar( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ) -> Dict[str, Any]: - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.delete"]) user_email = ( current_user.get("email") or current_user.get("preferred_username") @@ -960,8 +1009,11 @@ async def post_doda_eliminar( ) async def get_doda_eliminar_status( task_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ) -> Any: + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) try: ext = DodaExternalService() return ext.get_eliminar_status(task_id) @@ -996,7 +1048,7 @@ async def list_doda_alta_logs( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) return DodaAltaLogService.list( db, company_id, int(tenant_id), page, page_size, doda_id, search ) @@ -1014,7 +1066,7 @@ async def get_doda_alta_log( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) record = DodaAltaLogService.get(db, log_id, company_id, int(tenant_id)) if not record: raise HTTPException(status_code=404, detail="Registro de alta DODA no encontrado.") @@ -1034,7 +1086,7 @@ async def create_doda_alta_log( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.create"]) record = DodaAltaLogService.create(db, dto, company_id, int(tenant_id)) return DodaAltaLogResponseDTO.model_validate(record) @@ -1052,7 +1104,7 @@ async def update_doda_alta_log( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) record = DodaAltaLogService.get(db, log_id, company_id, int(tenant_id)) if not record: raise HTTPException(status_code=404, detail="Registro de alta DODA no encontrado.") @@ -1072,7 +1124,7 @@ async def delete_doda_alta_log( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.delete"]) record = DodaAltaLogService.get(db, log_id, company_id, int(tenant_id)) if not record: raise HTTPException(status_code=404, detail="Registro de alta DODA no encontrado.") diff --git a/backend/api/v1/modules/a76/general_catalogs/error_catalogs/routes.py b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/routes.py index 2feb6f7e..6af9fea3 100644 --- a/backend/api/v1/modules/a76/general_catalogs/error_catalogs/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/routes.py @@ -4,12 +4,12 @@ Rutas para gestión de catálogos de errores from typing import List -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from core.database import get_core_db from core.security import get_current_user -from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource from .dto import ( ErrorClassificationCreateDTO, ErrorClassificationResponseDTO, @@ -35,6 +35,11 @@ classification_crud = TenantCRUDRoutes( tags=["error-classifications"], resource_name="ErrorClassification", enable_list=True, + list_permissions=["cat_errors.view"], + get_permissions=["cat_errors.view"], + create_permissions=["cat_errors.create"], + update_permissions=["cat_errors.edit"], + delete_permissions=["cat_errors.delete"], ) # Add custom endpoints for classifications @@ -47,15 +52,17 @@ classification_crud = TenantCRUDRoutes( ) async def get_classification_by_code( code: str, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """Get an error classification by its code with all related errors""" + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_errors.view"]) classification = ErrorClassificationService.get_by_code( db, code, - tenant_id=current_user["tenant_id"], - company_id=current_user["company_id"] + tenant_id=tenant_id, + company_id=company_id, ) if not classification: raise HTTPException( @@ -74,15 +81,17 @@ async def get_classification_by_code( ) async def get_classification( id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """Get an error classification by its ID with all related errors""" + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_errors.view"]) classification = ErrorClassificationService.get_by_id( db, id, - tenant_id=current_user["tenant_id"], - company_id=current_user["company_id"] + tenant_id=tenant_id, + company_id=company_id, ) if not classification: raise HTTPException( @@ -104,6 +113,11 @@ catalog_crud = TenantCRUDRoutes( tags=["error-catalogs"], resource_name="ErrorCatalog", enable_list=True, + list_permissions=["cat_errors.view"], + get_permissions=["cat_errors.view"], + create_permissions=["cat_errors.create"], + update_permissions=["cat_errors.edit"], + delete_permissions=["cat_errors.delete"], ) # Add custom endpoints for catalogs @@ -116,15 +130,17 @@ catalog_crud = TenantCRUDRoutes( ) async def get_error_by_code( code: str, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """Get an error by its code with classification details""" + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_errors.view"]) error = ErrorCatalogService.get_by_code( db, code, - tenant_id=current_user["tenant_id"], - company_id=current_user["company_id"] + tenant_id=tenant_id, + company_id=company_id, ) if not error: raise HTTPException( @@ -141,15 +157,17 @@ async def get_error_by_code( ) async def get_errors_by_classification( classification_id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """Get all errors for a specific classification""" + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_errors.view"]) errors = ErrorCatalogService.get_by_classification( db, classification_id, - tenant_id=current_user["tenant_id"], - company_id=current_user["company_id"] + tenant_id=tenant_id, + company_id=company_id, ) return [ErrorCatalogResponseDTO.model_validate(error) for error in errors] @@ -163,15 +181,17 @@ async def get_errors_by_classification( ) async def get_error( id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """Get an error by its ID with classification details""" + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_errors.view"]) error = ErrorCatalogService.get_by_id( db, id, - tenant_id=current_user["tenant_id"], - company_id=current_user["company_id"] + tenant_id=tenant_id, + company_id=company_id, ) if not error: raise HTTPException( diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py index ae872c3b..757e0bec 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py @@ -25,9 +25,9 @@ def list_canadian_fractions( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_canadian.view"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_canadian.view"] + ) skip = (page - 1) * page_size service = CanadianTariffFractionService(db) items, total = service.get_multi( @@ -52,9 +52,9 @@ def get_canadian_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_canadian.view"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_canadian.view"] + ) service = CanadianTariffFractionService(db) item = service.get(id, tenant_id, company_id) if not item: @@ -68,9 +68,9 @@ def create_canadian_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_canadian.create"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_canadian.create"] + ) service = CanadianTariffFractionService(db) return service.create(item_in, tenant_id, company_id) @@ -82,9 +82,9 @@ def update_canadian_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_canadian.edit"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_canadian.edit"] + ) service = CanadianTariffFractionService(db) item = service.get(id, tenant_id, company_id) if not item: @@ -98,9 +98,9 @@ def delete_canadian_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_canadian.delete"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_canadian.delete"] + ) service = CanadianTariffFractionService(db) item = service.get(id, tenant_id, company_id) if not item: diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py index 648b98a2..5dca0eb2 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py @@ -20,9 +20,9 @@ def get_historical_fractions( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_historical.view"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_historical.view"] + ) skip = (page - 1) * page_size service = HistoricalTariffFractionService(db) items, total = service.get_multi(tenant_id, company_id, skip=skip, limit=page_size, historical_fraction=historical_fraction) @@ -46,9 +46,9 @@ async def get_rate( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_historical.view"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_historical.view"] + ) # Parse date try: @@ -80,9 +80,9 @@ def get_historical_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_historical.view"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_historical.view"] + ) service = HistoricalTariffFractionService(db) fraction = service.get(id, tenant_id, company_id) if not fraction: @@ -96,9 +96,9 @@ def create_historical_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_historical.create"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_historical.create"] + ) service = HistoricalTariffFractionService(db) return service.create(fraction_in, tenant_id, company_id) @@ -110,9 +110,9 @@ def update_historical_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_historical.edit"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_historical.edit"] + ) service = HistoricalTariffFractionService(db) fraction = service.get(id, tenant_id, company_id) if not fraction: @@ -126,9 +126,9 @@ def delete_historical_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_historical.delete"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_historical.delete"] + ) service = HistoricalTariffFractionService(db) fraction = service.get(id, tenant_id, company_id) if not fraction: diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py index 006250d0..52c5e05b 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py @@ -40,8 +40,10 @@ async def list_tariff_fractions( current_user: Dict[str, Any] = Depends(get_current_user), ): # Validar permisos según el catálogo solicitado - if catalog in ["mex", "usa"]: + if catalog == "mex": validate_access_to_resource(db, company_id, current_user, ["frac_sitar.view"]) + elif catalog == "usa": + validate_access_to_resource(db, company_id, current_user, ["frac_sitar_us.view"]) elif catalog == "american": validate_access_to_resource(db, company_id, current_user, ["frac_american.view"]) @@ -83,12 +85,19 @@ async def list_tariff_fractions( ) async def get_tariff_fraction( tariff_fraction_id: int, + catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"), company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): - # Por defecto asumimos vista de SITAR para este endpoint de consulta por ID general - validate_access_to_resource(db, company_id, current_user, ["frac_sitar.view"]) + if catalog == "mex": + validate_access_to_resource(db, company_id, current_user, ["frac_sitar.view"]) + elif catalog == "usa": + validate_access_to_resource(db, company_id, current_user, ["frac_sitar_us.view"]) + elif catalog == "american": + validate_access_to_resource(db, company_id, current_user, ["frac_american.view"]) + else: + raise HTTPException(status_code=400, detail=f"Unsupported catalog '{catalog}'") item = TariffFractionService.get_by_id(db, tariff_fraction_id) if not item: from fastapi import HTTPException diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py index a1cee9f5..acda6661 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py @@ -77,11 +77,9 @@ async def list_us_tariff_fractions( db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_american.view"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) - - validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_sitar_us.view"] + ) skip = (page - 1) * page_size try: diff --git a/backend/api/v1/modules/a76/general_catalogs/location/routes.py b/backend/api/v1/modules/a76/general_catalogs/location/routes.py index bd9949a5..529ad1bf 100644 --- a/backend/api/v1/modules/a76/general_catalogs/location/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/location/routes.py @@ -17,4 +17,9 @@ router = TenantCRUDRoutes( enable_list=True, enable_filters=True, max_page_size=1000, + list_permissions=["cat_locations.view"], + get_permissions=["cat_locations.view"], + create_permissions=["cat_locations.create"], + update_permissions=["cat_locations.edit"], + delete_permissions=["cat_locations.delete"], ).router diff --git a/backend/api/v1/modules/a76/general_catalogs/prevalidators/routes.py b/backend/api/v1/modules/a76/general_catalogs/prevalidators/routes.py index 71ee0120..539a2d6e 100644 --- a/backend/api/v1/modules/a76/general_catalogs/prevalidators/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/prevalidators/routes.py @@ -4,12 +4,12 @@ Rutas para gestión de prevalidadores from typing import List -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from core.database import get_core_db from core.security import get_current_user -from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource from .dto import ( PrevalidatorCreateDTO, PrevalidatorResponseDTO, @@ -29,6 +29,11 @@ prevalidator_crud = TenantCRUDRoutes( tags=["Prevalidators"], resource_name="Prevalidator", enable_list=True, + list_permissions=["cat_prevalidators.view"], + get_permissions=["cat_prevalidators.view"], + create_permissions=["cat_prevalidators.create"], + update_permissions=["cat_prevalidators.edit"], + delete_permissions=["cat_prevalidators.delete"], ) # Add custom endpoints @@ -41,15 +46,19 @@ prevalidator_crud = TenantCRUDRoutes( ) async def get_prevalidator_by_code( code: str, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """Get a prevalidator by its code""" + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["cat_prevalidators.view"] + ) prevalidator = PrevalidatorService.get_by_code( db, code, - tenant_id=current_user["tenant_id"], - company_id=current_user["company_id"] + tenant_id=tenant_id, + company_id=company_id, ) if not prevalidator: raise HTTPException( @@ -78,9 +87,12 @@ async def create_prevalidator( async def update_prevalidator( prevalidator_id: int, prevalidator_data: PrevalidatorUpdateDTO, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Update a prevalidator""" + validate_access_to_resource(db, company_id, current_user, ["cat_prevalidators.edit"]) prevalidator = PrevalidatorService.update( db, prevalidator_id, prevalidator_data) if not prevalidator: @@ -98,9 +110,12 @@ async def update_prevalidator( ) async def delete_prevalidator( prevalidator_id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Delete a prevalidator""" + validate_access_to_resource(db, company_id, current_user, ["cat_prevalidators.delete"]) success = PrevalidatorService.delete(db, prevalidator_id) if not success: raise HTTPException( @@ -117,10 +132,15 @@ async def delete_prevalidator( ) async def get_prevalidators_by_customs( customs: str, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Get all prevalidators for a specific customs""" - prevalidators = PrevalidatorService.get_by_customs(db, customs) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["cat_prevalidators.view"] + ) + prevalidators = PrevalidatorService.get_by_customs(db, customs, tenant_id, company_id) return [ PrevalidatorResponseDTO.model_validate(prevalidator) for prevalidator in prevalidators diff --git a/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py b/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py index f7c390a5..0e228d57 100644 --- a/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py @@ -27,6 +27,11 @@ signature_crud = TenantCRUDRoutes( enable_filters=True, # Enable filtering by code default_page_size=50, max_page_size=100, + list_permissions=["cat_signatures.view"], + get_permissions=["cat_signatures.view"], + create_permissions=["cat_signatures.create"], + update_permissions=["cat_signatures.edit"], + delete_permissions=["cat_signatures.delete"], ) router = signature_crud.router @@ -44,7 +49,9 @@ async def get_signature_by_code( current_user: Dict[str, Any] = Depends(signature_crud.auth_dependency), ): """Get a signature by its code""" - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["cat_signatures.view"] + ) signature = SignatureService.get_by_code(db, code, tenant_id, company_id) if not signature: raise HTTPException( @@ -61,9 +68,12 @@ async def get_signature_by_code( ) async def delete_signature( signature_id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(signature_crud.auth_dependency), ): """Delete a signature""" + validate_access_to_resource(db, company_id, current_user, ["cat_signatures.delete"]) success = SignatureService.delete(db, signature_id) if not success: raise HTTPException( diff --git a/backend/api/v1/modules/core/permissions/seed_v2.py b/backend/api/v1/modules/core/permissions/seed_v2.py index cda18719..00e92e10 100644 --- a/backend/api/v1/modules/core/permissions/seed_v2.py +++ b/backend/api/v1/modules/core/permissions/seed_v2.py @@ -77,12 +77,9 @@ permissions_general = ( gen_crud("cat_broker_concepts", "Conceptos de Agente Aduanal", "general_catalogs") + gen_crud("cat_classification", "Clasificación de Conceptos", "general_catalogs") + gen_crud("cat_identifiers", "Identificadores", "general_catalogs") + - gen_crud("cat_incoterms", "Incoterms (Catálogo)", "general_catalogs") + gen_crud("cat_inpc", "INPC", "general_catalogs") + gen_crud("cat_legends", "Leyendas Fijas", "general_catalogs") + gen_crud("cat_seals", "Sellos", "general_catalogs") + - gen_crud("cat_valuation", "Métodos de Valoración", "general_catalogs") + - gen_crud("cat_countries", "Países", "general_catalogs") + gen_crud("cat_ports", "Puertos", "general_catalogs") + gen_crud("cat_um_general", "UdM Generales", "general_catalogs") + gen_crud("cat_um_customs", "UdM Aduana MEX", "general_catalogs") + @@ -92,17 +89,15 @@ permissions_general = ( gen_crud("cat_unit_conversions", "Conversiones", "general_catalogs") + gen_crud("cat_equivalencies", "Equivalencias", "general_catalogs") + gen_crud("cat_exchange_rates", "Tipos de Cambio", "general_catalogs") + - gen_crud("cat_currency", "Tipos de Moneda", "general_catalogs") + gen_crud("cat_multi_currency_types", "Tipos de Moneda (Múltiples)", "general_catalogs") + - gen_crud("cat_inv_types", "Tipos de Factura", "general_catalogs") + gen_crud("cat_signatures", "Firmas Electrónicas", "general_catalogs") + gen_crud("cat_errors", "Catálogo de Errores", "general_catalogs") + gen_crud("cat_doda", "DODA", "general_catalogs") + gen_crud("cat_prevalidators", "Prevalidadores", "general_catalogs") + gen_crud("cat_notices", "Avisos Electrónicos", "general_catalogs") + - gen_crud("cat_crossing", "Avisos de Cruce", "general_catalogs") + - gen_crud("cat_warehouses", "Recintos", "general_catalogs") + - gen_crud("cat_sectors", "Sectores", "general_catalogs") + gen_crud("cat_sectors", "Sectores", "general_catalogs") + + gen_crud("cat_locations", "Ubicaciones", "general_catalogs") + + gen_crud("cat_depreciation_catalog", "Catálogo de Depreciación", "general_catalogs") ) # ============================================================================ diff --git a/frontend/src/lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte b/frontend/src/lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte index 519a708f..1e6b5073 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte @@ -70,12 +70,14 @@ let { dodaIdParam, defaultLastUser: defaultLastUserProp = '', + canMutate = true, onClose, onCreatedNavigateTo }: { /** 'new' o id numérico en string; desde ?doda_id= de la URL */ dodaIdParam: string; defaultLastUser?: string; + canMutate?: boolean; onClose: () => void; /** Tras crear DODA, actualiza ?doda_id= al id creado (sin desmontar la lista) */ onCreatedNavigateTo: (dodaId: number) => void; @@ -170,13 +172,22 @@ dodaFormT(dodaLoc, 'shortcuts_scope'), obtenerAtajosFormularioDodaPagina({ cambiarPestana: (pestana) => (activeTab = pestana), - manejarGuardar: handleSubmit, + manejarGuardar: () => { + if (!canMutate) return; + handleSubmit(); + }, manejarCerrar: () => { if (browser) onClose(); } }) ); + function ensureCanMutate(): boolean { + if (canMutate) return true; + error = 'Permission denied: cat_doda.create/edit'; + return false; + } + function getEmptyForm(): DodaCreate & { pedimentos_detail?: any[]; containers?: any[]; @@ -400,6 +411,7 @@ } async function handleAddSeal() { + if (!ensureCanMutate()) return; error = null; if (!isEdit || !id) { error = t('seal_save_first'); @@ -457,6 +469,7 @@ } async function handleAddSealFromContainerModal() { + if (!ensureCanMutate()) return; const trimmed = newSealValue.trim(); if (!trimmed) { error = t('seal_empty'); @@ -493,6 +506,7 @@ } function handleAmericanPedimentoModalConfirm() { + if (!ensureCanMutate()) return; error = null; const tipo = americanModalType.trim(); const valor = americanModalValue.trim(); @@ -539,6 +553,7 @@ } async function handleDeleteSeal(sealLine: number) { + if (!ensureCanMutate()) return; error = null; if (!isEdit || !id) return; if (selectedContainerIndex == null) return; @@ -563,6 +578,7 @@ } async function handleSubmit() { + if (!ensureCanMutate()) return; submitAttempted = true; error = null; warning = null; @@ -1621,7 +1637,7 @@
{#if rows.length === 0 && !refreshing} -

No hay importaciones pendientes para esta empresa.

+

{csvMsg('pending.empty')}

{:else if rows.length === 0 && refreshing} -

Comprobando con el servidor…

+

{csvMsg('pending.checking')}

{:else}
{/each} diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte index b15acaeb..85a364f4 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -16,6 +16,7 @@ criticalReferenceGaps, referenceStateReady } from '$lib/csv-import-commit-metrics'; + import { csvFmt, csvMsg } from '$lib/i18n/csv-msg'; let { open = $bindable(false), @@ -249,16 +250,16 @@
{#if isPending} - Validación de Importación + {csvMsg('modal.title_pending')} {:else if isFinished} - {hasErrors ? 'Importación con Observaciones' : 'Importación Exitosa'} + {hasErrors ? csvMsg('modal.title_warning') : csvMsg('modal.title_success')} {/if} {#if isPending} - Revise el análisis preliminar antes de confirmar la carga de datos. + {csvMsg('modal.desc_pending')} {:else if isFinished} - El proceso de importación ha finalizado. + {csvMsg('modal.desc_done')} {/if}
@@ -274,7 +275,7 @@ class="bg-card p-4 rounded-lg border flex flex-col items-center justify-center text-center shadow-sm" > Total Filas{csvMsg('modal.total_rows')} {scanResults.total_rows || 0} @@ -285,7 +286,7 @@ > Válidos{csvMsg('modal.valid_rows')} {scanResults.valid_rows || 0} Errores{csvMsg('modal.errors')} {scanResults.error_count || 0} @@ -327,10 +328,9 @@ >
-

Se detectaron problemas en el archivo

+

{csvMsg('modal.scan_problems_title')}

- Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para - importar solo las filas válidas (las erróneas se omitirán). + {csvMsg('modal.scan_problems_body')}

@@ -338,13 +338,13 @@
- Detalle de errores (para corregir en el CSV) + {csvMsg('modal.errors_heading')}
- {scanErrorsShown} de {scanErrorsTotal} error(es) + {csvFmt('modal.errors_badge', { shown: scanErrorsShown, total: scanErrorsTotal })}
@@ -362,10 +362,10 @@ class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm" > - Línea - Columna - Mensaje - Solución + {csvMsg('modal.th_line')} + {csvMsg('modal.th_column')} + {csvMsg('modal.th_message')} + {csvMsg('modal.th_solution')} @@ -382,21 +382,21 @@
{#if scanErrorsTruncated}

- Para consultar el resto de errores, descargue el CSV. + {csvMsg('modal.errors_truncated')}

{/if} {:else}

- Se detectaron {scanResults.error_count} fila(s) con errores pero el detalle no está disponible. Asegúrese de que el servidor esté actualizado y vuelva a subir el archivo. + {csvFmt('modal.errors_missing_detail', { count: scanResults.error_count })}

{/if} {:else}
-

Archivo validado correctamente

-

Todos los registros parecen correctos y listos para importar.

+

{csvMsg('modal.scan_ok_title')}

+

{csvMsg('modal.scan_ok_body')}

{/if} @@ -413,7 +413,7 @@ Insertados{csvMsg('modal.inserted')} {insertedCount} @@ -425,7 +425,7 @@ Actualizados{csvMsg('modal.updated')} {updatedCount} @@ -436,13 +436,13 @@
Rechazados{csvMsg('modal.rejected')}
{totalSkipped} {#if totalSkipped > 0 && commitResults.skipped_details && commitResults.skipped_details.length > 0}

- Revisa el detalle por línea en la tabla inferior. + {csvMsg('modal.rejected_hint')}

{/if} @@ -456,20 +456,17 @@ {#if refGaps > 0}
-

Brechas de referencia (FK / catálogos)

+

{csvMsg('modal.ref_gaps_title')}

- Hay {refGaps} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de - filas rechazadas antes de reintentar. + {csvFmt('modal.ref_gaps_body', { n: refGaps })}

{:else}
-

Estado de referencias

+

{csvMsg('modal.ref_state_title')}

- {refReady - ? 'Referencias listas para operar (sin brechas críticas reportadas).' - : 'Sin brechas numéricas; revisa el mensaje del servidor si aplica.'} + {refReady ? csvMsg('modal.ref_state_ok') : csvMsg('modal.ref_state_other')}

{/if} @@ -483,7 +480,7 @@ {#if commitSkippedSummary.length > 0}

- Resumen de motivos de rechazo + {csvMsg('modal.skipped_reasons_heading')}

{#each commitSkippedSummary as item} @@ -504,13 +501,13 @@
- Detalle de Errores + {csvMsg('modal.commit_errors_heading')}
- {commitResults.skipped_details.length} filas + {csvFmt('modal.rows_badge', { n: commitResults.skipped_details.length })}
@@ -528,10 +525,10 @@ class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm" > - Línea - Referencia - Motivo - Solución + {csvMsg('modal.th_line')} + {csvMsg('modal.th_reference')} + {csvMsg('modal.th_reason')} + {csvMsg('modal.th_solution')} @@ -558,7 +555,7 @@
{#if isPending && isUploading}
-

Importando registros…

+

{csvMsg('modal.importing_records')}

- Cancelar Operación + {csvMsg('modal.cancel_operation')} {:else if isFinished} - + {/if}
diff --git a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte index 0c9bdaaa..8896a5cb 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte @@ -4,7 +4,21 @@ import { UploadCloud, Lock } from 'lucide-svelte'; import { cn } from '$lib/utils'; import { toast } from 'svelte-sonner'; + import { browser } from '$app/environment'; import { api } from '$lib/api'; + import { cookieName, getLocale } from '$lib/paraglide/runtime'; + import { csvMsg } from '$lib/i18n/csv-msg'; + + /** Misma fuente que el switch de idioma (cookie Paraglide); `getLocale()` puede quedar desincronizado. */ + function csvTemplateLocale(): 'es' | 'en' { + if (browser) { + const cookies = document.cookie.split(';').map((c) => c.trim()); + const localeCookie = cookies.find((c) => c.startsWith(`${cookieName}=`)); + const current = localeCookie ? localeCookie.split('=')[1] : ''; + if (current) return current.toLowerCase().startsWith('en') ? 'en' : 'es'; + } + return String(getLocale()).toLowerCase().startsWith('en') ? 'en' : 'es'; + } let { items, @@ -62,7 +76,7 @@ const isValidExtension = file.name.toLowerCase().endsWith('.csv'); if (!isValidExtension) { - toast.error('Formato inválido. Solo se permiten archivos .csv'); + toast.error(csvMsg('toast.invalid_csv')); return; } @@ -106,8 +120,12 @@ if (!item.templateId) return; try { - toast.info(`Descargando plantilla para ${item.title}...`); - const { blob, filename } = await api.getCsvTemplateDownload(item.templateId); + const itemLabel = csvMsg(`items.${item.id}`); + toast.info(`${csvMsg('toast.download_loading')} ${itemLabel}`); + const { blob, filename } = await api.getCsvTemplateDownload( + item.templateId, + csvTemplateLocale() + ); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; @@ -116,9 +134,9 @@ link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); - toast.success(`Plantilla descargada: ${filename}`); + toast.success(`${csvMsg('toast.download_ok')} ${filename}`); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Error al descargar la plantilla'); + toast.error(err instanceof Error ? err.message : csvMsg('toast.download_err')); } } @@ -128,6 +146,10 @@ class:opacity-60={gridLocked} aria-busy={gridLocked ? true : undefined} > + {#snippet cardTitle(itemId: string)} +
{csvMsg(`items.${itemId}`)}
+ {/snippet} + {#if groupedItems.ungrouped.length > 0}
{#each groupedItems.ungrouped as item} @@ -171,7 +193,7 @@ - Próximamente + {csvMsg('soon')}
{/if} @@ -183,7 +205,7 @@
- ¡Suelta el archivo! + {csvMsg('drop_here')} {:else}
-
{item.title}
+ {@render cardTitle(item.id)} {/if} @@ -205,7 +227,7 @@ {#each Object.entries(groupedItems.groups) as [groupName, groupItems]}

- {groupName} + {csvMsg(`groups.${groupName}`)}

{#each groupItems as item} @@ -251,7 +273,7 @@ - Próximamente + {csvMsg('soon')}
{/if} @@ -263,7 +285,7 @@
- ¡Suelta el archivo! + {csvMsg('drop_here')} {:else}
-
{item.title}
+ {@render cardTitle(item.id)} {/if} diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 411ba348..41c7427e 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -91,6 +91,18 @@ export function getSidebarData(): SidebarData { items: [], permission: 'audit_logs.view', }, + { + title: m["sidebar.bulk_upload.title"](), + url: "#", + icon: ArrowUpFromLine, + items: [ + { + title: m["sidebar.bulk_upload.entry"](), + url: "/dashboard/csv-upload", + permission: "csv_upload.process", + }, + ], + }, { title: m["sidebar.reference_data.title"](), diff --git a/frontend/src/lib/components/sidebar/nav-projects.svelte b/frontend/src/lib/components/sidebar/nav-projects.svelte index ced6fb82..05910373 100644 --- a/frontend/src/lib/components/sidebar/nav-projects.svelte +++ b/frontend/src/lib/components/sidebar/nav-projects.svelte @@ -1,13 +1,6 @@ - Gestion + {m['sidebar.management_label']()} {#each projects as item (item.name)} @@ -48,58 +27,7 @@ {/snippet} - - - {#snippet child({ props })} - - - More - - {/snippet} - - - - - View Project - - - - Share Project - - - - - Delete Project - - - {/each} - - - - - More - - - (open = v)}> - - - - - - Carga CSV - - - - - diff --git a/frontend/src/lib/config/csv-upload.ts b/frontend/src/lib/config/csv-upload.ts index 426880c2..426eb969 100644 --- a/frontend/src/lib/config/csv-upload.ts +++ b/frontend/src/lib/config/csv-upload.ts @@ -22,9 +22,10 @@ import { export interface CsvUploadItem { id: string; - title: string; + /** Etiqueta vía i18n: csv_upload.items. */ icon: any; - group?: string; // For grouping within a tab + /** Clave i18n csv_upload.groups. */ + group?: string; modelTarget?: string; // The backend model this maps to description?: string; /** Backend template id for CSV download (e.g. customs_brokers, part_numbers). No physical file. */ @@ -36,9 +37,10 @@ export interface CsvUploadItem { export interface CsvUploadField { name: string; - label: string; + /** Ruta bajo csv_upload.* (p. ej. params.load_mode) */ + labelKey: string; type: 'text' | 'select' | 'boolean' | 'date' | 'radio'; - options?: { label: string; value: string | boolean | number }[]; + options?: { labelKey: string; value: string | boolean | number }[]; required?: boolean; defaultValue?: any; } @@ -46,9 +48,9 @@ export interface CsvUploadField { /** Global parameters shown in the CSV upload footer bar (one option or the other via select). */ export interface GlobalCsvParam { name: string; - label: string; + labelKey: string; type: 'select'; - options: { label: string; value: string }[]; + options: { labelKey: string; value: string }[]; defaultValue: string; } @@ -56,62 +58,62 @@ export interface GlobalCsvParam { export const globalCsvParams: GlobalCsvParam[] = [ { name: 'mode', - label: 'Modo de Carga', + labelKey: 'params.load_mode', type: 'select', options: [ - { label: 'Actualizar', value: 'update' }, - { label: 'Reemplazar', value: 'replace' } + { labelKey: 'options.update', value: 'update' }, + { labelKey: 'options.replace', value: 'replace' } ], defaultValue: 'update' }, { name: 'dateFormat', - label: 'Formato de Fecha', + labelKey: 'params.date_format', type: 'select', options: [ - { label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' }, - { label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' }, - { label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' } + { labelKey: 'options.date_dd_mm', value: 'dd/mm/yyyy' }, + { labelKey: 'options.date_mm_dd', value: 'mm/dd/yyyy' }, + { labelKey: 'options.date_iso', value: 'yyyy-mm-dd' } ], defaultValue: 'dd/mm/yyyy' }, { name: 'weight_unit', - label: 'Unidad de Peso', + labelKey: 'params.weight_unit', type: 'select', options: [ - { label: 'Kilos (Kgs)', value: 'kgs' }, - { label: 'Libras (Lbs)', value: 'lbs' } + { labelKey: 'options.kgs', value: 'kgs' }, + { labelKey: 'options.lbs', value: 'lbs' } ], defaultValue: 'kgs' }, { name: 'autonumber_series', - label: 'Autonumerar Partidas/Series', + labelKey: 'params.autonumber_series', type: 'select', options: [ - { label: 'Sí', value: 'true' }, - { label: 'No', value: 'false' } + { labelKey: 'options.yes', value: 'true' }, + { labelKey: 'options.no', value: 'false' } ], defaultValue: 'false' }, { name: 'load_subpartidas', - label: 'Levantar Subpartidas', + labelKey: 'params.load_subpartidas', type: 'select', options: [ - { label: 'Sí', value: 'true' }, - { label: 'No', value: 'false' } + { labelKey: 'options.yes', value: 'true' }, + { labelKey: 'options.no', value: 'false' } ], defaultValue: 'false' }, { name: 'recalculate_pedimento_date', - label: 'Recalcular Fecha Pedimento', + labelKey: 'params.recalculate_pedimento_date', type: 'select', options: [ - { label: 'Sí', value: 'true' }, - { label: 'No', value: 'false' } + { labelKey: 'options.yes', value: 'true' }, + { labelKey: 'options.no', value: 'false' } ], defaultValue: 'false' } @@ -122,11 +124,11 @@ export const tabSettings: Record = { catalogos: [ { name: 'mode', - label: 'Modo de Carga', + labelKey: 'params.load_mode', type: 'radio', options: [ - { label: 'Actualizar', value: 'update' }, - { label: 'Reemplazar', value: 'replace' } + { labelKey: 'options.update', value: 'update' }, + { labelKey: 'options.replace', value: 'replace' } ], defaultValue: 'update' } @@ -134,11 +136,11 @@ export const tabSettings: Record = { transportes: [ { name: 'mode', - label: 'Modo de Carga', + labelKey: 'params.load_mode', type: 'radio', options: [ - { label: 'Actualizar', value: 'update' }, - { label: 'Reemplazar', value: 'replace' } + { labelKey: 'options.update', value: 'update' }, + { labelKey: 'options.replace', value: 'replace' } ], defaultValue: 'update' } @@ -146,24 +148,24 @@ export const tabSettings: Record = { importacion: [ { name: 'autonumber_remesas', - label: 'Autonumerar Remesas', + labelKey: 'params.autonumber_remesas', type: 'boolean', defaultValue: false }, { name: 'recalculate_dates', - label: 'Recalcular Fechas', + labelKey: 'params.recalculate_dates', type: 'boolean', defaultValue: false }, { name: 'dateFormat', - label: 'Formato de Fecha', + labelKey: 'params.date_format', type: 'select', options: [ - { label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' }, - { label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' }, - { label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' } + { labelKey: 'options.date_dd_mm', value: 'dd/mm/yyyy' }, + { labelKey: 'options.date_mm_dd', value: 'mm/dd/yyyy' }, + { labelKey: 'options.date_iso', value: 'yyyy-mm-dd' } ], defaultValue: 'dd/mm/yyyy' } @@ -171,28 +173,28 @@ export const tabSettings: Record = { exportacion: [ { name: 'invoice_type', - label: 'Tipo de Factura', + labelKey: 'params.invoice_type', type: 'select', options: [ - { label: 'AFIJO', value: 'AFIJO' }, - { label: 'NORMAL', value: 'NORMAL' }, + { labelKey: 'options.afi', value: 'AFIJO' }, + { labelKey: 'options.normal', value: 'NORMAL' }, ], defaultValue: 'AFIJO', }, { name: 'is_regime_change', - label: 'Es Cambio de Régimen', + labelKey: 'params.is_regime_change', type: 'boolean', defaultValue: false, }, { name: 'dateFormat', - label: 'Formato de Fecha', + labelKey: 'params.date_format', type: 'select', options: [ - { label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' }, - { label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' }, - { label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' } + { labelKey: 'options.date_dd_mm', value: 'dd/mm/yyyy' }, + { labelKey: 'options.date_mm_dd', value: 'mm/dd/yyyy' }, + { labelKey: 'options.date_iso', value: 'yyyy-mm-dd' } ], defaultValue: 'dd/mm/yyyy' } @@ -204,7 +206,6 @@ export const tabSettings: Record = { export const catalogosConfig: CsvUploadItem[] = [ { id: 'customs_brokers', - title: 'Agentes Aduanales', icon: User, modelTarget: 'CustomsBroker', templateId: 'customs_brokers', @@ -212,7 +213,6 @@ export const catalogosConfig: CsvUploadItem[] = [ }, { id: 'clients_providers', - title: 'Clientes y Proveedores', icon: Users, modelTarget: 'ClientProvider', templateId: 'clients_providers', @@ -220,7 +220,6 @@ export const catalogosConfig: CsvUploadItem[] = [ }, { id: 'exchange_rates', - title: 'Tipo de Cambios', icon: DollarSign, modelTarget: 'ExchangeRate', templateId: 'exchange_rates', @@ -228,7 +227,6 @@ export const catalogosConfig: CsvUploadItem[] = [ }, { id: 'material_classes', - title: 'Clases de Materiales', icon: Package, modelTarget: 'MaterialClass', templateId: 'material_classes', @@ -236,7 +234,6 @@ export const catalogosConfig: CsvUploadItem[] = [ }, { id: 'part_numbers', - title: 'Números de parte', icon: Hash, modelTarget: 'Part', templateId: 'part_numbers', @@ -244,7 +241,6 @@ export const catalogosConfig: CsvUploadItem[] = [ }, { id: 'boms', - title: 'BOMs', icon: Briefcase, modelTarget: 'Bom', templateId: 'boms', @@ -253,30 +249,26 @@ export const catalogosConfig: CsvUploadItem[] = [ }, { id: 'items', - title: 'Partidas (Permisos)', icon: FileText, - group: 'Permisos', + group: 'permisos', modelTarget: 'ItemPermission', templateId: 'part_numbers' }, { id: 'headers', - title: 'Encabezados (Permisos)', icon: FileText, - group: 'Permisos', + group: 'permisos', modelTarget: 'HeaderPermission', disabled: true, }, { id: 'historical_fractions', - title: 'Fracciones Históricas', icon: Calendar, modelTarget: 'HistoricalFraction', disabled: true, }, { id: 'pedimentos', - title: 'Pedimentos', icon: FileDigit, modelTarget: 'Pedimento', templateId: 'pedimentos', @@ -288,7 +280,6 @@ export const catalogosConfig: CsvUploadItem[] = [ export const transportesConfig: CsvUploadItem[] = [ { id: 'transporters', - title: 'Transportistas', icon: Ship, modelTarget: 'Transporter', templateId: 'transporters', @@ -296,7 +287,6 @@ export const transportesConfig: CsvUploadItem[] = [ }, { id: 'transports', - title: 'Transportes', icon: Truck, modelTarget: 'Transport', templateId: 'transports', @@ -304,7 +294,6 @@ export const transportesConfig: CsvUploadItem[] = [ }, { id: 'drivers', - title: 'Conductores', icon: User, modelTarget: 'Driver', templateId: 'drivers', @@ -312,7 +301,6 @@ export const transportesConfig: CsvUploadItem[] = [ }, { id: 'trailers', - title: 'Trailers y Cajas', icon: Container, modelTarget: 'Trailer', templateId: 'trailers', @@ -326,27 +314,24 @@ export const importacionConfig: CsvUploadItem[] = [ // Impo Temp { id: 'imp_temp_header', - title: 'Encabezado', icon: FileText, - group: 'Impo. Temp.', + group: 'impo_temp', modelTarget: 'invoice_header', templateId: 'imp_temp_header', layoutModule: 'layouts_csv/facturas' }, { id: 'imp_temp_details', - title: 'Partidas', icon: Package, - group: 'Impo. Temp.', + group: 'impo_temp', modelTarget: 'invoice_details', templateId: 'imp_temp_details', layoutModule: 'layouts_csv/facturas' }, { id: 'imp_temp_series', - title: 'Series', icon: Hash, - group: 'Impo. Temp.', + group: 'impo_temp', modelTarget: 'invoice_series', templateId: 'imp_temp_series', layoutModule: 'layouts_csv/facturas' @@ -354,27 +339,24 @@ export const importacionConfig: CsvUploadItem[] = [ // Impo Def { id: 'imp_def_header', - title: 'Encabezado', icon: FileText, - group: 'Impo. Def.', + group: 'impo_def', modelTarget: 'invoice_header', templateId: 'imp_def_header', layoutModule: 'layouts_csv/facturas' }, { id: 'imp_def_details', - title: 'Partidas', icon: Package, - group: 'Impo. Def.', + group: 'impo_def', modelTarget: 'invoice_details', templateId: 'imp_def_details', layoutModule: 'layouts_csv/facturas' }, { id: 'imp_def_series', - title: 'Series', icon: Hash, - group: 'Impo. Def.', + group: 'impo_def', modelTarget: 'invoice_series', templateId: 'imp_def_series', layoutModule: 'layouts_csv/facturas' @@ -382,27 +364,24 @@ export const importacionConfig: CsvUploadItem[] = [ // Compras Mex { id: 'comp_mex_header', - title: 'Encabezado', icon: FileText, - group: 'Compras Mex.', + group: 'cmex', modelTarget: 'invoice_header', templateId: 'cmex_header', layoutModule: 'layouts_csv/facturas' }, { id: 'comp_mex_details', - title: 'Partidas', icon: Package, - group: 'Compras Mex.', + group: 'cmex', modelTarget: 'invoice_details', templateId: 'cmex_details', layoutModule: 'layouts_csv/facturas' }, { id: 'comp_mex_series', - title: 'Series', icon: Hash, - group: 'Compras Mex.', + group: 'cmex', modelTarget: 'invoice_series', templateId: 'cmex_series', layoutModule: 'layouts_csv/facturas' @@ -415,70 +394,62 @@ export const exportacionConfig: CsvUploadItem[] = [ // Expo Def / Cam. Reg. { id: 'exp_def_header', - title: 'Encabezado', icon: FileText, - group: 'Expo. Def./Cam. Reg.', + group: 'expo_def', modelTarget: 'invoice_header', templateId: 'exp_def_header', layoutModule: 'layouts_csv/exportacion' }, { id: 'exp_def_details', - title: 'Partidas', icon: Package, - group: 'Expo. Def./Cam. Reg.', + group: 'expo_def', modelTarget: 'invoice_details', templateId: 'exp_def_details', layoutModule: 'layouts_csv/exportacion' }, { id: 'exp_def_series', - title: 'Series', icon: Hash, - group: 'Expo. Def./Cam. Reg.', + group: 'expo_def', modelTarget: 'invoice_series', templateId: 'exp_def_series', layoutModule: 'layouts_csv/exportacion' }, { id: 'exp_def_nodes', - title: 'NODES', icon: Briefcase, - group: 'Expo. Def./Cam. Reg.', + group: 'expo_def', modelTarget: 'Nodes', disabled: true, }, // Expo Rep { id: 'exp_rep_header', - title: 'Encabezado', icon: FileText, - group: 'Expo. Rep.', + group: 'expo_rep', modelTarget: 'InvoiceHeader', disabled: true, }, { id: 'exp_rep_details', - title: 'Partidas', icon: Package, - group: 'Expo. Rep.', + group: 'expo_rep', modelTarget: 'InvoiceSalesDetails', disabled: true, }, { id: 'exp_rep_series', - title: 'Series', icon: Hash, - group: 'Expo. Rep.', + group: 'expo_rep', modelTarget: 'InvoiceSeries', disabled: true, }, // Manifiesto { id: 'manifest_header', - title: 'Encabezado', icon: FileText, - group: 'Manifiesto', + group: 'manifest', modelTarget: 'Manifest', disabled: true, }, diff --git a/frontend/src/lib/i18n/csv-msg.ts b/frontend/src/lib/i18n/csv-msg.ts new file mode 100644 index 00000000..fbf56fa6 --- /dev/null +++ b/frontend/src/lib/i18n/csv-msg.ts @@ -0,0 +1,46 @@ +/** + * Textos CSV desde `csv-upload-messages.{en,es}.json` (rama `csv_upload` extraída de messages/). + * Mantener en sync al editar `messages/*.json` (p. ej. volver a exportar la clave csv_upload). + */ +import enPack from './csv-upload-messages.en.json'; +import esPack from './csv-upload-messages.es.json'; +import { baseLocale, getLocale } from '$lib/paraglide/runtime'; + +type CsvUploadPack = Record; + +function pickPack(): CsvUploadPack { + let raw: string; + try { + raw = String(getLocale()).toLowerCase(); + } catch { + raw = String(baseLocale).toLowerCase(); + } + const root = raw.startsWith('en') ? enPack : esPack; + return root as CsvUploadPack; +} + +function walk(root: Record, keys: string[]): unknown { + let cur: unknown = root; + for (const k of keys) { + if (cur === null || typeof cur !== 'object') return undefined; + cur = (cur as Record)[k]; + } + return cur; +} + +/** Navega `csv_upload.a.b.c` a partir de `subPath` = `a.b.c`. */ +export function csvMsg(subPath: string): string { + const pack = pickPack(); + if (!pack) return subPath; + const v = walk(pack as Record, subPath.split('.')); + return typeof v === 'string' ? v : subPath; +} + +/** Sustituye `{clave}` en el string del mensaje. */ +export function csvFmt(subPath: string, vars: Record = {}): string { + let s = csvMsg(subPath); + for (const [k, val] of Object.entries(vars)) { + s = s.replaceAll(`{${k}}`, String(val)); + } + return s; +} diff --git a/frontend/src/lib/i18n/csv-upload-messages.en.json b/frontend/src/lib/i18n/csv-upload-messages.en.json new file mode 100644 index 00000000..2ebba1a5 --- /dev/null +++ b/frontend/src/lib/i18n/csv-upload-messages.en.json @@ -0,0 +1,193 @@ +{ + "page_title": "CSV import", + "intro_help": "Left-click: upload CSV file. Right-click: download template.", + "tab_catalogos": "Catalogs", + "tab_transportes": "Transportation", + "tab_importacion": "Import", + "tab_exportacion": "Export", + "section_catalogs": "General Catalogs", + "section_transport": "Transportation", + "section_import": "Import operations", + "section_export": "Export operations", + "params_header": "Global parameters", + "config_prefix": "Settings", + "soon": "Coming soon", + "drop_here": "Drop the file!", + "groups": { + "permisos": "Permissions", + "impo_temp": "Temporary import", + "impo_def": "Definitive import", + "cmex": "Mexican purchases", + "expo_def": "Definitive export / regime change", + "expo_rep": "Export replenishment", + "manifest": "Manifest" + }, + "items": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "material_classes": "Classes", + "part_numbers": "Parts", + "boms": "BOMs", + "items": "Lines (permissions)", + "headers": "Headers (permissions)", + "historical_fractions": "Historical tariff fractions", + "pedimentos": "Pedimentos", + "transporters": "Carriers", + "transports": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "imp_temp_header": "Header", + "imp_temp_details": "Lines", + "imp_temp_series": "Serial numbers", + "imp_def_header": "Header", + "imp_def_details": "Lines", + "imp_def_series": "Serial numbers", + "comp_mex_header": "Header", + "comp_mex_details": "Lines", + "comp_mex_series": "Serial numbers", + "exp_def_header": "Header", + "exp_def_details": "Lines", + "exp_def_series": "Serial numbers", + "exp_def_nodes": "NODES", + "exp_rep_header": "Header", + "exp_rep_details": "Lines", + "exp_rep_series": "Serial numbers", + "manifest_header": "Header" + }, + "params": { + "load_mode": "Load mode", + "date_format": "Date format", + "weight_unit": "Weight unit", + "autonumber_series": "Autonumber lines/series", + "load_subpartidas": "Load sub-lines", + "recalculate_pedimento_date": "Recalculate pedimento date", + "autonumber_remesas": "Autonumber consignments", + "recalculate_dates": "Recalculate dates", + "invoice_type": "Invoice type", + "is_regime_change": "Regime change" + }, + "options": { + "update": "Update", + "replace": "Replace", + "yes": "Yes", + "no": "No", + "kgs": "Kilograms (kg)", + "lbs": "Pounds (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL" + }, + "progress": { + "upload": "Uploading CSV file", + "scan": "Validating records on the server", + "commit": "Saving records to the database", + "upload_known": "Uploading file…", + "upload_unknown": "Uploading file (unknown size in browser)…", + "in_progress": "In progress…", + "resume_hint": "Resuming import saved in this tab…", + "rows_file": "File: ~{n} data row(s) — uploading (not yet validated on server)…", + "rows_scan": "Records processed: {current} / {total}", + "rows_commit": "Records saved: {current} / {total}", + "rows_commit_fallback": "Saving to database… ({current} / {total} using last known total)" + }, + "toast": { + "invalid_csv": "Invalid format. Only .csv files are allowed.", + "download_loading": "Downloading template…", + "download_ok": "Template downloaded.", + "download_err": "Could not download the template.", + "upload_err": "Could not upload the file.", + "upload_err_generic": "Unexpected error uploading the file.", + "scan_done": "Scan complete. Review the results.", + "import_done": "Import completed. Review the record list.", + "import_maybe_done": "Import may have completed. Review the record list.", + "stale_job": "This import is no longer available (session expired or job removed). You can start a new upload.", + "poll_err": "Could not fetch status", + "commit_err": "Could not start import", + "scan_alt": "Scan finished. If you do not see the modal, check the record list.", + "finished_none": "No records inserted. Review the errors below.", + "commit_warning_ok": "{inserted} inserted, {updated} updated. {skipped} rejected.", + "commit_warning_none": "No records inserted or updated. {skipped} rejected.", + "success_counts": "Import completed: {msg}", + "warn_skipped": "{n} records rejected or skipped", + "error_processing": "Processing error: {msg}", + "n_inserted": "{n} inserted", + "n_updated": "{n} updated", + "err_fetch_scan_result": "Could not fetch the scan result. Check the results modal.", + "err_unknown": "Unknown error", + "err_processing_fallback": "Processing error. Check the modal or details." + }, + "pending": { + "badge": "Pending", + "title": "Imports pending confirmation", + "description": "Scans ready to save to the database. Expired jobs disappear when you refresh.", + "refresh": "Refresh", + "empty": "No pending imports for this company.", + "checking": "Checking with the server…", + "total_rows": "Total rows", + "valid_rows": "Valid", + "resume": "Resume", + "remove": "Remove", + "profiles": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "pedimentos": "Pedimentos", + "material_classes": "Classes", + "vehicles": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "transporters": "Carriers", + "part_numbers": "Parts", + "boms": "BOMs", + "exportacion": "Export operations", + "imports": "Import operations" + } + }, + "config_empty": "No module-specific settings.", + "modal": { + "title_pending": "Import validation", + "title_success": "Import successful", + "title_warning": "Import with remarks", + "desc_pending": "Review the preliminary analysis before confirming.", + "desc_done": "The import process has finished.", + "total_rows": "Total rows", + "valid_rows": "Valid", + "invalid_rows": "Invalid", + "errors": "Errors", + "errors_heading": "Scan errors (fix in your CSV)", + "errors_badge": "{shown} of {total} error(s)", + "errors_truncated": "Download the CSV to see all errors.", + "errors_missing_detail": "{count} row(s) had errors but details are not available. Ensure the server is up to date and upload again.", + "scan_ok_title": "File validated successfully", + "scan_ok_body": "All rows look correct and ready to import.", + "scan_problems_title": "Problems found in the file", + "scan_problems_body": "Fix the issues listed below in your CSV and upload again, or confirm to import only valid rows (invalid rows will be skipped).", + "inserted": "Inserted", + "updated": "Updated", + "rejected": "Rejected", + "rejected_hint": "See line-by-line detail in the table below.", + "ref_gaps_title": "Reference gaps (FK / catalogs)", + "ref_gaps_body": "There are {n} critical reference gap(s). Review catalogs and rejected rows before retrying.", + "ref_state_title": "Reference state", + "ref_state_ok": "References ready to operate (no critical gaps reported).", + "ref_state_other": "No numeric gaps; review the server message if applicable.", + "skipped_reasons_heading": "Rejection reasons summary", + "commit_errors_heading": "Error detail", + "rows_badge": "{n} rows", + "importing_records": "Importing records…", + "cancel_operation": "Cancel", + "processing": "Processing…", + "confirm_load": "Confirm import", + "close": "Close", + "th_line": "Line", + "th_column": "Column", + "th_message": "Message", + "th_solution": "Solution", + "th_reference": "Reference", + "th_reason": "Reason", + "download_csv": "Download CSV" + } +} diff --git a/frontend/src/lib/i18n/csv-upload-messages.es.json b/frontend/src/lib/i18n/csv-upload-messages.es.json new file mode 100644 index 00000000..326d2842 --- /dev/null +++ b/frontend/src/lib/i18n/csv-upload-messages.es.json @@ -0,0 +1,193 @@ +{ + "page_title": "Importación CSV", + "intro_help": "Clic izquierdo: cargar archivo CSV. Clic derecho: descargar plantilla.", + "tab_catalogos": "Catálogos", + "tab_transportes": "Transportes", + "tab_importacion": "Importación", + "tab_exportacion": "Exportación", + "section_catalogs": "Catalogos Generales", + "section_transport": "Transportes", + "section_import": "Operaciones de importación", + "section_export": "Operaciones de exportación", + "params_header": "Parámetros globales", + "config_prefix": "Configuración", + "soon": "Próximamente", + "drop_here": "¡Suelta el archivo!", + "groups": { + "permisos": "Permisos", + "impo_temp": "Impo. temp.", + "impo_def": "Impo. def.", + "cmex": "Compras mex.", + "expo_def": "Expo. def./Cam. reg.", + "expo_rep": "Expo. rep.", + "manifest": "Manifiesto" + }, + "items": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "material_classes": "Clases", + "part_numbers": "Partes", + "boms": "BOMs", + "items": "Partidas (permisos)", + "headers": "Encabezados (permisos)", + "historical_fractions": "Fracciones históricas", + "pedimentos": "Pedimentos", + "transporters": "Transportistas", + "transports": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "imp_temp_header": "Encabezado", + "imp_temp_details": "Partidas", + "imp_temp_series": "Series", + "imp_def_header": "Encabezado", + "imp_def_details": "Partidas", + "imp_def_series": "Series", + "comp_mex_header": "Encabezado", + "comp_mex_details": "Partidas", + "comp_mex_series": "Series", + "exp_def_header": "Encabezado", + "exp_def_details": "Partidas", + "exp_def_series": "Series", + "exp_def_nodes": "NODES", + "exp_rep_header": "Encabezado", + "exp_rep_details": "Partidas", + "exp_rep_series": "Series", + "manifest_header": "Encabezado" + }, + "params": { + "load_mode": "Modo de carga", + "date_format": "Formato de fecha", + "weight_unit": "Unidad de peso", + "autonumber_series": "Autonumerar partidas/series", + "load_subpartidas": "Levantar subpartidas", + "recalculate_pedimento_date": "Recalcular fecha pedimento", + "autonumber_remesas": "Autonumerar remesas", + "recalculate_dates": "Recalcular fechas", + "invoice_type": "Tipo de factura", + "is_regime_change": "Es cambio de régimen" + }, + "options": { + "update": "Actualizar", + "replace": "Reemplazar", + "yes": "Sí", + "no": "No", + "kgs": "Kilos (kg)", + "lbs": "Libras (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL" + }, + "progress": { + "upload": "Subiendo archivo CSV", + "scan": "Validando registros en el servidor", + "commit": "Grabando registros en base de datos", + "upload_known": "Subiendo archivo…", + "upload_unknown": "Subiendo archivo (tamaño desconocido en el navegador)…", + "in_progress": "En proceso…", + "resume_hint": "Reanudando la importación guardada en esta pestaña…", + "rows_file": "Archivo: ~{n} fila(s) de datos — subiendo (aún no se validan registros en servidor)…", + "rows_scan": "Registros procesados: {current} / {total}", + "rows_commit": "Registros grabados: {current} / {total}", + "rows_commit_fallback": "Grabando en base de datos… ({current} / {total} según último total conocido)" + }, + "toast": { + "invalid_csv": "Formato inválido. Solo se permiten archivos .csv", + "download_loading": "Descargando plantilla…", + "download_ok": "Plantilla descargada.", + "download_err": "Error al descargar la plantilla", + "upload_err": "Error al subir el archivo", + "upload_err_generic": "Error inesperado al subir el archivo", + "scan_done": "Escaneo completado. Revisa los resultados.", + "import_done": "Importación completada. Revisa el listado de registros.", + "import_maybe_done": "La importación pudo completarse. Revisa el listado de registros.", + "stale_job": "Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.", + "poll_err": "Error al consultar el estado", + "commit_err": "Error al iniciar la importación", + "scan_alt": "El escaneo terminó. Si no ves el modal, revisa el listado de registros.", + "finished_none": "No se insertaron registros. Revisa los errores a continuación.", + "commit_warning_ok": "{inserted} insertados, {updated} actualizados. {skipped} rechazados.", + "commit_warning_none": "No se insertaron ni actualizaron registros. {skipped} rechazados.", + "success_counts": "Importación completada: {msg}", + "warn_skipped": "{n} registros fueron rechazados u omitidos", + "error_processing": "Error en el procesamiento: {msg}", + "n_inserted": "{n} insertados", + "n_updated": "{n} actualizados", + "err_fetch_scan_result": "Error al obtener el resultado. Revisa el modal de resultados.", + "err_unknown": "Error desconocido", + "err_processing_fallback": "Error en el procesamiento. Revisa el modal o los detalles." + }, + "pending": { + "badge": "Pendientes", + "title": "Importaciones pendientes de confirmar", + "description": "Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al actualizar.", + "refresh": "Actualizar", + "empty": "No hay importaciones pendientes para esta empresa.", + "checking": "Comprobando con el servidor…", + "total_rows": "Total filas", + "valid_rows": "Válidas", + "resume": "Reanudar", + "remove": "Quitar", + "profiles": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "pedimentos": "Pedimentos", + "material_classes": "Clases", + "vehicles": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "transporters": "Transportistas", + "part_numbers": "Partes", + "boms": "BOMs", + "exportacion": "Exportación (operaciones)", + "imports": "Importación (operaciones)" + } + }, + "config_empty": "No hay configuraciones específicas para este módulo.", + "modal": { + "title_pending": "Validación de importación", + "title_success": "Importación exitosa", + "title_warning": "Importación con observaciones", + "desc_pending": "Revise el análisis preliminar antes de confirmar la carga de datos.", + "desc_done": "El proceso de importación ha finalizado.", + "total_rows": "Total filas", + "valid_rows": "Válidos", + "invalid_rows": "Inválidos", + "errors": "Errores", + "errors_heading": "Detalle de errores (para corregir en el CSV)", + "errors_badge": "{shown} de {total} error(es)", + "errors_truncated": "Para consultar el resto de errores, descargue el CSV.", + "errors_missing_detail": "Se detectaron {count} fila(s) con errores pero el detalle no está disponible. Asegúrese de que el servidor esté actualizado y vuelva a subir el archivo.", + "scan_ok_title": "Archivo validado correctamente", + "scan_ok_body": "Todos los registros parecen correctos y listos para importar.", + "scan_problems_title": "Se detectaron problemas en el archivo", + "scan_problems_body": "Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para importar solo las filas válidas (las erróneas se omitirán).", + "inserted": "Insertados", + "updated": "Actualizados", + "rejected": "Rechazados", + "rejected_hint": "Revisa el detalle por línea en la tabla inferior.", + "ref_gaps_title": "Brechas de referencia (FK / catálogos)", + "ref_gaps_body": "Hay {n} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de filas rechazadas antes de reintentar.", + "ref_state_title": "Estado de referencias", + "ref_state_ok": "Referencias listas para operar (sin brechas críticas reportadas).", + "ref_state_other": "Sin brechas numéricas; revisa el mensaje del servidor si aplica.", + "skipped_reasons_heading": "Resumen de motivos de rechazo", + "commit_errors_heading": "Detalle de errores", + "rows_badge": "{n} filas", + "importing_records": "Importando registros…", + "cancel_operation": "Cancelar", + "processing": "Procesando…", + "confirm_load": "Confirmar carga", + "close": "Cerrar", + "th_line": "Línea", + "th_column": "Columna", + "th_message": "Mensaje", + "th_solution": "Solución", + "th_reference": "Referencia", + "th_reason": "Motivo", + "download_csv": "Descargar CSV" + } +} diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte index 876a166b..7c05bf78 100644 --- a/frontend/src/routes/dashboard/csv-upload/+page.svelte +++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte @@ -39,6 +39,7 @@ import { totalSkippedFromCommit } from '$lib/csv-import-commit-metrics'; import { countCsvDataRows } from '$lib/csv-upload-row-count'; import CsvPendingImportsSheet from '$lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte'; + import { csvFmt, csvMsg } from '$lib/i18n/csv-msg'; /** Intenta extraer un objeto tipo scan desde string tipo repr de Python. */ function parsePythonReprScan(s: string): Record | null { @@ -240,11 +241,11 @@ const csvProgressStepTitle = $derived.by(() => { switch (csvProgressPhase) { case 'upload': - return 'Subiendo archivo CSV'; + return csvMsg('progress.upload'); case 'scan': - return 'Validando registros en el servidor'; + return csvMsg('progress.scan'); case 'commit': - return 'Grabando registros en base de datos'; + return csvMsg('progress.commit'); default: return ''; } @@ -261,17 +262,26 @@ switch (csvProgressPhase) { case 'upload': if (csvImportRowTotal > 0) { - return `Archivo: ~${csvImportRowTotal} fila(s) de datos — subiendo (aún no se validan registros en servidor)…`; + return csvFmt('progress.rows_file', { n: csvImportRowTotal }); } return uploadLengthComputable - ? 'Subiendo archivo…' - : 'Subiendo archivo (tamaño desconocido en el navegador)…'; + ? csvMsg('progress.upload_known') + : csvMsg('progress.upload_unknown'); case 'scan': - return `Registros procesados: ${Math.min(scanProgressCurrent, denom)} / ${denom}`; + return csvFmt('progress.rows_scan', { + current: Math.min(scanProgressCurrent, denom), + total: denom + }); case 'commit': return scanProgressTotal > 0 - ? `Registros grabados: ${Math.min(scanProgressCurrent, denom)} / ${denom}` - : `Grabando en base de datos… (${Math.min(scanProgressCurrent, denom)} / ${denom} según último total conocido)`; + ? csvFmt('progress.rows_commit', { + current: Math.min(scanProgressCurrent, denom), + total: denom + }) + : csvFmt('progress.rows_commit_fallback', { + current: Math.min(scanProgressCurrent, denom), + total: denom + }); default: return ''; } @@ -306,7 +316,7 @@ const csvFooterAriaValueText = $derived( csvFooterProgressIndeterminate - ? `${csvProgressStepTitle}. ${csvProgressDetailLine || 'En proceso…'}` + ? `${csvProgressStepTitle}. ${csvProgressDetailLine || csvMsg('progress.in_progress')}` : csvProgressDetailLine ? `${csvProgressStepTitle}, ${csvFooterPercentLabel}. ${csvProgressDetailLine}` : `${csvProgressStepTitle}, ${csvFooterPercentLabel}` @@ -591,7 +601,7 @@ csvResumeOverlayHint = false; skipScanCompleteToastOnce = false; scanPhaseJobId = null; - currentImportLabel = (config.title && String(config.title).trim()) || null; + currentImportLabel = csvMsg(`items.${config.id}`); const onCsvFileUploadProgress = (e: { loaded: number; total: number }) => { if (e.total > 0) { @@ -626,12 +636,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -646,12 +656,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -673,12 +683,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -698,12 +708,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -721,12 +731,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -744,12 +754,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -764,12 +774,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -787,12 +797,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -810,12 +820,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -834,12 +844,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -854,12 +864,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -888,12 +898,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -915,12 +925,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } } @@ -943,11 +953,9 @@ const errMsg = res.error || ''; if (isStaleImportJobError(res.status, errMsg)) { removePendingLinkedToCurrentFlow(); - toast.info( - 'Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.' - ); + toast.info(csvMsg('toast.stale_job')); } else { - toast.error(errMsg || 'Error al consultar el estado'); + toast.error(errMsg || csvMsg('toast.poll_err')); } isUploading = false; clearCsvImportSession(); @@ -978,7 +986,7 @@ if (skipScanCompleteToastOnce) { skipScanCompleteToastOnce = false; } else { - toast.success('Escaneo completado. Revisa los resultados.'); + toast.success(csvMsg('toast.scan_done')); } isUploading = false; } else if (res.data?.status === 'failed' || res.data?.status === 'FAILURE') { @@ -996,7 +1004,7 @@ commitResults = parsedCommit; showResultModal = true; finalizeCommitAndClearPending(); - toast.success('Importación completada. Revisa el listado de registros.'); + toast.success(csvMsg('toast.import_done')); isUploading = false; clearCsvImportSession(); currentJobId = null; @@ -1026,7 +1034,7 @@ if (skipScanCompleteToastOnce) { skipScanCompleteToastOnce = false; } else { - toast.success('Escaneo completado. Revisa los resultados.'); + toast.success(csvMsg('toast.scan_done')); } isUploading = false; } else { @@ -1036,30 +1044,32 @@ (errRaw.includes('waiting_confirmation') || (errRaw.includes('total_rows') && errRaw.includes('job_id'))); if (looksLikeScanInError) { removePendingLinkedToCurrentFlow(); - toast.info('El escaneo terminó. Si no ves el modal, revisa el listado de registros.'); + toast.info(csvMsg('toast.scan_alt')); isUploading = false; clearCsvImportSession(); currentJobId = null; return; } let errText: string; + const maybeDoneMsg = csvMsg('toast.import_maybe_done'); if (typeof errRaw === 'string') { errText = errRaw.includes('finished') && errRaw.includes('inserted') - ? 'La importación pudo completarse. Revisa el listado de registros.' + ? maybeDoneMsg : errRaw.includes("'status'") && errRaw.includes('waiting_confirmation') - ? 'Error al obtener el resultado. Revisa el modal de resultados.' + ? csvMsg('toast.err_fetch_scan_result') : errRaw; } else if (typeof errRaw === 'object' && errRaw !== null) { - errText = (errRaw as { message?: string })?.message || 'Error en el procesamiento. Revisa el modal o los detalles.'; + errText = + (errRaw as { message?: string })?.message || csvMsg('toast.err_processing_fallback'); } else { - errText = 'Error desconocido'; + errText = csvMsg('toast.err_unknown'); } // No mostrar como error si el mensaje indica éxito - if (errText.includes('pudo completarse') || errText.includes('Revisa el listado')) { + if (errText === maybeDoneMsg || errText.includes(maybeDoneMsg)) { toast.success(errText); } else { - toast.error('Error en el procesamiento: ' + errText); + toast.error(csvFmt('toast.error_processing', { msg: errText })); } isUploading = false; removePendingLinkedToCurrentFlow(); @@ -1082,12 +1092,16 @@ if (totalOk === 0) { toast.error( backendMessage || - `No se insertaron ni actualizaron registros. ${totalSkipped} fueron rechazados.` + csvFmt('toast.commit_warning_none', { skipped: totalSkipped }) ); } else { toast.warning( backendMessage || - `Se aplicaron ${totalOk} registros (${inserted} insertados, ${updated} actualizados). ${totalSkipped} rechazados.` + csvFmt('toast.commit_warning_ok', { + inserted, + updated, + skipped: totalSkipped + }) ); } finalizeCommitAndClearPending(); @@ -1100,15 +1114,15 @@ const totalSkipped = totalSkippedFromCommit(res.data as Record); if (inserted > 0 || updated > 0) { - const parts = []; - if (inserted > 0) parts.push(`${inserted} insertados`); - if (updated > 0) parts.push(`${updated} actualizados`); - toast.success(`Importación completada: ${parts.join(', ')}`); + const parts: string[] = []; + if (inserted > 0) parts.push(csvFmt('toast.n_inserted', { n: inserted })); + if (updated > 0) parts.push(csvFmt('toast.n_updated', { n: updated })); + toast.success(csvFmt('toast.success_counts', { msg: parts.join(', ') })); if (totalSkipped > 0) { - toast.warning(`${totalSkipped} registros fueron rechazados u omitidos`); + toast.warning(csvFmt('toast.warn_skipped', { n: totalSkipped })); } } else { - toast.error('No se insertaron registros. Revisa los errores a continuación.'); + toast.error(csvMsg('toast.finished_none')); } finalizeCommitAndClearPending(); isUploading = false; @@ -1136,26 +1150,26 @@
-

Importación Masiva de Datos (CSV)

+

{csvMsg('page_title')}

- Click izquierdo: cargar archivo CSV. Click derecho: descargar estructura (plantilla). + {csvMsg('intro_help')}

- Catálogos - Transportes - Importación - Exportación + {csvMsg('tab_catalogos')} + {csvMsg('tab_transportes')} + {csvMsg('tab_importacion')} + {csvMsg('tab_exportacion')}
-

Catálogos Generales

+

{csvMsg('section_catalogs')}

-

Logística y Transporte

+

{csvMsg('section_transport')}

-

Operaciones de Importación

+

{csvMsg('section_import')}

-

Operaciones de Exportación

+

{csvMsg('section_export')}

{#if csvResumeOverlayHint} -

Reanudando la importación guardada en esta pestaña…

+

{csvMsg('progress.resume_hint')}

{/if} {#if currentImportLabel}

{currentImportLabel}

@@ -1340,7 +1354,7 @@ pollStatus(); } } catch (err) { - toast.error('Error al iniciar la importación'); + toast.error(csvMsg('toast.commit_err')); isUploading = false; } }} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5346ce8c..6ecf1384 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -40,16 +40,9 @@ export default defineConfig({ server: { port: 5173, // fija el puerto host: true, // escucha en 0.0.0.0 - allowedHosts: [ - 'anexo76-dev.aduanasoft.com', - // Requeridos para dev local, healthcheck del contenedor y E2E (Playwright - // con --network host apunta a 127.0.0.1:5173). Al definir allowedHosts, - // Vite 5.1+ reemplaza el default ['.localhost'] y bloquea todo lo que no - // esté aquí, devolviendo 403 "Blocked request". - '127.0.0.1', - 'localhost', - // 'otro-host.com' si necesitas más - ], + // Lista explícita + peticiones internas (p. ej. chunks JSON `?import`) pueden usar + // Hosts distintos y recibir 403. `true` permite cualquier Host en dev. + allowedHosts: true, proxy: { '/api/uploads': { target: 'http://backend:8000', From 62dded0b6198dff5883e711d6f3279ee65ae2172 Mon Sep 17 00:00:00 2001 From: hreyes Date: Wed, 6 May 2026 15:09:45 -0600 Subject: [PATCH 5/6] fix/test-doda-y-s3 --- backend/tests/unit/general_catalogs/doda/test_doda_print.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/tests/unit/general_catalogs/doda/test_doda_print.py b/backend/tests/unit/general_catalogs/doda/test_doda_print.py index a8098ef5..22abac7e 100644 --- a/backend/tests/unit/general_catalogs/doda/test_doda_print.py +++ b/backend/tests/unit/general_catalogs/doda/test_doda_print.py @@ -53,7 +53,8 @@ def _print_client(db_session: Session, test_tenant) -> TestClient: @patch.object( doda_routes, "validate_access_to_resource", - lambda db, company_id, current_user: int(current_user["tenant_id"]), + lambda db, company_id, current_user, required_permissions=None, require_all=True: int( + current_user["tenant_id"] ) def test_print_uses_s3_cache_when_fingerprint_matches( db_session: Session, test_tenant, monkeypatch: pytest.MonkeyPatch @@ -98,7 +99,8 @@ def test_print_uses_s3_cache_when_fingerprint_matches( @patch.object( doda_routes, "validate_access_to_resource", - lambda db, company_id, current_user: int(current_user["tenant_id"]), + lambda db, company_id, current_user, required_permissions=None, require_all=True: int( + current_user["tenant_id"] ) def test_print_422_without_digital_seal( db_session: Session, test_tenant, monkeypatch: pytest.MonkeyPatch From 373f5e119818bb58f83512c007003222618faac7 Mon Sep 17 00:00:00 2001 From: hreyes Date: Wed, 6 May 2026 15:13:12 -0600 Subject: [PATCH 6/6] fix/parentesis --- backend/tests/unit/general_catalogs/doda/test_doda_print.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/tests/unit/general_catalogs/doda/test_doda_print.py b/backend/tests/unit/general_catalogs/doda/test_doda_print.py index 22abac7e..f635e74d 100644 --- a/backend/tests/unit/general_catalogs/doda/test_doda_print.py +++ b/backend/tests/unit/general_catalogs/doda/test_doda_print.py @@ -55,6 +55,7 @@ def _print_client(db_session: Session, test_tenant) -> TestClient: "validate_access_to_resource", lambda db, company_id, current_user, required_permissions=None, require_all=True: int( current_user["tenant_id"] + ), ) def test_print_uses_s3_cache_when_fingerprint_matches( db_session: Session, test_tenant, monkeypatch: pytest.MonkeyPatch @@ -101,6 +102,7 @@ def test_print_uses_s3_cache_when_fingerprint_matches( "validate_access_to_resource", lambda db, company_id, current_user, required_permissions=None, require_all=True: int( current_user["tenant_id"] + ), ) def test_print_422_without_digital_seal( db_session: Session, test_tenant, monkeypatch: pytest.MonkeyPatch