Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into feature/tablas_nuevas_partes

This commit is contained in:
2026-03-18 11:03:26 -05:00
142 changed files with 10823 additions and 4453 deletions

0
archivo.txt Normal file
View File

View File

@@ -44,11 +44,14 @@ from api.v1.modules.public.reference_data.pedimento_codes.seed import (
from api.v1.modules.public.reference_data.pedimento_regimens.seed import (
seed as pedimento_regimens_seed,
)
from api.v1.modules.public.reference_data.sectors.seed import seed as sectors_seed
from api.v1.modules.a76.general_catalogs.sectors.seed import seed as sectors_seed
from api.v1.modules.public.reference_data.states.seed import seed as states_seed
from api.v1.modules.public.reference_data.transport_modes.seed import (
seed as transport_modes_seed,
)
from api.v1.modules.public.reference_data.pedimento_transport_catalog.seed import (
seed as pedimento_transport_catalog_seed,
)
from api.v1.modules.public.reference_data.transport_types.seed import (
seed as transport_types_seed,
)
@@ -104,6 +107,28 @@ def upgrade() -> None:
return "NULL"
return f"'{str(val).replace(chr(39), chr(39)*2)}'"
op.execute(
"""
CREATE TABLE IF NOT EXISTS public.pedimento_transport_catalog (
code VARCHAR(3) NOT NULL,
transport_en VARCHAR(80) NOT NULL,
transport_es VARCHAR(120) NOT NULL,
payment_date_code VARCHAR(1) NOT NULL,
CONSTRAINT pedimento_transport_catalog_pkey PRIMARY KEY (code),
CONSTRAINT pedimento_transport_catalog_payment_date_code_chk
CHECK (payment_date_code IN ('E','P'))
);
"""
)
op.execute(
"""
ALTER TABLE IF EXISTS a76.pedimento_transport_means
ALTER COLUMN entry_exit TYPE VARCHAR(3),
ALTER COLUMN arrival TYPE VARCHAR(3),
ALTER COLUMN departure TYPE VARCHAR(3);
"""
)
# --- SEEDS PUBLIC (Tablas base) ---
# Seeds
values_pc = ", ".join(
@@ -274,19 +299,8 @@ def upgrade() -> None:
"""
)
values_sectors = ", ".join(
[
f"('{key}', '{desc.replace(chr(39), chr(39)*2)}', '{authorized}')"
for key, desc, authorized in sectors_seed
]
)
op.execute(
f"""
INSERT INTO public.sectors (key, description, authorized) VALUES
{values_sectors}
ON CONFLICT (key) DO NOTHING;
"""
)
# Sectors se siembran por compañía en _seed_company_data
# (a76.sectors requiere tenant_id/company_id — no aplica en seed global)
values_tm = ", ".join(
[
@@ -302,6 +316,20 @@ def upgrade() -> None:
"""
)
values_ptc = ", ".join(
[
f"('{code}', '{en.replace(chr(39), chr(39)*2)}', '{es.replace(chr(39), chr(39)*2)}', '{pdc}')"
for code, en, es, pdc in pedimento_transport_catalog_seed
]
)
op.execute(
f"""
INSERT INTO public.pedimento_transport_catalog (code, transport_en, transport_es, payment_date_code) VALUES
{values_ptc}
ON CONFLICT (code) DO NOTHING;
"""
)
values_tt = ", ".join(
[
f"('{code}', '{desc.replace(chr(39), chr(39)*2)}')"
@@ -500,6 +528,16 @@ def upgrade() -> None:
def downgrade() -> None:
"""Downgrade schema."""
op.execute(
"""
ALTER TABLE IF EXISTS a76.pedimento_transport_means
ALTER COLUMN entry_exit TYPE VARCHAR(2),
ALTER COLUMN arrival TYPE VARCHAR(2),
ALTER COLUMN departure TYPE VARCHAR(2);
"""
)
op.drop_table("pedimento_transport_catalog", schema="public")
op.drop_table("us_tariff_fractions", schema="a76")
op.drop_table("historical_tariff_fractions", schema="a76")
op.drop_table("canadian_tariff_fractions", schema="a76")
@@ -507,7 +545,7 @@ def downgrade() -> None:
op.drop_table("transport_types", schema="public")
op.drop_table("trailer_types", schema="public")
op.drop_table("transport_modes", schema="public")
op.drop_table("sectors", schema="public")
op.drop_table("sectors", schema="a76")
op.drop_table("payment_methods", schema="public")
op.drop_table("material_types", schema="public")
op.drop_table("invoice_types", schema="public")

View File

@@ -74,6 +74,12 @@ class MovementType(str, Enum):
EXPIRATION = "expiration" # Balance cancelled due to deadline
REGIME_CHANGE_OUT = "regime_chg_out" # Eg. temporary → definitive (exit side)
# ── Reversal (annuls a prior ENTRY — used when un-processing an invoice) ─
# Inserting ENTRY_VOID with the same quantity as the original ENTRY leaves
# the net balance at zero, preventing any further discharges against that
# lot. A fresh ENTRY is created when the invoice is re-processed.
ENTRY_VOID = "entry_void"
# Which movement types reduce the balance (sign = -1)
NEGATIVE_MOVEMENTS = {
@@ -85,6 +91,7 @@ NEGATIVE_MOVEMENTS = {
MovementType.TRANSFER_OUT,
MovementType.EXPIRATION,
MovementType.REGIME_CHANGE_OUT,
MovementType.ENTRY_VOID,
}
# Which types count toward "used" (CANTUSADA in Anexo 24 report)

View File

@@ -56,7 +56,7 @@ class FaLineItemCreateDTO(BaseModel):
subitem_number: Optional[int] = Field(0, description="Número de subpartida")
# Special flags
download: Optional[bool] = Field(None, description="Indicador de descarga")
discharge: Optional[bool] = Field(None, description="Indicador de descarga")
own_equipment: Optional[bool] = Field(None, description="Equipo propio")
omit_annex31: Optional[bool] = Field(None, description="Omitir en Anexo 31")
@@ -108,7 +108,7 @@ class FaLineItemUpdateDTO(BaseModel):
subitem_number: Optional[int] = Field(None, description="Número de subpartida")
# Special flags
download: Optional[bool] = Field(None, description="Indicador de descarga")
discharge: Optional[bool] = Field(None, description="Indicador de descarga")
own_equipment: Optional[bool] = Field(None, description="Equipo propio")
omit_annex31: Optional[bool] = Field(None, description="Omitir en Anexo 31")
@@ -156,7 +156,7 @@ class FaLineItemResponseDTO(BaseModel):
subitem_number: Optional[int] = Field(None, description="Número de subpartida")
# Special flags
download: Optional[bool] = Field(None, description="Indicador de descarga")
discharge: Optional[bool] = Field(None, description="Indicador de descarga")
own_equipment: Optional[bool] = Field(None, description="Equipo propio")
omit_annex31: Optional[bool] = Field(None, description="Omitir en Anexo 31")

View File

@@ -70,7 +70,7 @@ class FaLineItem(Base, TenantScopedMixin, TimestampMixin):
subitem_number: Mapped[Optional[int]] = mapped_column(Integer) # SUBPARTIDA
# Special flags
download: Mapped[Optional[bool]] = mapped_column(Boolean) # DESCARGA
discharge: Mapped[Optional[bool]] = mapped_column(Boolean) # DESCARGA
own_equipment: Mapped[Optional[bool]] = mapped_column(Boolean) # EQUIPOPROPIO
omit_annex31: Mapped[Optional[bool]] = mapped_column(Boolean) # OMITITENANEXO31

View File

@@ -48,8 +48,8 @@ class FaLineItemService:
query = query.filter(
FaLineItem.own_equipment == filters["own_equipment"]
)
if filters.get("download") is not None:
query = query.filter(FaLineItem.download == filters["download"])
if filters.get("discharge") is not None:
query = query.filter(FaLineItem.discharge == filters["discharge"])
total = query.count()
items = query.offset(skip).limit(limit).all()
@@ -146,7 +146,7 @@ class FaLineItemService:
search_invoice=fa_line_item_data.search_invoice,
search_line=fa_line_item_data.search_line,
search_type=fa_line_item_data.search_type,
download=fa_line_item_data.download,
discharge=fa_line_item_data.discharge,
own_equipment=fa_line_item_data.own_equipment,
omit_annex31=fa_line_item_data.omit_annex31,
)

View File

@@ -6,7 +6,9 @@ Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
from decimal import Decimal
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from .validators import is_valid_rfc, is_valid_tax_id
# DTOs para dirección
@@ -54,7 +56,6 @@ class ClientProviderProgramsDTO(BaseModel):
manufacturer_id: Optional[str] = Field(
None, max_length=25, description="Manufacturer ID"
)
tax_id: Optional[str] = Field(None, max_length=30, description="Tax ID")
broker: Optional[str] = Field(None, max_length=6, description="Broker")
import_broker: Optional[str] = Field(
None, max_length=6, description="Import broker"
@@ -116,7 +117,6 @@ class ClientProviderCreateDTO(BaseModel):
)
position: Optional[str] = Field(None, max_length=30, description="Position")
incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm")
is_national_provider: Optional[bool] = None
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
@@ -127,6 +127,24 @@ class ClientProviderCreateDTO(BaseModel):
None, description="Programs information"
)
@model_validator(mode="after")
def validate_rfc_or_tax_id_format(self):
rfc = self.rfc
if not rfc or not (rfc := (rfc or "").strip()):
return self
proc = (self.type_nat_foreign or "N").strip().upper()[:1]
if proc == "E":
if not is_valid_tax_id(rfc):
raise ValueError(
"El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres."
)
else:
if not is_valid_rfc(rfc):
raise ValueError(
"El RFC no tiene el formato correcto. Ejemplo: XAXX010101000."
)
return self
class Config:
from_attributes = True
@@ -157,7 +175,6 @@ class ClientProviderUpdateDTO(BaseModel):
)
position: Optional[str] = Field(None, max_length=30, description="Position")
incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm")
is_national_provider: Optional[bool] = None
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
@@ -168,6 +185,24 @@ class ClientProviderUpdateDTO(BaseModel):
None, description="Programs information"
)
@model_validator(mode="after")
def validate_rfc_or_tax_id_format(self):
rfc = self.rfc
if not rfc or not (rfc := (rfc or "").strip()):
return self
proc = (self.type_nat_foreign or "N").strip().upper()[:1]
if proc == "E":
if not is_valid_tax_id(rfc):
raise ValueError(
"El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres."
)
else:
if not is_valid_rfc(rfc):
raise ValueError(
"El RFC no tiene el formato correcto. Ejemplo: XAXX010101000."
)
return self
class Config:
from_attributes = True
@@ -189,7 +224,6 @@ class ClientProviderResponseDTO(BaseModel):
responsible: Optional[str] = None
position: Optional[str] = None
incoterm: Optional[str] = None
is_national_provider: Optional[bool] = None
is_active: Optional[bool] = None
tenant_id: int
company_id: int

View File

@@ -45,6 +45,7 @@ class ClientProvider(Base, TenantScopedMixin, TimestampMixin):
type_nat_foreign: Mapped[Optional[str]] = mapped_column(String(1)) # TIPO NACIONAL/EXTRANJERO
name: Mapped[Optional[str]] = mapped_column(String(256))
short_name: Mapped[Optional[str]] = mapped_column(String(10))
# Identificador fiscal único: RFC (nacional) o TAX-ID (extranjero); no usar programs.tax_id para lo mismo
rfc: Mapped[Optional[str]] = mapped_column(String(30))
curp: Mapped[Optional[str]] = mapped_column(String(19))
client_or_provider: Mapped[ClientOrProviderEnum] = mapped_column(PgEnum(ClientOrProviderEnum, name="entity_client_or_provider", create_type=True, native_enum=True),nullable=False)
@@ -55,7 +56,6 @@ class ClientProvider(Base, TenantScopedMixin, TimestampMixin):
responsible: Mapped[Optional[str]] = mapped_column(String(80))
position: Mapped[Optional[str]] = mapped_column(String(30))
incoterm: Mapped[Optional[str]] = mapped_column(String(19))
is_national_provider: Mapped[Optional[bool]] = mapped_column(Boolean)
is_active: Mapped[Optional[bool]] = mapped_column(Boolean)
# Relationships
@@ -140,7 +140,6 @@ class ClientProviderPrograms(Base, TenantScopedMixin, TimestampMixin):
prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20))
secon_auth_date: Mapped[Optional[int]] = mapped_column(Integer)
manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25))
tax_id: Mapped[Optional[str]] = mapped_column(String(30))
broker: Mapped[Optional[str]] = mapped_column(String(6))
import_broker: Mapped[Optional[str]] = mapped_column(String(6))
transfer_key: Mapped[Optional[str]] = mapped_column(String(8))

View File

@@ -185,6 +185,7 @@ class ClientProviderService:
db_address = ClientProviderAddress(
client_id=client.id,
tenant_id=tenant_id,
company_id=company_id,
**client_data.address.model_dump(exclude_unset=True),
)
db.add(db_address)
@@ -199,6 +200,7 @@ class ClientProviderService:
db_programs = ClientProviderPrograms(
client_id=client.id,
tenant_id=tenant_id,
company_id=company_id,
**client_data.programs.model_dump(exclude_unset=True),
)
db.add(db_programs)

View File

@@ -0,0 +1,27 @@
"""
Validadores de formato para RFC (Nacional) y TAX-ID (Extranjero) en clientes y proveedores.
"""
import re
# Formato RFC México: 3-4 letras (A-Z, &, Ñ), 6 dígitos (fecha), 3 caracteres homoclave. Ej: XAXX010101000
RFC_PATTERN = re.compile(r"^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$", re.IGNORECASE)
# TAX-ID extranjero: 2 dígitos, guión, resto alfanumérico. Ej: 12-3456789 (EIN US). Total máx 30.
TAX_ID_PATTERN = re.compile(r"^\d{2}-[A-Z0-9]{1,27}$", re.IGNORECASE)
def is_valid_rfc(value: str) -> bool:
"""Valida formato RFC mexicano. Acepta cadena vacía/None como inválida (no opcional aquí)."""
if not value or not isinstance(value, str):
return False
normalized = value.strip().upper()
return bool(normalized and RFC_PATTERN.match(normalized))
def is_valid_tax_id(value: str) -> bool:
"""Valida formato TAX-ID (extranjero): 2 dígitos, guión y resto alfanumérico. Ej: 12-3456789."""
if not value or not isinstance(value, str):
return False
normalized = value.strip()
return bool(normalized and len(normalized) <= 30 and TAX_ID_PATTERN.match(normalized))

View File

