Merge branch 'development' into feature/table-celery-tasks
This commit is contained in:
@@ -36,7 +36,7 @@ jobs:
|
||||
set -e
|
||||
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"
|
||||
echo "Ejemplo: postgresql://usuario:clave@127.0.0.1:5432/nombre_bd (host real, no el texto \"host\")"
|
||||
exit 1
|
||||
fi
|
||||
python3 --version
|
||||
@@ -64,6 +64,8 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r "$GITHUB_WORKSPACE/backend/requirements.txt"
|
||||
cd "$GITHUB_WORKSPACE/backend"
|
||||
export TEST_DATABASE_URL="$TEST_DATABASE_URL"
|
||||
alembic upgrade head
|
||||
pytest -q tests -v -ra -s
|
||||
|
||||
build:
|
||||
|
||||
@@ -10,6 +10,7 @@ from alembic.operations import ops
|
||||
from core.config import settings
|
||||
from core.database import Base
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy.engine.url import make_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,9 +19,68 @@ logger = logging.getLogger(__name__)
|
||||
config = context.config
|
||||
|
||||
|
||||
def _strip_env_url(raw: str) -> str:
|
||||
"""Quita espacios/comillas típicos de secretos CI (.env, Gitea)."""
|
||||
url = raw.strip().strip('"').strip("'")
|
||||
return url
|
||||
|
||||
|
||||
def _normalize_alembic_sqlalchemy_url(url: str) -> str:
|
||||
"""Alembic usa el driver síncrono psycopg2; normaliza DSN típicos de app/tests."""
|
||||
url = _strip_env_url(url)
|
||||
if url.startswith("postgresql+asyncpg://"):
|
||||
return url.replace("postgresql+asyncpg://", "postgresql+psycopg2://", 1)
|
||||
if url.startswith("postgresql+psycopg2://"):
|
||||
return url
|
||||
if url.startswith("postgresql://"):
|
||||
return url.replace("postgresql://", "postgresql+psycopg2://", 1)
|
||||
if url.startswith("postgres://"):
|
||||
return url.replace("postgres://", "postgresql+psycopg2://", 1)
|
||||
return url
|
||||
|
||||
|
||||
def _validate_sqlalchemy_url(url: str, env_key: str) -> None:
|
||||
"""Misma validación que create_engine; evita urlparse (falla con esquemas tipo postgresql+psycopg2)."""
|
||||
try:
|
||||
make_url(url)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"{env_key} no es una URL de SQLAlchemy válida. "
|
||||
"Ejemplo: postgresql://usuario:clave@127.0.0.1:5432/nombre_bd"
|
||||
) from e
|
||||
|
||||
|
||||
def _reject_documentation_placeholder_host(url: str, source: str) -> None:
|
||||
"""
|
||||
Evita el error críptico de DNS: muchos ejemplos usan @host:5432 como texto literal.
|
||||
"""
|
||||
try:
|
||||
parsed = make_url(url)
|
||||
except Exception:
|
||||
return
|
||||
h = (parsed.host or "").strip().lower()
|
||||
if h == "host":
|
||||
raise RuntimeError(
|
||||
f"{source}: el hostname \"host\" es un placeholder de documentación, no un servidor real. "
|
||||
"Usa el host alcanzable desde el runner (IP, nombre DNS, servicio en docker-compose, "
|
||||
"o host.docker.internal si act corre en contenedor y Postgres en tu máquina)."
|
||||
)
|
||||
|
||||
|
||||
def get_database_url():
|
||||
"""Obtiene la URL de la base de datos (PostgreSQL) desde variables de entorno o alembic.ini."""
|
||||
# Intentar construir desde variables de entorno primero
|
||||
# CI / pytest: misma URL que los tests (secret TEST_DATABASE_URL) o DATABASE_URL explícita.
|
||||
# CRÍTICO: debe ser tupla con coma final si un solo elemento: ("X",) — si no, ("X") es str y el for
|
||||
# itera caracteres; env_key "_" + os.environ["_"] (común en shells) rompe con URL inválida.
|
||||
for env_key in ("TEST_DATABASE_URL", "DATABASE_URL"):
|
||||
raw = os.environ.get(env_key)
|
||||
if raw and raw.strip():
|
||||
normalized = _normalize_alembic_sqlalchemy_url(raw)
|
||||
_validate_sqlalchemy_url(normalized, env_key)
|
||||
_reject_documentation_placeholder_host(normalized, env_key)
|
||||
return normalized
|
||||
|
||||
# Construcción desde settings (CORE_DB_* en .env / entorno)
|
||||
host = settings.CORE_DB_HOST
|
||||
db = settings.CORE_DB_NAME
|
||||
user = settings.CORE_DB_USER
|
||||
@@ -32,18 +92,22 @@ def get_database_url():
|
||||
encoded_user = quote_plus(user)
|
||||
encoded_password = quote_plus(password)
|
||||
encoded_db = quote_plus(db)
|
||||
return f"postgresql+psycopg2://{encoded_user}:{encoded_password}@{host}:{port}/{encoded_db}"
|
||||
built = f"postgresql+psycopg2://{encoded_user}:{encoded_password}@{host}:{port}/{encoded_db}"
|
||||
_reject_documentation_placeholder_host(built, "CORE_DB_HOST")
|
||||
return built
|
||||
except Exception as e:
|
||||
logger.error(f"Error al construir URL: {e}")
|
||||
|
||||
# Fallback al archivo de configuración
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
if not url:
|
||||
if not url or "${" in url or "%(" in url:
|
||||
raise RuntimeError(
|
||||
"No se ha configurado la cadena de conexión a PostgreSQL. "
|
||||
"Proporciona las variables de entorno POSTGRES_* o configura sqlalchemy.url en alembic.ini"
|
||||
"Define TEST_DATABASE_URL o DATABASE_URL, o variables CORE_DB_*; "
|
||||
"sqlalchemy.url en alembic.ini con placeholders ${...} no está soportado."
|
||||
)
|
||||
|
||||
_reject_documentation_placeholder_host(url, "alembic.ini sqlalchemy.url")
|
||||
return url
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from core.exceptions import ErrorCollector
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -17,8 +17,14 @@ from api.v1.modules.a76.manifests.manifest.models import Manifest
|
||||
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
|
||||
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
|
||||
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
|
||||
from core.exceptions import ErrorCollector
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
def _normalize_invoice_currency_value(value) -> str:
|
||||
"""Lowercase currency code (foreign/local/manual) for comparisons."""
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
raw = getattr(value, "value", value)
|
||||
return str(raw).lower()
|
||||
|
||||
|
||||
def invoice_exists(
|
||||
@@ -174,10 +180,11 @@ def validate_required_fields_by_operation(
|
||||
|
||||
def validate_common(
|
||||
db: Session,
|
||||
invoice: schemas.InvoiceHeaderUpdate,
|
||||
invoice: Union[schemas.InvoiceHeaderCreate, schemas.InvoiceHeaderUpdate],
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
errors: ErrorCollector,
|
||||
existing_invoice: Optional[models.InvoiceHeader] = None,
|
||||
):
|
||||
if invoice.compliance_mx.pedimento_id:
|
||||
pedimento = (
|
||||
@@ -560,69 +567,97 @@ def validate_common(
|
||||
value=invoice.logistics.transport_num,
|
||||
)
|
||||
|
||||
invoice.financials.currency = invoice.financials.currency or "foreign"
|
||||
|
||||
if invoice.financials.currency not in [c.value for c in Currency]:
|
||||
errors.add_error(
|
||||
field="financials.currency",
|
||||
message="La Moneda proporcionada no es válida.",
|
||||
solution=[f"Selecciona una Moneda válida: {[c.value for c in Currency]}"],
|
||||
code="INVALID_CURRENCY",
|
||||
value=invoice.financials.currency,
|
||||
)
|
||||
else:
|
||||
# Only check for existing items during update operations (when invoice has an id)
|
||||
if hasattr(invoice, "id"):
|
||||
has_items = (
|
||||
db.query(LineItem)
|
||||
.filter(
|
||||
LineItem.invoice_id == invoice.id,
|
||||
LineItem.tenant_id == tenant_id,
|
||||
LineItem.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
if invoice.financials:
|
||||
submitted = invoice.financials.currency
|
||||
stored_str = ""
|
||||
if existing_invoice and existing_invoice.financials is not None:
|
||||
stored_str = _normalize_invoice_currency_value(
|
||||
existing_invoice.financials.currency
|
||||
)
|
||||
if has_items:
|
||||
errors.add_error(
|
||||
field="items",
|
||||
message=f"La opcion tipo de moneda {invoice.financials.currency} no puede ser modificada ya que la factura tiene items asociados.",
|
||||
solution=[
|
||||
"Verifica la moneda de los items asociados a la factura."
|
||||
],
|
||||
code="CURRENCY_CANNOT_BE_CHANGED",
|
||||
value=invoice.financials.currency,
|
||||
)
|
||||
|
||||
if invoice.financials.currency == "foreign":
|
||||
invoice.financials.currency_type = "USD"
|
||||
elif invoice.financials.currency == "local":
|
||||
invoice.financials.currency_type = "MXN"
|
||||
elif invoice.financials.currency == "manual":
|
||||
if not invoice.financials.currency_type:
|
||||
if submitted is None or submitted == "":
|
||||
if stored_str:
|
||||
invoice.financials.currency = stored_str
|
||||
else:
|
||||
invoice.financials.currency = "foreign"
|
||||
else:
|
||||
invoice.financials.currency = (
|
||||
_normalize_invoice_currency_value(submitted) or "foreign"
|
||||
)
|
||||
|
||||
if invoice.financials.currency not in [c.value for c in Currency]:
|
||||
errors.add_error(
|
||||
field="financials.currency_type",
|
||||
message="El Tipo de Moneda es obligatorio cuando la Moneda es 'manual'.",
|
||||
solution=["Proporciona un Tipo de Moneda válido"],
|
||||
code="REQUIRED_FIELD",
|
||||
value=invoice.financials.currency_type,
|
||||
field="financials.currency",
|
||||
message="La Moneda proporcionada no es válida.",
|
||||
solution=[f"Selecciona una Moneda válida: {[c.value for c in Currency]}"],
|
||||
code="INVALID_CURRENCY",
|
||||
value=invoice.financials.currency,
|
||||
)
|
||||
else:
|
||||
currency_exists = (
|
||||
db.query(CurrencyType)
|
||||
.filter(CurrencyType.code == invoice.financials.currency_type)
|
||||
.first()
|
||||
)
|
||||
if not currency_exists:
|
||||
invoice_id = getattr(invoice, "id", None)
|
||||
if (
|
||||
invoice_id is not None
|
||||
and existing_invoice is not None
|
||||
and existing_invoice.financials is not None
|
||||
):
|
||||
old_c = _normalize_invoice_currency_value(
|
||||
existing_invoice.financials.currency
|
||||
)
|
||||
new_c = invoice.financials.currency
|
||||
if old_c != new_c:
|
||||
has_items = (
|
||||
db.query(LineItem)
|
||||
.filter(
|
||||
LineItem.invoice_id == invoice_id,
|
||||
LineItem.tenant_id == tenant_id,
|
||||
LineItem.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if has_items:
|
||||
errors.add_error(
|
||||
field="items",
|
||||
message=(
|
||||
"La opción tipo de moneda no puede ser modificada "
|
||||
"ya que la factura tiene partidas asociadas."
|
||||
),
|
||||
solution=[
|
||||
"Verifica la moneda de las partidas asociadas a la factura."
|
||||
],
|
||||
code="CURRENCY_CANNOT_BE_CHANGED",
|
||||
value=invoice.financials.currency,
|
||||
)
|
||||
|
||||
if invoice.financials.currency == "foreign":
|
||||
invoice.financials.currency_type = "USD"
|
||||
elif invoice.financials.currency == "local":
|
||||
invoice.financials.currency_type = "MXN"
|
||||
elif invoice.financials.currency == "manual":
|
||||
if not invoice.financials.currency_type:
|
||||
errors.add_error(
|
||||
field="financials.currency_type",
|
||||
message="El Tipo de Moneda no existe en el Catálogo de Tipos de Moneda.",
|
||||
solution=[
|
||||
"Verifica el código del Tipo de Moneda",
|
||||
"Revisa el catálogo",
|
||||
],
|
||||
code="NOT_FOUND",
|
||||
message="El Tipo de Moneda es obligatorio cuando la Moneda es 'manual'.",
|
||||
solution=["Proporciona un Tipo de Moneda válido"],
|
||||
code="REQUIRED_FIELD",
|
||||
value=invoice.financials.currency_type,
|
||||
)
|
||||
else:
|
||||
currency_exists = (
|
||||
db.query(CurrencyType)
|
||||
.filter(CurrencyType.code == invoice.financials.currency_type)
|
||||
.first()
|
||||
)
|
||||
if not currency_exists:
|
||||
errors.add_error(
|
||||
field="financials.currency_type",
|
||||
message="El Tipo de Moneda no existe en el Catálogo de Tipos de Moneda.",
|
||||
solution=[
|
||||
"Verifica el código del Tipo de Moneda",
|
||||
"Revisa el catálogo",
|
||||
],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.financials.currency_type,
|
||||
)
|
||||
|
||||
if invoice.logistics.incoterm:
|
||||
incoterm_exists = (
|
||||
|
||||
@@ -55,7 +55,9 @@ def validate_update(
|
||||
)
|
||||
|
||||
# Primero ejecutar validaciones comunes
|
||||
validate_common(db, invoice, tenant_id, company_id, errors)
|
||||
validate_common(
|
||||
db, invoice, tenant_id, company_id, errors, existing_invoice=existing_invoice
|
||||
)
|
||||
|
||||
# Mapeo de columnas CSV a campos de la factura
|
||||
# Siguiendo la lógica del código Clarion original
|
||||
|
||||
@@ -55,7 +55,9 @@ def validate_update(
|
||||
)
|
||||
|
||||
# Primero ejecutar validaciones comunes
|
||||
validate_common(db, invoice, tenant_id, company_id, errors)
|
||||
validate_common(
|
||||
db, invoice, tenant_id, company_id, errors, existing_invoice=existing_invoice
|
||||
)
|
||||
|
||||
# Mapeo de columnas CSV a campos de la factura
|
||||
# Siguiendo la lógica del código Clarion original
|
||||
|
||||
@@ -27,6 +27,8 @@ class Settings(BaseSettings):
|
||||
CORE_DB_USER: str = "postgres"
|
||||
CORE_DB_PASSWORD: str = "postgres"
|
||||
|
||||
TEST_DATABASE_URL: str = "postgresql://postgres:postgres@localhost:5432/anexo76_core"
|
||||
|
||||
# Keycloak
|
||||
KEYCLOAK_SERVER_URL: str = "http://localhost:8080/kcauth"
|
||||
KEYCLOAK_REALM: str = "master"
|
||||
|
||||
@@ -44,14 +44,14 @@ flowchart TB
|
||||
|
||||
- **BD**: `TEST_DATABASE_URL` o `CORE_DATABASE_URL` o `settings.core_database_url`. Cada test corre dentro de una transacción; al terminar **no persiste** nada (rollback).
|
||||
- **Celery**: sin Redis; `task_always_eager` para que el endpoint dispare lógica síncrona.
|
||||
- **Seguridad en tests**: `validate_access_to_resource` se reemplaza por un stub que devuelve `tenant_id` del usuario fake.
|
||||
- **Seguridad en tests**: `validate_access_to_resource` se reemplaza por un stub que devuelve `tenant_id` del usuario fake (alineado con el fixture `test_tenant`).
|
||||
|
||||
### Builders: qué se construye antes de llamar a la API
|
||||
|
||||
Orden típico en casi todos los tests que tocan proceso:
|
||||
|
||||
1. `ensure_reference_data`: tipos de factura (`TEM`, `DEF`, `MEX`, `DONAC`) y régimen `A1` si no existen.
|
||||
2. `ensure_tenant_company(tenant_id=1, company_id=1)`.
|
||||
2. Fixture `test_tenant`: asigna IDs efímeros altos y crea tenant + company; el `app` usa ese `tenant_id` en el usuario fake.
|
||||
3. `create_business_catalogs`: proveedor/cliente/destinatario (con dirección idempotente vía `_ensure_client_provider_address`), agente aduanal, UoM, part number FA, tipo cambio, etc.
|
||||
4. `create_import_invoice_with_line` / `create_export_invoice_with_line` según el caso.
|
||||
|
||||
@@ -134,7 +134,7 @@ Si `TEST_DATABASE_URL` apunta a una base con datos previos, los builders reutili
|
||||
|
||||
- PostgreSQL test database available.
|
||||
- Environment variable:
|
||||
- `TEST_DATABASE_URL=postgresql://user:pass@host:5432/db_name`
|
||||
- `TEST_DATABASE_URL=postgresql://user:pass@127.0.0.1:5432/db_name` (sustituye por el host real alcanzable desde el runner; no uses el literal `host` de los ejemplos genéricos)
|
||||
|
||||
## Run commands
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ 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
|
||||
from tests.fixtures.builders import (
|
||||
EphemeralOrgIds,
|
||||
allocate_ephemeral_tenant_company_ids,
|
||||
ensure_tenant_company,
|
||||
)
|
||||
|
||||
|
||||
TEST_DB_URL = (
|
||||
@@ -49,6 +54,14 @@ def db_session() -> Generator[Session, None, None]:
|
||||
connection.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_tenant(db_session: Session) -> EphemeralOrgIds:
|
||||
"""Tenant y company dedicados por test (IDs efímeros en rango alto)."""
|
||||
tid, cid = allocate_ephemeral_tenant_company_ids(db_session)
|
||||
ensure_tenant_company(db_session, tenant_id=tid, company_id=cid)
|
||||
return EphemeralOrgIds(tenant_id=tid, company_id=cid)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def celery_eager() -> Generator[None, None, None]:
|
||||
prev_broker = celery_app.conf.broker_url
|
||||
@@ -77,7 +90,11 @@ def celery_eager() -> Generator[None, None, None]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(db_session: Session, monkeypatch: pytest.MonkeyPatch) -> FastAPI:
|
||||
def app(
|
||||
db_session: Session,
|
||||
test_tenant: EphemeralOrgIds,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> FastAPI:
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(process_routes.router, prefix="/api/v1/a76")
|
||||
|
||||
@@ -85,7 +102,7 @@ def app(db_session: Session, monkeypatch: pytest.MonkeyPatch) -> FastAPI:
|
||||
yield db_session
|
||||
|
||||
async def _override_current_user():
|
||||
return {"sub": "test-user", "tenant_id": 1}
|
||||
return {"sub": "test-user", "tenant_id": test_tenant.tenant_id}
|
||||
|
||||
# validate_access_to_resource is imported directly in the routes module.
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -4,13 +4,11 @@ from api.v1.modules.a24.balance_movements.models import BalanceMovement, Movemen
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +32,7 @@ def _patch_export_pipeline(monkeypatch, export_line):
|
||||
monkeypatch.setattr(export_main, "review_qty_series", lambda *args, **kwargs: None)
|
||||
|
||||
|
||||
def test_e2e_inventory_flow_import_then_export(client, db_session, monkeypatch):
|
||||
def test_e2e_inventory_flow_import_then_export(client, db_session, monkeypatch, test_tenant):
|
||||
"""
|
||||
E2E principal:
|
||||
- Alta catálogos/base
|
||||
@@ -42,16 +40,16 @@ def test_e2e_inventory_flow_import_then_export(client, db_session, monkeypatch):
|
||||
- Procesa factura EXP -> genera consumo/descarga
|
||||
- Valida decremento de saldo sin negativos
|
||||
"""
|
||||
tid, cid = test_tenant.tenant_id, test_tenant.company_id
|
||||
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)
|
||||
catalogs = create_business_catalogs(db_session, tenant_id=tid, company_id=cid)
|
||||
|
||||
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")
|
||||
db_session, tid, cid, 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")
|
||||
response = client.post(f"/api/v1/a76/invoices/{import_invoice.id}/process?company_id={cid}")
|
||||
assert response.status_code == 200
|
||||
|
||||
entry_movements = (
|
||||
@@ -67,11 +65,11 @@ def test_e2e_inventory_flow_import_then_export(client, db_session, monkeypatch):
|
||||
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")
|
||||
db_session, tid, cid, 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")
|
||||
response = client.post(f"/api/v1/a76/invoices/{export_invoice.id}/process?company_id={cid}")
|
||||
assert response.status_code == 200
|
||||
|
||||
consumptions = (
|
||||
|
||||
34
backend/tests/fixtures/builders.py
vendored
34
backend/tests/fixtures/builders.py
vendored
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import NamedTuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -34,6 +36,31 @@ from api.v1.modules.public.reference_data.invoice_types.models import InvoiceTyp
|
||||
from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento
|
||||
|
||||
|
||||
class EphemeralOrgIds(NamedTuple):
|
||||
"""Tenant + company creados por test (IDs altos, baja colisión con datos reales / CI)."""
|
||||
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
|
||||
|
||||
def allocate_ephemeral_tenant_company_ids(db: Session) -> tuple[int, int]:
|
||||
"""
|
||||
Genera par (tenant_id, company_id) no usados en esta sesión.
|
||||
Mismo entero para ambas PKs es válido (tablas distintas).
|
||||
"""
|
||||
for _ in range(64):
|
||||
n = uuid.uuid4().int
|
||||
tid = 1_500_000_000 + (n % 99_000_000)
|
||||
if db.get(Tenant, tid) is not None:
|
||||
continue
|
||||
if db.get(Company, tid) is not None:
|
||||
continue
|
||||
return tid, tid
|
||||
raise RuntimeError(
|
||||
"No se pudo asignar tenant/company efímero para tests (reintentar o revisar datos en BD)."
|
||||
)
|
||||
|
||||
|
||||
def ensure_reference_data(db: Session) -> None:
|
||||
for key, desc in [
|
||||
("TEM", "IMPORTACION TEMPORAL"),
|
||||
@@ -63,18 +90,21 @@ def ensure_reference_data(db: Session) -> None:
|
||||
db.flush()
|
||||
|
||||
|
||||
def ensure_tenant_company(db: Session, tenant_id: int = 1, company_id: int = 1) -> Company:
|
||||
def ensure_tenant_company(db: Session, tenant_id: int, company_id: int) -> 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}",
|
||||
slug=f"pytest-{tenant_id}-{uuid.uuid4().hex[:10]}",
|
||||
type=TenantType.SHARED,
|
||||
keycloak_realm="test",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(tenant)
|
||||
# El FK de a76.company → core.tenants exige que el tenant exista en esta transacción
|
||||
# antes del INSERT de company; un solo flush al final puede ordenar mal (Tenant híbrido Column+Mapped).
|
||||
db.flush()
|
||||
|
||||
company = db.get(Company, company_id)
|
||||
if company is None:
|
||||
|
||||
@@ -7,11 +7,11 @@ from api.v1.modules.a76.invoices.exports.process import main_process as export_m
|
||||
from api.v1.modules.a76.invoices.imports.process import main_process as import_main
|
||||
from core.exceptions import ValidationException
|
||||
from tests.fixtures.builders import (
|
||||
EphemeralOrgIds,
|
||||
create_business_catalogs,
|
||||
create_export_invoice_with_line,
|
||||
create_import_invoice_with_line,
|
||||
ensure_reference_data,
|
||||
ensure_tenant_company,
|
||||
)
|
||||
|
||||
|
||||
@@ -44,27 +44,30 @@ def _patch_export_pipeline(monkeypatch, export_line):
|
||||
monkeypatch.setattr(export_main, "review_qty_series", lambda *args, **kwargs: None)
|
||||
|
||||
|
||||
def _prepare_inventory(client, db_session, monkeypatch):
|
||||
def _prepare_inventory(client, db_session, monkeypatch, test_tenant: EphemeralOrgIds):
|
||||
tid, cid = test_tenant.tenant_id, test_tenant.company_id
|
||||
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)
|
||||
catalogs = create_business_catalogs(db_session, tenant_id=tid, company_id=cid)
|
||||
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")
|
||||
db_session, tid, cid, 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")
|
||||
resp = client.post(f"/api/v1/a76/invoices/{import_invoice.id}/process?company_id={cid}")
|
||||
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)
|
||||
def test_process_export_endpoint_consumes_existing_balances(client, db_session, monkeypatch, test_tenant):
|
||||
catalogs, import_invoice, import_line = _prepare_inventory(
|
||||
client, db_session, monkeypatch, test_tenant
|
||||
)
|
||||
tid, cid = test_tenant.tenant_id, test_tenant.company_id
|
||||
export_invoice, export_line = create_export_invoice_with_line(
|
||||
db_session, 1, 1, catalogs, import_invoice, import_line, qty=Decimal("3")
|
||||
db_session, tid, cid, 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")
|
||||
resp = client.post(f"/api/v1/a76/invoices/{export_invoice.id}/process?company_id={cid}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
consumptions = (
|
||||
@@ -78,15 +81,18 @@ def test_process_export_endpoint_consumes_existing_balances(client, db_session,
|
||||
assert consumptions
|
||||
|
||||
|
||||
def test_process_export_prevents_negative_balance(client, db_session, monkeypatch):
|
||||
catalogs, import_invoice, import_line = _prepare_inventory(client, db_session, monkeypatch)
|
||||
def test_process_export_prevents_negative_balance(client, db_session, monkeypatch, test_tenant):
|
||||
catalogs, import_invoice, import_line = _prepare_inventory(
|
||||
client, db_session, monkeypatch, test_tenant
|
||||
)
|
||||
tid, cid = test_tenant.tenant_id, test_tenant.company_id
|
||||
export_invoice, export_line = create_export_invoice_with_line(
|
||||
db_session, 1, 1, catalogs, import_invoice, import_line, qty=Decimal("99")
|
||||
db_session, tid, cid, 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")
|
||||
client.post(f"/api/v1/a76/invoices/{export_invoice.id}/process?company_id={cid}")
|
||||
|
||||
# Debe no crear consumos al existir insuficiencia de saldo.
|
||||
consumptions = (
|
||||
@@ -100,19 +106,19 @@ def test_process_export_prevents_negative_balance(client, db_session, monkeypatc
|
||||
assert consumptions == []
|
||||
|
||||
|
||||
def test_process_endpoint_prevents_double_processing_import(client, db_session, monkeypatch):
|
||||
def test_process_endpoint_prevents_double_processing_import(client, db_session, monkeypatch, test_tenant):
|
||||
tid, cid = test_tenant.tenant_id, test_tenant.company_id
|
||||
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)
|
||||
catalogs = create_business_catalogs(db_session, tenant_id=tid, company_id=cid)
|
||||
invoice, line = create_import_invoice_with_line(
|
||||
db_session, 1, 1, catalogs, invoice_type="TEM", invoice_number="IMP-DOUBLE-01", qty=Decimal("5")
|
||||
db_session, tid, cid, 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")
|
||||
first = client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id={cid}")
|
||||
assert first.status_code == 200
|
||||
with pytest.raises(ValidationException):
|
||||
client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id=1")
|
||||
client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id={cid}")
|
||||
|
||||
# No debe duplicar entradas para la misma factura/línea procesada.
|
||||
entries = (
|
||||
|
||||
@@ -6,7 +6,6 @@ from tests.fixtures.builders import (
|
||||
create_business_catalogs,
|
||||
create_import_invoice_with_line,
|
||||
ensure_reference_data,
|
||||
ensure_tenant_company,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,16 +19,16 @@ def _patch_import_pipeline(monkeypatch, import_line):
|
||||
monkeypatch.setattr(import_main, "_validate_lines", lambda *args, **kwargs: ([], {}))
|
||||
|
||||
|
||||
def test_process_import_endpoint_creates_balance_entries(client, db_session, monkeypatch):
|
||||
def test_process_import_endpoint_creates_balance_entries(client, db_session, monkeypatch, test_tenant):
|
||||
tid, cid = test_tenant.tenant_id, test_tenant.company_id
|
||||
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)
|
||||
catalogs = create_business_catalogs(db_session, tenant_id=tid, company_id=cid)
|
||||
invoice, line = create_import_invoice_with_line(
|
||||
db_session, 1, 1, catalogs, invoice_type="TEM", invoice_number="IMP-INT-01", qty=Decimal("7")
|
||||
db_session, tid, cid, 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")
|
||||
resp = client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id={cid}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
entries = (
|
||||
@@ -44,16 +43,16 @@ def test_process_import_endpoint_creates_balance_entries(client, db_session, mon
|
||||
assert len(entries) >= 1
|
||||
|
||||
|
||||
def test_process_import_def_does_not_create_balance_entries(client, db_session, monkeypatch):
|
||||
def test_process_import_def_does_not_create_balance_entries(client, db_session, monkeypatch, test_tenant):
|
||||
tid, cid = test_tenant.tenant_id, test_tenant.company_id
|
||||
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)
|
||||
catalogs = create_business_catalogs(db_session, tenant_id=tid, company_id=cid)
|
||||
invoice, line = create_import_invoice_with_line(
|
||||
db_session, 1, 1, catalogs, invoice_type="DEF", invoice_number="IMP-DEF-01", qty=Decimal("7")
|
||||
db_session, tid, cid, 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")
|
||||
resp = client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id={cid}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
entries = (
|
||||
|
||||
@@ -15,22 +15,21 @@ 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):
|
||||
def test_net_balance_accounts_for_returns_and_entry_void(db_session, test_tenant):
|
||||
"""
|
||||
Balance neto esperado:
|
||||
entry 10 - consumption 4 + return 1 - entry_void 2 = 5
|
||||
"""
|
||||
tid, cid = test_tenant.tenant_id, test_tenant.company_id
|
||||
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)
|
||||
catalogs = create_business_catalogs(db_session, tenant_id=tid, company_id=cid)
|
||||
import_invoice, import_line = create_import_invoice_with_line(
|
||||
db_session,
|
||||
1,
|
||||
1,
|
||||
tid,
|
||||
cid,
|
||||
catalogs,
|
||||
invoice_type="TEM",
|
||||
invoice_number="IMP-UNIT-01",
|
||||
|
||||
Reference in New Issue
Block a user