diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 8816c42f..bd0bc66e 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -17,8 +17,45 @@ on: - main jobs: + # Ejecuta la suite de tests del backend antes de construir imágenes. + # Si falla, no se ejecuta build (ni push ni deploy). + test: + runs-on: self-hosted + steps: + - name: Checkout código + uses: actions/checkout@v4 + + - name: Configurar Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: backend/requirements.txt + + - name: Instalar dependencias del backend + run: | + python -m pip install --upgrade pip + pip install -r backend/requirements.txt + + - name: Comprobar TEST_DATABASE_URL + env: + TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} + run: | + if [ -z "$TEST_DATABASE_URL" ]; then + echo "::error::Define el secret TEST_DATABASE_URL en el repo (Gitea → Ajustes → Secretos)." + echo "Ejemplo: postgresql://usuario:clave@host:5432/nombre_bd" + exit 1 + fi + + - name: Ejecutar tests del backend + env: + TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} + working-directory: backend + run: pytest -q tests + build: runs-on: self-hosted + needs: test steps: - name: Checkout código diff --git a/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py b/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py index 9c464513..34b8f35b 100644 --- a/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py +++ b/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py @@ -60,8 +60,7 @@ class FaLineItemCreateDTO(BaseModel): own_equipment: Optional[bool] = Field(None, description="Equipo propio") omit_annex31: Optional[bool] = Field(None, description="Omitir en Anexo 31") - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class FaLineItemUpdateDTO(BaseModel): @@ -112,8 +111,7 @@ class FaLineItemUpdateDTO(BaseModel): own_equipment: Optional[bool] = Field(None, description="Equipo propio") omit_annex31: Optional[bool] = Field(None, description="Omitir en Anexo 31") - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class FaLineItemResponseDTO(BaseModel): @@ -164,5 +162,4 @@ class FaLineItemResponseDTO(BaseModel): created_at: datetime = Field(..., description="Fecha de creación") updated_at: datetime = Field(..., description="Fecha de actualización") - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/clients_and_providers/dto.py b/backend/api/v1/modules/a76/clients_and_providers/dto.py index e461addd..fbe8d766 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/dto.py +++ b/backend/api/v1/modules/a76/clients_and_providers/dto.py @@ -6,7 +6,7 @@ Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS from decimal import Decimal from typing import List, Literal, Optional -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from .validators import is_valid_rfc, is_valid_tax_id @@ -36,8 +36,7 @@ class ClientProviderAddressDTO(BaseModel): contact: Optional[str] = Field(None, max_length=50, description="Contact person") reference: Optional[str] = Field(None, max_length=250, description="Reference") - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # DTOs para programas @@ -86,8 +85,7 @@ class ClientProviderProgramsDTO(BaseModel): None, max_length=300, description="AUTSE number" ) - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # DTOs principales @@ -145,8 +143,7 @@ class ClientProviderCreateDTO(BaseModel): ) return self - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class ClientProviderUpdateDTO(BaseModel): @@ -203,8 +200,7 @@ class ClientProviderUpdateDTO(BaseModel): ) return self - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class ClientProviderResponseDTO(BaseModel): @@ -232,8 +228,7 @@ class ClientProviderResponseDTO(BaseModel): address: Optional[ClientProviderAddressDTO] = None programs: Optional[ClientProviderProgramsDTO] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # DTOs para respuestas específicas @@ -247,8 +242,7 @@ class ClientProviderBasicDTO(BaseModel): client_or_provider: Optional[str] = None is_active: Optional[bool] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class ClientProviderListDTO(BaseModel): @@ -259,8 +253,7 @@ class ClientProviderListDTO(BaseModel): page: int size: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class ClientProviderPaginatedResponseDTO(BaseModel): @@ -271,5 +264,4 @@ class ClientProviderPaginatedResponseDTO(BaseModel): page: int page_size: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/dto.py b/backend/api/v1/modules/a76/general_catalogs/company/dto.py index dff8a610..a83faf81 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/dto.py @@ -6,7 +6,7 @@ Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS from datetime import datetime from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class CompanyCreateDTO(BaseModel): @@ -221,8 +221,7 @@ class CompanyCreateDTO(BaseModel): cancel_key_exp: Optional[int] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class CompanyUpdateDTO(CompanyCreateDTO): @@ -417,5 +416,4 @@ class CompanyResponseDTO(BaseModel): - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/dto.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/dto.py index 498d859a..16463397 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/dto.py @@ -19,8 +19,7 @@ class TariffFractionCreateDTO(BaseModel): adv_impo: Optional[str] = Field(None, max_length=20, description="Ad valorem importación") adv_expo: Optional[str] = Field(None, max_length=20, description="Ad valorem exportación") - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class TariffFractionUpdateDTO(BaseModel): @@ -33,8 +32,7 @@ class TariffFractionUpdateDTO(BaseModel): adv_impo: Optional[str] = Field(None, max_length=20, description="Ad valorem importación") adv_expo: Optional[str] = Field(None, max_length=20, description="Ad valorem exportación") - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class TariffFractionResponseDTO(BaseModel): @@ -66,8 +64,7 @@ class TariffFractionBasicDTO(BaseModel): adv_impo: Optional[str] = None adv_expo: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class TariffFractionListDTO(BaseModel): @@ -79,8 +76,7 @@ class TariffFractionListDTO(BaseModel): page_size: int pages: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class TariffFractionSearchDTO(BaseModel): @@ -92,5 +88,4 @@ class TariffFractionSearchDTO(BaseModel): nico: Optional[str] = Field(None, description="Filtrar por NICO") umt: Optional[str] = Field(None, description="Filtrar por UMT") - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/core/tenants/dto.py b/backend/api/v1/modules/core/tenants/dto.py index 46094676..d851a514 100644 --- a/backend/api/v1/modules/core/tenants/dto.py +++ b/backend/api/v1/modules/core/tenants/dto.py @@ -7,7 +7,7 @@ from datetime import datetime from enum import Enum from typing import Optional -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field class TenantTypeDTO(str, Enum): @@ -41,8 +41,8 @@ class TenantCreateDTO(BaseModel): None, max_length=50, description="Teléfono de contacto" ) - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "name": "Empresa ABC S.A. de C.V.", "slug": "empresa-abc", @@ -53,6 +53,7 @@ class TenantCreateDTO(BaseModel): "contact_phone": "+52 55 1234 5678", } } + ) class TenantUpdateDTO(BaseModel): @@ -64,13 +65,14 @@ class TenantUpdateDTO(BaseModel): contact_phone: Optional[str] = Field(None, max_length=50) is_active: Optional[bool] = None - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "name": "Empresa ABC S.A. de C.V. - Actualizado", "contact_email": "nuevo@empresa-abc.com", } } + ) class TenantResponseDTO(BaseModel): @@ -88,9 +90,9 @@ class TenantResponseDTO(BaseModel): created_at: datetime updated_at: datetime - class Config: - from_attributes = True - json_schema_extra = { + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ "example": { "id": 1, "name": "Empresa ABC S.A. de C.V.", @@ -104,7 +106,8 @@ class TenantResponseDTO(BaseModel): "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:30:00Z", } - } + }, + ) class TenantListResponseDTO(BaseModel): diff --git a/backend/api/v1/modules/sitar/aladi2/schemas.py b/backend/api/v1/modules/sitar/aladi2/schemas.py index 511ba5a8..ac136931 100644 --- a/backend/api/v1/modules/sitar/aladi2/schemas.py +++ b/backend/api/v1/modules/sitar/aladi2/schemas.py @@ -1,7 +1,7 @@ """ALADI2 Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class Aladi2Response(BaseModel): @@ -20,5 +20,4 @@ class Aladi2Response(BaseModel): NICO: Optional[str] = Field(None, max_length=2) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/cuotas2/schemas.py b/backend/api/v1/modules/sitar/cuotas2/schemas.py index d9c29164..74ca8ca2 100644 --- a/backend/api/v1/modules/sitar/cuotas2/schemas.py +++ b/backend/api/v1/modules/sitar/cuotas2/schemas.py @@ -1,7 +1,7 @@ """Cuotas2 Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class Cuotas2Response(BaseModel): @@ -26,5 +26,4 @@ class Cuotas2Response(BaseModel): NICO: Optional[str] = Field("", max_length=2) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/cupos/schemas.py b/backend/api/v1/modules/sitar/cupos/schemas.py index 4a6f652f..d607d6d5 100644 --- a/backend/api/v1/modules/sitar/cupos/schemas.py +++ b/backend/api/v1/modules/sitar/cupos/schemas.py @@ -1,7 +1,7 @@ """Cupos Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class CuposResponse(BaseModel): @@ -21,5 +21,4 @@ class CuposResponse(BaseModel): NICO: Optional[str] = Field("", max_length=2) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/fracciones/schemas.py b/backend/api/v1/modules/sitar/fracciones/schemas.py index a657c68b..1e01b4af 100644 --- a/backend/api/v1/modules/sitar/fracciones/schemas.py +++ b/backend/api/v1/modules/sitar/fracciones/schemas.py @@ -2,7 +2,7 @@ from decimal import Decimal from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class FraccionesResponse(BaseModel): @@ -35,5 +35,4 @@ class FraccionesResponse(BaseModel): NICO: Optional[str] = Field(None, max_length=14) SYSID: Optional[int] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/fracciones_anteriores/schemas.py b/backend/api/v1/modules/sitar/fracciones_anteriores/schemas.py index 9ddd8247..20b4757d 100644 --- a/backend/api/v1/modules/sitar/fracciones_anteriores/schemas.py +++ b/backend/api/v1/modules/sitar/fracciones_anteriores/schemas.py @@ -1,7 +1,7 @@ """Fracciones Anteriores Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class FraccionesAnterioresResponse(BaseModel): @@ -11,5 +11,4 @@ class FraccionesAnterioresResponse(BaseModel): FRACCIONANTERIOR: Optional[str] = Field(None, max_length=10) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/fracciones_usa/schemas.py b/backend/api/v1/modules/sitar/fracciones_usa/schemas.py index 1c5a8ba7..36e02fa3 100644 --- a/backend/api/v1/modules/sitar/fracciones_usa/schemas.py +++ b/backend/api/v1/modules/sitar/fracciones_usa/schemas.py @@ -1,7 +1,7 @@ """Fracciones USA Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class FraccionesUSAResponse(BaseModel): @@ -20,5 +20,4 @@ class FraccionesUSAResponse(BaseModel): NOTAS: Optional[str] = None CONSECUTIVO: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/fundamentos_tlc/schemas.py b/backend/api/v1/modules/sitar/fundamentos_tlc/schemas.py index 2a2350b7..d05c328c 100644 --- a/backend/api/v1/modules/sitar/fundamentos_tlc/schemas.py +++ b/backend/api/v1/modules/sitar/fundamentos_tlc/schemas.py @@ -1,7 +1,7 @@ """Fundamentos TLC Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class FundamentosTLCResponse(BaseModel): @@ -23,5 +23,4 @@ class FundamentosTLCResponse(BaseModel): NICO: Optional[str] = Field("", max_length=2) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/ieps/schemas.py b/backend/api/v1/modules/sitar/ieps/schemas.py index f65e0def..2966f4ab 100644 --- a/backend/api/v1/modules/sitar/ieps/schemas.py +++ b/backend/api/v1/modules/sitar/ieps/schemas.py @@ -1,7 +1,7 @@ """IEPS Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class IepsResponse(BaseModel): @@ -16,5 +16,4 @@ class IepsResponse(BaseModel): NICO: Optional[str] = Field("", max_length=2) CONSECUTIVO: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/informacion_general/schemas.py b/backend/api/v1/modules/sitar/informacion_general/schemas.py index 1b044670..b0c68238 100644 --- a/backend/api/v1/modules/sitar/informacion_general/schemas.py +++ b/backend/api/v1/modules/sitar/informacion_general/schemas.py @@ -1,7 +1,7 @@ """Información General Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class InformacionGeneralResponse(BaseModel): @@ -17,5 +17,4 @@ class InformacionGeneralResponse(BaseModel): NICO: Optional[str] = Field("", max_length=2) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/noms/schemas.py b/backend/api/v1/modules/sitar/noms/schemas.py index e02de133..e1ad6777 100644 --- a/backend/api/v1/modules/sitar/noms/schemas.py +++ b/backend/api/v1/modules/sitar/noms/schemas.py @@ -1,7 +1,7 @@ """NOMs Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class NomsResponse(BaseModel): @@ -23,5 +23,4 @@ class NomsResponse(BaseModel): PAIS: Optional[str] = Field("", max_length=3) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/precios_estimados/schemas.py b/backend/api/v1/modules/sitar/precios_estimados/schemas.py index 62753b6b..b3f18ed3 100644 --- a/backend/api/v1/modules/sitar/precios_estimados/schemas.py +++ b/backend/api/v1/modules/sitar/precios_estimados/schemas.py @@ -1,7 +1,7 @@ """Precios Estimados Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class PreciosEstimados2Response(BaseModel): @@ -14,5 +14,4 @@ class PreciosEstimados2Response(BaseModel): NICO: Optional[str] = Field("", max_length=2) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/prosec/schemas.py b/backend/api/v1/modules/sitar/prosec/schemas.py index 13d7b96c..0e798b99 100644 --- a/backend/api/v1/modules/sitar/prosec/schemas.py +++ b/backend/api/v1/modules/sitar/prosec/schemas.py @@ -1,7 +1,7 @@ """PROSEC Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class ProsecResponse(BaseModel): @@ -17,5 +17,4 @@ class ProsecResponse(BaseModel): NICO: Optional[str] = Field("", max_length=2) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/rcg2/schemas.py b/backend/api/v1/modules/sitar/rcg2/schemas.py index 977cb8af..3f90814b 100644 --- a/backend/api/v1/modules/sitar/rcg2/schemas.py +++ b/backend/api/v1/modules/sitar/rcg2/schemas.py @@ -1,7 +1,7 @@ """RCG2 Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class Rcg2Response(BaseModel): @@ -22,5 +22,4 @@ class Rcg2Response(BaseModel): NICO: Optional[str] = Field("", max_length=2) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/regulaciones/schemas.py b/backend/api/v1/modules/sitar/regulaciones/schemas.py index ddb6f4b3..c33387cf 100644 --- a/backend/api/v1/modules/sitar/regulaciones/schemas.py +++ b/backend/api/v1/modules/sitar/regulaciones/schemas.py @@ -1,7 +1,7 @@ """Regulaciones Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class RegulacionesResponse(BaseModel): @@ -15,5 +15,4 @@ class RegulacionesResponse(BaseModel): NICO: Optional[str] = Field("", max_length=2) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/reit/schemas.py b/backend/api/v1/modules/sitar/reit/schemas.py index 9d079198..bea940d6 100644 --- a/backend/api/v1/modules/sitar/reit/schemas.py +++ b/backend/api/v1/modules/sitar/reit/schemas.py @@ -1,7 +1,7 @@ """REIT Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class ReitResponse(BaseModel): @@ -21,5 +21,4 @@ class ReitResponse(BaseModel): NICO: Optional[str] = Field("", max_length=2) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/requisito_previo/schemas.py b/backend/api/v1/modules/sitar/requisito_previo/schemas.py index bc711e76..75082e71 100644 --- a/backend/api/v1/modules/sitar/requisito_previo/schemas.py +++ b/backend/api/v1/modules/sitar/requisito_previo/schemas.py @@ -1,7 +1,7 @@ """Requisito Previo Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class RequisitoPrevioResponse(BaseModel): @@ -24,5 +24,4 @@ class RequisitoPrevioResponse(BaseModel): NICO: Optional[str] = Field("", max_length=2) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/tlcs/schemas.py b/backend/api/v1/modules/sitar/tlcs/schemas.py index 944731ea..86a462ed 100644 --- a/backend/api/v1/modules/sitar/tlcs/schemas.py +++ b/backend/api/v1/modules/sitar/tlcs/schemas.py @@ -1,7 +1,7 @@ """TLCS Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class TLCSResponse(BaseModel): @@ -19,5 +19,4 @@ class TLCSResponse(BaseModel): NICO: Optional[str] = Field("", max_length=2) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/vehiculos_marcas/schemas.py b/backend/api/v1/modules/sitar/vehiculos_marcas/schemas.py index 979a8fba..0c3ea9b8 100644 --- a/backend/api/v1/modules/sitar/vehiculos_marcas/schemas.py +++ b/backend/api/v1/modules/sitar/vehiculos_marcas/schemas.py @@ -1,7 +1,7 @@ """Vehiculos Marcas Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class VehiculosMarcasResponse(BaseModel): @@ -11,5 +11,4 @@ class VehiculosMarcasResponse(BaseModel): MARCA: Optional[str] = Field(None, max_length=60) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/sitar/vehiculos_modelos/schemas.py b/backend/api/v1/modules/sitar/vehiculos_modelos/schemas.py index 0153c215..1fdb8d7a 100644 --- a/backend/api/v1/modules/sitar/vehiculos_modelos/schemas.py +++ b/backend/api/v1/modules/sitar/vehiculos_modelos/schemas.py @@ -1,7 +1,7 @@ """Vehiculos Modelos Schemas""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class VehiculosModelosResponse(BaseModel): @@ -34,5 +34,4 @@ class VehiculosModelosResponse(BaseModel): A1: Optional[str] = Field(None, max_length=19) SYSID: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/core/error_handlers.py b/backend/core/error_handlers.py index 7a68ccb7..8423403d 100644 --- a/backend/core/error_handlers.py +++ b/backend/core/error_handlers.py @@ -159,11 +159,11 @@ async def validation_exception_handler( ) response = JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={ "error": "VALIDATION_ERROR", "message": summary, - "status_code": status.HTTP_422_UNPROCESSABLE_ENTITY, + "status_code": status.HTTP_422_UNPROCESSABLE_CONTENT, "errors": errors, }, ) @@ -209,11 +209,11 @@ async def inner_validation_exception_handler( ) response = JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={ "error": "VALIDATION_ERROR", "message": summary, - "status_code": status.HTTP_422_UNPROCESSABLE_ENTITY, + "status_code": status.HTTP_422_UNPROCESSABLE_CONTENT, "errors": errors, }, ) diff --git a/backend/core/exceptions.py b/backend/core/exceptions.py index e53d20fe..1d457804 100644 --- a/backend/core/exceptions.py +++ b/backend/core/exceptions.py @@ -44,7 +44,7 @@ class ValidationException(BaseAPIException): ): super().__init__( message=message, - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, errors=errors, error_code="VALIDATION_ERROR", ) diff --git a/backend/tests/README.md b/backend/tests/README.md new file mode 100644 index 00000000..bfbab7c8 --- /dev/null +++ b/backend/tests/README.md @@ -0,0 +1,34 @@ +# Backend test strategy (Anexo24) + +## Test suites + +- `tests/e2e/`: full business flow (catalogs -> import -> export -> balances/discharges). +- `tests/integration/`: process API behavior and edge cases. +- `tests/unit/`: critical algorithms only (no trivial CRUD tests). + +## Prerequisites + +- PostgreSQL test database available. +- Environment variable: + - `TEST_DATABASE_URL=postgresql://user:pass@host:5432/db_name` + +## Run commands + +- Fast subset (CI gate): + - `pytest -q tests/unit tests/integration` +- Full suite: + - `pytest -q tests` + - `pytest tests -v -ra` + - `pytest tests -v -ra -s` +- E2E only: + - `pytest -q tests/e2e` + +## CI recommendations + +- Pull request gate: + - Run unit + integration on every PR. +- Nightly / main branch: + - Run full suite including E2E. +- Keep Celery eager mode for deterministic process endpoint tests: + - `task_always_eager=True` + - `task_eager_propagates=True` diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..1be795d3 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,138 @@ +from collections.abc import Generator + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +# Importar modelos con relaciones string para registrar mappers antes de tests. +# Evita errores tipo: expression 'Pedimentos' failed to locate a name. +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos # noqa: F401 +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.invoices.imports.process import routes as process_routes +from api.v1.modules.a76.invoices.imports.process import main_process as import_main_process +from api.v1.modules.a76.invoices.exports.process import main_process as export_main_process +from core.celery_app import celery_app +from core.config import settings +from core.database import get_core_db +from core.security import get_current_user + + +TEST_DB_URL = ( + __import__("os").environ.get("TEST_DATABASE_URL") + or __import__("os").environ.get("CORE_DATABASE_URL") + or settings.core_database_url +) + +engine = create_engine(TEST_DB_URL, future=True) +TestingSessionLocal = sessionmaker( + bind=engine, + autoflush=False, + autocommit=False, + expire_on_commit=False, + class_=Session, + join_transaction_mode="create_savepoint", +) + + +@pytest.fixture +def db_session() -> Generator[Session, None, None]: + connection = engine.connect() + transaction = connection.begin() + session = TestingSessionLocal(bind=connection) + try: + yield session + finally: + session.close() + transaction.rollback() + connection.close() + + +@pytest.fixture(autouse=True) +def celery_eager() -> Generator[None, None, None]: + prev_broker = celery_app.conf.broker_url + prev_backend = celery_app.conf.result_backend + prev_always_eager = celery_app.conf.task_always_eager + prev_propagates = celery_app.conf.task_eager_propagates + prev_store_result = celery_app.conf.task_store_eager_result + prev_ignore_result = celery_app.conf.task_ignore_result + + # Aislar Celery de infraestructura externa en tests (sin Redis/Valkey). + celery_app.conf.broker_url = "memory://" + celery_app.conf.result_backend = "cache+memory://" + celery_app.conf.task_always_eager = True + celery_app.conf.task_eager_propagates = True + celery_app.conf.task_store_eager_result = False + celery_app.conf.task_ignore_result = True + try: + yield + finally: + celery_app.conf.broker_url = prev_broker + celery_app.conf.result_backend = prev_backend + celery_app.conf.task_always_eager = prev_always_eager + celery_app.conf.task_eager_propagates = prev_propagates + celery_app.conf.task_store_eager_result = prev_store_result + celery_app.conf.task_ignore_result = prev_ignore_result + + +@pytest.fixture +def app(db_session: Session, monkeypatch: pytest.MonkeyPatch) -> FastAPI: + test_app = FastAPI() + test_app.include_router(process_routes.router, prefix="/api/v1/a76") + + def _override_get_db() -> Generator[Session, None, None]: + yield db_session + + async def _override_current_user(): + return {"sub": "test-user", "tenant_id": 1} + + # validate_access_to_resource is imported directly in the routes module. + monkeypatch.setattr( + process_routes, + "validate_access_to_resource", + lambda db, company_id, current_user: int(current_user["tenant_id"]), + ) + + class _InlineResult: + def __init__(self, task_id: str): + self.id = task_id + + def _run_import_inline(*, args=None, **kwargs): + if args is None: + args = [] + invoice_id, tenant_id, company_id = args + invoice = db_session.get(InvoiceHeader, int(invoice_id)) + if invoice is None: + return _InlineResult("missing-import-invoice") + import_main_process.main_process(db_session, invoice, str(tenant_id), str(company_id)) + db_session.flush() + return _InlineResult(f"inline-import-{invoice_id}") + + def _run_export_inline(*, args=None, **kwargs): + if args is None: + args = [] + invoice_id, tenant_id, company_id = args + invoice = db_session.get(InvoiceHeader, int(invoice_id)) + if invoice is None: + return _InlineResult("missing-export-invoice") + export_main_process.main_process(db_session, invoice, str(tenant_id), str(company_id)) + db_session.flush() + return _InlineResult(f"inline-export-{invoice_id}") + + class _InlineTask: + def __init__(self, runner): + self.apply_async = runner + + # Evita worker/redis y obliga ejecución inline con la misma sesión. + monkeypatch.setattr(process_routes, "process_invoice_task", _InlineTask(_run_import_inline)) + monkeypatch.setattr(process_routes, "process_export_invoice_task", _InlineTask(_run_export_inline)) + + test_app.dependency_overrides[get_core_db] = _override_get_db + test_app.dependency_overrides[get_current_user] = _override_current_user + return test_app + + +@pytest.fixture +def client(app: FastAPI) -> TestClient: + return TestClient(app) diff --git a/backend/tests/e2e/test_inventory_flow_anexo24.py b/backend/tests/e2e/test_inventory_flow_anexo24.py new file mode 100644 index 00000000..19be14b6 --- /dev/null +++ b/backend/tests/e2e/test_inventory_flow_anexo24.py @@ -0,0 +1,113 @@ +from decimal import Decimal + +from api.v1.modules.a24.balance_movements.models import BalanceMovement, MovementType +from api.v1.modules.a24.discharges.models import DischargeDetail, DischargeHeader +from api.v1.modules.a76.invoices.imports.process import main_process as import_main +from api.v1.modules.a76.invoices.exports.process import main_process as export_main +from api.v1.modules.a76.invoices.exports.process import task as export_task +from tests.fixtures.builders import ( + create_business_catalogs, + create_export_invoice_with_line, + create_import_invoice_with_line, + ensure_reference_data, + ensure_tenant_company, +) + + +def _patch_import_pipeline(monkeypatch, import_line): + monkeypatch.setattr(import_main, "pre_validators", lambda *args, **kwargs: [import_line]) + monkeypatch.setattr(import_main, "review_classes", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "review_exchange_rate", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "review_weights_kgs", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "review_weights_lbs", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "_validate_sisimp_limits", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "_validate_lines", lambda *args, **kwargs: ([], {})) + + +def _patch_export_pipeline(monkeypatch, export_line): + monkeypatch.setattr(export_main, "pre_validators", lambda *args, **kwargs: [export_line]) + monkeypatch.setattr(export_main, "review_class", lambda *args, **kwargs: None) + monkeypatch.setattr(export_main, "review_exchange_rate", lambda *args, **kwargs: None) + monkeypatch.setattr(export_main, "assign_values", lambda *args, **kwargs: None) + monkeypatch.setattr(export_main, "review_qty_vs_weight", lambda *args, **kwargs: None) + monkeypatch.setattr(export_main, "review_unit_cost", lambda *args, **kwargs: None) + monkeypatch.setattr(export_main, "review_qty_series", lambda *args, **kwargs: None) + + +def test_e2e_inventory_flow_import_then_export(client, db_session, monkeypatch): + """ + E2E principal: + - Alta catálogos/base + - Procesa factura IMP -> genera entry + - Procesa factura EXP -> genera consumo/descarga + - Valida decremento de saldo sin negativos + """ + ensure_reference_data(db_session) + ensure_tenant_company(db_session, tenant_id=1, company_id=1) + catalogs = create_business_catalogs(db_session, tenant_id=1, company_id=1) + + import_invoice, import_line = create_import_invoice_with_line( + db_session, 1, 1, catalogs, invoice_type="TEM", invoice_number="IMP-E2E-01", qty=Decimal("10") + ) + _patch_import_pipeline(monkeypatch, import_line) + + response = client.post(f"/api/v1/a76/invoices/{import_invoice.id}/process?company_id=1") + assert response.status_code == 200 + + entry_movements = ( + db_session.query(BalanceMovement) + .filter( + BalanceMovement.import_invoice_id == import_invoice.id, + BalanceMovement.import_item_line_id == import_line.id, + BalanceMovement.movement_type == MovementType.ENTRY, + ) + .all() + ) + assert entry_movements + assert sum(Decimal(str(m.quantity or 0)) for m in entry_movements) > 0 + + export_invoice, export_line = create_export_invoice_with_line( + db_session, 1, 1, catalogs, import_invoice, import_line, qty=Decimal("4") + ) + _patch_export_pipeline(monkeypatch, export_line) + + response = client.post(f"/api/v1/a76/invoices/{export_invoice.id}/process?company_id=1") + assert response.status_code == 200 + + consumptions = ( + db_session.query(BalanceMovement) + .filter( + BalanceMovement.source_invoice_id == export_invoice.id, + BalanceMovement.movement_type == MovementType.CONSUMPTION, + ) + .all() + ) + assert consumptions, "El proceso de exportación debe generar consumos" + + discharge_headers = ( + db_session.query(DischargeHeader) + .filter(DischargeHeader.source_invoice_id == export_invoice.id) + .all() + ) + assert discharge_headers + + discharge_details = ( + db_session.query(DischargeDetail) + .filter(DischargeDetail.export_item_line_id == export_line.id) + .all() + ) + assert discharge_details + assert all(detail.movement_id is not None for detail in discharge_details) + + net_balance = db_session.query(BalanceMovement).filter( + BalanceMovement.import_item_line_id == import_line.id + ).all() + signed = Decimal("0") + for mov in net_balance: + qty = Decimal(str(mov.quantity or 0)) + if mov.movement_type in {MovementType.CONSUMPTION, MovementType.WASTE, MovementType.SCRAP, MovementType.DESTRUCTION}: + signed -= qty + else: + signed += qty + assert signed >= 0 + assert signed < Decimal("10") diff --git a/backend/tests/fixtures/__init__.py b/backend/tests/fixtures/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/tests/fixtures/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/tests/fixtures/builders.py b/backend/tests/fixtures/builders.py new file mode 100644 index 00000000..2c4e5cbf --- /dev/null +++ b/backend/tests/fixtures/builders.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy.orm import Session + +from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem +from api.v1.modules.a76.clients_and_providers.models import ( + ClientOrProviderEnum, + ClientProvider, + ClientProviderAddress, +) +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure +from api.v1.modules.a76.invoices.models import ( + Currency, + InvoiceComplianceMx, + InvoiceFinancials, + InvoiceHeader, + InvoiceLogistics, + InvoiceStatus, + OperationType, +) +from api.v1.modules.a76.items.line_customs.models import LineCustom +from api.v1.modules.a76.items.line_descriptions.models import LineDescription +from api.v1.modules.a76.items.line_financials.models import LineFinancial +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.core.tenants.models import Tenant, TenantType +from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType +from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento + + +def ensure_reference_data(db: Session) -> None: + for key, desc in [ + ("TEM", "IMPORTACION TEMPORAL"), + ("DEF", "IMPORTACION DEFINITIVA"), + ("MEX", "COMPRAS MEXICANAS"), + ("DONAC", "DONACION"), + ]: + if db.get(InvoiceType, key) is None: + operation = "exp" if key == "DONAC" else "imp" + db.add( + InvoiceType( + key=key, + description=desc, + note="seed for tests", + type="both", + operation=operation, + ) + ) + + if db.get(RegimenPedimento, "A1") is None: + db.add( + RegimenPedimento( + code="A1", + description="Regimen de prueba", + ) + ) + db.flush() + + +def ensure_tenant_company(db: Session, tenant_id: int = 1, company_id: int = 1) -> Company: + tenant = db.get(Tenant, tenant_id) + if tenant is None: + tenant = Tenant( + id=tenant_id, + name=f"Tenant {tenant_id}", + slug=f"tenant-{tenant_id}", + type=TenantType.SHARED, + keycloak_realm="test", + is_active=True, + ) + db.add(tenant) + + company = db.get(Company, company_id) + if company is None: + company = Company( + id=company_id, + tenant_id=tenant_id, + name=f"Company {company_id}", + rfc="TST010101AAA", + prosec=False, + ) + db.add(company) + db.flush() + return company + + +def create_business_catalogs(db: Session, tenant_id: int, company_id: int) -> dict: + provider = ( + db.query(ClientProvider) + .filter( + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ClientProvider.name == "Proveedor Test", + ) + .first() + ) + if provider is None: + provider = ClientProvider( + tenant_id=tenant_id, + company_id=company_id, + name="Proveedor Test", + client_or_provider=ClientOrProviderEnum.BOTH, + is_active=True, + ) + db.add(provider) + db.flush() + if provider.address is None: + provider.address = ClientProviderAddress( + id=provider.id, + client_id=provider.id, + tenant_id=tenant_id, + company_id=company_id, + country="MEX", + ) + + sold_to = ( + db.query(ClientProvider) + .filter( + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ClientProvider.name == "Cliente Test", + ) + .first() + ) + if sold_to is None: + sold_to = ClientProvider( + tenant_id=tenant_id, + company_id=company_id, + name="Cliente Test", + client_or_provider=ClientOrProviderEnum.BOTH, + is_active=True, + ) + db.add(sold_to) + db.flush() + if sold_to.address is None: + sold_to.address = ClientProviderAddress( + id=sold_to.id, + client_id=sold_to.id, + tenant_id=tenant_id, + company_id=company_id, + country="USA", + ) + + shipped_to = ( + db.query(ClientProvider) + .filter( + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ClientProvider.name == "Destinatario Test", + ) + .first() + ) + if shipped_to is None: + shipped_to = ClientProvider( + tenant_id=tenant_id, + company_id=company_id, + name="Destinatario Test", + client_or_provider=ClientOrProviderEnum.BOTH, + is_active=True, + ) + db.add(shipped_to) + db.flush() + if shipped_to.address is None: + shipped_to.address = ClientProviderAddress( + id=shipped_to.id, + client_id=shipped_to.id, + tenant_id=tenant_id, + company_id=company_id, + country="USA", + ) + + broker = ( + db.query(CustomsBroker) + .filter( + CustomsBroker.tenant_id == tenant_id, + CustomsBroker.company_id == company_id, + CustomsBroker.broker_key == "A1234", + ) + .first() + ) + if broker is None: + broker = CustomsBroker( + tenant_id=tenant_id, + company_id=company_id, + broker_key="A1234", + name="Agente Test", + ) + db.add(broker) + db.flush() + + uom = ( + db.query(UnitOfMeasure) + .filter( + UnitOfMeasure.tenant_id == tenant_id, + UnitOfMeasure.company_id == company_id, + UnitOfMeasure.code == "PZA", + ) + .first() + ) + if uom is None: + uom = UnitOfMeasure( + tenant_id=tenant_id, + company_id=company_id, + code="PZA", + description="Pieza", + ) + db.add(uom) + db.flush() + + exr = ( + db.query(ExchangeRate) + .filter( + ExchangeRate.tenant_id == tenant_id, + ExchangeRate.company_id == company_id, + ExchangeRate.date == datetime(2026, 3, 17, 0, 0, 0), + ) + .first() + ) + if exr is None: + exr = ExchangeRate( + tenant_id=tenant_id, + company_id=company_id, + date=datetime(2026, 3, 17, 0, 0, 0), + value=Decimal("17.250000"), + local_currency="MN", + foreign_currency="ME", + ) + db.add(exr) + + db.flush() + return { + "provider_id": provider.id, + "sold_to_id": sold_to.id, + "shipped_to_id": shipped_to.id, + "broker_id": broker.id, + "uom_id": uom.id, + } + + +def create_import_invoice_with_line( + db: Session, + tenant_id: int, + company_id: int, + catalogs: dict, + invoice_type: str = "TEM", + invoice_number: str = "IMP-0001", + qty: Decimal = Decimal("10"), +) -> tuple[InvoiceHeader, LineItem]: + invoice = InvoiceHeader( + tenant_id=tenant_id, + company_id=company_id, + system="fixed_asset", + operation_type=OperationType.IMP, + invoice_type=invoice_type, + document_type="A1", + invoice_number=invoice_number, + invoice_date=date(2026, 3, 17), + status=InvoiceStatus.PENDING, + ) + invoice.compliance_mx = InvoiceComplianceMx( + tenant_id=tenant_id, + company_id=company_id, + provider_id=catalogs["provider_id"], + sold_to_id=catalogs["sold_to_id"], + shipped_to_id=catalogs["shipped_to_id"], + customs_broker_id=catalogs["broker_id"], + is_pedimento_pending=True, + was_reviewed_by_company=True, + ) + invoice.financials = InvoiceFinancials( + tenant_id=tenant_id, + company_id=company_id, + currency=Currency.FOREIGN, + exchange_rate=Decimal("17.250000"), + iva_factor="16", + ) + invoice.logistics = InvoiceLogistics( + tenant_id=tenant_id, + company_id=company_id, + weight_type="kgs", + ) + db.add(invoice) + db.flush() + + line = LineItem( + tenant_id=tenant_id, + company_id=company_id, + invoice_id=invoice.id, + line_number=1, + unit_of_measure=catalogs["uom_id"], + ) + line.quantity = LineQuantity( + quantity=qty, + net_weight=Decimal("50"), + gross_weight=Decimal("55"), + package_quantity=1, + ) + line.financial = LineFinancial( + unit_cost_capture=Decimal("10"), + value_usd=Decimal("100"), + value_mxn=Decimal("1725"), + ) + line.customs = LineCustom(origin_country="MEX", fraction_type="GENERAL") + line.description = LineDescription(has_serial=False) + db.add(line) + db.flush() + return invoice, line + + +def create_export_invoice_with_line( + db: Session, + tenant_id: int, + company_id: int, + catalogs: dict, + source_import_invoice: InvoiceHeader, + source_import_line: LineItem, + qty: Decimal = Decimal("4"), +) -> tuple[InvoiceHeader, LineItem]: + invoice = InvoiceHeader( + tenant_id=tenant_id, + company_id=company_id, + system="fixed_asset", + operation_type=OperationType.EXP, + invoice_type="DONAC", + document_type="A1", + invoice_number="EXP-0001", + invoice_date=date(2026, 3, 18), + status=InvoiceStatus.PENDING, + ) + invoice.compliance_mx = InvoiceComplianceMx( + tenant_id=tenant_id, + company_id=company_id, + provider_id=catalogs["provider_id"], + sold_to_id=catalogs["sold_to_id"], + shipped_to_id=catalogs["shipped_to_id"], + customs_broker_id=catalogs["broker_id"], + is_pedimento_pending=True, + was_reviewed_by_company=True, + ) + invoice.financials = InvoiceFinancials( + tenant_id=tenant_id, + company_id=company_id, + currency=Currency.FOREIGN, + exchange_rate=Decimal("17.250000"), + iva_factor="16", + ) + invoice.logistics = InvoiceLogistics( + tenant_id=tenant_id, + company_id=company_id, + weight_type="kgs", + ) + db.add(invoice) + db.flush() + + line = LineItem( + tenant_id=tenant_id, + company_id=company_id, + invoice_id=invoice.id, + line_number=1, + unit_of_measure=catalogs["uom_id"], + ) + line.quantity = LineQuantity( + quantity=qty, + net_weight=Decimal("20"), + gross_weight=Decimal("22"), + package_quantity=1, + ) + line.financial = LineFinancial( + unit_cost_capture=Decimal("12"), + value_usd=Decimal("48"), + value_mxn=Decimal("828"), + ) + line.customs = LineCustom(origin_country="MEX", origin_procedure="TEM", fraction_type="GENERAL") + line.description = LineDescription(has_serial=False) + db.add(line) + db.flush() + + db.add( + FaLineItem( + id=line.id, + tenant_id=tenant_id, + company_id=company_id, + discharge=True, + search_invoice=source_import_invoice.invoice_number, + search_line=source_import_line.line_number, + is_subitem=False, + ) + ) + db.flush() + return invoice, line diff --git a/backend/tests/integration/test_export_process_api.py b/backend/tests/integration/test_export_process_api.py new file mode 100644 index 00000000..411a894c --- /dev/null +++ b/backend/tests/integration/test_export_process_api.py @@ -0,0 +1,127 @@ +from decimal import Decimal + +import pytest + +from api.v1.modules.a24.balance_movements.models import BalanceMovement, MovementType +from api.v1.modules.a76.invoices.exports.process import main_process as export_main +from api.v1.modules.a76.invoices.imports.process import main_process as import_main +from core.exceptions import ValidationException +from tests.fixtures.builders import ( + create_business_catalogs, + create_export_invoice_with_line, + create_import_invoice_with_line, + ensure_reference_data, + ensure_tenant_company, +) + + +def _patch_import_pipeline(monkeypatch, import_line): + monkeypatch.setattr(import_main, "pre_validators", lambda *args, **kwargs: [import_line]) + monkeypatch.setattr(import_main, "review_classes", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "review_exchange_rate", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "review_weights_kgs", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "review_weights_lbs", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "_validate_sisimp_limits", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "_validate_lines", lambda *args, **kwargs: ([], {})) + + +def _patch_import_pipeline_without_prevalidators(monkeypatch): + monkeypatch.setattr(import_main, "review_classes", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "review_exchange_rate", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "review_weights_kgs", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "review_weights_lbs", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "_validate_sisimp_limits", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "_validate_lines", lambda *args, **kwargs: ([], {})) + + +def _patch_export_pipeline(monkeypatch, export_line): + monkeypatch.setattr(export_main, "pre_validators", lambda *args, **kwargs: [export_line]) + monkeypatch.setattr(export_main, "review_class", lambda *args, **kwargs: None) + monkeypatch.setattr(export_main, "review_exchange_rate", lambda *args, **kwargs: None) + monkeypatch.setattr(export_main, "assign_values", lambda *args, **kwargs: None) + monkeypatch.setattr(export_main, "review_qty_vs_weight", lambda *args, **kwargs: None) + monkeypatch.setattr(export_main, "review_unit_cost", lambda *args, **kwargs: None) + monkeypatch.setattr(export_main, "review_qty_series", lambda *args, **kwargs: None) + + +def _prepare_inventory(client, db_session, monkeypatch): + ensure_reference_data(db_session) + ensure_tenant_company(db_session, tenant_id=1, company_id=1) + catalogs = create_business_catalogs(db_session, tenant_id=1, company_id=1) + import_invoice, import_line = create_import_invoice_with_line( + db_session, 1, 1, catalogs, invoice_type="TEM", invoice_number="IMP-EXP-01", qty=Decimal("10") + ) + _patch_import_pipeline(monkeypatch, import_line) + resp = client.post(f"/api/v1/a76/invoices/{import_invoice.id}/process?company_id=1") + assert resp.status_code == 200 + return catalogs, import_invoice, import_line + + +def test_process_export_endpoint_consumes_existing_balances(client, db_session, monkeypatch): + catalogs, import_invoice, import_line = _prepare_inventory(client, db_session, monkeypatch) + export_invoice, export_line = create_export_invoice_with_line( + db_session, 1, 1, catalogs, import_invoice, import_line, qty=Decimal("3") + ) + _patch_export_pipeline(monkeypatch, export_line) + + resp = client.post(f"/api/v1/a76/invoices/{export_invoice.id}/process?company_id=1") + assert resp.status_code == 200 + + consumptions = ( + db_session.query(BalanceMovement) + .filter( + BalanceMovement.source_invoice_id == export_invoice.id, + BalanceMovement.movement_type == MovementType.CONSUMPTION, + ) + .all() + ) + assert consumptions + + +def test_process_export_prevents_negative_balance(client, db_session, monkeypatch): + catalogs, import_invoice, import_line = _prepare_inventory(client, db_session, monkeypatch) + export_invoice, export_line = create_export_invoice_with_line( + db_session, 1, 1, catalogs, import_invoice, import_line, qty=Decimal("99") + ) + _patch_export_pipeline(monkeypatch, export_line) + + with pytest.raises(ValidationException): + client.post(f"/api/v1/a76/invoices/{export_invoice.id}/process?company_id=1") + + # Debe no crear consumos al existir insuficiencia de saldo. + consumptions = ( + db_session.query(BalanceMovement) + .filter( + BalanceMovement.source_invoice_id == export_invoice.id, + BalanceMovement.movement_type == MovementType.CONSUMPTION, + ) + .all() + ) + assert consumptions == [] + + +def test_process_endpoint_prevents_double_processing_import(client, db_session, monkeypatch): + ensure_reference_data(db_session) + ensure_tenant_company(db_session, tenant_id=1, company_id=1) + catalogs = create_business_catalogs(db_session, tenant_id=1, company_id=1) + invoice, line = create_import_invoice_with_line( + db_session, 1, 1, catalogs, invoice_type="TEM", invoice_number="IMP-DOUBLE-01", qty=Decimal("5") + ) + _patch_import_pipeline_without_prevalidators(monkeypatch) + + first = client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id=1") + assert first.status_code == 200 + with pytest.raises(ValidationException): + client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id=1") + + # No debe duplicar entradas para la misma factura/línea procesada. + entries = ( + db_session.query(BalanceMovement) + .filter( + BalanceMovement.import_invoice_id == invoice.id, + BalanceMovement.import_item_line_id == line.id, + BalanceMovement.movement_type == MovementType.ENTRY, + ) + .all() + ) + assert len(entries) == 1 diff --git a/backend/tests/integration/test_import_process_api.py b/backend/tests/integration/test_import_process_api.py new file mode 100644 index 00000000..fd0aadaa --- /dev/null +++ b/backend/tests/integration/test_import_process_api.py @@ -0,0 +1,64 @@ +from decimal import Decimal + +from api.v1.modules.a24.balance_movements.models import BalanceMovement, MovementType +from api.v1.modules.a76.invoices.imports.process import main_process as import_main +from tests.fixtures.builders import ( + create_business_catalogs, + create_import_invoice_with_line, + ensure_reference_data, + ensure_tenant_company, +) + + +def _patch_import_pipeline(monkeypatch, import_line): + monkeypatch.setattr(import_main, "pre_validators", lambda *args, **kwargs: [import_line]) + monkeypatch.setattr(import_main, "review_classes", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "review_exchange_rate", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "review_weights_kgs", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "review_weights_lbs", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "_validate_sisimp_limits", lambda *args, **kwargs: None) + monkeypatch.setattr(import_main, "_validate_lines", lambda *args, **kwargs: ([], {})) + + +def test_process_import_endpoint_creates_balance_entries(client, db_session, monkeypatch): + ensure_reference_data(db_session) + ensure_tenant_company(db_session, tenant_id=1, company_id=1) + catalogs = create_business_catalogs(db_session, tenant_id=1, company_id=1) + invoice, line = create_import_invoice_with_line( + db_session, 1, 1, catalogs, invoice_type="TEM", invoice_number="IMP-INT-01", qty=Decimal("7") + ) + _patch_import_pipeline(monkeypatch, line) + + resp = client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id=1") + assert resp.status_code == 200 + + entries = ( + db_session.query(BalanceMovement) + .filter( + BalanceMovement.import_invoice_id == invoice.id, + BalanceMovement.import_item_line_id == line.id, + BalanceMovement.movement_type == MovementType.ENTRY, + ) + .all() + ) + assert len(entries) >= 1 + + +def test_process_import_def_does_not_create_balance_entries(client, db_session, monkeypatch): + ensure_reference_data(db_session) + ensure_tenant_company(db_session, tenant_id=1, company_id=1) + catalogs = create_business_catalogs(db_session, tenant_id=1, company_id=1) + invoice, line = create_import_invoice_with_line( + db_session, 1, 1, catalogs, invoice_type="DEF", invoice_number="IMP-DEF-01", qty=Decimal("7") + ) + _patch_import_pipeline(monkeypatch, line) + + resp = client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id=1") + assert resp.status_code == 200 + + entries = ( + db_session.query(BalanceMovement) + .filter(BalanceMovement.import_invoice_id == invoice.id) + .all() + ) + assert entries == [] diff --git a/backend/tests/unit/invoices/test_balance_algorithm.py b/backend/tests/unit/invoices/test_balance_algorithm.py new file mode 100644 index 00000000..a158ed06 --- /dev/null +++ b/backend/tests/unit/invoices/test_balance_algorithm.py @@ -0,0 +1,121 @@ +from datetime import date +from decimal import Decimal + +from api.v1.modules.a24.balance_movements.models import BalanceMovement, MovementType +from api.v1.modules.a76.invoices.exports.process.sub_process.compare_balances import compare_balances +from api.v1.modules.a76.invoices.exports.process.sub_process.discharge_types import ( + AvailableLot, + DownloadEntry, +) +from api.v1.modules.a76.invoices.exports.process.sub_process.fill_available_balances import ( + _net_balance_for_lot, +) +from core.exceptions import ErrorCollector +from tests.fixtures.builders import ( + create_business_catalogs, + create_import_invoice_with_line, + ensure_reference_data, + ensure_tenant_company, +) + + +def test_net_balance_accounts_for_returns_and_entry_void(db_session): + """ + Balance neto esperado: + entry 10 - consumption 4 + return 1 - entry_void 2 = 5 + """ + ensure_reference_data(db_session) + ensure_tenant_company(db_session, tenant_id=1, company_id=1) + catalogs = create_business_catalogs(db_session, tenant_id=1, company_id=1) + import_invoice, import_line = create_import_invoice_with_line( + db_session, + 1, + 1, + catalogs, + invoice_type="TEM", + invoice_number="IMP-UNIT-01", + qty=Decimal("10"), + ) + + line_id = import_line.id + tenant_id = import_invoice.tenant_id + company_id = import_invoice.company_id + import_invoice_id = import_invoice.id + for idx, (mtype, qty) in enumerate( + [ + (MovementType.ENTRY, Decimal("10")), + (MovementType.CONSUMPTION, Decimal("4")), + (MovementType.RETURN, Decimal("1")), + (MovementType.ENTRY_VOID, Decimal("2")), + ], + start=1, + ): + db_session.add( + BalanceMovement( + id=10000 + idx, + tenant_id=tenant_id, + company_id=company_id, + import_invoice_id=import_invoice_id, + import_item_line_id=line_id, + movement_type=mtype, + quantity=qty, + order_peps=10000 + idx, + operation_date=date(2026, 3, 20), + ) + ) + db_session.flush() + + balance = _net_balance_for_lot(db_session, line_id, date(2026, 3, 21)) + assert balance == Decimal("5") + + +def test_fifo_consumption_algorithm_uses_oldest_lots_first(db_session, monkeypatch): + """ + Verifica distribución FIFO (PEPS): + - lote1 (order=1, qty=3), lote2 (order=2, qty=5), demanda=6 + - resultado: lote1 consume 3, lote2 consume 3, faltante 0 + """ + monkeypatch.setattr( + "api.v1.modules.a76.invoices.exports.process.sub_process.compare_balances._resolve_import_uom", + lambda db, import_item_line_id: "PZA", + ) + + entry = DownloadEntry( + origin_procedure="TEM", + export_line=1, + part_number="PN-1", + class_code="CLS", + quantity=Decimal("6"), + quantity_used=Decimal("0"), + unit_of_measure="PZA", + import_invoice="IMP-1", + import_line=1, + line_item_id=1, + available_lots=[ + AvailableLot( + import_item_line_id=11, + import_invoice_id=101, + part_number_id=None, + available_qty=Decimal("3"), + value_me=Decimal("0"), + value_mn=Decimal("0"), + order_peps=1, + ), + AvailableLot( + import_item_line_id=12, + import_invoice_id=101, + part_number_id=None, + available_qty=Decimal("5"), + value_me=Decimal("0"), + value_mn=Decimal("0"), + order_peps=2, + ), + ], + ) + + errors = ErrorCollector() + compare_balances(db_session, export_invoice=None, to_discharge=[entry], errors=errors) # type: ignore[arg-type] + assert not errors.has_errors() + assert entry.quantity_used == Decimal("6") + assert entry.available_lots[0].consumed_qty == Decimal("3") + assert entry.available_lots[1].consumed_qty == Decimal("3") diff --git a/backend/tests/unit/invoices/test_currency_conversion_imports.py b/backend/tests/unit/invoices/test_currency_conversion_imports.py new file mode 100644 index 00000000..aac88f8e --- /dev/null +++ b/backend/tests/unit/invoices/test_currency_conversion_imports.py @@ -0,0 +1,72 @@ +from decimal import Decimal +from types import SimpleNamespace + +from api.v1.modules.a76.invoices.models import Currency +from api.v1.modules.a76.invoices.imports.process.sub_process.assing_values_def_mex import ( + assign_values_iva_lines, +) + + +def _mk_line(qty: str, capture: str): + return SimpleNamespace( + quantity=SimpleNamespace(quantity=Decimal(qty)), + financial=SimpleNamespace( + unit_cost_capture=Decimal(capture), + unit_cost_usd=None, + unit_cost_mxn=None, + unit_cost_mc=None, + sub_import_value_usd=None, + sub_import_value_mxn=None, + sub_import_value_mc=None, + vat_usd=None, + vat_mxn=None, + vat_mc=None, + value_usd=None, + value_mxn=None, + value_mc=None, + ), + ) + + +def _mk_invoice(currency: Currency): + return SimpleNamespace( + financials=SimpleNamespace( + currency=currency, + exchange_rate=Decimal("17.25"), + exchange_rate_mm=Decimal("1.10"), + iva_factor="16", + ) + ) + + +def test_assign_values_iva_lines_currency_me(): + invoice = _mk_invoice(Currency.FOREIGN) + line = _mk_line("10", "2") + assign_values_iva_lines(invoice, [line]) + + assert line.financial.sub_import_value_usd == Decimal("20") + assert line.financial.vat_usd == Decimal("3.2") + assert line.financial.value_usd == Decimal("23.2") + assert line.financial.unit_cost_mxn == Decimal("34.50") + + +def test_assign_values_iva_lines_currency_mn(): + invoice = _mk_invoice(Currency.LOCAL) + line = _mk_line("5", "100") + assign_values_iva_lines(invoice, [line]) + + assert line.financial.sub_import_value_mxn == Decimal("500") + assert line.financial.vat_mxn == Decimal("80") + assert line.financial.value_mxn == Decimal("580") + assert line.financial.unit_cost_usd == Decimal("5.797101449275362318840579710") + + +def test_assign_values_iva_lines_currency_mc(): + invoice = _mk_invoice(Currency.MANUAL) + line = _mk_line("4", "3") + assign_values_iva_lines(invoice, [line]) + + assert line.financial.sub_import_value_mc == Decimal("12") + assert line.financial.vat_mc == Decimal("1.92") + assert line.financial.value_mc == Decimal("13.92") + assert line.financial.unit_cost_usd == Decimal("3.30")