@@ -16,6 +16,7 @@ from ...audit_log.services.service import AuditService
from ..units_of_measure.seed import seed as units_of_measure_seed
from ..fractions.historical_tariff_fractions.seed import seed as historical_tariff_fractions_seed
from ..fractions.warning_fractions.seed import seed as warning_fractions_seed
from ..sectors.seed import seed as sectors_seed
from core.context import get_user_context
from sqlalchemy import text
@@ -774,6 +775,21 @@ class CompanyService:
"""))
db.execute(text("ALTER TABLE public.warning_fractions ENABLE TRIGGER ALL;"))
# 4. Sectors
values_sectors = ", ".join(
[
f"({format_value(key)}, {format_value(description)}, {str(authorized).upper()}, {tenant_id}, {company_id})"
for key, description, authorized in sectors_seed
]
)
if values_sectors:
db.execute(text(f"""
INSERT INTO a76.sectors (key, description, authorized, tenant_id, company_id)
VALUES {values_sectors}
ON CONFLICT (key, tenant_id, company_id) DO NOTHING;
"""))
def get_companies_by_tenant(self, tenant_id: int) -> List[Company]:
"""Get all companies for a tenant"""
return (

View File

@@ -32,6 +32,7 @@ class IdentifierDetailBase(BaseModel):
part_line: Optional[int] = Field(None, description="Part Line")
identifier_code: Optional[str] = Field(
None, max_length=2, description="Identifier Code")
item_line_id: Optional[int] = Field(None, description="Item Line ID")
module: Optional[str] = Field(None, max_length=20, description="Module")
complement1: Optional[str] = Field(
None, max_length=50, description="Complement 1")

View File

@@ -1,10 +1,13 @@
from typing import Optional
from typing import Optional, TYPE_CHECKING
from sqlalchemy import Integer, String, UniqueConstraint, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a76.items.models import LineItem
class Identifier(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "identifiers"
__table_args__ = (
@@ -44,6 +47,8 @@ class IdentifierDetail(Base, TenantScopedMixin, TimestampMixin):
Integer, nullable=True) # LINEAPARTIDA
identifier_code: Mapped[Optional[str]] = mapped_column(
String(2), ForeignKey("a76.identifiers.code"), nullable=True) # ID
item_line_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.item_lines.id"), nullable=True)
module: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True) # MODULO
complement1: Mapped[Optional[str]] = mapped_column(
@@ -54,3 +59,4 @@ class IdentifierDetail(Base, TenantScopedMixin, TimestampMixin):
String(50), nullable=True) # COMPLEMENTO3
identifier: Mapped["Identifier"] = relationship(back_populates="details")
line: Mapped[Optional["LineItem"]] = relationship(back_populates="identifiers")

View File

@@ -26,6 +26,7 @@ from .doda.routes import router as doda_router
from .prevalidators.routes import router as prevalidators_router
from .electronic_notices.routes import router as electronic_notices_router
from .location.routes import router as location_router
from .sectors.routes import router as sectors_router
router = APIRouter()
@@ -56,3 +57,4 @@ router.include_router(error_catalogs_router)
router.include_router(doda_router)
router.include_router(prevalidators_router)
router.include_router(electronic_notices_router)
router.include_router(sectors_router)

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class SectorBaseDTO(BaseModel):
key: str = Field(..., description="Clave del sector (ej: 'XIX', 'IIa')", max_length=8)
description: str = Field(..., description="Descripción del sector", max_length=150)
authorized: Optional[bool] = Field(False, description="True = autorizado para PROSEC")
class SectorCreateDTO(SectorBaseDTO):
pass
class SectorUpdateDTO(BaseModel):
key: Optional[str] = Field(None, max_length=8)
description: Optional[str] = Field(None, max_length=150)
authorized: Optional[bool] = None
class SectorResponseDTO(SectorBaseDTO):
id: int
company_id: int
tenant_id: int
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,23 @@
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import Boolean, Integer, PrimaryKeyConstraint, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
class Sector(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "sectors" # GSectores
__table_args__ = (
PrimaryKeyConstraint("id", name="sectors_pkey"),
UniqueConstraint("tenant_id", "company_id", "key", name="sectors_key_ukey"),
{"schema": "a76", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(8), nullable=False)
description: Mapped[str] = mapped_column(String(150), nullable=False)
authorized: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false")
def __repr__(self):
return f"<Sector(id={self.id}, key={self.key}, description={self.description}, authorized={self.authorized})>"

View File

@@ -0,0 +1,23 @@
"""
Routes for managing Sectors (GSectores) — a76 tenant-scoped catalog.
"""
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import SectorCreateDTO, SectorResponseDTO, SectorUpdateDTO
from .service import SectorService
router = TenantCRUDRoutes(
service=SectorService,
create_schema=SectorCreateDTO,
update_schema=SectorUpdateDTO,
response_schema=SectorResponseDTO,
prefix="/sectors",
tags=["a76 / sectors"],
resource_name="Sector",
id_name="sector_id",
enable_list=True,
enable_filters=True,
default_page_size=50,
max_page_size=100,
).router

View File

@@ -0,0 +1,36 @@
# (key, description, authorized)
seed = [
("I", "INDUSTRIA ELECTRICA", False),
("II", "INDUSTRIA ELECTRONICA", False),
("IIa", "PARA LOS BIENES A QUE SE REFIERE LA FRACCION II, INCISO a) o b), DE ARTICULO 4to DE ESTE DECRETO.", False),
("IIb", "PARA LOS BIENES A QUE SE REFIERE LA FRACCION II, INCISO b), DE ARTICULO 4to DE ESTE DECRETO.", False),
("III", "INDUSTRIA DEL MUEBLE", False),
("IV", "INDUSTRIA DEL JUGUETE, JUEGOS DE RECREO Y ARTICULOS DEPORTIVOS", False),
("IX", "INDUSTRIA DE MAQUINARIA AGRICOLA", False),
("V", "INDUSTRIA DEL CALZADO", False),
("VI", "INDUSTRIA MINERA Y METALURGICA", False),
("VII", "INDUSTRIA DE BIENES DE CAPITAL", False),
("VIII", "INDUSTRIA FOTOGRAFICA", False),
("X", "INDUSTRIAS DIVERSAS", False),
("XI", "INDUSTRIA QUIMICA", False),
("XII", "INDUSTRIAS DE MANUFACTURAS DEL CAUCHO Y PLASTICOS", False),
("XIII", "INDUSTRIA SIDERURGICA", False),
("XIV", "INDUSTRIA DE PRODUCTOS FARMOQUIMICOS, MEDICAMENTOS Y EQUIPO MEDICO", False),
("XIX", "INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", False),
("XIXa", "INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", False),
("XIXb", "INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", False),
("XV", "INDUSTRIA DEL TRANSPORTE, EXCEPTO EL SECTOR DE LA INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", False),
("XVa", "INDUSTRIA DEL TRANSPORTE, EXCEPTO EL SECTOR DE LA INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES.", False),
("XVb", "INDUSTRIA DEL TRANSPORTE, EXCEPTO EL SECTOR DE LA INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES.", False),
("XVI", "INDUSTRIA DEL PAPEL Y CARTON", False),
("XVII", "INDUSTRIA DE LA MADERA", False),
("XVIII", "INDUSTRIA DEL CUERO Y PIELES", False),
("XX", "INDUSTRIA TEXTIL Y DE LA CONFECCION", False),
("XXa", "INDUSTRIA TEXTIL Y DE LA CONFECCION", False),
("XXb", "INDUSTRIA TEXTIL Y DE LA CONFECCION", False),
("XXc", "INDUSTRIA TEXTIL Y DE LA CONFECCION", False),
("XXd", "INDUSTRIA TEXTIL Y DE LA CONFECCION", False),
("XXe", "INDUSTRIA TEXTIL Y DE LA CONFECCION", False),
("XXI", "INDUSTRIA DE CHOCOLATES, DULCES Y SIMILARES", False),
("XXII", "INDUSTRIA DEL CAFE", False),
]

View File

@@ -0,0 +1,143 @@
"""
Service layer for Sectors (GSectores) — a76 tenant-scoped catalog.
"""
import logging
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from . import dto, models
logger = logging.getLogger(__name__)
class SectorService:
"""Service for Sector CRUD operations with tenant support"""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[models.Sector], int]:
"""Get all sectors for a tenant/company with pagination"""
query = db.query(models.Sector).filter(
models.Sector.tenant_id == tenant_id,
models.Sector.company_id == company_id,
)
if filters:
if filters.get("key"):
query = query.filter(
models.Sector.key.ilike(f"%{filters['key']}%")
)
if filters.get("description"):
query = query.filter(
models.Sector.description.ilike(f"%{filters['description']}%")
)
total = query.count()
sectors = query.order_by(models.Sector.key).offset(skip).limit(limit).all()
return sectors, total
@staticmethod
def get_by_id(
db: Session, sector_id: int, tenant_id: int, company_id: int
) -> Optional[models.Sector]:
"""Get sector by ID"""
return (
db.query(models.Sector)
.filter(
models.Sector.id == sector_id,
models.Sector.tenant_id == tenant_id,
models.Sector.company_id == company_id,
)
.first()
)
@staticmethod
def create(
db: Session,
sector_data: dto.SectorCreateDTO,
tenant_id: int,
company_id: int,
) -> models.Sector:
"""Create a new sector"""
new_sector = models.Sector(
**sector_data.model_dump(), tenant_id=tenant_id, company_id=company_id
)
db.add(new_sector)
try:
db.commit()
db.refresh(new_sector)
return new_sector
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError creating sector: {str(e)}")
raise HTTPException(
status_code=400,
detail="Ya existe un sector con esa clave para esta empresa.",
)
@staticmethod
def update(
db: Session,
sector_id: int,
tenant_id: int,
sector_data: dto.SectorUpdateDTO,
company_id: int,
) -> Optional[models.Sector]:
"""Update a sector"""
sector = SectorService.get_by_id(db, sector_id, tenant_id, company_id)
if not sector:
return None
update_data = sector_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(sector, field, value)
try:
db.commit()
db.refresh(sector)
return sector
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError updating sector {sector_id}: {str(e)}")
raise HTTPException(
status_code=400,
detail="Ya existe un sector con esa clave para esta empresa.",
)
@staticmethod
def delete(
db: Session, sector_id: int, tenant_id: int, company_id: int
) -> bool:
"""Delete a sector"""
sector = SectorService.get_by_id(db, sector_id, tenant_id, company_id)
if not sector:
return False
try:
db.delete(sector)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError deleting sector {sector_id}: {str(e)}")
if "foreign key constraint" in str(e).lower():
raise HTTPException(
status_code=400,
detail="No se puede eliminar el sector porque tiene registros relacionados.",
)
raise HTTPException(status_code=400, detail="Error al eliminar el sector.")
except Exception as e:
db.rollback()
logger.error(f"Error deleting sector {sector_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error al eliminar el sector.")

View File

@@ -0,0 +1,47 @@
from decimal import Decimal
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
def _get_unit_equivalence(
db: Session,
from_unit: str,
to_unit: str,
tenant_id: str,
company_id: str,
) -> tuple[str, Decimal]:
"""
Busca una conversión entre dos unidades de medida.
Paridad: REVEQUIVALENCIA (Clarion SCAII).
Retorna (multi_divide, factor_conv):
- ('M', factor) → multiplicar cantidad por factor
- ('D', factor) → dividir cantidad por factor
- ('', 0) → no existe equivalencia
"""
conv = (
db.query(UnitConversion)
.filter(
UnitConversion.tenant_id == tenant_id,
UnitConversion.company_id == company_id,
UnitConversion.from_unit_code == from_unit,
UnitConversion.to_unit_code == to_unit,
)
.first()
)
if conv and conv.conversion_factor:
return "M", conv.conversion_factor
conv_inv = (
db.query(UnitConversion)
.filter(
UnitConversion.tenant_id == tenant_id,
UnitConversion.company_id == company_id,
UnitConversion.from_unit_code == to_unit,
UnitConversion.to_unit_code == from_unit,
)
.first()
)
if conv_inv and conv_inv.conversion_factor:
return "D", conv_inv.conversion_factor
return "", Decimal(0)

View File

@@ -6,8 +6,7 @@ from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMe
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
from .review_rule_octave import _get_unit_equivalence
from .review_equivalence import _get_unit_equivalence
def revisa_uma(
db: Session,

View File

@@ -0,0 +1,204 @@
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
from .pre_validators import pre_validators
from .sub_process.assign_no_discharges import assign_no_discharges_items, assign_no_discharges_series
from .sub_process.review_class import review_class
from .sub_process.review_exchange_rate import review_exchange_rate
from .sub_process.assign_values import assign_values
from .sub_process.review_exchange_rate import review_exchange_rate
from .sub_process.review_qty_vs_weight import review_qty_vs_weight
from .sub_process.review_unit_cost import review_unit_cost
from .sub_process.review_limits import limit_weight, limit_value
from .sub_process.series.review_qty_series import review_qty_series
from .sub_process.download_balance_collector import collect_lines_to_discharge
from .sub_process.discharge_types import DownloadEntry
from .sub_process.finalize_invoice import (
finalize_invoice_no_discharge,
finalize_invoice_with_discharge,
)
from .sub_process.review_origin_procedure import review_origin_procedure
from .sub_process.fill_available_balances import fill_available_balances
from .sub_process.compare_balances import compare_balances
from .sub_process.verify_consolidated import verify_consolidated
from .sub_process.generate_definitive_import import (
generate_definitive_import,
generate_definitive_import_all_lines,
)
# ---------------------------------------------------------------------------
# Bloque reutilizable: descarga normal (AFIJO / DONAC / SCRAP / REEXP / VEMEX)
# ---------------------------------------------------------------------------
def _process_with_discharge(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
errors: ErrorCollector,
) -> None:
"""
Secuencia común para los tipos de factura que realizan descarga de saldos:
AFIJO, DONAC, SCRAP, REEXP, VEMEX.
"""
assign_no_discharges_series(db, lines, errors)
review_class(db, lines, errors)
review_exchange_rate(db, invoice, errors)
assign_values(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors)
review_qty_vs_weight(lines, invoice.logistics.weight_type.upper(), errors)
review_unit_cost(lines, errors)
total_qty, total_net_weight = limit_weight(lines)
total_value = limit_value(lines)
review_qty_series(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors)
# QUIERE_DESCARGAR → LLENA_QUEUE_SALDOS → COMPARA_SALDOS
to_discharge = collect_lines_to_discharge(db, invoice, lines, errors)
fill_available_balances(db, invoice, to_discharge, errors)
compare_balances(db, invoice, to_discharge, errors)
verify_consolidated(db, invoice, to_discharge, errors)
finalize_invoice_with_discharge(db, invoice, lines, errors, to_discharge)
# ---------------------------------------------------------------------------
# Proceso principal
# ---------------------------------------------------------------------------
def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str) -> dict:
"""
Proceso principal para actualizar facturas de exportación.
Flujo (porta la rutina principal del legacy SCAII Facturas de Exportación):
1. Validaciones previas (pre_validators)
2. TODO: Compartir parámetros generales (QSisGen / GEmpresa)
3. TODO: Compartir parámetros de exportación (QSisExpo) según EsCambioRegimen
4. TODO: Validar permisos de usuario (GUsuarios / GNivelesSeguridad)
5. TODO: Iniciar transacción SQL (BEGIN TRAN)
6. Verificar que existan partidas
7. TODO: Obtener tipo de cambio según SisGen:CalValBaseTCPedExpo
(TCPED desde la fecha de pago del pedimento, o TCFAC desde la factura)
8. TODO: Validar que la factura no exista ya en Importaciones Definitivas (si GeneraID='S')
9. CASE invoice_type → ejecutar sub-proceso específico por tipo:
- NODES : sin descarga
- AFIJO / DONAC / SCRAP : con descarga + lógica de CambioRegimen opcional
- REEXP / VEMEX : con descarga + revisión de procedencia DEF
10. Si hay errores: rollback implícito (raise)
Si no hay errores: COMMIT y marcar factura como procesada
"""
errors = ErrorCollector()
# --- Paso 1: Validaciones previas ----------------------------------------
lines = pre_validators(db, invoice, tenant_id, company_id, errors)
errors.raise_if_errors()
# --- Paso 2-4: Parámetros generales, parámetros expo y permisos ----------
# TODO: Compartir QSisGen / GEmpresa
# TODO: Compartir QSisExpo (EsCambioRegimen = 'S' → SisExp:EsCambioRegimen = 'CR')
# TODO: Validar permisos usuario (GUsuarios / GNivelesSeguridad)
# --- Paso 5: Iniciar transacción -----------------------------------------
# TODO: BEGIN TRAN (en el legacy: GSQLFile{PROP:SQL} = 'BEGIN TRAN')
# --- Paso 6: Verificar que existan partidas ------------------------------
if not lines:
errors.add_error(
field="items",
message="Esta Factura no tiene partidas.",
solution=["Capturar al menos una partida a la factura."],
code="NO_ITEMS_FOUND",
)
errors.raise_if_errors()
# --- Paso 7: Tipo de cambio ----------------------------------------------
# TODO: Si SisGen:CalValBaseTCPedExpo = 1:
# invoice.which_exchange_rate = 'TCPED'
# Buscar pedimento (EqiPed:Pedimento = EqiFex:PedimentoExpo)
# Buscar GTipoCambio por EqiPed:Fecha_Pago
# exchange_rate = GenTC:Valor
# Else:
# invoice.which_exchange_rate = 'TCFAC'
# exchange_rate = invoice.financials.exchange_rate
# --- Paso 8: Validar que la factura no exista en ImportDef ---------------
# TODO: Si invoice.generate_id = True:
# Buscar en QFacImpDef por invoice.invoice_number
# Si ya existe → agregar error
# --- Paso 9: Sub-proceso por tipo de factura -----------------------------
invoice_type = invoice.invoice_type
if invoice_type == "NODES":
# Sin descarga de saldos
assign_no_discharges_items(lines, errors)
assign_no_discharges_series(db, lines, errors)
review_class(db, lines, errors)
review_exchange_rate(db, invoice, errors)
assign_values(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors)
review_qty_vs_weight(lines, invoice.logistics.weight_type.upper(), errors)
review_unit_cost(lines, errors)
review_qty_series(db, invoice, lines, tenant_id, company_id, errors)
total_qty, total_net_weight = limit_weight(lines)
total_value = limit_value(lines)
finalize_invoice_no_discharge(db, invoice, lines, errors)
elif invoice_type == "AFIJO":
if invoice.compliance_mx and invoice.compliance_mx.is_regime_change:
review_origin_procedure(db, invoice, lines, "TEM", errors)
if invoice.generate_id and invoice.generate_desc_parties == "Todas":
def_inv = generate_definitive_import(db, invoice, errors)
if def_inv:
generate_definitive_import_all_lines(db, invoice, def_inv, errors)
_process_with_discharge(db, invoice, lines, errors)
elif invoice_type == "DONAC":
_process_with_discharge(db, invoice, lines, errors)
elif invoice_type == "SCRAP":
if invoice.compliance_mx and invoice.compliance_mx.is_regime_change:
review_origin_procedure(db, invoice, lines, "TEM", errors)
if invoice.generate_id and invoice.generate_desc_parties == "Todas":
def_inv = generate_definitive_import(db, invoice, errors)
if def_inv:
generate_definitive_import_all_lines(db, invoice, def_inv, errors)
_process_with_discharge(db, invoice, lines, errors)
elif invoice_type == "REEXP":
review_origin_procedure(db, invoice, lines, "DEF", errors)
_process_with_discharge(db, invoice, lines, errors)
elif invoice_type == "VEMEX":
review_origin_procedure(db, invoice, lines, "DEF", errors)
_process_with_discharge(db, invoice, lines, errors)
else:
errors.add_error(
field="invoice_type",
message=f"{invoice_type} no es un Tipo de Factura válido, llamar al proveedor del Sistema SCAII.",
solution=["Verificar el tipo de factura de exportación."],
code="INVALID_INVOICE_TYPE",
value=invoice_type,
)
# --- Paso 10: Commit / Rollback ------------------------------------------
errors.raise_if_errors()
# TODO: COMMIT TRAN (en el legacy: gSQLFile{PROP:SQL} = 'COMMIT TRAN')
# TODO: GBitacora('ACTUALIZAR FACTURA', invoice.invoice_number)
# invoice.status and totals are set inside finalize_invoice_no_discharge / termina_ac_o_lp_normal
db.flush()
return {"status": "success", "invoice_id": str(invoice.id)}

View File

@@ -0,0 +1,122 @@
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.general_catalogs.fractions.warning_fractions.models import WarningFraction
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from core.exceptions import ErrorCollector
def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector):
if invoice.status == InvoiceStatus.PROCESSED:
errors.add_error(
"status",
"La factura ya fue procesada y no puede volver a actualizarse. Desactualícela primero.",
solution=["Use el botón 'Desactualizar' antes de volver a procesar la factura."],
code="ALREADY_PROCESSED",
value=invoice.status,
)
errors.raise_if_errors()
return
if not invoice.invoice_date:
errors.add_required_error("invoice_date")
if invoice.invoice_type != "VEMEX":
if not invoice.document_type:
errors.add_required_error("document_type")
if not invoice.compliance_mx.provider_id:
errors.add_required_error("compliance_mx.provider_id")
if not invoice.compliance_mx.sold_to_id:
errors.add_required_error("compliance_mx.sold_to_id")
if not invoice.compliance_mx.shipped_to_id:
errors.add_required_error("compliance_mx.shipped_to_id")
else:
shipped_to_exists = db.query(ClientProvider).filter(
ClientProvider.id == invoice.compliance_mx.shipped_to_id,
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
).first()
if not shipped_to_exists:
errors.add_error(
field="compliance_mx.shipped_to_id",
message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.",
solution=["Verifica el ID del Destinatario", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.compliance_mx.shipped_to_id,
)
if not shipped_to_exists.address or not shipped_to_exists.address.country:
errors.add_error(
field="compliance_mx.shipped_to_id",
message="El Destinatario no tiene capturado el pais.",
solution=["Captura el pais de envío del Destinatario", "Revisa el catálogo"],
code="MISSING_COUNTRY",
value=invoice.compliance_mx.shipped_to_id,
)
if invoice.invoice_type != "VEMEX":
if not invoice.compliance_mx.customs_broker_id:
errors.add_required_error("compliance_mx.customs_broker_id")
if not invoice.financials.exchange_rate or invoice.financials.exchange_rate <= 0:
errors.add_range_error(
"financials.exchange_rate",
min_value=0.0001,
)
if not invoice.financials.currency:
errors.add_required_error("El Tipo de Moneda esta vacio no se puede actualizar")
elif invoice.financials.currency == "manual" and not invoice.financials.currency_type:
errors.add_required_error("financials.currency_type")
#TODO: SSISGEN: Seguridad Ejemplo en: BrowseQFacImp
# 2.- Existe tipo de cambio para la factura seleccionada
#TODO: SSISGEN: VALIDACION DEL TIPO DE CAMBIO EN BASE A LA FECHA DE PAGO DEL PEDIMENTO.
# 3.- Validacion que deber de existir un pedimento cuando es requerido
if not invoice.compliance_mx.is_pedimento_pending and not invoice.compliance_mx.pedimento_id:
errors.add_required_error("compliance_mx.pedimento_number")
# 4.- Verificacion de que existan partidas para la factura, si no hay partidas no se puede procesar
item_count = (
db.query(func.count(LineItem.id))
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == invoice.tenant_id,
LineItem.company_id == invoice.company_id,
)
.scalar()
)
if item_count == 0:
errors.add_error(
field="items",
message="La factura no tiene partidas capturadas.",
solution=["Capture al menos una partida antes de procesar la factura."],
code="NO_ITEMS_FOUND",
)
# Advertencias para las fracciones y su horario
lines = (
db.query(LineItem)
.options(joinedload(LineItem.fa_data))
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.all()
)
return lines

View File

@@ -0,0 +1,74 @@
from typing import Any, Dict
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.celery_app import celery_app
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .task import process_export_invoice_task
router = APIRouter()
@router.post("/invoices/{invoice_id}/process")
def trigger_invoice_process(
invoice_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Inicia el procesamiento de una factura de exportación como tarea Celery.
Retorna el task_id para hacer polling del progreso.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
task = process_export_invoice_task.apply_async(
args=[invoice_id, str(tenant_id), str(company_id)]
)
return {"task_id": task.id}
@router.get("/invoices/process/{task_id}/status")
def get_invoice_process_status(task_id: str):
"""
Consulta el estado de progreso de una tarea de procesamiento de factura.
Retorna:
- state: 'PROCESSING' | 'SUCCESS' | 'FAILURE'
- info: { current: int, status: str } (cuando state == 'PROCESSING')
- result: dict (cuando state == 'SUCCESS' o 'FAILURE')
"""
task_result = celery_app.AsyncResult(task_id)
if task_result.state in ("PENDING", "STARTED"):
return {
"state": "PROCESSING",
"info": {"current": 0, "status": "Iniciando..."},
}
if task_result.state == "PROGRESS":
return {
"state": "PROCESSING",
"info": task_result.info or {"current": 0, "status": "Procesando..."},
}
if task_result.state == "SUCCESS":
return {
"state": "SUCCESS",
"result": task_result.result,
}
error_info = task_result.result
if isinstance(error_info, Exception):
error_msg = str(error_info)
else:
error_msg = str(error_info) if error_info else "Error desconocido"
return {
"state": "FAILURE",
"result": error_msg,
}

View File

@@ -0,0 +1,82 @@
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.series.models import Serie
from core.exceptions import ErrorCollector
def assign_no_discharges_items(
lines: List[LineItem],
errors: ErrorCollector,
) -> None:
"""
ASIGNA_NODESCARGA_PARTIDAS
Sets ``discharge = False`` on every line item of the invoice.
Used exclusively by invoice type NODES (no discharge).
Legacy equivalent
-----------------
UPDATE QEqeMaq SET Descarga = 0
FROM QEqeMaq
WHERE Consecutivo = <invoice.id>
"""
try:
for line in lines:
line.fa_data.discharge = False
except Exception as exc:
errors.add_error(
field="items.discharge",
message="Error al asignar No-Descarga en las partidas de exportación.",
solution=["Verifique la integridad de las partidas de la factura."],
code="ASSIGN_NO_DISCHARGE_ITEMS_ERROR",
value=str(exc),
)
def assign_no_discharges_series(
db: Session,
lines: List[LineItem],
errors: ErrorCollector,
) -> None:
"""
ASIGNA_NODESCARGA_SERIES
Sets ``marca = False`` on every ``Serie`` row whose parent ``LineItem``
has ``discharge = False`` (or ``discharge`` is ``None``).
Legacy equivalent
-----------------
UPDATE QSeriesExpo
SET Marca = 0
FROM QSeriesExpo SerExpo
LEFT JOIN QEqeMaq EqiPex
ON EqiPex.Consecutivo = SerExpo.Consecutivo
AND EqiPex.LineaExpo = SerExpo.LineaExpo
WHERE SerExpo.Consecutivo = <invoice.id>
AND EqiPex.Descarga = 0
"""
try:
no_discharge_line_ids = {
line.id
for line in lines
if not line.fa_data.discharge
}
if not no_discharge_line_ids:
return
(
db.query(Serie)
.filter(Serie.line_item_id.in_(no_discharge_line_ids))
.update({"discharge": False}, synchronize_session="fetch")
)
except Exception as exc:
errors.add_error(
field="series.discharge",
message="Error al asignar No-Descarga en las series de exportación.",
solution=["Verifique la integridad de las series de la factura."],
code="ASSIGN_NO_DISCHARGE_SERIES_ERROR",
value=str(exc),
)

View File

@@ -0,0 +1,236 @@
"""
ASIGNA_VALORES_PARTIDAS_ASIGNA_PESOS
Resets and recalculates unit costs, export values
(KGS ↔ LBS) for every line item of an export invoice.
Two cost-assignment strategies (controlled by SisExp:ValFactTC — TODO):
TCE → bulk SQL UPDATE using the invoice-level exchange rate (Loc:TipoCambio).
else → per-line loop that resolves each line's exchange rate from its
source import invoice (TEM → QFacImp, DEF → QFacImpDef).
After costs are assigned the routine always:
1. Calls REVISA_UMA for each line.
"""
from decimal import Decimal
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import Currency, InvoiceHeader
from api.v1.modules.a76.invoices.common.process.review_uma import revisa_uma
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
_KGS_TO_LBS = Decimal("2.204624")
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _assign_costs_tce(
lines: List[LineItem],
currency: Currency,
tc: Decimal,
tc_mm: Decimal,
) -> None:
"""
Bulk-style cost assignment when SisExp:ValFactTC = 'TCE'.
Uses the single invoice-level exchange rate for all lines.
Legacy equivalent (branch 1 of the IF SisExp:ValFactTC):
UPDATE QEqeMaq SET CostoUnitarioDlls = ..., CostoUnitarioPesos = ...,
ValorExpoMN = ..., ValorExpoME = ..., ValorExpoMC = ...
WHERE Consecutivo = <invoice.id>
"""
for line in lines:
if line.financial is None or line.quantity is None:
continue
capture = line.financial.unit_cost_capture or Decimal(0)
qty = line.quantity.quantity or Decimal(0)
if currency == Currency.FOREIGN: # ME
line.financial.unit_cost_usd = capture
line.financial.unit_cost_mxn = capture * tc
line.financial.value_mxn = qty * capture * tc
line.financial.value_usd = qty * capture
line.financial.value_mc = qty * capture
elif currency == Currency.LOCAL: # MN
line.financial.unit_cost_mxn = capture
line.financial.unit_cost_usd = (capture / tc) if tc else Decimal(0)
line.financial.value_mxn = qty * capture
line.financial.value_usd = (qty * capture / tc) if tc else Decimal(0)
line.financial.value_mc = qty * capture
elif currency == Currency.MANUAL: # MC
cost_usd = capture * tc_mm
line.financial.unit_cost_usd = cost_usd
line.financial.unit_cost_mxn = cost_usd * tc
line.financial.value_mxn = qty * cost_usd * tc
line.financial.value_usd = qty * cost_usd
line.financial.value_mc = qty * capture
def _assign_costs_per_line(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
currency: Currency,
tc_mm: Decimal,
errors: ErrorCollector,
) -> None:
"""
Per-line cost assignment when SisExp:ValFactTC != 'TCE'.
Each line resolves the exchange rate from its source import invoice
(TEM → QFacImp header, DEF → QFacImpDef header).
Legacy equivalent (ELSE branch LOOP QEqeMaq):
If TipoMovImpo = 'TEM' → ACCESS:QFacImp.TryFetch(EqiFim:FKFacturaImpo)
Else → ACCESS:QFacImpDef.TryFetch(EqiFID:FKFacImpoDef)
then assign CostoUnitarioDlls / CostoUnitarioPesos / ValorExpoMN/ME/MC
"""
from api.v1.modules.a76.invoices.models import InvoiceHeader as InvHeader
for line in lines:
if line.financial is None or line.quantity is None:
continue
capture = line.financial.unit_cost_capture or Decimal(0)
qty = line.quantity.quantity or Decimal(0)
# Resolve the exchange rate from the source import invoice
line_tc = _get_source_invoice_tc(db, invoice, line, errors)
if currency == Currency.FOREIGN: # ME
line.financial.unit_cost_usd = capture
line.financial.unit_cost_mxn = capture * line_tc
elif currency == Currency.LOCAL: # MN
line.financial.unit_cost_usd = (capture / line_tc) if line_tc else Decimal(0)
line.financial.unit_cost_mxn = capture
elif currency == Currency.MANUAL: # MC
cost_usd = capture * tc_mm
line.financial.unit_cost_usd = cost_usd
line.financial.unit_cost_mxn = cost_usd * line_tc
# Values are always: cost × qty
line.financial.value_mxn = (line.financial.unit_cost_mxn or Decimal(0)) * qty
line.financial.value_usd = (line.financial.unit_cost_usd or Decimal(0)) * qty
line.financial.value_mc = capture * qty
def _get_source_invoice_tc(
db: Session,
invoice: InvoiceHeader,
line: LineItem,
errors: ErrorCollector,
) -> Decimal:
"""
Returns the exchange rate of the import invoice linked to this export line.
Movement type 'TEM' → look up QFacImp (temporary import header).
Any other type → look up QFacImpDef (definitive import header).
Falls back to the export invoice's own exchange rate if the source invoice
is not found, and records a warning-level error.
Legacy fields:
EqiPex:TipoMovImpo → line.customs.origin_procedure
EqiPex:FacturaImpo → line.reference.import_invoice (TODO: confirm field)
"""
fallback_tc = Decimal(str(invoice.financials.exchange_rate or 0))
movement_type = (line.customs.origin_procedure or "").strip().upper() if line.customs else ""
import_invoice_number = (line.reference.import_invoice if line.reference else None) or ""
if not import_invoice_number:
return fallback_tc
from api.v1.modules.a76.invoices.models import InvoiceHeader as InvHeader
if movement_type == "TEM":
source = (
db.query(InvHeader)
.filter(
InvHeader.invoice_number == import_invoice_number,
InvHeader.tenant_id == invoice.tenant_id,
InvHeader.company_id == invoice.company_id,
)
.first()
)
else:
# DEF: definitive import
source = (
db.query(InvHeader)
.filter(
InvHeader.invoice_number == import_invoice_number,
InvHeader.tenant_id == invoice.tenant_id,
InvHeader.company_id == invoice.company_id,
)
.first()
)
if source is None or source.financials is None:
errors.add_error(
field=f"line[{line.line_number}].import_invoice",
message=(
f"No se encontró la factura de importación '{import_invoice_number}' "
f"referenciada en la partida {line.line_number}."
),
solution=[
"Verificar el número de factura de importación en la partida.",
],
code="SOURCE_INVOICE_NOT_FOUND",
)
return fallback_tc
return Decimal(str(source.financials.exchange_rate or 0)) or fallback_tc
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def assign_values(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> None:
"""
ASIGNA_VALORES_PARTIDAS_ASIGNA_PESOS
1. Assigns unit costs and export values to every line (ME / MN / MC).
Strategy A (TCE): bulk assignment using the invoice exchange rate.
Strategy B (per-line): resolves exchange rate per source import invoice.
2. Calls REVISA_UMA for each line.
"""
currency = invoice.financials.currency
tc = Decimal(str(invoice.financials.exchange_rate or 0))
tc_mm = Decimal(str(invoice.financials.exchange_rate_mm or 0))
# --- Step 1: Assign costs / values ---------------------------------------
# TODO: Read SisExp:ValFactTC from the export system parameters model.
# When ValFactTC = 'TCE' use _assign_costs_tce (single TC for all lines).
# Otherwise use _assign_costs_per_line (TC from each source import invoice).
# For now the per-line strategy is always used as the safe default.
val_fact_tc = "PER_LINE" # TODO: replace with SisExp.val_fact_tc
if val_fact_tc == "TCE":
_assign_costs_tce(lines, currency, tc, tc_mm)
else:
_assign_costs_per_line(db, invoice, lines, currency, tc_mm, errors)
# --- Step 2: REVISA_UMA --------------------------------------------------
for line in lines:
revisa_uma(db=db, line=line, tenant_id=tenant_id, company_id=company_id, errors=errors)
# --- Step 3: Assign weights ----------------------------------------------
# In anexo76 will be calculated in realtime based in weight type by conversion factor

View File

@@ -0,0 +1,165 @@
"""
COMPARA_SALDOS_POR_FACTURA
Compares the discharge queue (QueADescargar) against the available PEPS lots
(QSaldoActual) and distributes the quantity to discharge across the available
lots, updating ``entry.quantity_used`` and ``lot.available_qty`` accordingly.
Also validates that the unit of measure on the export line matches the one
on the import lot.
If after consuming all available lots a discharge entry still has remaining
quantity, no explicit error is raised here — the caller (compare_balances)
detects this and reports an insufficient-balance error.
Legacy mapping
--------------
QADesc:Cantidad → entry.quantity
QADesc:CantUsada → entry.quantity_used
QSaldo:Cantidad → lot.available_qty (net balance from ledger)
QSaldo:CantUsada → lot_used (tracked locally; lots are mutated in-place)
QADesc:UniMed → entry.unit_of_measure
QSaldo:UniMed → resolved from import line (stored on AvailableLot via uom)
"""
from decimal import Decimal
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
from .discharge_types import AvailableLot, DownloadEntry
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
def _resolve_import_uom(db: Session, import_item_line_id: int) -> Optional[str]:
"""
Returns the unit-of-measure code of the import line (QSaldo:UniMed).
Equivalent to EqiPim:UnidadMedida resolved via the import LineItem.
"""
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
row = db.execute(
select(LineItem.unit_of_measure).where(LineItem.id == import_item_line_id)
).scalar_one_or_none()
if row is None:
return None
uom = db.get(UnitOfMeasure, row)
return uom.code if uom else None
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def compare_balances(
db: Session,
export_invoice: InvoiceHeader,
to_discharge: List[DownloadEntry],
errors: ErrorCollector,
) -> None:
"""
COMPARA_SALDOS_POR_FACTURA
For each discharge entry distributes the quantity to discharge across the
available PEPS lots attached to the entry by ``fill_available_balances``.
Mutates ``entry.quantity_used`` and ``lot.available_qty`` in-place.
After this call, ``entry.quantity_used`` should equal ``entry.quantity``
for every entry; if not, there is insufficient balance.
Parameters
----------
db : active SQLAlchemy session
export_invoice : the export invoice being processed
to_discharge : list of DownloadEntry objects populated by
fill_available_balances (entry.available_lots must be set)
errors : shared error collector
"""
# Sort mirrors Clarion:
# Sort(QueADescargar, -Procedencia, FacturaImpo, LineaImpo)
# Sort(QSaldoActual, -Procedencia, FacturaImpo, LineaImpo)
sorted_entries = sorted(
to_discharge,
key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line),
reverse=True,
)
for entry in sorted_entries:
if not entry.available_lots:
# No lots were found for this entry — balance check will catch it
continue
# ── Validate unit of measure matches the import lot ───────────────────
first_lot = entry.available_lots[0]
import_uom = _resolve_import_uom(db, first_lot.import_item_line_id)
if import_uom and entry.unit_of_measure and import_uom != entry.unit_of_measure:
errors.add_error(
field=f"line[{entry.export_line}].unit_of_measure",
message=(
f"La U.M.: '{entry.unit_of_measure}' de la partida: {entry.export_line} "
f"es diferente a la U.M: '{import_uom}' registrada en importación."
),
solution=["Revisar la Partida de Exportación y cambiar la Unidad de Medida."],
code="UOM_MISMATCH",
value={
"export_line": entry.export_line,
"export_uom": entry.unit_of_measure,
"import_uom": import_uom,
},
)
continue
# ── Distribute quantity across available lots (PEPS order) ────────────
# Lots are already ordered by order_peps (oldest first) from
# fill_available_balances; sort defensively here too.
sorted_lots: List[AvailableLot] = sorted(
entry.available_lots, key=lambda lot: lot.order_peps
)
for lot in sorted_lots:
remaining_entry = entry.quantity - entry.quantity_used
remaining_lot = lot.available_qty
if remaining_entry <= 0:
break # Entry fully satisfied
if remaining_lot <= 0:
continue # Lot exhausted — try next
# Consume as much as possible from this lot
consume = min(remaining_entry, remaining_lot)
entry.quantity_used += consume
lot.available_qty -= consume
lot.consumed_qty += consume
# ── Check if the entry was fully satisfied ────────────────────────────
if entry.quantity_used < entry.quantity:
shortage = entry.quantity - entry.quantity_used
errors.add_error(
field=f"line[{entry.export_line}].quantity",
message=(
f"Saldo insuficiente para la partida: {entry.export_line}. "
f"Se requieren {entry.quantity} y solo hay {entry.quantity_used} disponibles "
f"(faltan {shortage})."
),
solution=[
"Verificar el saldo disponible de la Factura de Importación.",
"Reducir la cantidad a descargar.",
],
code="INSUFFICIENT_BALANCE",
value={
"export_line": entry.export_line,
"required": str(entry.quantity),
"available": str(entry.quantity_used),
"shortage": str(shortage),
},
)

View File

@@ -0,0 +1,73 @@
"""
Shared dataclasses for the export invoice discharge process.
Kept in a standalone module (no local imports) so that
download_balance_collector, review_series_exist, review_series_other_lines
and fill_available_balances can all import from here without circular deps.
"""
from dataclasses import dataclass, field
from decimal import Decimal
from typing import List, Optional
@dataclass
class AvailableLot:
"""
One PEPS lot available for discharge — equivalent to one QSaldoActual record.
Fields
------
import_item_line_id : a76.item_lines.id of the import line (the lot)
import_invoice_id : a76.invoice_header.id of the import invoice
part_number_id : denormalized from the import line
available_qty : net balance available (QSaldo:Cantidad); mutated by
compare_balances() as quantity is distributed
consumed_qty : how much was actually taken from this lot by
compare_balances(); used by register_discharge_ledger
to create the exact BalanceMovement amount
value_me : USD value of the full lot (for proportional calc)
value_mn : MXN value of the full lot (for proportional calc)
order_peps : PEPS ordering key — lower = older = consumed first
"""
import_item_line_id: int
import_invoice_id: int
part_number_id: Optional[int]
available_qty: Decimal
value_me: Optional[Decimal]
value_mn: Optional[Decimal]
order_peps: int
consumed_qty: Decimal = field(default_factory=Decimal)
@dataclass
class DownloadEntry:
"""
Represents one export line that will be discharged from inventory.
Equivalent to the QADesc (QueADescargar) record in the legacy system.
Fields
------
origin_procedure : TipoMovImpo 'TEM' (temporal) | 'DEF' (definitiva)
export_line : LineaExpo line number on the export invoice
part_number : NumParte part number code
class_code : Clase class code
quantity : CantExpo quantity to discharge
quantity_used : CantUsada amount already consumed (starts at 0)
unit_of_measure : UniMed unit of measure code
import_invoice : FacturaImpo source import invoice number
import_line : LineaImpo source import line number
line_item_id : internal DB id of the LineItem (for series lookups)
available_lots : PEPS lots attached by fill_available_balances
"""
origin_procedure: str
export_line: int
part_number: str
class_code: str
quantity: Decimal
unit_of_measure: str
import_invoice: str
import_line: int
line_item_id: int
quantity_used: Decimal = field(default_factory=Decimal)
available_lots: List[AvailableLot] = field(default_factory=list)

View File

@@ -0,0 +1,125 @@
"""
QUIERE_DESCARGAR
Collects every export line item that has discharge = True and builds the
"QueADescargar" list used by the balance-verification steps that follow
(LLENA_QUEUE_SALDOS_DISPONIBLES_FACTURA, COMPARA_SALDOS_POR_FACTURA,
VERIFICAQCONSOLIDADO).
For each discharge line the routine also triggers two series sub-validations:
· REVISA_SERIES_EXISTA → verifies the series to be discharged exist
· REVISA_SERIES_OTRAS_PAR → verifies the series are not already discharged
on another line (REVISA_SERIES_DESC was commented-out in the legacy)
Legacy equivalent
-----------------
SELECT EqiPex.TipoMovImpo, EqiPex.LineaExpo, EqiPex.NumParte,
EqiPex.Clase, EqiPex.CantExpo, EqiPex.UnidadMedida,
EqiPex.FacturaImpo, EqiPex.LineaImpo
FROM QEqeMaq EqiPex
WHERE Consecutivo = <invoice.id>
AND EqiPex.Descarga = 1
"""
from decimal import Decimal
from typing import List, Set, Tuple
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
from .discharge_types import AvailableLot, DownloadEntry # re-exported for callers
from .series.review_series_exist import review_series_exist
from .series.review_series_other_lines import review_series_other_lines
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def collect_lines_to_discharge(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
errors: ErrorCollector,
) -> List[DownloadEntry]:
"""
QUIERE_DESCARGAR
Builds and returns the list of ``DownloadEntry`` records for every line
that has ``discharge = True``. If no lines have discharge enabled the
list is empty and the subsequent balance steps are skipped.
For each collected line the function also runs:
· _revisa_series_exista
· _revisa_series_otras_par
Parameters
----------
db : active SQLAlchemy session
invoice : the export invoice being processed
lines : all line items of the invoice (already loaded by pre_validators)
errors : shared error collector
Returns
-------
List[DownloadEntry] — the "QueADescargar" equivalent
"""
to_discharge: List[DownloadEntry] = []
discharge_lines = [line for line in lines if line.fa_data and line.fa_data.discharge]
if not discharge_lines:
return to_discharge
# Shared across all lines — accumulates series keys to detect duplicates
# between lines (equivalent to QueueSeries in the Clarion)
seen_series: Set[Tuple] = set()
for line in discharge_lines:
origin_procedure = (
line.customs.origin_procedure
if line.customs and line.customs.origin_procedure
else ""
)
part_number = ""
if line.part_info:
part_number = line.part_info.part_number or ""
class_code = ""
if line.class_info:
class_code = line.class_info.class_code or ""
quantity = (
line.quantity.quantity or Decimal(0)
if line.quantity
else Decimal(0)
)
uom_code = ""
if line.unit_of_measure_info:
uom_code = line.unit_of_measure_info.code or ""
import_invoice_number = ""
import_line_number = 0
if line.fa_data:
import_invoice_number = line.fa_data.search_invoice or ""
import_line_number = line.fa_data.search_line or 0
entry = DownloadEntry(
origin_procedure=origin_procedure,
export_line=line.line_number,
part_number=part_number,
class_code=class_code,
quantity=quantity,
quantity_used=Decimal(0),
unit_of_measure=uom_code,
import_invoice=import_invoice_number,
import_line=import_line_number,
line_item_id=line.id,
)
to_discharge.append(entry)
review_series_exist(db, invoice, line, entry, errors)
review_series_other_lines(db, invoice, line, entry, seen_series, errors)
return to_discharge

View File

@@ -0,0 +1,276 @@
"""
LLENA_QUEUE_SALDOS_DISPONIBLES_FACTURA
For each entry in ``to_discharge`` (QueADescargar), validates the source
import invoice/line and computes the net available balance from
``a24.balance_movement`` using the PEPS ledger.
New logic vs. legacy
--------------------
The legacy Clarion routine read ``CantImpo - CantRetornadaTemp - CantRetornada``
directly from the import line record. Since we migrated to an append-only
ledger (a24.balance_movement), the available balance is now computed as:
SUM(signed_qty) per import_item_line_id
where sign = +1 for positive movement types and -1 for negative ones
(see NEGATIVE_MOVEMENTS set in the BalanceMovement model).
Validations preserved from legacy
----------------------------------
1. Import invoice must exist.
2. Import invoice must be processed (status != 'NA' / not 'unprocessed').
3. Import invoice date must not be later than the export invoice date.
4. Import line must exist.
5. Net available balance must be > 0 (otherwise the lot is skipped).
Skipped items (equivalent to legacy CYCLE)
------------------------------------------
- Entries already seen in the same call (duplicate import_invoice + import_line
combination) — handled naturally since each entry is unique in QueADescargar.
- Lots with net balance <= 0.
Output
------
On success, ``entry.available_lots`` is populated with one ``AvailableLot``
per lot that has available balance. Errors are added to ``errors``.
"""
import datetime
from decimal import Decimal
from typing import List, Optional
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a24.balance_movements.models import BalanceMovement, NEGATIVE_MOVEMENTS
from core.exceptions import ErrorCollector
from .discharge_types import AvailableLot, DownloadEntry
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
def _fetch_import_invoice(
db: Session,
invoice_number: str,
export_invoice: InvoiceHeader,
) -> Optional[InvoiceHeader]:
return (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.invoice_number == invoice_number,
InvoiceHeader.tenant_id == export_invoice.tenant_id,
InvoiceHeader.company_id == export_invoice.company_id,
)
.first()
)
def _fetch_import_line(
db: Session,
invoice_id: int,
line_number: int,
tenant_id: int,
company_id: int,
) -> Optional[LineItem]:
return (
db.query(LineItem)
.filter(
LineItem.invoice_id == invoice_id,
LineItem.line_number == line_number,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.first()
)
def _net_balance_for_lot(
db: Session,
import_item_line_id: int,
as_of_date: datetime.date,
) -> Decimal:
"""
Computes the net available balance for one import lot as of ``as_of_date``.
Equivalent to the legacy two-step calculation:
1. Base check: CantImpo - CantRetornadaTemp - CantRetornada (general balance)
2. CALCULA_SALDO_FECHA_EXPO: only count exits with operation_date <= export date
In the new ledger, ENTRY movements have no operation_date restriction (the
lot exists from its import date). EXIT movements (CONSUMPTION, WASTE, etc.)
are only counted if their operation_date <= as_of_date, mirroring the
Clarion "FechaFactura > EqiFex:FechaFactura → CYCLE" guard.
balance = SUM(+qty for ENTRY-type movements)
- SUM( qty for EXIT-type movements WHERE operation_date <= as_of_date)
"""
sign_expr = case(
(BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), Decimal(-1)),
else_=Decimal(1),
)
# Positive movements: always count (entries, returns, adjustments in)
# Negative movements: only count if they occurred on or before the export date
date_filter = case(
(
BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS),
BalanceMovement.operation_date <= as_of_date,
),
else_=True,
)
result = db.execute(
select(func.sum(sign_expr * BalanceMovement.quantity)).where(
BalanceMovement.import_item_line_id == import_item_line_id,
date_filter,
)
).scalar()
return Decimal(str(result or 0))
def _peps_order_for_lot(db: Session, import_item_line_id: int) -> int:
"""Returns the minimum (oldest) order_peps for this lot."""
result = db.execute(
select(func.min(BalanceMovement.order_peps)).where(
BalanceMovement.import_item_line_id == import_item_line_id,
)
).scalar()
return int(result or 0)
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def fill_available_balances(
db: Session,
export_invoice: InvoiceHeader,
to_discharge: List[DownloadEntry],
errors: ErrorCollector,
) -> None:
"""
LLENA_QUEUE_SALDOS_DISPONIBLES_FACTURA
Validates each discharge entry and populates ``entry.available_lots``
with the net balance available from the PEPS ledger.
Parameters
----------
db : active SQLAlchemy session
export_invoice : the export invoice being processed
to_discharge : list of DownloadEntry objects (QueADescargar)
errors : shared error collector
"""
export_date: datetime.date = (
export_invoice.invoice_date.date()
if hasattr(export_invoice.invoice_date, "date")
else export_invoice.invoice_date
)
# Sort mirrors Clarion: Sort(QueADescargar, FacturaImpo, LineaImpo)
sorted_entries = sorted(
to_discharge,
key=lambda e: (e.import_invoice, e.import_line),
)
# Track already-resolved (invoice, line) pairs to skip duplicates
seen: set = set()
for entry in sorted_entries:
key = (entry.import_invoice, entry.import_line)
if key in seen:
continue
seen.add(key)
if not entry.import_invoice or entry.import_line == 0:
continue
# ── 1. Validate import invoice ────────────────────────────────────────
import_invoice = _fetch_import_invoice(db, entry.import_invoice, export_invoice)
if import_invoice is None:
errors.add_error(
field=f"line[{entry.export_line}].import_invoice",
message=f"La Factura de Importación: '{entry.import_invoice}' no existe.",
solution=["Seleccionar otra factura de Importación."],
code="IMPORT_INVOICE_NOT_FOUND",
value=entry.import_invoice,
)
continue
# Status 'NA' == not processed (Clarion: Estatus = 'NA')
if import_invoice.status == InvoiceStatus.PENDING:
errors.add_error(
field=f"line[{entry.export_line}].import_invoice",
message=f"La Factura de Importación: '{entry.import_invoice}' está Desactualizada.",
solution=["Actualizar la factura de Importación."],
code="IMPORT_INVOICE_UNPROCESSED",
value=entry.import_invoice,
)
continue
# Import date must not be later than export date
imp_date: datetime.date = (
import_invoice.invoice_date.date()
if hasattr(import_invoice.invoice_date, "date")
else import_invoice.invoice_date
)
if imp_date > export_date:
errors.add_error(
field=f"line[{entry.export_line}].import_invoice",
message=(
f"La Factura de Importación: '{entry.import_invoice}' tiene una Fecha Mayor "
f"a la Fecha de Descarga."
),
solution=[
f"Seleccionar otra factura de Importación con Fecha Anterior al "
f"{export_date.strftime('%d/%m/%Y')}."
],
code="IMPORT_INVOICE_DATE_AFTER_EXPORT",
value={"import_date": str(imp_date), "export_date": str(export_date)},
)
continue
# ── 2. Validate import line ───────────────────────────────────────────
import_line = _fetch_import_line(
db,
import_invoice.id,
entry.import_line,
export_invoice.tenant_id,
export_invoice.company_id,
)
if import_line is None:
errors.add_error(
field=f"line[{entry.export_line}].import_line",
message=(
f"La Factura de Importación: '{entry.import_invoice}' "
f"con Línea: {entry.import_line} no existe."
),
solution=["Seleccionar otra Línea de Importación a Descargar."],
code="IMPORT_LINE_NOT_FOUND",
value={"import_invoice": entry.import_invoice, "import_line": entry.import_line},
)
continue
# ── 3. Compute net available balance from ledger (as of export date) ───
# Equivalent to: CantImpo - CantRetornadaTemp - CantRetornada (general)
# then CALCULA_SALDO_FECHA_EXPO (only exits on or before export_date).
available = _net_balance_for_lot(db, import_line.id, export_date)
if available <= 0:
# No balance — skip this lot (equivalent to Clarion CYCLE)
continue
# ── 4. Build AvailableLot and attach to entry ─────────────────────────
fin = import_line.financial
lot = AvailableLot(
import_item_line_id=import_line.id,
import_invoice_id=import_invoice.id,
part_number_id=import_line.part_number_id,
available_qty=available,
value_me=Decimal(str(fin.value_usd or 0)) if fin else None,
value_mn=Decimal(str(fin.value_mxn or 0)) if fin else None,
order_peps=_peps_order_for_lot(db, import_line.id),
)
entry.available_lots.append(lot)

View File

@@ -0,0 +1,284 @@
"""
finalize_invoice_no_discharge / finalize_invoice_with_discharge
(TERMINA_AC_O_LP_NODES / TERMINA_AC_O_LP_NORMAL)
Last step of export invoice processing. Both variants:
1. TODO: DO REVISACLASESHABILITADAS
2. Validate SisExp quantity / weight / value limits (min and max).
3. If no errors: assign invoice-level totals and mark as PROCESSED.
The "with_discharge" variant additionally:
4. DO GENERAIMPODEFINITIVA (if is_regime_change and generate_id)
5. DO REGISTRA_DESCARGA_IMPORTACION (update returned qty/value on import lines)
6. DO REGISTRA_DESCARGA_SERIES (flag import series as exported)
The legacy 'Of LP' branch (print-preview / progress-bar UI) is not ported.
"""
import datetime
from decimal import Decimal
from typing import TYPE_CHECKING, List
from sqlalchemy.orm import Session
from .register_import_discharge import register_import_discharge
from .register_discharge_series import register_discharge_series
from .register_discharge_ledger import register_discharge_ledger
if TYPE_CHECKING:
from .discharge_types import DownloadEntry
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
from .review_limits import limit_weight, limit_value
from .generate_definitive_import import generate_definitive_import
# ---------------------------------------------------------------------------
# REVISACLASESHABILITADAS
# ---------------------------------------------------------------------------
def _review_enabled_classes(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
errors: ErrorCollector,
) -> None:
"""
REVISACLASESHABILITADAS
Verifies that every line item's class is active (not disabled).
Clarion: loops QEqeMaq for the invoice, fetches QClaAct by class code,
errors if HabilitaDeshabilitaClase = 1 → Python: Class.is_active = False.
"""
for line in lines:
if not line.class_id:
continue
cls: Class | None = db.get(Class, line.class_id)
if cls is not None and cls.is_active is False:
errors.add_error(
field=f"line[{line.line_number}].class",
message=(
f"La Clase: '{cls.class_code}' esta desactivada, "
"no se pueden hacer movimientos."
),
solution=["Seleccionar una clase activa."],
code="CLASS_DISABLED",
)
# ---------------------------------------------------------------------------
# SisExp limit checks (shared by both public functions)
# ---------------------------------------------------------------------------
def _validate_sisexp_limits(
invoice: InvoiceHeader,
total_qty: Decimal,
total_net_weight: Decimal,
total_value: Decimal,
errors: ErrorCollector,
) -> None:
"""
Validates invoice totals against the SisExp min/max limit parameters.
TODO: Read actual SisExp parameters from the tenant system-config model.
Until then all limits default to 0 (= disabled) so no checks fire.
Clarion names → Python (TODO):
SisExp:CantLimiteMin / SisExp:CantLimite → qty min / max
SisExp:PesoLimiteMin / SisExp:PesoLimite → weight min / max
SisExp:ValorLimiteMin / SisExp:ValorLimite → value min / max
"""
# TODO: load from SisExp tenant config
cant_limite_min: Decimal = Decimal(0)
cant_limite: Decimal = Decimal(0)
peso_limite_min: Decimal = Decimal(0)
peso_limite: Decimal = Decimal(0)
valor_limite_min: Decimal = Decimal(0)
valor_limite: Decimal = Decimal(0)
solution = ["Consulte a su Administrador de sistema para parametrizar la factura."]
code = "PAR.EXPO"
if cant_limite_min != 0 and cant_limite_min > total_qty:
errors.add_error(
field="invoice.total_quantity",
message=(
f"La cantidad total de la factura: {total_qty} "
f"no supera a la cantidad mínima parametrizada: {cant_limite_min}."
),
solution=solution, code=code,
)
if cant_limite != 0 and cant_limite < total_qty:
errors.add_error(
field="invoice.total_quantity",
message=(
f"La cantidad total de la factura: {total_qty} "
f"excede a la cantidad máxima parametrizada: {cant_limite}."
),
solution=solution, code=code,
)
if peso_limite_min != 0 and peso_limite_min > total_net_weight:
errors.add_error(
field="invoice.net_weight",
message=(
f"El Peso Neto total de la factura: {total_net_weight} "
f"no supera el Peso mínimo parametrizado: {peso_limite_min}."
),
solution=solution, code=code,
)
if peso_limite != 0 and peso_limite < total_net_weight:
errors.add_error(
field="invoice.net_weight",
message=(
f"El Peso Neto total de la factura: {total_net_weight} "
f"excede el Peso máximo parametrizado: {peso_limite}."
),
solution=solution, code=code,
)
if valor_limite_min != 0 and valor_limite_min > total_value:
errors.add_error(
field="invoice.total_value",
message=(
f"El Valor total de la factura: {total_value} "
f"no supera el Valor mínimo parametrizado: {valor_limite_min}."
),
solution=solution, code=code,
)
if valor_limite != 0 and valor_limite < total_value:
errors.add_error(
field="invoice.total_value",
message=(
f"El Valor total de la factura: {total_value} "
f"excede el Valor máximo parametrizado: {valor_limite}."
),
solution=solution, code=code,
)
# ---------------------------------------------------------------------------
# DO ASIGNA_VALORES_FACTURA
# ---------------------------------------------------------------------------
def _assign_invoice_totals(
invoice: InvoiceHeader,
lines: List[LineItem],
) -> None:
"""
DO ASIGNA_VALORES_FACTURA
Aggregates line-level values (MN, ME, qty, packages, net/gross weight)
and writes the totals to the invoice header, then marks it as PROCESSED.
Clarion equivalent:
SELECT SUM(ValorExpoMN), SUM(ValorExpoME), SUM(CantExpo),
SUM(CantBultos), SUM(PesoNeto), SUM(PesoBruto)
FROM QEqeMaq WHERE Consecutivo = <invoice.id>
TODO: SisGen:CalValBaseTCPedExpo = 1 → invoice.financials.exchange_rate = Loc:TipoCambio
TODO: SisGen:CalValBaseTCPedExpo = 1 →
invoice.process_log = 'Se Actualizó con el Tipo de Cambio de la Fecha de Pago de Pedimento.'
TODO: SisGen:ActSeguridad = 1 → invoice.updated_by = current_user
"""
total_value_mn = Decimal(0)
total_value_me = Decimal(0)
total_qty = Decimal(0)
total_packages = 0
total_net_weight = Decimal(0)
total_gross_weight = Decimal(0)
for line in lines:
if line.financial:
total_value_mn += Decimal(str(line.financial.value_mxn or 0))
total_value_me += Decimal(str(line.financial.value_usd or 0))
if line.quantity:
total_qty += line.quantity.quantity or Decimal(0)
total_packages += line.quantity.package_quantity or 0
total_net_weight += line.quantity.net_weight or Decimal(0)
total_gross_weight += line.quantity.gross_weight or Decimal(0)
if invoice.financials is not None:
invoice.financials.value_mn = float(total_value_mn)
invoice.financials.value_me = float(total_value_me)
invoice.financials.total_quantity = float(total_qty)
invoice.financials.total_packages = total_packages
invoice.financials.net_weight = float(total_net_weight)
invoice.financials.gross_weight = float(total_gross_weight)
invoice.party_count = len([l for l in lines if not (l.fa_data and l.fa_data.is_subitem)])
invoice.updated_date = datetime.date.today()
invoice.status = InvoiceStatus.PROCESSED
# ---------------------------------------------------------------------------
# Public entry points
# ---------------------------------------------------------------------------
def finalize_invoice_no_discharge(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
errors: ErrorCollector,
) -> None:
"""
TERMINA_AC_O_LP_NODES
Finalizes a NODES-type export invoice (no inventory discharge).
Flow:
1. Verify all line classes are active (REVISACLASESHABILITADAS).
2. Validate SisExp limits (qty / weight / value).
3. If no errors: write invoice totals and set status = PROCESSED.
"""
_review_enabled_classes(db, invoice, lines, errors)
total_qty, total_net_weight = limit_weight(lines)
total_value = limit_value(lines)
_validate_sisexp_limits(invoice, total_qty, total_net_weight, total_value, errors)
if not errors.has_errors():
_assign_invoice_totals(invoice, lines)
def finalize_invoice_with_discharge(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
errors: ErrorCollector,
to_discharge: List["DownloadEntry"] | None = None,
) -> None:
"""
TERMINA_AC_O_LP_NORMAL
Finalizes a discharge-type export invoice (AFIJO / DONAC / SCRAP / REEXP / VEMEX).
Flow:
1. Verify all line classes are active (REVISACLASESHABILITADAS).
2. Validate SisExp limits (qty / weight / value).
3. If no errors:
a. DO GENERAIMPODEFINITIVA (only if is_regime_change and generate_id)
b. TODO: DO REGISTRA_DESCARGA_IMPORTACION (write a24 discharge movements)
c. TODO: DO REGISTRA_DESCARGA_SERIES (write series discharge records)
d. Write invoice totals and set status = PROCESSED.
Note: the legacy 'Of LP' branch (print-preview UI) is not ported.
"""
_review_enabled_classes(db, invoice, lines, errors)
total_qty, total_net_weight = limit_weight(lines)
total_value = limit_value(lines)
_validate_sisexp_limits(invoice, total_qty, total_net_weight, total_value, errors)
if not errors.has_errors():
if invoice.compliance_mx and invoice.compliance_mx.is_regime_change and invoice.generate_id:
generate_definitive_import(db, invoice, errors)
if to_discharge:
# Write BalanceMovement (CONSUMPTION) + DischargeHeader + DischargeDetail
register_discharge_ledger(db, invoice, to_discharge)
# Update quantity_returned / value_returned on the import lines
register_import_discharge(db, invoice, to_discharge)
register_discharge_series(db, invoice, to_discharge)
_assign_invoice_totals(invoice, lines)

View File

@@ -0,0 +1,389 @@
"""
GENERAIMPODEFINITIVA
Generates a definitive import invoice header from the export invoice when
processing a regime-change (cambio de régimen) export.
If an invoice with the same number already exists as a definitive import,
the step is skipped (idempotent). After creating the header the function
calls the appropriate lines sub-routine based on ``generate_desc_parties``:
'Todas' → generate_definitive_import_all_lines
other → generate_definitive_import_discharged_lines
Legacy equivalent
-----------------
GENERAIMPODEFINITIVA Routine
Access:QFacImpDef.TryFetch(EqiFID:FKFacImpoDef)
If ErrorCode() = 35 Then ← not found → create
INSERT INTO QFacImpDef (...)
End
IF EqiFex:GenPartidas = 'Todas' THEN
DO GENERAIMPODEFINITIVA_PARTIDAS_TODAS
ELSE
DO GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA
END
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus, OperationType
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
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.series.models import Serie
from core.exceptions import ErrorCollector
from .discharge_types import DownloadEntry
# ---------------------------------------------------------------------------
# Header creation
# ---------------------------------------------------------------------------
def _create_definitive_import_header(
db: Session,
export_invoice: InvoiceHeader,
) -> InvoiceHeader:
"""
Inserts a new InvoiceHeader of type 'IMD' (Importación Definitiva) cloning
the relevant fields from the export invoice.
Clarion field mapping (EqiFex → EqiFID):
FacturaExpo → invoice_number
FechaFactura → invoice_date / updated_date
TipoCambio → financials.exchange_rate
TipoPeso → logistics.weight_type
Proveedor → provider_id
VendidoConsignado → sold_to_header ('Vendido a:')
VendidoA → sold_to_id
EnviadoTransferido → shipped_to_header ('Enviado a:')
EnviadoA → shipped_to_id
AAduanal → customs_broker_id
AAduanalAme → customs_broker_us_id
Aduana_Cruce → compliance_mx.aduana
Cant_Partidas → party_count
Transportista → logistics.carrier_id (approx)
Incoterm → logistics.incoterm
Precinto → logistics.precinto (approx)
SubEmpresa → sub_company (approx)
Flete / Seguros / etc. → financials.*
Observaciones → notes
TipoMoneda → financials.currency_type
ClaveMoneda → financials.currency (approx)
ModTrans → logistics.transport_mode (approx)
Ped_Pendiente_Asignar → compliance_mx.is_pedimento_pending
PedimentoExpo → compliance_mx.pedimento_id (approx)
Remesa → compliance_mx.remesa
Estatus → status = PENDING ('NA')
TipoDoc → invoice_type = 'IMD'
ProvImpoDefCR → 'C' (fixed — always definitiva por cambio de régimen)
Sujecion → 'MaqEquipo' (fixed)
"""
exp = export_invoice
exp_fin = exp.financials
exp_log = exp.logistics
exp_comp = exp.compliance_mx
def_invoice = InvoiceHeader(
tenant_id=exp.tenant_id,
company_id=exp.company_id,
system=exp.system,
operation_type=OperationType.IMPORT,
invoice_type="IMD",
invoice_number=exp.invoice_number,
invoice_date=exp.invoice_date,
updated_date=exp.invoice_date,
party_count=exp.party_count,
generate_id=False,
status=InvoiceStatus.PENDING,
# Clients / providers
provider_id=exp.provider_id,
sold_to_header="Vendido a:",
sold_to_id=exp.sold_to_id,
shipped_to_header="Enviado a:",
shipped_to_id=exp.shipped_to_id,
customs_broker_id=exp.customs_broker_id,
customs_broker_us_id=exp.customs_broker_us_id,
# Notes
notes=exp.notes,
notes_english=exp.notes_english,
)
db.add(def_invoice)
db.flush() # get def_invoice.id before creating child records
# ── Financials ────────────────────────────────────────────────────────
if exp_fin is not None:
from api.v1.modules.a76.invoices.models import InvoiceFinancials
def_fin = InvoiceFinancials(
tenant_id=exp.tenant_id,
company_id=exp.company_id,
invoice_id=def_invoice.id,
currency=exp_fin.currency,
currency_type=exp_fin.currency_type,
exchange_rate=exp_fin.exchange_rate,
freight=exp_fin.freight,
insurance=exp_fin.insurance,
insurance_value=exp_fin.insurance_value,
packaging=exp_fin.packaging,
other_increments=exp_fin.other_increments,
)
db.add(def_fin)
# ── Compliance / pedimento ────────────────────────────────────────────
if exp_comp is not None:
from api.v1.modules.a76.invoices.models import InvoiceComplianceMX
def_comp = InvoiceComplianceMX(
tenant_id=exp.tenant_id,
company_id=exp.company_id,
invoice_id=def_invoice.id,
aduana=exp_comp.aduana,
remesa=exp_comp.remesa,
pedimento_id=exp_comp.pedimento_id,
is_pedimento_pending=exp_comp.is_pedimento_pending,
)
db.add(def_comp)
# ── Logistics ─────────────────────────────────────────────────────────
if exp_log is not None:
from api.v1.modules.a76.invoices.models import InvoiceLogistics
def_log = InvoiceLogistics(
tenant_id=exp.tenant_id,
company_id=exp.company_id,
invoice_id=def_invoice.id,
weight_type=exp_log.weight_type,
incoterm=exp_log.incoterm,
)
db.add(def_log)
db.flush()
return def_invoice
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def generate_definitive_import(
db: Session,
invoice: InvoiceHeader,
errors: ErrorCollector,
) -> InvoiceHeader | None:
"""
GENERAIMPODEFINITIVA
Creates a definitive import invoice header from ``invoice`` (export) when
processing a regime-change export.
If a definitive import with the same ``invoice_number`` already exists,
the function is a no-op and returns the existing record.
After creating (or finding) the header, delegates to the lines sub-routine:
generate_desc_parties == 'Todas' → TODO: GENERAIMPODEFINITIVA_PARTIDAS_TODAS
otherwise → TODO: GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA
Parameters
----------
db : active SQLAlchemy session
invoice : the export invoice being processed (regime-change type)
errors : shared error collector
Returns
-------
The existing or newly created definitive import InvoiceHeader, or None if
a non-blocking SQL error occurred.
"""
# ── 1. Check whether the definitive import already exists ────────────────
existing: InvoiceHeader | None = db.execute(
select(InvoiceHeader).where(
InvoiceHeader.tenant_id == invoice.tenant_id,
InvoiceHeader.company_id == invoice.company_id,
InvoiceHeader.invoice_number == invoice.invoice_number,
InvoiceHeader.invoice_type == "IMD",
)
).scalar_one_or_none()
if existing is not None:
def_invoice = existing
else:
# ── 2. Create the definitiva header ──────────────────────────────────
def_invoice = _create_definitive_import_header(db, invoice)
# ── 3. Generate lines ────────────────────────────────────────────────────
# to_discharge / all_lines must be passed by the caller after this returns.
# See generate_definitive_import_all_lines() and
# generate_definitive_import_discharged_lines() below.
return def_invoice
# ---------------------------------------------------------------------------
# Shared helper — copies one export line into the definitive import invoice
# (EqiPex → EqiPdf, identical body in both PARTIDAS_CON_DESCARGA and
# PARTIDAS_TODAS Clarion routines)
# ---------------------------------------------------------------------------
def _copy_line_to_definitive(
db: Session,
export_line: LineItem,
def_invoice: InvoiceHeader,
def_line_number: int,
) -> None:
"""
Copies a single export LineItem (and its series) into a new definitive
import LineItem under ``def_invoice``.
Clarion fixed values:
EsSubPartida = 'P' → is_subitem = False
ContieneSubP = 'N' → contains_subitems = False
SubPartida = 0 → subitem_number = 0
EsReparacion = 0 → (no repair flag needed)
"""
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
def_line = LineItem(
tenant_id=def_invoice.tenant_id,
company_id=def_invoice.company_id,
invoice_id=def_invoice.id,
line_number=def_line_number,
part_number_id=export_line.part_number_id,
class_id=export_line.class_id,
unit_of_measure=export_line.unit_of_measure,
)
db.add(def_line)
db.flush() # get def_line.id
if export_line.quantity:
src_q = export_line.quantity
db.add(LineQuantity(
item_line_id=def_line.id,
quantity=src_q.quantity,
net_weight=src_q.net_weight,
gross_weight=src_q.gross_weight,
package_quantity=src_q.package_quantity,
package_id=src_q.package_id,
))
if export_line.financial:
db.add(LineFinancial(
item_line_id=def_line.id,
unit_cost_capture=export_line.financial.unit_cost_capture,
))
if export_line.customs:
src_c = export_line.customs
db.add(LineCustom(
item_line_id=def_line.id,
fraction=src_c.fraction,
fraction_type=src_c.fraction_type,
rate=src_c.rate,
sector=src_c.sector,
origin_country=src_c.origin_country,
))
if export_line.description:
src_d = export_line.description
db.add(LineDescription(
item_line_id=def_line.id,
description_spanish=src_d.description_spanish,
extra_description=src_d.extra_description,
description_english=src_d.description_english,
package_description=src_d.package_description,
brand=src_d.brand,
model=src_d.model,
has_serial=src_d.has_serial,
))
db.add(FaLineItem(
item_line_id=def_line.id,
is_subitem=False,
contains_subitems=False,
subitem_number=0,
))
# Series: QSeriesExpo → QSeriesDef
export_series: list[Serie] = (
db.execute(select(Serie).where(Serie.line_item_id == export_line.id))
.scalars()
.all()
)
for serie in export_series:
db.add(Serie(
tenant_id=def_invoice.tenant_id,
company_id=def_invoice.company_id,
line_item_id=def_line.id,
row=serie.row,
serial_numbers=serie.serial_numbers, # SerieImpo ← SerieExpo
model=serie.model, # ModeloImpo ← ModeloExpo
brand=serie.brand, # ParteImpo ← ParteExpo
))
# ---------------------------------------------------------------------------
# GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA
# ---------------------------------------------------------------------------
def generate_definitive_import_discharged_lines(
db: Session,
export_invoice: InvoiceHeader,
def_invoice: InvoiceHeader,
to_discharge: list[DownloadEntry],
errors: ErrorCollector,
) -> None:
"""
GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA
Creates definitive import lines only for the lines in the discharge list,
sorted by (import_invoice, import_line).
Clarion: Sort(QueADescargar, FacturaImpo, LineaImpo) → loop
"""
sorted_entries = sorted(
to_discharge,
key=lambda e: (e.import_invoice, e.import_line),
)
def_line_number = 0
for entry in sorted_entries:
export_line: LineItem | None = db.get(LineItem, entry.line_item_id)
if export_line is None:
continue
def_line_number += 1
_copy_line_to_definitive(db, export_line, def_invoice, def_line_number)
db.flush()
# ---------------------------------------------------------------------------
# GENERAIMPODEFINITIVA_PARTIDAS_TODAS
# ---------------------------------------------------------------------------
def generate_definitive_import_all_lines(
db: Session,
export_invoice: InvoiceHeader,
def_invoice: InvoiceHeader,
errors: ErrorCollector,
) -> None:
"""
GENERAIMPODEFINITIVA_PARTIDAS_TODAS
Creates definitive import lines for ALL lines of the export invoice,
sorted by line_number (QueuePartidaID sorted by LineaExpo).
Clarion: Sort(QueuePartidaID, LineaExpo) → loop over all export lines
"""
export_lines: list[LineItem] = (
db.execute(
select(LineItem)
.where(LineItem.invoice_id == export_invoice.id)
.order_by(LineItem.line_number)
)
.scalars()
.all()
)
def_line_number = 0
for export_line in export_lines:
def_line_number += 1
_copy_line_to_definitive(db, export_line, def_invoice, def_line_number)
db.flush()

