Merge pull request 'feature/testing' (#241) from feature/testing into development

Reviewed-on: ADUANASOFT/anexo76#241
This commit is contained in:
2026-03-24 17:33:48 +00:00
37 changed files with 1179 additions and 109 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
},
)

View File

@@ -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",
)

34
backend/tests/README.md Normal file
View File

@@ -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`

View File

@@ -0,0 +1 @@

138
backend/tests/conftest.py Normal file
View File

@@ -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)

View File

@@ -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")

1
backend/tests/fixtures/__init__.py vendored Normal file
View File

@@ -0,0 +1 @@

396
backend/tests/fixtures/builders.py vendored Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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 == []

View File

@@ -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")

View File

@@ -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")