View File

@@ -0,0 +1,239 @@
"""
register_discharge_ledger
=========================
Creates the full Annex-24 discharge record for one export invoice:
1. ONE DischargeHeader (one per export event)
2. N BalanceMovement rows (type=CONSUMPTION, one per lot consumed)
3. N DischargeDetail rows (one per export-line × import-lot pair),
each referencing its BalanceMovement (design rule 3)
Design rules from a24.balance_movement (preserved here):
1. NEVER update existing balance_movement rows — only INSERT.
2. Balance = SUM of movements.
3. Every DischargeDetail.movement_id MUST reference a BalanceMovement row.
4. order_peps = movement.id (set after flush, globally monotonic).
"""
import datetime
import logging
from decimal import Decimal
from typing import List, Optional
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a24.balance_movements.models import BalanceMovement, MovementType
from api.v1.modules.a24.discharges.models import (
DischargeDetail,
DischargeHeader,
DischargeStatus,
DischargeType,
)
from api.v1.modules.a76.invoices.models import InvoiceHeader as A76InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from .discharge_types import DownloadEntry, AvailableLot
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _discharge_type_for_invoice(invoice: InvoiceHeader) -> DischargeType:
mapping = {
"AFIJO": DischargeType.TEMPORARY,
"DONAC": DischargeType.TEMPORARY,
"SCRAP": DischargeType.WASTE_SCRAP,
"REEXP": DischargeType.DEFINITIVE,
"VEMEX": DischargeType.DEFINITIVE,
}
return mapping.get(invoice.invoice_type or "", DischargeType.TEMPORARY)
def _export_date(invoice: InvoiceHeader) -> datetime.date:
d = invoice.invoice_date
return d.date() if hasattr(d, "date") else d
def _proportional_value(
consume: Decimal,
lot_consumed_total: Decimal,
lot_value: Optional[Decimal],
) -> Optional[Decimal]:
"""Returns the proportional value for *consume* units out of *lot_consumed_total*."""
if not lot_value or lot_consumed_total <= 0:
return None
return (consume / lot_consumed_total) * lot_value
def _proportional_qty(consume: Decimal, base_qty: Optional[Decimal], base_total: Optional[Decimal]) -> Optional[Decimal]:
"""
Proratea un valor (peso/valor) en proporción a lo consumido.
- consume: cantidad consumida del lote
- base_qty: valor total del lote (ej. peso neto total del lote)
- base_total: cantidad total del lote (ej. quantity del lote)
"""
if base_qty is None:
return None
if base_total is None or base_total <= 0:
return None
return (consume / base_total) * base_qty
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def register_discharge_ledger(
db: Session,
export_invoice: InvoiceHeader,
to_discharge: List[DownloadEntry],
) -> Optional[DischargeHeader]:
"""
Persists the complete Annex-24 discharge record for *export_invoice*.
Expects that compare_balances() has already run and populated
``lot.consumed_qty`` for every lot that was drawn from.
Returns the created DischargeHeader, or None if nothing was discharged.
"""
# Only process entries that actually consumed something
active = [e for e in to_discharge if e.quantity_used > Decimal(0)]
if not active:
return None
op_date = _export_date(export_invoice)
discharge_type = _discharge_type_for_invoice(export_invoice)
# Caches to avoid N+1 queries in loops
export_line_cache: dict[int, LineItem] = {}
import_line_cache: dict[int, LineItem] = {}
import_invoice_number_cache: dict[int, str] = {}
# ── 1. DischargeHeader ────────────────────────────────────────────────
header = DischargeHeader(
tenant_id=export_invoice.tenant_id,
company_id=export_invoice.company_id,
source_invoice_id=export_invoice.id,
discharge_type=discharge_type,
status=DischargeStatus.APPLIED,
discharge_date=op_date,
)
db.add(header)
db.flush() # get header.id
total_movements = 0
for entry in active:
export_line_id: Optional[int] = entry.line_item_id
export_line_obj: Optional[LineItem] = None
if export_line_id:
export_line_obj = export_line_cache.get(export_line_id)
if export_line_obj is None:
export_line_obj = db.get(LineItem, export_line_id)
if export_line_obj is not None:
export_line_cache[export_line_id] = export_line_obj
# Only iterate lots that were actually consumed
consumed_lots: List[AvailableLot] = [
lot for lot in entry.available_lots if lot.consumed_qty > Decimal(0)
]
for lot in consumed_lots:
consume = lot.consumed_qty
# Load import-line object for denormalized customs/weights fields
import_line_obj = import_line_cache.get(lot.import_item_line_id)
if import_line_obj is None:
import_line_obj = db.get(LineItem, lot.import_item_line_id)
if import_line_obj is not None:
import_line_cache[lot.import_item_line_id] = import_line_obj
# Import invoice number (for origin_import_invoice in DischargeDetail)
origin_import_invoice: Optional[str] = None
if lot.import_invoice_id:
origin_import_invoice = import_invoice_number_cache.get(lot.import_invoice_id)
if origin_import_invoice is None:
inv = db.get(A76InvoiceHeader, lot.import_invoice_id)
origin_import_invoice = inv.invoice_number if inv else None
if origin_import_invoice:
import_invoice_number_cache[lot.import_invoice_id] = origin_import_invoice
# ── 2. BalanceMovement (CONSUMPTION) ──────────────────────────
# Proportional value: consume / lot_consumed_total × lot_value
# lot_consumed_total == consume for single-lot entries (most cases)
value_me = _proportional_value(consume, consume, lot.value_me)
value_mn = _proportional_value(consume, consume, lot.value_mn)
movement = BalanceMovement(
tenant_id=export_invoice.tenant_id,
company_id=export_invoice.company_id,
import_invoice_id=lot.import_invoice_id,
import_item_line_id=lot.import_item_line_id,
part_number_id=lot.part_number_id,
movement_type=MovementType.CONSUMPTION,
quantity=consume,
value_me=value_me,
value_mn=value_mn,
source_invoice_id=export_invoice.id,
source_item_line_id=export_line_id,
order_peps=0, # placeholder — set after flush (rule 4)
operation_date=op_date,
notes=(
f"Descarga por factura de exportación "
f"{export_invoice.invoice_number}"
),
)
db.add(movement)
db.flush() # get movement.id
movement.order_peps = movement.id # rule 4: monotonic
# ── 3. DischargeDetail ─────────────────────────────────────────
# Denormalized fields expected by reports:
imp_cust = import_line_obj.customs if import_line_obj else None
imp_qty = import_line_obj.quantity if import_line_obj else None
imp_total_qty = imp_qty.quantity if imp_qty else None
net_weight = _proportional_qty(consume, imp_qty.net_weight if imp_qty else None, imp_total_qty)
gross_weight = _proportional_qty(consume, imp_qty.gross_weight if imp_qty else None, imp_total_qty)
detail = DischargeDetail(
tenant_id=export_invoice.tenant_id,
company_id=export_invoice.company_id,
discharge_header_id=header.id,
export_item_line_id=export_line_id,
import_item_line_id=lot.import_item_line_id,
movement_id=movement.id,
quantity_discharged=consume,
unit_of_measure=entry.unit_of_measure or None,
value_me=value_me,
value_mn=value_mn,
net_weight=net_weight,
gross_weight=gross_weight,
tariff_fraction=imp_cust.fraction if imp_cust else None,
fraction_type=imp_cust.fraction_type if imp_cust else None,
ad_valorem=imp_cust.advalorem if imp_cust else None,
country_of_origin=imp_cust.origin_country if imp_cust else None,
sector=imp_cust.sector if imp_cust else None,
procedence=entry.origin_procedure or None,
part_number=entry.part_number or None,
export_part_number=(
export_line_obj.part_info.part_number
if export_line_obj and export_line_obj.part_info and export_line_obj.part_info.part_number
else None
),
origin_import_invoice=origin_import_invoice,
)
db.add(detail)
total_movements += 1
logger.info(
"register_discharge_ledger: invoice=%s header_id=%s details=%d",
export_invoice.invoice_number,
header.id,
total_movements,
)
return header

View File

@@ -0,0 +1,181 @@
"""
REGISTRA_DESCARGA_SERIES
Marks each import serie that is being discharged by this export invoice by
setting ``discharge = True`` (Clarion: ``SerImp:SerieExpo = 1``).
The routine iterates over every export serie with ``discharge = True``,
resolves the corresponding import serie (via ``import_serie_row`` or serial
number lookup), and flags it as exported so it cannot be discharged again.
Clarion mapping
---------------
QueueSeries records (SerDes) → export Serie rows with discharge=True,
grouped by DownloadEntry
SerDes:ConsectivoImpo → import InvoiceHeader.id (via invoice_number)
SerDes:LineaImpo → import LineItem.line_number
SerDes:Renglon → Serie.row on the import side
SerDes:Procedencia → entry.origin_procedure ('TEM' | 'DEF')
SerImp/SerDef:SerieExpo = 1 → import_serie.discharge = True
Legacy equivalent
-----------------
Sort(QueueSeries, -Procedencia, ConsectivoImpo, LineaImpo)
Loop: GET import serie by (Consecutivo, LineaImpo, Renglon) → set SerieExpo=1
"""
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.series.models import Serie
from .discharge_types import DownloadEntry
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
def _fetch_import_invoice(
db: Session,
invoice_number: str,
export_invoice: InvoiceHeader,
) -> Optional[InvoiceHeader]:
return (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.invoice_number == invoice_number,
InvoiceHeader.tenant_id == export_invoice.tenant_id,
InvoiceHeader.company_id == export_invoice.company_id,
)
.first()
)
def _fetch_import_line_id(
db: Session,
invoice_id: int,
line_number: int,
tenant_id: int,
company_id: int,
) -> Optional[int]:
return db.execute(
select(LineItem.id).where(
LineItem.invoice_id == invoice_id,
LineItem.line_number == line_number,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
).scalar_one_or_none()
def _fetch_import_serie(
db: Session,
import_line_id: int,
row: int,
) -> Optional[Serie]:
"""Fetch the import serie by (line_item_id, row) — equiv. TryFetch PKConsec_Lin_Ren."""
return db.execute(
select(Serie).where(
Serie.line_item_id == import_line_id,
Serie.row == row,
)
).scalar_one_or_none()
def _resolve_import_serie_row(
db: Session,
import_line_id: int,
serial_number: str,
) -> Optional[int]:
"""
Fallback: find the import serie row by matching serial_number when
export_serie.serie_row is not set.
"""
return db.execute(
select(Serie.row).where(
Serie.line_item_id == import_line_id,
Serie.serial_numbers == serial_number,
)
).scalar_one_or_none()
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def register_discharge_series(
db: Session,
export_invoice: InvoiceHeader,
to_discharge: List[DownloadEntry],
) -> None:
"""
REGISTRA_DESCARGA_SERIES
For every export serie with ``discharge = True`` on each entry in
``to_discharge``, locates the matching import serie and marks it as
discharged (``discharge = True``).
Parameters
----------
db : active SQLAlchemy session
export_invoice : the export invoice being processed
to_discharge : list of DownloadEntry records (QueADescargar)
"""
# Sort mirrors Clarion: Sort(QueueSeries, -Procedencia, ConsectivoImpo, LineaImpo)
# Descending procedencia puts 'TEM' before 'DEF' (T > D alphabetically)
sorted_entries = sorted(
to_discharge,
key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line),
reverse=True,
)
for entry in sorted_entries:
if not entry.import_invoice or entry.import_line == 0:
continue
# ── Resolve import invoice and line ───────────────────────────────────
import_invoice = _fetch_import_invoice(db, entry.import_invoice, export_invoice)
if import_invoice is None:
continue
import_line_id = _fetch_import_line_id(
db,
import_invoice.id,
entry.import_line,
export_invoice.tenant_id,
export_invoice.company_id,
)
if import_line_id is None:
continue
# ── Fetch all export series for this discharge line ───────────────────
export_series: List[Serie] = (
db.execute(
select(Serie).where(
Serie.line_item_id == entry.line_item_id,
Serie.discharge == True, # noqa: E712
)
)
.scalars()
.all()
)
for export_serie in export_series:
# Resolve which row in the import series table this corresponds to
import_row = export_serie.serie_row
if import_row is None:
import_row = _resolve_import_serie_row(
db, import_line_id, export_serie.serial_numbers or ""
)
if import_row is None:
continue
import_serie = _fetch_import_serie(db, import_line_id, import_row)
if import_serie is None:
continue
# SerImp:SerieExpo = 1 (or SerDef:SerieExpo = 1)
import_serie.discharge = True

View File

@@ -0,0 +1,173 @@
"""
REGISTRA_DESCARGA_IMPORTACION
Updates the import line items (temporary or definitive) with the returned
quantities and values consumed by this export invoice.
For each entry in ``to_discharge`` (QSaldoActual in the legacy) the routine:
· Looks up the source import invoice header (TEM → QFacImp, DEF → QFacImpDef).
· Looks up the corresponding import line item.
· Increments quantity_returned, value_returned_mxn, value_returned_usd on the
import line's quantity/financial sub-records.
· For TEM invoices, also calculates vat_used_mxn / vat_used_usd when the
import invoice date is on or after 2014-12-31 (Clarion date 78165).
Legacy equivalent
-----------------
Loop QSaldoActual:
If TEM → fetch QFacImp + QEqiMaq, update CantRetornada, ValorRetornadoMN/ME,
ValorIVAMNUsado / ValorIVAMEUsado
Else → fetch QFacImpDef + QEqiDef, update CantRetornada, ValorRetornadoMN/ME
"""
import datetime
from decimal import Decimal
from typing import List, Optional
from sqlalchemy.orm import Session, joinedload
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from .discharge_types import DownloadEntry
# Cutoff date: Clarion day 78165 ≈ 2014-12-31
_VAT_CUTOFF = datetime.date(2014, 12, 31)
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
def _fetch_import_invoice(
db: Session,
invoice_number: str,
export_invoice: InvoiceHeader,
) -> Optional[InvoiceHeader]:
"""Return the import InvoiceHeader that matches *invoice_number*."""
return (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.invoice_number == invoice_number,
InvoiceHeader.tenant_id == export_invoice.tenant_id,
InvoiceHeader.company_id == export_invoice.company_id,
)
.first()
)
def _fetch_import_line(
db: Session,
invoice_id: int,
line_number: int,
tenant_id: int,
company_id: int,
) -> Optional[LineItem]:
"""Return the LineItem for *invoice_id* / *line_number*, with financial and quantity loaded."""
return (
db.query(LineItem)
.options(
joinedload(LineItem.quantity),
joinedload(LineItem.financial),
)
.filter(
LineItem.invoice_id == invoice_id,
LineItem.line_number == line_number,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.first()
)
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def register_import_discharge(
db: Session,
export_invoice: InvoiceHeader,
to_discharge: List[DownloadEntry],
) -> None:
"""
REGISTRA_DESCARGA_IMPORTACION
Accumulates discharged quantities and values back onto the source import
line items (temporal or definitive).
Parameters
----------
db : active SQLAlchemy session
export_invoice : the export invoice being processed
to_discharge : list of DownloadEntry records (QSaldoActual equivalent);
each entry's ``quantity`` holds the amount consumed
(QSaldo:CantUsada in the legacy).
"""
# Sort mirrors the Clarion: Sort(QSaldoActual, -Procedencia, FacturaImpo, LineaImpo)
# (descending procedencia puts 'TEM' before 'DEF' alphabetically reversed)
sorted_entries = sorted(
to_discharge,
key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line),
reverse=False,
)
for entry in sorted_entries:
qty_used = entry.quantity # CantUsada — full entry qty consumed
if not entry.import_invoice or entry.import_line == 0:
continue
# ── Fetch source import invoice header ───────────────────────────────
import_invoice = _fetch_import_invoice(db, entry.import_invoice, export_invoice)
if import_invoice is None:
continue
# ── Fetch source import line item ─────────────────────────────────────
import_line = _fetch_import_line(
db,
import_invoice.id,
entry.import_line,
export_invoice.tenant_id,
export_invoice.company_id,
)
if import_line is None:
continue
# ── Calculate proportional values ─────────────────────────────────────
# value_returned = qty_used * (line_value / line_qty)
fin = import_line.financial
qty_rec = import_line.quantity
if fin is None or qty_rec is None:
continue
original_qty = qty_rec.quantity or Decimal(0)
if original_qty == 0:
continue
value_mn = Decimal(str(fin.value_mxn or 0))
value_usd = Decimal(str(fin.value_usd or 0))
returned_mn = qty_used * value_mn / original_qty
returned_usd = qty_used * value_usd / original_qty
# ── Accumulate returned qty and value ─────────────────────────────────
qty_rec.quantity_returned = (qty_rec.quantity_returned or Decimal(0)) + qty_used
fin.value_returned_mxn = (fin.value_returned_mxn or Decimal(0)) + returned_mn
fin.value_returned_usd = (fin.value_returned_usd or Decimal(0)) + returned_usd
# ── VAT used (TEM only, and only for invoices on/after cutoff date) ───
# Clarion: IF EqiFim:FechaFactura > 78165 (≈ 2014-12-31)
if entry.origin_procedure == "TEM":
inv_date = import_invoice.invoice_date
if isinstance(inv_date, datetime.datetime):
inv_date = inv_date.date()
if inv_date and inv_date >= _VAT_CUTOFF:
iva_factor = Decimal(0)
if import_invoice.financials and import_invoice.financials.iva_factor:
iva_factor = Decimal(str(import_invoice.financials.iva_factor))
fin.vat_used_mxn = (returned_mn * iva_factor) / 100
fin.vat_used_usd = (returned_usd * iva_factor) / 100
else:
fin.vat_used_mxn = Decimal(0)
fin.vat_used_usd = Decimal(0)

View File

@@ -0,0 +1,181 @@
"""
REVISA_CLASE
Validates that every line item of an export invoice has a valid class in the
catalog (QClaAct) and that the associated tariff fraction exists in either
the active fractions catalog (SFracciones) or the historical catalog
(GFraccionesHistorico).
Two-pass logic (ported from legacy SCAII):
Pass A no class errors:
Iterate all lines and validate their fractions.
Pass B class errors detected:
Report each missing class and also validate its fraction.
"""
from typing import List
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import (
HistoricalTariffFraction,
)
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
# ---------------------------------------------------------------------------
# Fraction helpers (shared with imports, same catalog sources)
# ---------------------------------------------------------------------------
def _fraction_exists_via_sitar(fraction_code: str) -> bool:
"""
Returns True when the fraction exists in SITAR (active or anterior).
Falls back to False when SITAR is not configured or unreachable.
Format: first 8 chars = base fraction, chars 9-10 = NICO/country (optional).
"""
if not fraction_code:
return True
base_frac = fraction_code[:8].strip()
nico = fraction_code[8:10].strip() if len(fraction_code) > 8 else ""
try:
from api.v1.modules.sitar.fracciones.service import FraccionesService
from api.v1.modules.sitar.fracciones_anteriores.service import (
FraccionesAnterioresService,
)
results = FraccionesService.search_sync(
fraccion=base_frac,
nico=nico if nico else None,
limit=1,
)
if results:
return True
hist_results = FraccionesAnterioresService.search_sync(
fraccion_anterior=base_frac,
limit=1,
)
return len(hist_results) > 0
except Exception:
return False
def _fraction_exists_in_local_db(db: Session, fraction_code: str) -> bool:
"""Fallback: validates against local TariffFraction and HistoricalTariffFraction tables."""
if not fraction_code:
return True
base_frac = fraction_code[:8]
nico = fraction_code[8:10] if len(fraction_code) > 8 else ""
tariff_q = db.query(TariffFraction).filter(
func.left(TariffFraction.code, 8) == base_frac
)
if nico:
tariff_q = tariff_q.filter(TariffFraction.nico == nico)
else:
tariff_q = tariff_q.filter(
or_(TariffFraction.nico.is_(None), TariffFraction.nico == "")
)
if tariff_q.first() is not None:
return True
hist_q = db.query(HistoricalTariffFraction).filter(
HistoricalTariffFraction.historical_fraction == base_frac
)
if nico:
hist_q = hist_q.filter(HistoricalTariffFraction.country == nico)
else:
hist_q = hist_q.filter(
or_(
HistoricalTariffFraction.country.is_(None),
HistoricalTariffFraction.country == "",
)
)
return hist_q.first() is not None
def _fraction_exists_in_catalog(db: Session, fraction_code: str) -> bool:
"""SITAR first, local DB as fallback."""
if _fraction_exists_via_sitar(fraction_code):
return True
return _fraction_exists_in_local_db(db, fraction_code)
def _validate_line_fraction(
db: Session,
line: LineItem,
errors: ErrorCollector,
) -> None:
"""Adds a FRACCION error when the line's export fraction is not in any catalog."""
fraction = line.customs.fraction if line.customs else None
if not fraction:
return
if _fraction_exists_in_catalog(db, fraction):
return
class_code = line.class_info.class_code if line.class_info else ""
errors.add_error(
field=f"line[{line.line_number}].fraction",
message=(
f"La Factura contiene la fraccion: {fraction} asociada al Clase {class_code} "
"que no existe en el catálogo de fracciones"
),
solution=["Agregar la fracción a fracciones históricas."],
code="FRACCION",
)
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def review_class(
db: Session,
lines: List[LineItem],
errors: ErrorCollector,
) -> None:
"""
Validates class and fraction integrity for all line items of an export invoice.
Logic ported from legacy REVISA_CLASE:
1. Count line items whose class_id is not present in the active classes catalog.
2a. No class errors (TotalReg = 0):
Iterate every line and validate its tariff fraction.
2b. Class errors found (TotalReg > 0):
For each invalid line: report a CLASE error, then validate its fraction.
"""
invalid_class_lines = [
line for line in lines
if line.class_id is None or db.get(Class, line.class_id) is None
]
has_class_errors = bool(invalid_class_lines)
if not has_class_errors:
# Pass A: all classes exist — validate fractions for every line
for line in lines:
_validate_line_fraction(db, line, errors)
else:
# Pass B: report missing classes and validate their fractions
for line in invalid_class_lines:
class_code = line.class_info.class_code if line.class_info else ""
errors.add_error(
field=f"line[{line.line_number}].class",
message=f"La clase: {class_code or '(vacía)'} no existe en catálogo de clases",
solution=[
f"Borrar la partida: {line.line_number}, "
f"o dar de alta la clase: {class_code or '(vacía)'} en el catálogo de Clases"
],
code="CLASE",
)
_validate_line_fraction(db, line, errors)

View File

@@ -0,0 +1,91 @@
"""
REVISA_TIPOCAMBIO
Validates that the exchange rate captured on the export invoice matches the
rate registered in the exchange-rate catalogue for the invoice date.
Only runs when SisGen:CalValBaseTCPedExpo = 0 (use invoice-date TC, not
pedimento-payment-date TC). When the flag is 1 the TC is taken from the
pedimento and this check is skipped — that branch is handled in the TODO
for step 7 of main_process.
Clarion mapping
---------------
gtipocambio → a76.exchange_rate (ExchangeRate model)
FECHA → ExchangeRate.date (cast to DATE for comparison)
VALOR → ExchangeRate.value
EqiFex:FechaFactura → invoice.invoice_date
EqiFex:TipoCambio → invoice.financials.exchange_rate
"""
import datetime
from decimal import Decimal
from typing import Optional
from sqlalchemy import cast, Date, select
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from core.exceptions import ErrorCollector
def review_exchange_rate(
db: Session,
invoice: InvoiceHeader,
errors: ErrorCollector,
) -> None:
"""
REVISA_TIPOCAMBIO
Checks that the invoice's exchange rate matches the catalogue value for
the invoice date.
Parameters
----------
db : active SQLAlchemy session
invoice : the export invoice being processed
errors : shared error collector
"""
# TODO: skip when SisGen:CalValBaseTCPedExpo = 1
# (TC is taken from pedimento payment date, validated elsewhere)
if not invoice.financials:
return
invoice_date: datetime.date = (
invoice.invoice_date.date()
if hasattr(invoice.invoice_date, "date")
else invoice.invoice_date
)
# Look up the catalogue rate for the invoice date
catalogue_rate: Optional[ExchangeRate] = db.execute(
select(ExchangeRate).where(
ExchangeRate.tenant_id == invoice.tenant_id,
ExchangeRate.company_id == invoice.company_id,
cast(ExchangeRate.date, Date) == invoice_date,
)
).scalar_one_or_none()
if catalogue_rate is None:
# No rate registered for this date — cannot validate, skip
# (the Clarion loop simply finds no rows and exits cleanly)
return
invoice_tc = Decimal(str(invoice.financials.exchange_rate or 0))
catalogue_tc = Decimal(str(catalogue_rate.value or 0))
if invoice_tc != catalogue_tc:
errors.add_error(
field="financials.exchange_rate",
message="No está capturado correctamente el Tipo de Cambio.",
solution=[
"Capture o modifique el tipo de cambio que corresponda a la "
"factura en el catálogo de Tipo de Cambio."
],
code="EXCHANGE_RATE_MISMATCH",
value={
"invoice_date": str(invoice_date),
"invoice_tc": str(invoice_tc),
"catalogue_tc": str(catalogue_tc),
},
)

View File

@@ -0,0 +1,80 @@
"""
TOT_PAR_LIM_CANT_PESO / TOT_PAR_LIM_VALOR
Computes invoice-level totals (quantity, net weight, capture value) from all
line items and writes them back to the invoice financials.
These totals are used downstream to enforce the SisExp limit parameters
(CantLimite, PesoLimite, ValorLimite — TODO when SisExp model is available).
Legacy equivalents
------------------
TOT_PAR_LIM_CANT_PESO:
SELECT SUM(CantExpo), SUM(PesoNeto)
FROM QEqeMaq
WHERE Consecutivo = <invoice.id>
→ stored in Loc:CantExpoLim, Loc:PesoNetoLim
TOT_PAR_LIM_VALOR:
SELECT SUM(CostoUnitarioCaptura * CantExpo)
FROM QEqeMaq
WHERE Consecutivo = <invoice.id>
→ stored in Loc:ValorExpoLim
"""
from decimal import Decimal
from typing import List
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
def limit_weight(
lines: List[LineItem],
) -> tuple[Decimal, Decimal]:
"""
TOT_PAR_LIM_CANT_PESO
Sums exported quantity and net weight across all line items and Returns the totals.
Returns
-------
(total_quantity, total_net_weight)
after the call.
"""
total_qty = Decimal(0)
total_net_weight = Decimal(0)
for line in lines:
if line.quantity is None:
continue
total_qty += line.quantity.quantity or Decimal(0)
total_net_weight += line.quantity.net_weight or Decimal(0)
return total_qty, total_net_weight
def limit_value(
lines: List[LineItem],
) -> Decimal:
"""
TOT_PAR_LIM_VALOR
Sums (unit_cost_capture × quantity) across all line items and writes the
result to ``invoice.financials.value_mn`` as the capture-based total value.
Returns
-------
total_capture_value — also available on invoice.financials after the call.
Note: the legacy field Loc:ValorExpoLim is only used to compare against
SisExp limit parameters (TODO when SisExp model is available).
"""
total_value = Decimal(0)
for line in lines:
if line.financial is None or line.quantity is None:
continue
capture = line.financial.unit_cost_capture or Decimal(0)
qty = line.quantity.quantity or Decimal(0)
total_value += capture * qty
return total_value

View File

@@ -0,0 +1,74 @@
"""
REVISA_PROCEDENCIA_PAR
Validates that every line item of the export invoice has an import origin
procedure (TipoMovImpo) that matches what the invoice type requires:
· Regime-change (AFIJO / SCRAP with EsCambioRegimen='S') → all lines must be 'TEM'
· REEXP / VEMEX → all lines must be 'DEF'
Clarion mapping
---------------
Loc:Procedencia → expected_procedure parameter ('TEM' | 'DEF')
GSQLFile2.SQL2:C2 → line.customs.origin_procedure
GSQLFile2.SQL2:C1 → line.line_number
EqiFex:TipoFactura → invoice.invoice_type
"""
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
def review_origin_procedure(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
expected_procedure: str,
errors: ErrorCollector,
) -> None:
"""
REVISA_PROCEDENCIA_PAR
Verifies that every export line's import origin procedure matches
``expected_procedure``.
Parameters
----------
db : active SQLAlchemy session
invoice : the export invoice being processed
lines : all line items of the invoice
expected_procedure : 'TEM' (regime-change) or 'DEF' (REEXP / VEMEX)
errors : shared error collector
"""
expected = expected_procedure.strip().upper()
for line in lines:
line_procedure = (
(line.customs.origin_procedure or "").strip().upper()
if line.customs
else ""
)
if line_procedure != expected:
errors.add_error(
field=f"line[{line.line_number}].origin_procedure",
message=(
f"La partida: {line.line_number} tiene una factura de importación "
f"de procedencia: '{line_procedure}', diferente a la que acepta el "
f"Tipo de Factura: '{invoice.invoice_type}'."
),
solution=[
"Para Cambio de Régimen todo debe ser procedencia TEM, "
"para Ventas y Reexpediciones debe ser procedencia DEF."
],
code="INVALID_ORIGIN_PROCEDURE",
value={
"line_number": line.line_number,
"found": line_procedure,
"expected": expected,
"invoice_type": invoice.invoice_type,
},
)

View File

@@ -0,0 +1,72 @@
"""
REVISA_CANT_vs_PESONETO_KGS / REVISA_CANT_vs_PESONETO_LBS
Validates that the net weight of each export line matches its quantity
when the line's unit of measure is weight-based (KGS or LBS).
Rule (identical for both variants, only the unit differs):
- KGS: if UnitOfMeasure = 'KGS' → net_weight_kgs must equal quantity
- LBS: if UnitOfMeasure = 'LBS' → net_weight_lbs must equal quantity
Legacy equivalents
------------------
KGS:
SELECT COUNT(*) FROM QEqeMaq
WHERE Consecutivo = <id> AND UnidadMedida = 'KGS' AND PesoNetoKGS <> CantExpo
LBS:
SELECT COUNT(*) FROM QEqeMaq
WHERE Consecutivo = <id> AND UnidadMedida = 'LBS' AND PesoNetoLBS <> CantExpo
"""
from decimal import Decimal
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
_UNIT_KGS = "KGS"
_UNIT_LBS = "LBS"
def review_qty_vs_weight(
lines: List[LineItem],
unit_code: str,
errors: ErrorCollector,
) -> None:
"""
Generic validator used by both KGS and LBS variants.
For every line whose unit of measure code matches ``unit_code``,
checks that the exported quantity equals the exported quantity. Adds a PESO_NETO error for each mismatch.
Parameters
----------
lines : all LineItem rows for the invoice
unit_code : 'KGS' or 'LBS' — only lines with this UOM are evaluated
errors : collector for validation errors
"""
for line in lines:
uom = line.unit_of_measure_info
if uom is None:
continue
line_uom_code = (uom.code or "").strip().upper()
if line_uom_code != unit_code:
continue
if line.quantity is None:
continue
qty = line.quantity.quantity or Decimal(0)
net_weight = getattr(line.quantity.quantity, None) or Decimal(0)
if net_weight != qty:
errors.add_error(
field=f"line[{line.line_number}].quantity",
message=f"La cantidad es de {qty} {unit_code} y el Peso Neto es de {net_weight} {unit_code}.",
solution=["Igualar el Peso Neto con la cantidad a Exportar."],
code="PESO_NETO",
)

View File

@@ -0,0 +1,60 @@
"""
REVISA_COSTOS_0
Validates that every principal line item (non sub-item) of an export invoice
has a unit cost greater than zero.
A zero unit cost on a principal line is an error because values and customs
declarations cannot be computed without it.
Legacy equivalent
-----------------
SELECT COUNT(*) FROM QEqeMaq EqiPex
WHERE EqiPex.Consecutivo = <invoice.id>
AND EqiPex.CostoUnitarioCaptura = 0
AND EqiPex.EsSubPartida = 'P' -- 'P' = Principal (not a sub-item)
"""
from decimal import Decimal
from typing import List
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
def review_unit_cost(
lines: List[LineItem],
errors: ErrorCollector,
) -> None:
"""
REVISA_COSTOS_0
For every principal line (``fa_data.is_subitem`` is False or None) checks
that ``financial.unit_cost_capture`` is not zero. Adds a COSTO_CERO error
for each offending line.
Sub-items are skipped because their cost derives from the principal line
and may legitimately be zero at this stage.
"""
for line in lines:
# Skip sub-items — EsSubPartida = 'P' means is_subitem is False/None
is_subitem = line.fa_data.is_subitem if line.fa_data else False
if is_subitem:
continue
unit_cost = (
line.financial.unit_cost_capture
if line.financial
else None
)
if unit_cost is not None and unit_cost != Decimal(0):
continue
errors.add_error(
field=f"line[{line.line_number}].unit_cost_capture",
message=f"La partida: {line.line_number} no tiene capturado el costo unitario",
solution=[
f"Asignar el costo unitario a la partida: {line.line_number}, "
"o desactivar el parámetro de En Base al Costo de Captura."
],
code="COSTO_CERO",
)

View File

@@ -0,0 +1,121 @@
"""
REVISA_CANT_SERIES
Validates series count against export quantity for each line that carries
serial numbers (LlevaSerie = 1 / has_serial = True).
Rules (ported from legacy SCAII REVISA_CANT_SERIES):
1. If the line carries series but no series records exist → error SERIES_VACIAS.
2. If SisGen:CantvsCantSeries = 1:
a. RFC-exception companies (hardcoded set):
- If invoice is a cambio de régimen (is_regime_change): only validate
when the line's unit of measure is 'PZA'.
- Otherwise: always validate count vs quantity.
b. All other companies: always validate count vs quantity.
Note: the GNiv:CantSerievsCant = 0 block (series > quantity warning) was
commented-out in the original Clarion and is therefore not ported.
"""
from decimal import Decimal
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.series.models import Serie
from core.exceptions import ErrorCollector
# TODO: Read SisGen:CantvsCantSeries from the tenant system-config model
_SISGEN_CANT_VS_CANT_SERIES: int = 0 # 0 = disabled
# RFCs where qty-vs-series validation is conditional on UOM = PZA when is_regime_change
_RFC_EXCEPCION_PZA = {
"IMS030409FZ0",
"TOP140430PB6",
"AMA7504258K2",
"BZG111091T9",
}
def _validate_line_series(
db: Session,
invoice: InvoiceHeader,
line: LineItem,
company_rfc: str,
errors: ErrorCollector,
) -> None:
"""Validates series count for a single line that has has_serial = True."""
series_count = (
db.query(Serie)
.filter(Serie.line_item_id == line.id)
.count()
)
# Rule 1: series flag active but no series records exist
if series_count == 0:
errors.add_error(
field=f"line[{line.line_number}].series",
message="La opción de contiene series esta activada y no existen registros de Series",
solution=[
"Desactivar la opción de Lleva series o registrar las series a esta partida."
],
code="SERIES_VACIAS",
)
return
# Rule 2: quantity vs series count check (controlled by SisGen flag)
# TODO: Replace _SISGEN_CANT_VS_CANT_SERIES with the real config value
if _SISGEN_CANT_VS_CANT_SERIES != 1:
return
qty = line.quantity.quantity if line.quantity else None
if qty is None:
return
is_regime_change = bool(
invoice.compliance_mx and invoice.compliance_mx.is_regime_change
)
uom_code = ""
if line.unit_of_measure_info:
uom_code = (line.unit_of_measure_info.code or "").strip().upper()
if company_rfc in _RFC_EXCEPCION_PZA:
# RFC-exception: when cambio de régimen only validate for PZA lines
if is_regime_change and uom_code != "PZA":
return
# For all other companies (and exception RFCs without cambio de régimen),
# always compare count vs quantity
if series_count != qty:
errors.add_error(
field=f"line[{line.line_number}].series",
message="La Cantidad de Series No Coincide con la Cantidad de la Partida.",
solution=[f"Nivelar las series de la Partida {line.line_number}."],
code="SERIES_VS_CANT",
)
def review_qty_series(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> None:
"""
REVISA_CANT_SERIES
Iterates all line items and validates series count for those that carry
serial numbers (has_serial = True / LlevaSerie = 1).
"""
company = db.get(Company, company_id)
company_rfc = (company.rfc or "").strip().upper() if company else ""
for line in lines:
if not (line.description and line.description.has_serial):
continue
_validate_line_series(db, invoice, line, company_rfc, errors)

View File

@@ -0,0 +1,149 @@
"""
REVISA_SERIES_EXISTA
Verifies that every export series marked for discharge (discharge=True / Marca=1)
references a row that actually exists in the corresponding import invoice line.
Clarion mapping
---------------
QSeriesExpo → Serie (line_item_id = export LineItem.id)
QSeriesImpo → Serie (line_item_id = import LineItem.id, for TEM invoices)
QSeriesDef → Serie (line_item_id = import LineItem.id, for DEF invoices)
SerExpo.Marca = 1 → Serie.discharge = True
SerExpo.LineaSerieImpo → Serie.serie_row
SerImp.Renglon / SerDef.Renglon → Serie.row (on the import side)
Logic
-----
For each export serie with discharge=True on this line, check that
``serie_row`` exists as a ``row`` in the series of the referenced
import invoice line. If it does not → error.
The check differs by origin_procedure:
TEM → look in import invoice (InvoiceType='TEM')
DEF → look in import invoice (InvoiceType='DEF')
"""
from typing import List, Set
from sqlalchemy import select
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.series.models import Serie
from api.v1.modules.a76.invoices.exports.process.sub_process.discharge_types import (
DownloadEntry,
)
from core.exceptions import ErrorCollector
def review_series_exist(
db: Session,
invoice: InvoiceHeader,
line: LineItem,
entry: DownloadEntry,
errors: ErrorCollector,
) -> None:
"""
REVISA_SERIES_EXISTA
Checks that every export serie marked for discharge on ``line`` references
an import serie row that actually exists in the import invoice line.
Parameters
----------
db : active SQLAlchemy session
invoice : the export invoice being processed
line : the export LineItem whose series are being validated
entry : the DownloadEntry for this line (provides import_invoice / import_line)
errors : shared error collector
"""
# ── 1. Export series with discharge=True on this line ────────────────────
export_series: List[Serie] = (
db.execute(
select(Serie).where(
Serie.line_item_id == line.id,
Serie.discharge == True, # noqa: E712 — SQLAlchemy requires ==
)
)
.scalars()
.all()
)
if not export_series:
return
# ── 2. Resolve the import LineItem ───────────────────────────────────────
invoice_type_filter = entry.origin_procedure.upper() # 'TEM' or 'DEF'
import_line_id: int | None = (
db.execute(
select(LineItem.id)
.join(InvoiceHeader, LineItem.invoice_id == InvoiceHeader.id)
.where(
InvoiceHeader.tenant_id == invoice.tenant_id,
InvoiceHeader.invoice_number == entry.import_invoice,
InvoiceHeader.invoice_type == invoice_type_filter,
LineItem.line_number == entry.import_line,
)
)
.scalar_one_or_none()
)
if import_line_id is None:
# The import line itself was not found — already caught by fill_available_balances,
# but add a targeted error here as well.
errors.add_error(
field=f"line[{line.line_number}].series",
message=(
f"No se encontró la línea {entry.import_line} de la factura de "
f"importación '{entry.import_invoice}' para validar las series."
),
solution=["Verificar que la factura y línea de importación existen y están procesadas."],
code="IMPORT_LINE_NOT_FOUND_FOR_SERIES",
)
return
# ── 3. Fetch the set of valid import serie rows ───────────────────────────
valid_rows: Set[int] = set(
db.execute(
select(Serie.row).where(
Serie.line_item_id == import_line_id,
)
)
.scalars()
.all()
)
# ── 4. Validate each export serie ────────────────────────────────────────
invoice_type_label = (
"Impo. Tem." if invoice_type_filter == "TEM" else "Impo. Def."
)
invoice_type_code = (
"FAC_IMPO_TEM" if invoice_type_filter == "TEM" else "FAC_IMPO_DEF"
)
for serie in export_series:
ref_row = serie.serie_row
if ref_row is None or ref_row not in valid_rows:
errors.add_error(
field=f"line[{line.line_number}].series[{serie.row}]",
message=(
f"La Línea: {serie.serie_row} "
f"(Serie: {serie.serial_numbers or ''}) "
f"no existe en la Factura de {invoice_type_label}: "
f"'{entry.import_invoice}' con Línea: {entry.import_line}."
),
solution=[
"Capturar un número de Serie que exista en la Factura "
"y Línea a Descargar de Importación."
],
code=invoice_type_code,
value={
"export_serie_row": serie.row,
"serie_row": ref_row,
"import_invoice": entry.import_invoice,
"import_line": entry.import_line,
},
)

View File

@@ -0,0 +1,210 @@
"""
REVISA_SERIES_OTRAS_PAR
Verifies that no export serie marked for discharge is already assigned for
discharge on a different line of the same export invoice.
Clarion mapping
---------------
QueueSeries → ``seen_series: set[tuple]`` passed in from the caller.
The set accumulates across all discharge lines so that a serie
registered on line 1 is detected as duplicate when line 2 is
processed.
SerExpo (QSeriesExpo) → Serie (line_item_id = export LineItem.id)
SerImp (QSeriesImpo) → Serie (line_item_id = import LineItem.id, TEM)
SerDef (QSeriesDef) → Serie (line_item_id = import LineItem.id, DEF)
Key tuple (equivalent to QueueSeries record used for GET/ADD):
(export_invoice_number, import_line, serial_number,
origin_procedure, serie_row, import_invoice_number)
Logic
-----
For each export serie with discharge=True on this line:
1. Build the key tuple.
2. Resolve ``serie_row`` if blank:
TEM → look up the matching row in QSeriesImpo by serial_number
DEF → look up the matching row in QSeriesDef by serial_number
3. If the key is already in ``seen_series`` → duplicate error.
4. Otherwise → add to ``seen_series`` (mark as seen for subsequent lines).
"""
from typing import Optional, Set, Tuple
from sqlalchemy import select
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.series.models import Serie
from api.v1.modules.a76.invoices.exports.process.sub_process.discharge_types import (
DownloadEntry,
)
from core.exceptions import ErrorCollector
# Type alias for the duplicate-detection key (equiv. to QueueSeries record)
_SeriesKey = Tuple[str, int, str, str, Optional[int], str]
def review_series_other_lines(
db: Session,
invoice: InvoiceHeader,
line: LineItem,
entry: DownloadEntry,
seen_series: Set[_SeriesKey],
errors: ErrorCollector,
) -> None:
"""
REVISA_SERIES_OTRAS_PAR
Checks that no export serie on ``line`` (with discharge=True) is already
registered for discharge on another line of the same invoice.
Parameters
----------
db : active SQLAlchemy session
invoice : the export invoice being processed
line : the export LineItem whose series are being validated
entry : the DownloadEntry for this line
seen_series : mutable set shared across all calls within one invoice
processing run — accumulates keys as lines are processed
errors : shared error collector
"""
export_series = (
db.execute(
select(Serie).where(
Serie.line_item_id == line.id,
Serie.discharge == True, # noqa: E712
)
)
.scalars()
.all()
)
if not export_series:
return
# Resolve the import line id once (needed for serie row lookup)
import_line_id = _resolve_import_line_id(db, invoice, entry)
for serie in export_series:
serial = serie.serial_numbers or ""
import_serie_row = serie.serie_row
# If import_serie_row is not set on the export serie, resolve it from
# the import series table by matching serial_number
if import_serie_row is None and import_line_id is not None:
import_serie_row = _resolve_import_serie_row(
db, import_line_id, serial
)
key: _SeriesKey = (
invoice.invoice_number or "",
entry.import_line,
serial,
entry.origin_procedure,
import_serie_row,
entry.import_invoice,
)
if key in seen_series:
# Find which export line already claimed this serie
existing_line = _find_existing_export_line(
db, invoice, line.id, serial, entry
)
errors.add_error(
field=f"line[{entry.export_line}].series[{serie.row}]",
message=(
f"La Serie: '{serial}' ya fue descargada y está capturada "
f"para ser Descargada en la Partida: {existing_line}."
),
solution=[
"Capturar otro número de Serie o capturar el Renglón "
"de la Serie de Importación."
],
code="SERIE_DUPLICATE_DISCHARGE",
value={
"serial": serial,
"export_line": entry.export_line,
"conflicting_line": existing_line,
},
)
else:
seen_series.add(key)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _resolve_import_line_id(
db: Session,
invoice: InvoiceHeader,
entry: DownloadEntry,
) -> Optional[int]:
"""Returns the import LineItem.id for the invoice/line referenced by the entry."""
from api.v1.modules.a76.invoices.models import InvoiceHeader as IH
from api.v1.modules.a76.items.models import LineItem as LI
return db.execute(
select(LI.id)
.join(IH, LI.invoice_id == IH.id)
.where(
IH.tenant_id == invoice.tenant_id,
IH.invoice_number == entry.import_invoice,
LI.line_number == entry.import_line,
)
).scalar_one_or_none()
def _resolve_import_serie_row(
db: Session,
import_line_id: int,
serial_number: str,
) -> Optional[int]:
"""
Looks up the ``row`` of an import serie by serial_number on the given
import line — equivalent to the SQL3 query in the Clarion for both TEM
and DEF cases (both use the same Serie model now).
"""
return db.execute(
select(Serie.row).where(
Serie.line_item_id == import_line_id,
Serie.serial_numbers == serial_number,
)
).scalar_one_or_none()
def _find_existing_export_line(
db: Session,
invoice: InvoiceHeader,
current_line_id: int,
serial_number: str,
entry: DownloadEntry,
) -> int:
"""
Returns the export_line number of another line on the same invoice that
already has this serial registered for discharge.
Falls back to entry.export_line if not found (shouldn't happen in practice).
"""
from api.v1.modules.a76.items.models import LineItem as LI
# Find all export lines on this invoice that are not the current one
other_line_ids = db.execute(
select(LI.id, LI.line_number).where(
LI.invoice_id == invoice.id,
LI.id != current_line_id,
)
).all()
for row in other_line_ids:
match = db.execute(
select(Serie.id).where(
Serie.line_item_id == row.id,
Serie.serial_numbers == serial_number,
Serie.discharge == True, # noqa: E712
)
).scalar_one_or_none()
if match is not None:
return row.line_number
return entry.export_line

View File

@@ -0,0 +1,82 @@
"""
VERIFICAQCONSOLIDADO
Second-pass check after COMPARA_SALDOS_POR_FACTURA: iterates every discharge
entry and reports an error for any line that still has unmet quantity
(QADesc:Cantidad - QADesc:CantUsada <> 0).
The Clarion routine distinguishes TEM vs DEF in the error message; this
translation preserves that distinction.
Note: compare_balances already raises INSUFFICIENT_BALANCE errors per entry.
This routine acts as a final consolidation gate — if compare_balances is
called with raise_if_errors() afterwards, this function may be redundant in
practice. It is kept as a faithful port and can serve as the sole
insufficient-balance check if compare_balances is ever made non-raising.
"""
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader
from core.exceptions import ErrorCollector
from .discharge_types import DownloadEntry
def verify_consolidated(
db: Session,
export_invoice: InvoiceHeader,
to_discharge: List[DownloadEntry],
errors: ErrorCollector,
) -> None:
"""
VERIFICAQCONSOLIDADO
Reports an error for every discharge entry whose quantity was not fully
satisfied by ``compare_balances``.
Parameters
----------
db : active SQLAlchemy session
export_invoice : the export invoice being processed
to_discharge : list of DownloadEntry objects after compare_balances ran
errors : shared error collector
"""
# Sort mirrors Clarion: Sort(QueADescargar, -Procedencia, FacturaImpo, LineaImpo)
sorted_entries = sorted(
to_discharge,
key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line),
reverse=True,
)
for entry in sorted_entries:
remaining = entry.quantity - entry.quantity_used
if remaining == 0:
continue
uom = entry.unit_of_measure or ""
procedure = (entry.origin_procedure or "").strip().upper()
if procedure == "TEM":
message = (
f"Insuficiencia TEM: La Linea: {entry.export_line} se quiere "
f"descargar: {entry.quantity} {uom} y hay: {entry.quantity_used} {uom}."
)
else: # DEF or any other
message = (
f"Insuficiencia DEF.: La Linea: {entry.export_line} se quiere "
f"descargar: {entry.quantity} {uom} y hay: {entry.quantity_used} {uom}."
)
errors.add_error(
field=f"line[{entry.export_line}].quantity",
message=message,
solution=["Asignar Facturas con Saldos Disponibles."],
code="INSUFFICIENT_BALANCE_CONSOLIDATED",
value={
"export_line": entry.export_line,
"required": str(entry.quantity),
"available": str(entry.quantity_used),
"shortage": str(remaining),
"origin_procedure": procedure,
},
)

View File

@@ -0,0 +1,50 @@
from celery import Task
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.exceptions import ValidationException
from api.v1.modules.a76.invoices.models import InvoiceHeader
from .main_process import main_process
def _progress(task: Task, current: int, status: str) -> None:
task.update_state(state="PROGRESS", meta={"current": current, "status": status})
@celery_app.task(bind=True, name="process_export_invoice_task")
def process_export_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str) -> dict:
"""
Procesa una factura de exportación ejecutando todas las validaciones y
actualizaciones del proceso principal de exportación con reporte de progreso.
"""
db = CoreSessionLocal()
try:
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
}
_progress(self, 10, "Procesando factura de exportación...")
result = main_process(db, invoice, tenant_id, company_id)
db.commit()
_progress(self, 100, "Proceso completado.")
return {**result, "invoice_id": invoice_id}
except ValidationException as exc:
db.rollback()
return {
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
except Exception as exc:
db.rollback()
raise exc
finally:
db.close()

View File

@@ -0,0 +1,120 @@
"""
create_balance_entries
Generates one ``BalanceMovement`` (type ENTRY) for every line item of a
processed import invoice, writing the initial inventory balance for each lot.
Design rules (from a24.balance_movement):
1. NEVER update existing rows — only INSERT.
2. Balance = SUM of movements. No cached balance columns.
3. order_peps is set to the new movement's id (globally monotonic) via a
post-flush assignment — SQLAlchemy fills autoincrement ids after flush.
This function is called AFTER all validations pass and BEFORE db.flush() at
the end of the import main_process, so all inserts are part of the same
transaction.
"""
from decimal import Decimal
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a24.balance_movements.models import BalanceMovement, MovementType
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
def create_balance_entries(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
) -> List[BalanceMovement]:
"""
Inserts one ``BalanceMovement(type=ENTRY)`` for every import line item.
Parameters
----------
db : active SQLAlchemy session (inside the process transaction)
invoice : the import invoice that has just been validated and totalled
lines : all LineItem rows of the invoice
Returns
-------
List of the newly created BalanceMovement objects (already added to the
session, ids available after the next flush).
Notes
-----
- ``order_peps`` is set equal to ``movement.id`` right after flush so that
the PEPS index is globally monotonic — older imports always have a lower
value and are consumed first on export.
- Sub-items (fa_data.is_subitem = True) are skipped; only principal lines
contribute to inventory.
- Lines with quantity = 0 are skipped to keep the ledger clean.
"""
movements: List[BalanceMovement] = []
operation_date = (
invoice.invoice_date.date()
if hasattr(invoice.invoice_date, "date")
else invoice.invoice_date
)
for line in lines:
# Skip sub-items — they have no independent balance
is_subitem = line.fa_data.is_subitem if line.fa_data else False
if is_subitem:
continue
qty = (
Decimal(str(line.quantity.quantity or 0))
if line.quantity
else Decimal(0)
)
if qty <= 0:
continue
value_me = (
Decimal(str(line.financial.value_usd or 0))
if line.financial
else Decimal(0)
)
value_mn = (
Decimal(str(line.financial.value_mxn or 0))
if line.financial
else Decimal(0)
)
net_weight = (
Decimal(str(line.quantity.net_weight or 0))
if line.quantity
else Decimal(0)
)
movement = BalanceMovement(
tenant_id=invoice.tenant_id,
company_id=invoice.company_id,
import_invoice_id=invoice.id,
import_item_line_id=line.id,
part_number_id=line.part_number_id,
movement_type=MovementType.ENTRY,
quantity=qty,
value_me=value_me if value_me > 0 else None,
value_mn=value_mn if value_mn > 0 else None,
net_weight=net_weight if net_weight > 0 else None,
source_invoice_id=None,
source_item_line_id=None,
order_peps=0, # placeholder — set after flush (see below)
operation_date=operation_date,
notes=f"Entrada por factura de importación {invoice.invoice_number}",
)
db.add(movement)
movements.append(movement)
if movements:
# Flush to get autoincrement ids, then set order_peps = id so that
# the PEPS index is monotonic and requires no separate sequence.
db.flush()
for mov in movements:
mov.order_peps = mov.id
return movements

View File

@@ -0,0 +1,160 @@
"""
void_balance_entries
Cancels every open ENTRY balance of an import invoice by inserting a matching
ENTRY_VOID movement for each one.
Design rules preserved:
1. NEVER update or delete balance_movement rows — only INSERT.
2. Net balance after void = SUM(ENTRY qty) - SUM(ENTRY_VOID qty) = 0.
3. order_peps is set to the new movement's id (post-flush, globally monotonic).
Called when an import invoice is un-processed (reverted) so that the lots can
no longer be consumed by export discharges. A subsequent re-process will
insert fresh ENTRY rows with up-to-date values.
Guard:
If any ENTRY has already been partially or fully consumed (i.e. there exist
CONSUMPTION/WASTE/SCRAP/DESTRUCTION movements against it), the void is
blocked and a ``ValueError`` is raised — you cannot un-process an invoice
whose materials are already in use.
"""
from decimal import Decimal
from typing import List
from sqlalchemy import select, func, case
from sqlalchemy.orm import Session
from api.v1.modules.a24.balance_movements.models import (
BalanceMovement,
MovementType,
NEGATIVE_MOVEMENTS,
USED_MOVEMENTS,
)
from api.v1.modules.a76.invoices.models import InvoiceHeader
def void_balance_entries(
db: Session,
invoice: InvoiceHeader,
) -> List[BalanceMovement]:
"""
Inserts ``ENTRY_VOID`` movements that cancel every open ENTRY for the
given import invoice.
Parameters
----------
db : active SQLAlchemy session (inside the revert transaction)
invoice : the import invoice being un-processed
Returns
-------
List of the newly created ENTRY_VOID BalanceMovement objects.
Raises
------
ValueError
If any lot of the invoice has already been (partially) consumed by
an export, waste, scrap or destruction. In that case the invoice
cannot be un-processed without first cancelling those discharges.
"""
# ── 1. Fetch all ENTRY movements for this invoice ────────────────────────
entries: List[BalanceMovement] = (
db.execute(
select(BalanceMovement).where(
BalanceMovement.import_invoice_id == invoice.id,
BalanceMovement.movement_type == MovementType.ENTRY,
)
)
.scalars()
.all()
)
if not entries:
return []
import_line_ids = [e.import_item_line_id for e in entries]
# ── 2. Guard: check no lot has been consumed ─────────────────────────────
sign_expr = case(
(BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), -1),
else_=1,
)
used_expr = case(
(BalanceMovement.movement_type.in_(USED_MOVEMENTS), BalanceMovement.quantity),
else_=Decimal(0),
)
lot_summary = (
db.execute(
select(
BalanceMovement.import_item_line_id,
func.sum(sign_expr * BalanceMovement.quantity).label("balance"),
func.sum(used_expr).label("used"),
)
.where(
BalanceMovement.import_item_line_id.in_(import_line_ids),
)
.group_by(BalanceMovement.import_item_line_id)
)
.all()
)
consumed_lots = [row for row in lot_summary if (row.used or 0) > 0]
if consumed_lots:
lot_ids = ", ".join(str(r.import_item_line_id) for r in consumed_lots)
raise ValueError(
f"No se puede des-procesar la factura '{invoice.invoice_number}': "
f"los siguientes lotes ya tienen consumos registrados y deben "
f"cancelarse primero (item_line ids: {lot_ids})."
)
# ── 3. Build the ENTRY_VOID map: one void per ENTRY ──────────────────────
# Map lot_id → open balance (should equal the original ENTRY qty since no
# consumptions exist, but we use the actual net balance to be safe).
balance_map: dict[int, Decimal] = {
row.import_item_line_id: Decimal(str(row.balance or 0))
for row in lot_summary
}
operation_date = (
invoice.invoice_date.date()
if hasattr(invoice.invoice_date, "date")
else invoice.invoice_date
)
voids: List[BalanceMovement] = []
for entry in entries:
open_qty = balance_map.get(entry.import_item_line_id, Decimal(0))
if open_qty <= 0:
continue
void_mov = BalanceMovement(
tenant_id=invoice.tenant_id,
company_id=invoice.company_id,
import_invoice_id=invoice.id,
import_item_line_id=entry.import_item_line_id,
part_number_id=entry.part_number_id,
movement_type=MovementType.ENTRY_VOID,
quantity=open_qty,
value_me=entry.value_me,
value_mn=entry.value_mn,
net_weight=entry.net_weight,
source_invoice_id=None,
source_item_line_id=None,
order_peps=0, # set after flush
operation_date=operation_date,
notes=(
f"Anulación de entrada por des-procesamiento de "
f"factura {invoice.invoice_number} (entry id={entry.id})"
),
)
db.add(void_mov)
voids.append(void_mov)
if voids:
db.flush()
for mov in voids:
mov.order_peps = mov.id
return voids

View File

@@ -23,8 +23,9 @@ from .sub_process.review_rule_octave import (
valida_imp_regla_octava,
descuenta_cupo_r_octava,
)
from .sub_process.review_uma import revisa_uma
from ...common.process.review_uma import revisa_uma
from .sub_process.assing_values import assign_values_lines, assign_values_invoice
from ..balance.create_balance_entries import create_balance_entries
@@ -282,4 +283,7 @@ def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id
# Paso 7: Actualizar totales, IVA e incrementables y marcar como procesada
_update_invoice_totals(invoice)
# Paso 8: Generar saldos en a24.balance_movement (una entrada por partida)
create_balance_entries(db, invoice, lines)
db.flush()

View File

@@ -30,7 +30,7 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_
errors.add_required_error("compliance_mx.sold_to_id")
if not invoice.compliance_mx.shipped_to_id:
errors.add_required_error("compliance_mx.shipped_by_id")
errors.add_required_error("compliance_mx.shipped_to_id")
if not invoice.compliance_mx.customs_broker_id:
errors.add_required_error("compliance_mx.customs_broker_id")

View File

@@ -7,7 +7,9 @@ from core.celery_app import celery_app
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from api.v1.modules.a76.invoices.models import InvoiceHeader, OperationType
from .task import process_invoice_task
from ...exports.process.task import process_export_invoice_task
router = APIRouter()
@@ -20,14 +22,25 @@ def trigger_invoice_process(
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Inicia el procesamiento de una factura de importación como tarea Celery.
Inicia el procesamiento de una factura como tarea Celery.
Detecta automáticamente si es importación o exportación por el
operation_type de la factura y despacha al proceso correspondiente.
Retorna el task_id para hacer polling del progreso.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
task = process_invoice_task.apply_async(
args=[invoice_id, str(tenant_id), str(company_id)]
)
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
raise HTTPException(status_code=404, detail=f"Factura {invoice_id} no encontrada.")
if invoice.operation_type == OperationType.EXP:
task = process_export_invoice_task.apply_async(
args=[invoice_id, str(tenant_id), str(company_id)]
)
else:
task = process_invoice_task.apply_async(
args=[invoice_id, str(tenant_id), str(company_id)]
)
return {"task_id": task.id}
@@ -62,7 +75,6 @@ def get_invoice_process_status(task_id: str):
"result": task_result.result,
}
# FAILURE u otro estado de error
error_info = task_result.result
if isinstance(error_info, Exception):
error_msg = str(error_info)

View File

@@ -6,13 +6,13 @@ from typing import Optional, List
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.fractions.previous_fractions.models import PreviousFraction
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.rule_octave.balances.models import OctaveBalance
from api.v1.modules.a76.rule_octave.country.models import CountryRuleOct
from api.v1.modules.a76.rule_octave.fractions.models import FractionRuleOctave
from api.v1.modules.a76.rule_octave.permissions.models import OctavePermission
from ....common.process.review_equivalence import _get_unit_equivalence
from core.exceptions import ErrorCollector
# Clarion date 80354 ≈ 2010-05-31 (see previous_fractions/models.py)
@@ -50,51 +50,6 @@ class OctavePermitEntry:
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _get_unit_equivalence(
db: Session,
from_unit: str,
to_unit: str,
tenant_id: str,
company_id: str,
) -> tuple[str, Decimal]:
"""
Busca una conversión entre dos unidades de medida.
Paridad: REVEQUIVALENCIA (Clarion SCAII).
Retorna (multi_divide, factor_conv):
- ('M', factor) → multiplicar cantidad por factor
- ('D', factor) → dividir cantidad por factor
- ('', 0) → no existe equivalencia
"""
conv = (
db.query(UnitConversion)
.filter(
UnitConversion.tenant_id == tenant_id,
UnitConversion.company_id == company_id,
UnitConversion.from_unit_code == from_unit,
UnitConversion.to_unit_code == to_unit,
)
.first()
)
if conv and conv.conversion_factor:
return "M", conv.conversion_factor
conv_inv = (
db.query(UnitConversion)
.filter(
UnitConversion.tenant_id == tenant_id,
UnitConversion.company_id == company_id,
UnitConversion.from_unit_code == to_unit,
UnitConversion.to_unit_code == from_unit,
)
.first()
)
if conv_inv and conv_inv.conversion_factor:
return "D", conv_inv.conversion_factor
return "", Decimal(0)
def _previous_fraction_exists(
db: Session,
tenant_id: str,

View File

@@ -12,6 +12,7 @@ from .sub_process.review_exchange_rate import review_exchange_rate
from .sub_process.review_weights import review_weights_kgs, review_weights_lbs
from .sub_process.review_rule_octave import valida_imp_regla_octava, descuenta_cupo_r_octava
from .sub_process.assing_values import assign_values_lines, assign_values_invoice
from ..balance.create_balance_entries import create_balance_entries
from .main_process import _validate_sisimp_limits, _update_invoice_totals, _validate_lines
@@ -99,6 +100,11 @@ def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id
sql_errors=sql_errors,
)
_update_invoice_totals(invoice)
# ── Paso 8: Generar saldos en a24.balance_movement ───────────────────
_progress(self, 98, "Generando saldos de inventario...")
create_balance_entries(db, invoice, lines)
db.flush()
db.commit()

View File

@@ -9,6 +9,7 @@ from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
from .sub_process.review_rule_octave import borra_saldos_regla_octava
from ..balance.void_balance_entries import void_balance_entries
# ─────────────────────────────────────────────────────────────────────────────
@@ -231,4 +232,11 @@ def revert_process(
# ── Paso 2c: UPDATE QEqiMaq ───────────────────────────────────────────────
_reset_line_quantities(lines)
# ── Paso 2d: Anular saldos en a24.balance_movement ───────────────────────
# Inserta ENTRY_VOID por cada ENTRY abierto de esta factura, dejando el
# balance neto en 0 para que las descargas de exportación no puedan
# consumir esos lotes. El guard interno confirma que no haya consumos
# activos (ya validado arriba, pero se mantiene como doble seguro).
void_balance_entries(db, invoice)
return sql_errors

View File

@@ -58,7 +58,7 @@ def revert_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id:
)
# ── Paso 4: Confirmar transacción ─────────────────────────────────────
_progress(self, 95, "Confirmando cambios...")
_progress(self, 95, "Anulando saldos de inventario y confirmando...")
db.flush()
db.commit()

View File

@@ -451,6 +451,8 @@ class InvoiceCollectionsCreate(InvoiceCollectionsBase):
class InvoiceHeaderCreate(InvoiceHeaderBase):
"""Schema for creating Invoice Header with nested relations"""
status: Optional[InvoiceStatus] = Field(InvoiceStatus.PENDING, description="Status: pending, processed, reversed")
compliance_mx: Optional[InvoiceComplianceMxCreate] = None
financials: Optional[InvoiceFinancialsCreate] = None
logistics: Optional[InvoiceLogisticsCreate] = None

View File

@@ -95,7 +95,7 @@ def calculate_values(
Traduce el CALCULOS ROUTINE de Clarion:
- Busca la factura de importación por fa_data.search_invoice (TEM → DEF como fallback)
- Copia clase, unidad de medida, fracción (si fa_data.download), país, tipo fracción,
- Copia clase, unidad de medida, fracción (si fa_data.discharge), país, tipo fracción,
bultos y descripción inglés desde la línea de importación encontrada
- Calcula valores en moneda (USD/MXN/MC) según la moneda de la factura
"""
@@ -168,8 +168,8 @@ def calculate_values(
line.class_id = import_line.class_id
line.unit_of_measure = import_line.unit_of_measure
# Fracción: copiar solo si fa_data.download == True (≡ ColumnaV != '')
if fa_data and fa_data.download and import_line.customs:
# Fracción: copiar solo si fa_data.discharge == True (≡ ColumnaV != '')
if fa_data and fa_data.discharge and import_line.customs:
line.customs.fraction = import_line.customs.fraction
if import_line.customs:

View File

@@ -16,7 +16,7 @@ from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMe
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.sectors.models import Sector
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
from api.v1.modules.public.reference_data.valuation_methods.models import (
ValuationMethod,
)
@@ -222,7 +222,7 @@ def validate_common(
)
elif (
# Col. H: valida unidad de medida sólo cuando hay descarga
fa_data.download is True
fa_data.discharge is True
and line.unit_of_measure
and import_line.unit_of_measure
and line.unit_of_measure != import_line.unit_of_measure
@@ -314,7 +314,11 @@ def validate_common(
)
elif fraction_type.strip().upper() == FractionType.PROSEC and sector:
sector_db: Sector = (
db.query(Sector).filter(Sector.key == sector).scalar()
db.query(Sector).filter(
Sector.key == sector,
Sector.tenant_id == tenant_id,
Sector.company_id == company_id,
).scalar()
)
if sector_db:
errors.add_error(

View File

@@ -83,11 +83,11 @@ def validate_create(
errors.add_required_error(field=f"line[{line_number}].fa_data.movement_type_import")
# Col. F: ¿Descarga la línea? (DescargaPartida) — obligatorio
if fa_data.download is None:
errors.add_required_error(field=f"line[{line_number}].fa_data.download")
if fa_data.discharge is None:
errors.add_required_error(field=f"line[{line_number}].fa_data.discharge")
# Col. D / E: Factura y Línea de Impo — obligatorios sólo si hay descarga
if fa_data.download is True:
if fa_data.discharge is True:
if not fa_data.search_invoice:
errors.add_required_error(field=f"line[{line_number}].fa_data.search_invoice")
if not fa_data.search_line:

View File

@@ -207,8 +207,8 @@ def validate_update(
fa_data.movement_type_import = existing_fa_data.movement_type_import
# Col. F: ¿Descarga la línea? (Descarga)
if fa_data.download is None:
fa_data.download = existing_fa_data.download
if fa_data.discharge is None:
fa_data.discharge = existing_fa_data.discharge
# Col. D: Factura de Importación — obligatoria sólo si hay descarga
if not fa_data.search_invoice:

View File

@@ -15,7 +15,7 @@ from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.sectors.models import Sector
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
from api.v1.modules.public.reference_data.valuation_methods.models import (
ValuationMethod,
)
@@ -239,7 +239,11 @@ def validate_common(
)
elif fraction_type.strip().upper() == FractionType.PROSEC and sector:
sector_db: Sector = (
db.query(Sector).filter(Sector.key == sector).scalar()
db.query(Sector).filter(
Sector.key == sector,
Sector.tenant_id == tenant_id,
Sector.company_id == company_id,
).scalar()
)
if sector_db:
errors.add_error(

View File

@@ -4,7 +4,7 @@ SQLAlchemy v2 - Annex 24 Compliance
"""
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from typing import Optional, List, TYPE_CHECKING
from core.database import Base
from decimal import Decimal
from sqlalchemy import Boolean, Date, String, Integer, Numeric, SmallInteger, ForeignKey
@@ -25,6 +25,10 @@ if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.invoices.models import InvoiceHeader
# Imported at runtime so SQLAlchemy's mapper registry can resolve the class name
# used in the relationship string below.
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
# ============================================================================
# CORE ENTITIES
# ============================================================================
@@ -216,10 +220,14 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
)
fa_data: Mapped[Optional["FaLineItem"]] = relationship(
"FaLineItem",
back_populates="master_info",
cascade="all, delete-orphan",
uselist=False,
)
identifiers: Mapped[List["IdentifierDetail"]] = relationship(
"IdentifierDetail",
back_populates="line",
cascade="all, delete-orphan",
)
part_info: Mapped[Optional["Part"]] = relationship(
"Part",
foreign_keys=[part_number_id],

View File

@@ -3,7 +3,8 @@ API Endpoints for Items management
Handles CRUD operations for Item with one-to-many relationships to LineItems
"""
from typing import Dict, Any, Optional
import datetime
from typing import Dict, Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from sqlalchemy.orm import Session
@@ -191,6 +192,39 @@ async def get_items_by_invoice(
skip=skip,
limit=limit
)
@router.get("/invoice/{invoice_id}/items-with-balance", response_model=List[dict])
async def get_items_with_balance(
invoice_id: int = Path(..., description="Import Invoice ID"),
company_id: int = Query(..., description="Company ID"),
as_of_date: Optional[datetime.date] = Query(
None,
description=(
"Cut-off date for balance calculation. Only consumptions on or "
"before this date are subtracted (CALCULA_SALDO_FECHA_EXPO logic)."
),
),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Returns every line of the given import invoice with its available balance
from the a24.balance_movement ledger.
Each item in the response includes:
- id, line_number, part_number, class_code, unit_of_measure_code
- quantity : original imported quantity
- available_balance : net balance still available for export discharge
- has_balance : true when available_balance > 0
Use ``as_of_date`` to restrict consumption movements to a specific date
(pass the export invoice date so that future discharges are not counted).
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = ItemService()
return service.get_lines_with_balance(db, invoice_id, tenant_id, company_id, as_of_date)
# ============================================================================
# STATISTICS & UTILITIES
# ============================================================================

View File

@@ -37,6 +37,12 @@ from .line_references.schemas import (
LineReferenceResponse,
)
from api.v1.modules.a76.general_catalogs.identifiers.dto import (
IdentifierDetailCreate,
IdentifierDetailUpdate,
IdentifierDetailResponse,
)
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a24.fa.fa_item_lines.dto import (
FaLineItemCreateDTO,
FaLineItemUpdateDTO,
@@ -238,6 +244,9 @@ class LineItemCreate(LineItemBase):
series: Optional[list[SerieCreate]] = Field(
None, description="Series data for this line (multiple per line)"
)
identifiers: Optional[list[IdentifierDetailCreate]] = Field(
None, description="Identifiers for this line"
)
class LineItemUpdate(LineItemBase):
@@ -267,6 +276,9 @@ class LineItemUpdate(LineItemBase):
series: Optional[list[SerieUpdate]] = Field(
None, description="Series data for this line (replace all)"
)
identifiers: Optional[list[IdentifierDetailUpdate]] = Field(
None, description="Identifiers for this line"
)
class LineItemResponse(LineItemBase):
@@ -295,6 +307,7 @@ class LineItemResponse(LineItemBase):
reference: Optional[LineReferenceResponse] = None
fa_data: Optional[FaLineItemResponseDTO] = None
series: Optional[list[SerieResponse]] = None
identifiers: Optional[list[IdentifierDetailResponse]] = None
# Fields populated from relationships
class_code: Optional[str] = None

View File

@@ -1,5 +1,5 @@
from typing import Optional
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy import Boolean, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
@@ -16,9 +16,11 @@ class Serie(Base, TenantScopedMixin, TimestampMixin):
serial_numbers: Mapped[Optional[str]] = mapped_column(String(50)) # SERIEEXPO
model: Mapped[Optional[str]] = mapped_column(String(50)) # MODELOEXPO
sub_model: Mapped[Optional[str]] = mapped_column(String(50)) # SUBMODELOEXPO
brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCA
expo_brad: Mapped[Optional[str]] = mapped_column(String(50)) # MARCAEXPO
brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCAEXPO
number_id: Mapped[Optional[str]] = mapped_column(String(25)) # NUMIDEXPO
discharge: Mapped[Optional[bool]] = mapped_column(Boolean) # MARCA
serie_row: Mapped[Optional[int]] = mapped_column(Integer) # LINEASERIEIMPO <-- IN CASE OF EXPO
image_path: Mapped[Optional[str]] = mapped_column(String(255)) # PATH DE IMAGEN (MEX)

View File

@@ -10,6 +10,9 @@ class SerieBase(BaseModel):
brand: Optional[str] = Field(None, max_length=50, description="Brand (MARCA)")
expo_brad: Optional[str] = Field(None, max_length=50, description="Expo brand (MARCAEXPO)")
number_id: Optional[str] = Field(None, max_length=25, description="Number ID (NUMIDEXPO)")
import_invoice: Optional[str] = Field(None, max_length=15, description="Import invoice (FACTURAIMPO)")
import_line: Optional[int] = Field(None, description="Import line (LINEAIMPO)")
image_path: Optional[str] = Field(None, max_length=255, description="Image path (IMAGEPATHMEX)")
class SerieCreate(SerieBase):

View File

@@ -12,10 +12,12 @@ After refactoring: LineItem is the main entity, representing a single line item
There is no intermediate Item entity anymore. Each LineItem belongs directly to an InvoiceHeader.
"""
import datetime
import logging
from decimal import Decimal
from typing import Optional, List, Tuple
from fastapi import HTTPException
from sqlalchemy import and_, or_
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
@@ -40,6 +42,8 @@ from .models import LineItem
from .series.models import Serie
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
from api.v1.modules.a24.balance_movements.models import BalanceMovement, NEGATIVE_MOVEMENTS
logger = logging.getLogger(__name__)
@@ -166,7 +170,7 @@ class ItemService:
# FA data uses line.id as primary key
if line_data.fa_data:
fa_dict = line_data.fa_data.model_dump(
exclude_unset=True, exclude={"line_item_id"}
exclude_unset=True, exclude={"line_item_id", "includes_subitems"}
)
fa_dict.update(
{"id": line.id, "tenant_id": tenant_id, "company_id": company_id}
@@ -195,6 +199,26 @@ class ItemService:
serie_dict["row"] = 1
db.add(Serie(**serie_dict))
# Identifier Detail data
if hasattr(line_data, "identifiers") and line_data.identifiers:
id_list = (
line_data.identifiers
if isinstance(line_data.identifiers, list)
else [line_data.identifiers]
)
for d in id_list:
id_dict = (
d.model_dump(exclude_unset=True)
if hasattr(d, "model_dump")
else (dict(d) if isinstance(d, dict) else {})
)
if not id_dict:
continue
id_dict["item_line_id"] = line.id
id_dict["tenant_id"] = tenant_id
id_dict["company_id"] = company_id
db.add(IdentifierDetail(**id_dict))
@staticmethod
def _attach_series(db: Session, item: LineItem) -> None:
"""Query and attach all Serie rows for this item as a list."""
@@ -206,6 +230,16 @@ class ItemService:
)
item.series = list(series)
@staticmethod
def _attach_identifiers(db: Session, item: LineItem) -> None:
"""Query and attach all IdentifierDetail rows for this item."""
identifiers = (
db.query(IdentifierDetail)
.filter(IdentifierDetail.item_line_id == item.id)
.all()
)
item.identifiers = list(identifiers)
@staticmethod
def get_by_id(
db: Session, item_id: int, tenant_id: int, company_id: int
@@ -232,6 +266,7 @@ class ItemService:
)
if result:
ItemService._attach_series(db, result)
ItemService._attach_identifiers(db, result)
return result
@staticmethod
@@ -285,6 +320,7 @@ class ItemService:
items = query.offset(skip).limit(limit).all()
for item in items:
ItemService._attach_series(db, item)
ItemService._attach_identifiers(db, item)
return items, total
@staticmethod
@@ -318,6 +354,7 @@ class ItemService:
items = query.offset(skip).limit(limit).all()
for item in items:
ItemService._attach_series(db, item)
ItemService._attach_identifiers(db, item)
return items, total
@staticmethod
@@ -338,10 +375,11 @@ class ItemService:
errors.raise_if_errors("Error al crear el item - invoice_id es requerido")
invoice = invoice_exists_by_id(
db, item_data.invoice_id, tenant_id, company_id, errors
db, item_data.invoice_id, tenant_id, company_id, None
)
if not invoice:
errors.add_error("invoice_id", "La factura no existe", code="NOT_FOUND", value=str(item_data.invoice_id))
errors.raise_if_errors("Error al encontra la factura para el item")
if not invoice_processed(db, item_data.invoice_id, tenant_id, company_id, errors):
errors.raise_if_errors("Error al crear el item - la factura ya fue actualizada, no se pueden agregar items")
@@ -443,6 +481,7 @@ class ItemService:
db.commit()
db.refresh(db_item)
ItemService._attach_series(db, db_item)
ItemService._attach_identifiers(db, db_item)
return db_item
except IntegrityError as e:
@@ -476,9 +515,10 @@ class ItemService:
errors = ErrorCollector()
invoice = invoice_exists_by_id(
db, item_data.invoice_id, tenant_id, company_id, errors
db, item_data.invoice_id, tenant_id, company_id, None
)
if not invoice:
errors.add_error("invoice_id", "La factura no existe", code="NOT_FOUND", value=str(item_data.invoice_id))
errors.raise_if_errors("Error al encontra la factura para el item")
# Lock invoice
@@ -591,6 +631,7 @@ class ItemService:
).delete()
db.query(FaLineItem).filter(FaLineItem.id == db_item.id).delete()
db.query(Serie).filter(Serie.line_item_id == db_item.id).delete()
db.query(IdentifierDetail).filter(IdentifierDetail.item_line_id == db_item.id).delete()
db.flush()
# Create new nested data
@@ -604,6 +645,7 @@ class ItemService:
db.commit()
db.refresh(db_item)
ItemService._attach_series(db, db_item)
ItemService._attach_identifiers(db, db_item)
return db_item
except HTTPException:
@@ -647,3 +689,125 @@ class ItemService:
db.rollback()
logger.error(f"Error deleting item: {e}")
raise HTTPException(status_code=500, detail="Error deleting item")
@staticmethod
def get_lines_with_balance(
db: Session,
invoice_id: int,
tenant_id: int,
company_id: int,
as_of_date: Optional[datetime.date] = None,
) -> List[dict]:
"""
Returns every line of an import invoice together with its current
available balance calculated from the a24.balance_movement ledger.
Lines with balance <= 0 are included but marked as unavailable so
the frontend can grey them out / disable them.
Parameters
----------
as_of_date : optional cut-off date. Only negative movements
(consumptions, etc.) on or before this date are counted,
mirroring the CALCULA_SALDO_FECHA_EXPO Clarion logic.
If None, all movements are counted (no date restriction).
"""
lines: List[LineItem] = (
db.query(LineItem)
.filter(
LineItem.invoice_id == invoice_id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.options(
joinedload(LineItem.quantity),
joinedload(LineItem.description),
joinedload(LineItem.part_info),
joinedload(LineItem.class_info),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.fa_data),
joinedload(LineItem.invoice),
)
.order_by(LineItem.line_number)
.all()
)
result = []
for line in lines:
available_balance = ItemService._compute_balance(db, line.id, as_of_date)
qty = line.quantity
desc = line.description
fa = line.fa_data
inv = line.invoice
# Count subitems (lines that reference this line as parent via subitem_number)
subitem_count = 0
if fa and fa.contains_subitems:
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem as FaModel
subitem_count = (
db.query(func.count(LineItem.id))
.join(FaModel, FaModel.id == LineItem.id)
.filter(
LineItem.invoice_id == invoice_id,
LineItem.tenant_id == tenant_id,
FaModel.is_subitem == True,
FaModel.subitem_number == line.line_number,
)
.scalar() or 0
)
result.append({
"id": line.id,
"line_number": line.line_number,
# Invoice info
"invoice_number": inv.invoice_number if inv else None,
"invoice_date": inv.invoice_date.isoformat() if inv and inv.invoice_date else None,
"invoice_status": inv.status if inv and inv.status else None,
# Part / class
"part_number": line.part_info.part_number if line.part_info else None,
"class_code": line.class_info.class_code if line.class_info else None,
"description_spanish": desc.description_spanish if desc else None,
"unit_of_measure_code": line.unit_of_measure_info.code if line.unit_of_measure_info else None,
# Quantities
"quantity": float(qty.quantity) if qty and qty.quantity is not None else None,
"quantity_returned_temp": float(qty.quantity_returned_temp) if qty and qty.quantity_returned_temp is not None else None,
"quantity_returned": float(qty.quantity_returned) if qty and qty.quantity_returned is not None else None,
# Balance
"available_balance": float(available_balance),
"has_balance": available_balance > Decimal(0),
# FA / subitem info
"is_subitem": fa.is_subitem if fa else None,
"contains_subitems": fa.contains_subitems if fa else None,
"subitem_count": subitem_count,
})
return result
@staticmethod
def _compute_balance(
db: Session,
item_line_id: int,
as_of_date: Optional[datetime.date],
) -> Decimal:
"""Net available balance for one import line from the ledger."""
sign_expr = case(
(BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), Decimal(-1)),
else_=Decimal(1),
)
if as_of_date is not None:
date_filter = case(
(
BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS),
BalanceMovement.operation_date <= as_of_date,
),
else_=True,
)
else:
date_filter = True # type: ignore[assignment]
result = db.execute(
select(func.sum(sign_expr * BalanceMovement.quantity)).where(
BalanceMovement.import_item_line_id == item_line_id,
date_filter,
)
).scalar()
return Decimal(str(result or 0))

View File

@@ -12,6 +12,22 @@ from ..common.cell_value import cell_to_str
# Valores que indican que la primera fila es cabecera (primera columna normalizada)
FIRST_COLUMN_HEADER_VALUES = ("CLAVE CLASE", "CLASE")
_ENCODING_FALLBACKS: Tuple[str, ...] = ("utf-8-sig", "utf-8", "cp1252", "latin-1")
def _read_text_sample(file_path: str, sample_bytes: int = 2048) -> str:
with open(file_path, "rb") as f:
raw = f.read(sample_bytes)
last_err: Optional[Exception] = None
for enc in _ENCODING_FALLBACKS:
try:
return raw.decode(enc)
except Exception as e:
last_err = e
if last_err:
raise last_err
return ""
def detect_headers_or_data(
file_path: str,
@@ -26,8 +42,15 @@ def detect_headers_or_data(
- Si no -> has_header=False, fieldnames=TEMPLATE_DOWNLOAD_HEADERS (la primera fila es dato).
"""
try:
with open(file_path, "r", encoding=encoding) as f:
sample = f.read(2048)
# `encoding` se mantiene por compatibilidad; si falla, hacemos fallback para CSVs tipo Excel (cp1252/latin-1).
if encoding and encoding.lower() not in ("auto", "detect"):
try:
with open(file_path, "r", encoding=encoding) as f:
sample = f.read(2048)
except Exception:
sample = _read_text_sample(file_path, sample_bytes=2048)
else:
sample = _read_text_sample(file_path, sample_bytes=2048)
except Exception:
return None, True
lines = sample.splitlines()

View File

@@ -3,11 +3,16 @@ Validadores reutilizables para import CSV de clientes y proveedores.
Paridad Clarion: procedencia E/N, tipo C/P/A, clave máx 8, SECON, Prosec, Vinculación,
Es Empresa Certificada, Transformador/SubMaq, desfase.
"""
import re
from typing import Dict, Any, Optional
from api.v1.modules.a76.clients_and_providers.models import ClientOrProviderEnum
RFC_MAX = 30
# Formato RFC México: 3-4 letras, 6 dígitos, 3 homoclave. TAX-ID: 2 dígitos, guión, resto. Ej: 12-3456789.
RFC_PATTERN = re.compile(r"^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$", re.IGNORECASE)
TAX_ID_PATTERN = re.compile(r"^\d{2}-[A-Z0-9]{1,27}$", re.IGNORECASE)
NAME_MAX = 256
SHORT_NAME_MAX = 10
# Clarion: Col C máx 8 caracteres
@@ -29,6 +34,36 @@ def check_required_max(row: Dict[str, Any], col: str, max_len: int, line_num: in
return None
def check_rfc_format(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
"""Col E (RFC): si tiene valor, debe cumplir formato RFC México."""
val = (row.get("RFC") or "").strip()
if not val:
return None
if not RFC_PATTERN.match(val.upper()):
return {
"line": line_num,
"col": "RFC",
"msg": "El formato del RFC es inválido.",
"solution": "Capturar en la columna E un RFC con formato válido (ej. XAXX010101000).",
}
return None
def check_tax_id_format(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
"""Col E (TAX-ID): 2 dígitos, guión y resto alfanumérico. Ej: 12-3456789. Máx 30 caracteres."""
val = (row.get("RFC") or "").strip()
if not val:
return None
if len(val) > 30 or not TAX_ID_PATTERN.match(val):
return {
"line": line_num,
"col": "RFC",
"msg": "El formato del TAX-ID es inválido.",
"solution": "Capturar en la columna E un TAX-ID con formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres.",
}
return None
def check_max_length(row: Dict[str, Any], col: str, max_len: int, line_num: int) -> Optional[Dict[str, Any]]:
val = (row.get(col) or "").strip()
if not val:

View File

@@ -43,7 +43,6 @@ MAX_LEN = {
"prosec_authorization": 20,
"secon_authorization": 20,
"manufacturer_id": 25,
"tax_id_programs": 30,
"broker": 6,
"import_broker": 6,
"transfer_key": 8,
@@ -85,11 +84,15 @@ def row_to_client_provider_data(
"""
Mapea fila normalizada a datos para ClientProvider, ClientProviderAddress y ClientProviderPrograms.
Devuelve (cp_data, address_data_or_none, programs_data_or_none).
Para compatibilidad con Clarion: se requiere RFC o SHORT_NAME para considerar la fila válida.
Identificador fiscal unificado: solo se guarda en ClientProvider.rfc (RFC o TAX-ID según procedencia).
Se toma de columna RFC (E); si viene vacía y hay TAX_ID_PROGRAMS (AB), se usa esa para la misma columna rfc.
"""
rfc = _str_or_none(row_norm.get("RFC"), MAX_LEN["rfc"])
rfc_col = _str_or_none(row_norm.get("RFC"), MAX_LEN["rfc"])
tax_id_programs_col = _str_or_none(row_norm.get("TAX_ID_PROGRAMS"), MAX_LEN["rfc"])
# Una sola columna: identificador fiscal en cp.rfc (nacional=RFC, extranjero=TAX-ID)
rfc_unified = rfc_col or tax_id_programs_col
short_name = _str_or_none(row_norm.get("SHORT_NAME"), MAX_LEN["short_name"])
if not rfc and not short_name:
if not rfc_unified and not short_name:
return ({}, None, None)
client_or_provider = parse_client_or_provider(row_norm.get("TIPO")) or ClientOrProviderEnum.BOTH
@@ -110,7 +113,7 @@ def row_to_client_provider_data(
cp_data = {
"tenant_id": tenant_id,
"company_id": company_id,
"rfc": rfc or None,
"rfc": rfc_unified or None,
"name": _str_or_none(row_norm.get("NOMBRE"), MAX_LEN["name"]),
"short_name": short_name,
"curp": _str_or_none(row_norm.get("CURP"), MAX_LEN["curp"]),
@@ -124,7 +127,6 @@ def row_to_client_provider_data(
"position": _str_or_none(row_norm.get("POSICION"), MAX_LEN["position"]),
"incoterm": _str_or_none(row_norm.get("INCOTERM"), MAX_LEN["incoterm"]),
"is_active": parse_active(row_norm.get("ACTIVO")),
"is_national_provider": True if procedencia == "N" else (False if procedencia == "E" else None),
}
# Address
@@ -174,12 +176,12 @@ def row_to_client_provider_data(
tipo_prog or num_prog or fecha_secon is not None or prosec_val or num_aut_prosec
or is_certified or reg_cert
or _str_or_none(row_norm.get("MANUFACTURER_ID"))
or _str_or_none(row_norm.get("TAX_ID_PROGRAMS"))
or _str_or_none(row_norm.get("BROKER_EXPO"))
or _str_or_none(row_norm.get("BROKER_IMPO"))
or _str_or_none(row_norm.get("CLAVE_TRANSFER"))
or applied_proportion is not None
):
# Identificador fiscal solo en ClientProvider.rfc (columna única)
programs_data = {
"program": tipo_prog[:7] if tipo_prog else None,
"program_number": num_prog,
@@ -190,7 +192,6 @@ def row_to_client_provider_data(
"is_certified_company": is_certified,
"certified_company_registry": reg_cert,
"manufacturer_id": _str_or_none(row_norm.get("MANUFACTURER_ID"), MAX_LEN["manufacturer_id"]),
"tax_id": _str_or_none(row_norm.get("TAX_ID_PROGRAMS"), MAX_LEN["tax_id_programs"]),
"broker": _str_or_none(row_norm.get("BROKER_EXPO"), MAX_LEN["broker"]),
"import_broker": _str_or_none(row_norm.get("BROKER_IMPO"), MAX_LEN["import_broker"]),
"transfer_key": _str_or_none(row_norm.get("CLAVE_TRANSFER"), MAX_LEN["transfer_key"]),

View File

@@ -13,6 +13,8 @@ from ..common.common_validators import (
CURP_MAX,
check_required_max,
check_max_length,
check_rfc_format,
check_tax_id_format,
check_tipo_client_provider,
check_procedencia,
check_short_name_max_clarion,
@@ -161,8 +163,23 @@ def validate_row_client_provider(
if err:
return err
# Compatibilidad: RFC requerido y longitudes (como antes)
# Identificador fiscal requerido: Nacional = RFC, Extranjero = TAX-ID (mismo campo "RFC" en layout).
procedencia = (row.get("PROCEDENCIA") or "").strip().upper()[:1]
err = check_required_max(row, "RFC", RFC_MAX, line_num)
if err:
if procedencia == "E":
err = {
"line": line_num,
"col": "RFC",
"msg": "Requerido (TAX-ID)",
"solution": "Capturar el TAX-ID del cliente/proveedor extranjero en la columna E (RFC/TAX-ID).",
}
return err
# Validar formato según procedencia: RFC (N) o TAX-ID (E).
if procedencia == "N":
err = check_rfc_format(row, line_num)
elif procedencia == "E":
err = check_tax_id_format(row, line_num)
if err:
return err
err = check_max_length(row, "NOMBRE", NAME_MAX, line_num)

View File

@@ -5,7 +5,7 @@ Si se pasa headerless_first_cell_values, se detecta si la primera fila es cabece
"""
import csv
import io
from typing import Iterator, Tuple, Dict, Any, Optional, List, Set
from typing import Iterator, Tuple, Dict, Any, Optional, List, Set, Sequence
def _normalize_empty_headers(headers: List[str]) -> List[str]:
@@ -21,6 +21,32 @@ def _normalize_empty_headers(headers: List[str]) -> List[str]:
return result
_ENCODING_FALLBACKS: Sequence[str] = ("utf-8-sig", "utf-8", "cp1252", "latin-1")
def _detect_text_encoding(
file_path: str,
encodings: Sequence[str] = _ENCODING_FALLBACKS,
sample_bytes: int = 8192,
) -> str:
"""
Detecta encoding por prueba de decodificación en un sample en binario.
Nota: latin-1 decodifica cualquier byte; por eso debe ir al final.
"""
with open(file_path, "rb") as f:
raw = f.read(sample_bytes)
last_err: Optional[Exception] = None
for enc in encodings:
try:
raw.decode(enc)
return enc
except Exception as e:
last_err = e
if last_err:
raise last_err
return "utf-8-sig"
def iter_csv_rows(
file_path: str,
fieldnames: Optional[List[str]] = None,
@@ -34,7 +60,8 @@ def iter_csv_rows(
(quitando BOM, strip, upper) está en headerless_first_cell_values, se trata como dato y se usan fieldnames.
headerless_second_cell_key_pattern se ignora si no se usa (reservado para otros layouts).
"""
with open(file_path, "r", encoding="utf-8-sig") as f:
encoding = _detect_text_encoding(file_path)
with open(file_path, "r", encoding=encoding) as f:
sample = f.read(2048)
f.seek(0)
try:
@@ -91,6 +118,7 @@ def iter_csv_rows(
def count_csv_rows(file_path: str, has_header: bool = True) -> int:
"""Cuenta filas del CSV. Si has_header=True (por defecto), no cuenta la cabecera."""
with open(file_path, "r", encoding="utf-8-sig") as f:
encoding = _detect_text_encoding(file_path)
with open(file_path, "r", encoding=encoding) as f:
total_lines = sum(1 for _ in f)
return total_lines if not has_header else max(0, total_lines - 1)

View File

@@ -895,7 +895,7 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.sectors.models import Sector
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction
@@ -1018,7 +1018,11 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
valid_fraction_ame.add((row[0] or "").strip())
authorized_sectors: Set[str] = set()
for row in session.query(Sector.key).filter(Sector.authorized == True).all():
for row in session.query(Sector.key).filter(
Sector.authorized == True,
Sector.tenant_id == tenant_id,
Sector.company_id == company_id,
).all():
if row[0]:
authorized_sectors.add((row[0] or "").strip().upper())
@@ -1151,7 +1155,7 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.sectors.models import Sector
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction
@@ -1277,7 +1281,11 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
valid_fraction_ame.add((row[0] or "").strip())
authorized_sectors: Set[str] = set()
for row in session.query(Sector.key).filter(Sector.authorized == True).all():
for row in session.query(Sector.key).filter(
Sector.authorized == True,
Sector.tenant_id == tenant_id,
Sector.company_id == company_id,
).all():
if row[0]:
authorized_sectors.add((row[0] or "").strip().upper())
@@ -1679,7 +1687,7 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.sectors.models import Sector
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction
@@ -1804,7 +1812,11 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
valid_fraction_ame.add((row[0] or "").strip())
authorized_sectors: Set[str] = set()
for row in session.query(Sector.key).filter(Sector.authorized == True).all():
for row in session.query(Sector.key).filter(
Sector.authorized == True,
Sector.tenant_id == tenant_id,
Sector.company_id == company_id,
).all():
if row[0]:
authorized_sectors.add((row[0] or "").strip().upper())

View File

@@ -54,7 +54,7 @@ def load_parts_fk_sets(
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.sectors.models import Sector
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import (
HistoricalTariffFraction,
@@ -102,7 +102,11 @@ def load_parts_fk_sets(
for row in (
session.query(Sector.key)
.filter(Sector.authorized == True)
.filter(
Sector.authorized == True,
Sector.tenant_id == tenant_id,
Sector.company_id == company_id,
)
.all()
):
if row[0]:

View File

@@ -5,6 +5,7 @@ Paridad Clarion: VALIDA_TODA_PARTES (obligatorios A, B, E salvo excepción RFC),
from typing import Dict, Any, Optional, Set
from ..common.common_validators import check_max_length, check_decimal
from decimal import Decimal, InvalidOperation
MSG_NUMPARTE_VACIO = (
@@ -108,9 +109,33 @@ def validate_row_types(row: Dict[str, Any], line_num: int) -> Optional[Dict[str,
err = check_decimal(row, "COSTOUNIT", line_num)
if err:
return err
# No negativos
raw = (row.get("COSTOUNIT") or "").strip()
if raw:
try:
if Decimal(raw) < 0:
return {
"line": line_num,
"col": "COSTOUNIT",
"msg": "Error: (Col. I) El Costo Unitario no puede ser negativo.",
}
except (InvalidOperation, ValueError):
# check_decimal already handles format; ignore here
pass
err = check_decimal(row, "PESOUNIT", line_num)
if err:
return err
raw = (row.get("PESOUNIT") or "").strip()
if raw:
try:
if Decimal(raw) < 0:
return {
"line": line_num,
"col": "PESOUNIT",
"msg": "Error: (Col. K) El Peso Unitario no puede ser negativo.",
}
except (InvalidOperation, ValueError):
pass
return None
@@ -292,6 +317,15 @@ def validate_row_sector(
"""Col P (Sector): si O=PROSEC entonces P obligatorio, empresa PROSEC, sector autorizado; si O≠PROSEC y P no vacío error."""
pref = (row.get("PREFERENCIA") or "").strip().upper()
sector = (row.get("SECTOR") or "").strip()
# Formato: solo A-Z/0-9 (sin espacios ni especiales), hasta 8
if sector:
s = sector.strip().upper()
if not (1 <= len(s) <= 8) or not s.isalnum():
return {
"line": line_num,
"col": "SECTOR",
"msg": "Error: (Col. P) El Sector contiene caracteres no permitidos. Use solo letras y números sin espacios (máx. 8 caracteres).",
}
if pref == "PROSEC":
if not sector:
return {

View File

@@ -35,7 +35,7 @@ def load_pedimentos_fk_sets(
valid_clave_regimen_tipo, # (pedimento_code, regimen_code, type_code)
valid_aduana_seccion, # customs_code
existing_pedimento_keys, # key strings para actualizar
valid_anexo22_claves, # stub vacío hasta tener catálogo
valid_anexo22_claves, # catálogo de transporte Anexo 22 (pedimento_transport_catalog.code)
valid_patentes, # CustomsBroker.license (tenant/company)
short_name_to_id, # short_name normalizado (upper) -> client id (primera aparición gana)
"""
@@ -48,6 +48,9 @@ def load_pedimentos_fk_sets(
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento
from api.v1.modules.public.reference_data.pedimento_transport_catalog.models import (
PedimentoTransportCatalog,
)
valid_client_ids: Set[int] = set()
valid_regimes: Set[str] = set()
@@ -96,6 +99,10 @@ def load_pedimentos_fk_sets(
for cs in session.query(CustomsSection).all():
valid_aduana_seccion.add(cs.customs_code.strip())
for tm in session.query(PedimentoTransportCatalog.code).all():
if (tm[0] or "").strip():
valid_anexo22_claves.add((tm[0] or "").strip().upper())
for cb in (
session.query(CustomsBroker)
.filter(

View File

@@ -1,14 +1,14 @@
from datetime import datetime
from decimal import Decimal
from typing import List, Optional
from typing import List, Optional, Literal
from pydantic import BaseModel, Field, ConfigDict
from api.v1.modules.a24.inv.inv_aphis.dto import InvPartAphisGeneralDTO
# --- SUB-DTO: DATOS ADUANALES (FaData) ---
class FaDataDTO(BaseModel):
origin_country: Optional[str] = None
sector: Optional[str] = None
fraction_type: Optional[str] = None
origin_country: Optional[str] = Field(default=None, pattern=r"^[A-Z]{3}$")
sector: Optional[str] = Field(default=None, pattern=r"^[A-Za-z0-9]{1,8}$")
fraction_type: Optional[Literal["GENERAL", "PROSEC", "ALADI", "TLCS"]] = None
model_config = ConfigDict(from_attributes=True)
@@ -154,11 +154,11 @@ class PartBase(BaseModel):
part_class: Optional[str] = None
unit_of_measure: Optional[str] = "PZ"
unit_cost: Optional[Decimal] = None
unit_cost: Optional[Decimal] = Field(default=None, ge=0)
currency_key: Optional[str] = None
currency_type: Optional[str] = None
unit_weight: Optional[Decimal] = None
unit_weight: Optional[Decimal] = Field(default=None, ge=0)
weight_type: Optional[str] = None
fraction: Optional[str] = None

View File

@@ -8,6 +8,9 @@ from api.v1.modules.public.reference_data.customs_sections.models import Customs
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen
from api.v1.modules.public.reference_data.transport_types.models import TransportType
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode
from api.v1.modules.public.reference_data.pedimento_transport_catalog.models import (
PedimentoTransportCatalog,
)
# Import A76 Services
from api.v1.modules.a76.customs_brokers.services import CustomsBrokerService
@@ -19,6 +22,9 @@ from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSec
from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO
from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO
from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO
from api.v1.modules.public.reference_data.pedimento_transport_catalog.dto import (
PedimentoTransportCatalogDTO,
)
from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO
from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO
from .dtos.pedimentos import PedimentosResponse
@@ -71,6 +77,16 @@ class PedimentoCatalogService:
except Exception as e:
print(f"Error fetching transport_modes: {e}")
try:
response.pedimento_transport_catalog = [
PedimentoTransportCatalogDTO.model_validate(obj)
for obj in db.query(PedimentoTransportCatalog)
.order_by(PedimentoTransportCatalog.code.asc())
.all()
]
except Exception as e:
print(f"Error fetching pedimento_transport_catalog: {e}")
# Helper to fetch tenant/company specific data
def fetch_tenant_data():
# Customs Brokers

View File

@@ -10,27 +10,27 @@ class PedimentoTransportMeansBase(BaseModel):
pedimento_id: Optional[int] = Field(None, description="Pedimento ID")
tenant_id: Optional[int] = Field(None, description="Tenant ID")
destination: Optional[int] = Field(None, description="Destination")
entry_exit: Optional[str] = Field(None, max_length=2, description="Entry/exit")
arrival: str = Field(..., max_length=2, description="Arrival")
departure: str = Field(..., max_length=2, description="Departure")
entry_exit: Optional[str] = Field(None, max_length=3, description="Entry/exit")
arrival: str = Field(..., max_length=3, description="Arrival")
departure: str = Field(..., max_length=3, description="Departure")
class PedimentoTransportMeansCreate(BaseModel):
"""Schema for creating a new Pedimento Transport Means - pedimento_id and tenant_id are set by backend"""
destination: Optional[int] = Field(None, description="Destination")
entry_exit: Optional[str] = Field(None, max_length=2, description="Entry/exit")
arrival: str = Field(..., max_length=2, description="Arrival")
departure: str = Field(..., max_length=2, description="Departure")
entry_exit: Optional[str] = Field(None, max_length=3, description="Entry/exit")
arrival: str = Field(..., max_length=3, description="Arrival")
departure: str = Field(..., max_length=3, description="Departure")
class PedimentoTransportMeansUpdate(BaseModel):
"""Schema for updating a Pedimento Transport Means"""
destination: Optional[int] = None
entry_exit: Optional[str] = Field(None, max_length=2)
arrival: Optional[str] = Field(None, max_length=2)
departure: Optional[str] = Field(None, max_length=2)
entry_exit: Optional[str] = Field(None, max_length=3)
arrival: Optional[str] = Field(None, max_length=3)
departure: Optional[str] = Field(None, max_length=3)
class PedimentoTransportMeansResponse(PedimentoTransportMeansBase):

View File

@@ -39,9 +39,9 @@ class PedimentoTransportMeans(Base, TenantScopedMixin, TimestampMixin):
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
destination: Mapped[int] = mapped_column(SmallInteger)
entry_exit: Mapped[str] = mapped_column(String(2))
arrival: Mapped[str] = mapped_column(String(2))
departure: Mapped[str] = mapped_column(String(2))
entry_exit: Mapped[str] = mapped_column(String(3))
arrival: Mapped[str] = mapped_column(String(3))
departure: Mapped[str] = mapped_column(String(3))
pedimento: Mapped["Pedimentos"] = relationship(
"Pedimentos", back_populates="pedimento_transport_means"

View File

@@ -13,6 +13,9 @@ from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO
from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO
from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO
from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO
from api.v1.modules.public.reference_data.pedimento_transport_catalog.dto import (
PedimentoTransportCatalogDTO,
)
from .dtos.pedimentos import PedimentosResponse
@@ -26,6 +29,7 @@ class PedimentoCatalogsResponse(BaseModel):
clients: List[ClientProviderResponseDTO] = []
transport_types: List[TransportTypeDTO] = []
transport_modes: List[TransportModeDTO] = []
pedimento_transport_catalog: List[PedimentoTransportCatalogDTO] = []
class PedimentoCreationResponse(PedimentoCatalogsResponse):

View File

@@ -136,12 +136,8 @@ class AvisoConsolidadoExportacionService:
if client_obj:
# Fetch Address
c_addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == target_client_id).first()
# Fetch Fiscal Data (RFC)
c_prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == target_client_id).first()
c_rfc = ""
if c_prog and c_prog.tax_id: c_rfc = c_prog.tax_id
elif hasattr(client_obj, 'rfc'): c_rfc = client_obj.rfc
# Identificador fiscal único en ClientProvider.rfc (RFC o TAX-ID)
c_rfc = getattr(client_obj, "rfc", "") or ""
c_dir_str = "DOMICILIO NO REGISTRADO"
if c_addr:

View File

@@ -127,11 +127,7 @@ class ConsolidadoImportacionMexService:
ciudad=(addr.city or "") if addr else "",
estado=(addr.state or "") if addr else "",
pais=(addr.country or "MEX") if addr else "MEX",
tax_id=(
prog.tax_id
if (prog and prog.tax_id)
else (getattr(main, "rfc", "") or "")
),
tax_id=getattr(main, "rfc", "") or "",
programa="IMMEX" if (prog and prog.program) else "",
autorizacion=prog.program_number if prog else "",
prosec=(

View File

@@ -82,7 +82,7 @@ class ConsolidadoImportacionMexService:
ciudad=(addr.city or "") if addr else "",
estado=(addr.state or "") if addr else "",
pais=(addr.country or "MEX") if addr else "MEX",
tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
tax_id=getattr(main, "rfc", "") or "",
programa="IMMEX" if (prog and prog.program) else "",
autorizacion=prog.program_number if prog else "",
prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "",

View File

@@ -159,11 +159,7 @@ class FacturaImportacionMexService:
ciudad=(addr.city or "") if addr else "",
estado=(addr.state or "") if addr else "",
pais=(addr.country or "MEX") if addr else "MEX",
tax_id=(
prog.tax_id
if (prog and prog.tax_id)
else (getattr(main, "rfc", "") or "")
),
tax_id=getattr(main, "rfc", "") or "",
programa="IMMEX" if (prog and prog.program) else "",
autorizacion=prog.program_number if prog else "",
prosec=(

View File

@@ -82,7 +82,7 @@ class FacturaImportacionMexService:
ciudad=(addr.city or "") if addr else "",
estado=(addr.state or "") if addr else "",
pais=(addr.country or "MEX") if addr else "MEX",
tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
tax_id=getattr(main, "rfc", "") or "",
programa="IMMEX" if (prog and prog.program) else "",
autorizacion=prog.program_number if prog else "",
prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "",

View File

@@ -153,11 +153,7 @@ class FacturaImportacionUsaService:
ciudad=(addr.city or "") if addr else "",
estado=(addr.state or "") if addr else "",
pais=(addr.country or "USA") if addr else "USA",
tax_id=(
prog.tax_id
if (prog and prog.tax_id)
else (getattr(main, "rfc", "") or "")
),
tax_id=getattr(main, "rfc", "") or "",
programa="IMMEX" if (prog and prog.program) else "",
autorizacion=prog.program_number if prog else "",
prosec=(

View File

@@ -103,7 +103,7 @@ class PackingListService:
ciudad=(addr.city or "") if addr else "",
estado=(addr.state or "") if addr else "",
pais=(addr.country or "MEX") if addr else "MEX",
tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
tax_id=getattr(main, "rfc", "") or "",
programa="IMMEX" if (prog and prog.program) else "",
autorizacion=prog.program_number if prog else "",
prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "",

View File

@@ -108,22 +108,24 @@ class DatabaseHelper:
if not client_code:
return {"name": None, "rfc": None, "tax_id": None}
client_type = 'PROVIDER' if is_supplier else 'CLIENT'
# In our schema, enum values are lowercase: 'client', 'provider', 'both'
client_type = 'provider' if is_supplier else 'client'
try:
sql = text("""
SELECT cp.name, cp.rfc, cpp.tax_id
SELECT cp.name, cp.rfc
FROM a76.clients_and_providers cp
LEFT JOIN a76.clients_and_providers_programs cpp ON cpp.client_id = cp.id
WHERE cp.id = :client_code AND cp.client_or_provider = :client_type
WHERE cp.id = :client_code
AND (cp.client_or_provider = :client_type OR cp.client_or_provider = 'both')
""")
result = db.execute(sql, {"client_code": client_code, "client_type": client_type}).fetchone()
if result:
# Unified identifier: cp.rfc contains either RFC (national) or TAX-ID (foreign)
return {
"name": result[0],
"rfc": result[1],
"tax_id": result[2]
"tax_id": result[1],
}
else:
logger.debug(f"Client {client_code} not found as {client_type}")

View File

@@ -433,11 +433,11 @@ class RepairImportQueries:
@staticmethod
def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str:
"""Build optimized query for NORMAL mode (grouped by invoice with totals)."""
# Use fa_item_lines.download field (true = discharged, false = not discharged)
# Use fa_item_lines.discharge field (true = discharged, false = not discharged)
if "SiDes" in discharge_clause:
discharge_filter = "AND fil.download = true"
discharge_filter = "AND fil.discharge = true"
elif "NoDes" in discharge_clause:
discharge_filter = "AND fil.download = false"
discharge_filter = "AND fil.discharge = false"
else:
discharge_filter = ""
@@ -514,11 +514,11 @@ class RepairImportQueries:
@staticmethod
def build_main_query(db_name: str, where_str: str, discharge_clause: str = "") -> str:
"""Build main SQL query for repair import data."""
# Use fa_item_lines.download field (true = discharged, false = not discharged)
# Use fa_item_lines.discharge field (true = discharged, false = not discharged)
if "SiDes" in discharge_clause:
discharge_filter = "AND fil.download = true"
discharge_filter = "AND fil.discharge = true"
elif "NoDes" in discharge_clause:
discharge_filter = "AND fil.download = false"
discharge_filter = "AND fil.discharge = false"
else:
discharge_filter = ""
return f"""
@@ -609,11 +609,11 @@ class RepairImportQueries:
@staticmethod
def build_totals_query(db_name: str, discharge_clause: str = "") -> str:
"""Build query to get totals for a repair import invoice."""
# Use fa_item_lines.download field (true = discharged, false = not discharged)
# Use fa_item_lines.discharge field (true = discharged, false = not discharged)
if "SiDes" in discharge_clause:
discharge_filter = "AND fil.download = true"
discharge_filter = "AND fil.discharge = true"
elif "NoDes" in discharge_clause:
discharge_filter = "AND fil.download = false"
discharge_filter = "AND fil.discharge = false"
else:
discharge_filter = ""
return f"""
@@ -818,11 +818,11 @@ class ExportQueries:
Only sums partidas where is_subitem is false (main partidas, not sub-items).
"""
# Use fa_item_lines.download field (true = discharged, false = not discharged)
# Use fa_item_lines.discharge field (true = discharged, false = not discharged)
if "SiDes" in discharge_clause:
discharge_filter = "AND fil.download = true"
discharge_filter = "AND fil.discharge = true"
elif "NoDes" in discharge_clause:
discharge_filter = "AND fil.download = false"
discharge_filter = "AND fil.discharge = false"
else:
discharge_filter = ""
return f"""
@@ -861,11 +861,11 @@ class ExportRepairQueries:
@staticmethod
def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str:
"""Build optimized query for NORMAL mode (grouped by invoice with totals)."""
# Use fa_item_lines.download field (true = discharged, false = not discharged)
# Use fa_item_lines.discharge field (true = discharged, false = not discharged)
if "SiDes" in discharge_clause:
discharge_filter = "AND fil.download = true"
discharge_filter = "AND fil.discharge = true"
elif "NoDes" in discharge_clause:
discharge_filter = "AND fil.download = false"
discharge_filter = "AND fil.discharge = false"
else:
discharge_filter = ""
@@ -1033,11 +1033,11 @@ class ExportRepairQueries:
@staticmethod
def build_totals_query(db_name: str, discharge_clause: str = "") -> str:
"""Build query to get totals for an export repair invoice."""
# Use fa_item_lines.download field (true = discharged, false = not discharged)
# Use fa_item_lines.discharge field (true = discharged, false = not discharged)
if "SiDes" in discharge_clause:
discharge_filter = "AND fil.download = true"
discharge_filter = "AND fil.discharge = true"
elif "NoDes" in discharge_clause:
discharge_filter = "AND fil.download = false"
discharge_filter = "AND fil.discharge = false"
else:
discharge_filter = ""
return f"""

View File

@@ -227,7 +227,7 @@ BASE_SELECT = """
COALESCE(icm.vucem_operation_num,'') AS "C41",
COALESCE(cl.material_key,'') AS "C42",
CONCAT(ped.year,'-',ped.customs_office,'-',ped.license,'-',ped.pedimento_number) AS "C43",
'' AS "C44",
COALESCE(ptc.payment_date_code, 'P') AS "C44",
COALESCE(ilc.octave_fraction,'') AS "C45",
'' AS "C47",
COALESCE(ped.pedimento_code,'') AS "C48",
@@ -243,6 +243,8 @@ BASE_JOINS = """
JOIN a76.invoice_compliance_mx icm ON icm.invoice_id = ih.id
LEFT JOIN a76.pedimentos ped ON ped.id = icm.pedimento_id
LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id
LEFT JOIN a76.pedimento_transport_means ptm ON ptm.pedimento_id = ped.id
LEFT JOIN public.pedimento_transport_catalog ptc ON ptc.code = ptm.entry_exit
LEFT JOIN a76.classes cl ON cl.id = il.class_id
LEFT JOIN a76.item_line_quantities ilq ON ilq.item_line_id = il.id
LEFT JOIN a76.item_line_financials ilf ON ilf.item_line_id = il.id
@@ -440,20 +442,21 @@ def _build_row(
peso_usado = (cant_ret * peso_neto / cant_orig) if cant_orig != 0 else Decimal(0)
peso_saldo = peso_neto - peso_usado
# TIPO DE CAMBIO — per Clarion logic:
# Si TipoPedimentoTransporteE IN ('4','1','98E') → usar Fecha_Inicio, else Fecha_Pago
# TIPO DE CAMBIO:
# C44 now carries payment_date_code from pedimento_transport_catalog:
# E => Fecha_Inicio, P => Fecha_Pago.
# If explicitly "invoice_date", we use invoice_date (C11) instead.
tc = Decimal(1)
fecha_pago = row.get("C7")
fecha_inicio = row.get("C9")
fecha_factura= row.get("C11")
transport_type = str(row.get("C44") or "")
payment_date_code = str(row.get("C44") or "").upper()
tc_fecha_display = None
# TIPO DE CAMBIO
tc_fecha = None
if use_fp:
tc_fecha = fecha_inicio if transport_type in ("1", "4", "98E") else fecha_pago
tc_fecha = fecha_inicio if payment_date_code == "E" else fecha_pago
else:
tc_fecha = fecha_factura

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel, ConfigDict, Field
class PedimentoTransportCatalogDTO(BaseModel):
code: str = Field(..., min_length=1, max_length=3)
transport_en: str
transport_es: str
payment_date_code: str = Field(..., min_length=1, max_length=1)
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,26 @@
from core.database import Base
from sqlalchemy import CheckConstraint, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column
class PedimentoTransportCatalog(Base):
__tablename__ = "pedimento_transport_catalog"
__table_args__ = (
PrimaryKeyConstraint("code", name="pedimento_transport_catalog_pkey"),
CheckConstraint(
"payment_date_code IN ('E', 'P')",
name="pedimento_transport_catalog_payment_date_code_chk",
),
{"schema": "public", "extend_existing": True},
)
code: Mapped[str] = mapped_column(String(3), nullable=False)
transport_en: Mapped[str] = mapped_column(String(80), nullable=False)
transport_es: Mapped[str] = mapped_column(String(120), nullable=False)
payment_date_code: Mapped[str] = mapped_column(String(1), nullable=False)
def __repr__(self) -> str:
return (
f"<PedimentoTransportCatalog(code={self.code}, transport_en={self.transport_en}, "
f"transport_es={self.transport_es}, payment_date_code={self.payment_date_code})>"
)

View File

@@ -0,0 +1,93 @@
from typing import Any, Dict
from core.database import get_core_db
from core.security import get_current_user
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from .dto import PedimentoTransportCatalogDTO
from .models import PedimentoTransportCatalog
router = APIRouter(prefix="/pedimento-transport-catalog")
@router.get("/", response_model=Dict[str, Any])
async def list_pedimento_transport_catalog(
page: int = Query(1, ge=1, description="Numero de pagina"),
page_size: int = Query(100, ge=1, le=200, description="Tamano de pagina"),
db: Session = Depends(get_core_db),
):
skip = (page - 1) * page_size
query = db.query(PedimentoTransportCatalog).order_by(PedimentoTransportCatalog.code.asc())
items = query.offset(skip).limit(page_size).all()
total = query.count()
return {
"items": [PedimentoTransportCatalogDTO.model_validate(obj) for obj in items],
"total": total,
"page": page,
"page_size": page_size,
}
@router.get("/{code}", response_model=PedimentoTransportCatalogDTO)
async def get_pedimento_transport_catalog(code: str, db: Session = Depends(get_core_db)):
obj = (
db.query(PedimentoTransportCatalog)
.filter(PedimentoTransportCatalog.code == code)
.first()
)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
return obj
@router.post("/", response_model=PedimentoTransportCatalogDTO, status_code=201)
async def create_pedimento_transport_catalog(
data: PedimentoTransportCatalogDTO,
db: Session = Depends(get_core_db),
user=Depends(get_current_user),
):
obj = PedimentoTransportCatalog(**data.model_dump())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.put("/{code}", response_model=PedimentoTransportCatalogDTO)
async def update_pedimento_transport_catalog(
code: str,
data: PedimentoTransportCatalogDTO,
db: Session = Depends(get_core_db),
user=Depends(get_current_user),
):
obj = (
db.query(PedimentoTransportCatalog)
.filter(PedimentoTransportCatalog.code == code)
.first()
)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
for field, value in data.model_dump().items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{code}", status_code=204)
async def delete_pedimento_transport_catalog(
code: str,
db: Session = Depends(get_core_db),
user=Depends(get_current_user),
):
obj = (
db.query(PedimentoTransportCatalog)
.filter(PedimentoTransportCatalog.code == code)
.first()
)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
db.delete(obj)
db.commit()
return None

View File

@@ -0,0 +1,16 @@
seed = [
("1", "MARITIME", "MARITIMO", "E"),
("2", "DOUBLE-TRACK RAIL", "FERROVIARIO DE DOBLE VIA", "P"),
("3", "ROAD-RAIL", "CARRETERO-FERROVIARIO", "P"),
("4", "AIR", "AEREO.", "E"),
("5", "POSTAL", "POSTAL.", "P"),
("6", "RAIL", "FERROVIARIO.", "P"),
("7", "ROAD", "CARRETERO.", "P"),
("8", "PIPELINE", "TUBERIA.", "P"),
("10", "CABLE", "CABLES.", "P"),
("11", "DUCT", "DUCTOS.", "P"),
("12", "PEDESTRIAN", "PEATONAL.", "P"),
("98", "NOT DECLARED TRANSPORT MODE", "NO SE DECLARA MEDIO DE TRANSPORTE", "P"),
("98E", "NOT DECLARED TRANSPORT MODE", "NO SE DECLARA MEDIO DE TRANSPORTE", "E"),
("99", "OTHERS", "OTROS.", "P"),
]

View File

@@ -19,9 +19,9 @@ from .invoice_types.routes import router as invoice_types_router
from .license_exceptions.routes import router as license_exceptions_router
from .material_types.routes import router as material_types_router
from .payment_methods.routes import router as payment_methods_router
from .pedimento_transport_catalog.routes import router as pedimento_transport_catalog_router
from .pedimento_codes.routes import router as pedimento_codes_router
from .pedimento_regimens.routes import router as pedimento_regimens_router
from .sectors.routes import router as sectors_router
from .states.routes import router as states_router
from .trailer_types.routes import router as trailer_types_router
from .transport_modes.routes import router as transport_modes_router
@@ -37,6 +37,11 @@ router.include_router(
prefix="/reference_data",
tags=["public / reference_data / agency_tariff_codes"],
)
router.include_router(
pedimento_transport_catalog_router,
prefix="/reference_data",
tags=["public / reference_data / pedimento_transport_catalog"],
)
router.include_router(
pedimento_codes_router,
prefix="/reference_data",
@@ -95,11 +100,6 @@ router.include_router(
prefix="/reference_data",
tags=["public / reference_data / valuation_methods"],
)
router.include_router(
sectors_router,
prefix="/reference_data",
tags=["public / public / reference_data / sectors"],
)
router.include_router(
transport_modes_router,
prefix="/reference_data",

Some files were not shown because too many files have changed in this diff Show More