diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index f83e68bb..ddc5fcce 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -44,7 +44,7 @@ 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, @@ -268,19 +268,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( [ @@ -493,7 +482,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") diff --git a/backend/api/v1/modules/a24/balance_movements/models.py b/backend/api/v1/modules/a24/balance_movements/models.py index a0d5d71c..2fd7fe84 100644 --- a/backend/api/v1/modules/a24/balance_movements/models.py +++ b/backend/api/v1/modules/a24/balance_movements/models.py @@ -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) diff --git a/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py b/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py index 6b4cdc52..9c464513 100644 --- a/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py +++ b/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py @@ -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") diff --git a/backend/api/v1/modules/a24/fa/fa_item_lines/models.py b/backend/api/v1/modules/a24/fa/fa_item_lines/models.py index 75496b34..f07b73c8 100644 --- a/backend/api/v1/modules/a24/fa/fa_item_lines/models.py +++ b/backend/api/v1/modules/a24/fa/fa_item_lines/models.py @@ -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 diff --git a/backend/api/v1/modules/a24/fa/fa_item_lines/service.py b/backend/api/v1/modules/a24/fa/fa_item_lines/service.py index 890307bf..aaa8e851 100644 --- a/backend/api/v1/modules/a24/fa/fa_item_lines/service.py +++ b/backend/api/v1/modules/a24/fa/fa_item_lines/service.py @@ -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, ) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py index 9f940c62..37158c12 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -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 ( diff --git a/backend/api/v1/modules/a76/general_catalogs/router.py b/backend/api/v1/modules/a76/general_catalogs/router.py index 291dde07..75975e40 100644 --- a/backend/api/v1/modules/a76/general_catalogs/router.py +++ b/backend/api/v1/modules/a76/general_catalogs/router.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/sectors/__init__.py b/backend/api/v1/modules/a76/general_catalogs/sectors/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/sectors/dto.py b/backend/api/v1/modules/a76/general_catalogs/sectors/dto.py new file mode 100644 index 00000000..557dce03 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/sectors/dto.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/sectors/models.py b/backend/api/v1/modules/a76/general_catalogs/sectors/models.py new file mode 100644 index 00000000..e210332f --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/sectors/models.py @@ -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"" diff --git a/backend/api/v1/modules/a76/general_catalogs/sectors/routes.py b/backend/api/v1/modules/a76/general_catalogs/sectors/routes.py new file mode 100644 index 00000000..83d09f12 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/sectors/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/sectors/seed.py b/backend/api/v1/modules/a76/general_catalogs/sectors/seed.py new file mode 100644 index 00000000..ee9bfd56 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/sectors/seed.py @@ -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), +] diff --git a/backend/api/v1/modules/a76/general_catalogs/sectors/service.py b/backend/api/v1/modules/a76/general_catalogs/sectors/service.py new file mode 100644 index 00000000..6d1ec4ee --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/sectors/service.py @@ -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.") diff --git a/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py b/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py new file mode 100644 index 00000000..1d0825b6 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py @@ -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) diff --git a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_uma.py b/backend/api/v1/modules/a76/invoices/common/process/review_uma.py similarity index 98% rename from backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_uma.py rename to backend/api/v1/modules/a76/invoices/common/process/review_uma.py index b4e104b9..96b0f9ae 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_uma.py +++ b/backend/api/v1/modules/a76/invoices/common/process/review_uma.py @@ -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, diff --git a/backend/api/v1/modules/a76/invoices/exports/process/main_process.py b/backend/api/v1/modules/a76/invoices/exports/process/main_process.py new file mode 100644 index 00000000..2dcdf9e4 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/main_process.py @@ -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, invoice, lines, invoice.tenant_id, invoice.company_id, 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": "ok", "invoice_id": str(invoice.id)} diff --git a/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py b/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py new file mode 100644 index 00000000..bc62c4a6 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py @@ -0,0 +1,117 @@ +from sqlalchemy import func +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.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.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).filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ).all() + + return lines + + + + + + + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/exports/process/routes.py b/backend/api/v1/modules/a76/invoices/exports/process/routes.py new file mode 100644 index 00000000..1f8a2297 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/routes.py @@ -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, + } diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/__init__.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_no_discharges.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_no_discharges.py new file mode 100644 index 00000000..8505fcb2 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_no_discharges.py @@ -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 = + """ + 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 = + 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), + ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_values.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_values.py new file mode 100644 index 00000000..2dc6b770 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_values.py @@ -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 = + """ + 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 + diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/compare_balances.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/compare_balances.py new file mode 100644 index 00000000..940011b9 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/compare_balances.py @@ -0,0 +1,164 @@ +""" +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 + + # ── 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), + }, + ) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/discharge_types.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/discharge_types.py new file mode 100644 index 00000000..f27140bf --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/discharge_types.py @@ -0,0 +1,68 @@ +""" +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) + 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 + + +@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) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/download_balance_collector.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/download_balance_collector.py new file mode 100644 index 00000000..ba9955f4 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/download_balance_collector.py @@ -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 = + 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.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 diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py new file mode 100644 index 00000000..51e3d176 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py @@ -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.UNPROCESSED: + 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) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py new file mode 100644 index 00000000..bb1bab97 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py @@ -0,0 +1,280 @@ +""" +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 + +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 = + + 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: + register_import_discharge(db, invoice, to_discharge) + register_discharge_series(db, invoice, to_discharge) + + _assign_invoice_totals(invoice, lines) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/generate_definitive_import.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/generate_definitive_import.py new file mode 100644 index 00000000..221a7e86 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/generate_definitive_import.py @@ -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() diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_series.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_series.py new file mode 100644 index 00000000..c6000ee4 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_series.py @@ -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 diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_import_discharge.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_import_discharge.py new file mode 100644 index 00000000..d9409bd1 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_import_discharge.py @@ -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) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_class.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_class.py new file mode 100644 index 00000000..4857f926 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_class.py @@ -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) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_exchange_rate.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_exchange_rate.py new file mode 100644 index 00000000..9e3142e1 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_exchange_rate.py @@ -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), + }, + ) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_limits.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_limits.py new file mode 100644 index 00000000..b62759f8 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_limits.py @@ -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 = + → stored in Loc:CantExpoLim, Loc:PesoNetoLim + +TOT_PAR_LIM_VALOR: + SELECT SUM(CostoUnitarioCaptura * CantExpo) + FROM QEqeMaq + WHERE Consecutivo = + → 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 diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_origin_procedure.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_origin_procedure.py new file mode 100644 index 00000000..5e3783fd --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_origin_procedure.py @@ -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, + }, + ) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_qty_vs_weight.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_qty_vs_weight.py new file mode 100644 index 00000000..7dad03d8 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_qty_vs_weight.py @@ -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 = AND UnidadMedida = 'KGS' AND PesoNetoKGS <> CantExpo + +LBS: + SELECT COUNT(*) FROM QEqeMaq + WHERE Consecutivo = 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", + ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_unit_cost.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_unit_cost.py new file mode 100644 index 00000000..91a065d8 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_unit_cost.py @@ -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 = + 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", + ) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/__init__.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_qty_series.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_qty_series.py new file mode 100644 index 00000000..5d4a3200 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_qty_series.py @@ -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) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_exist.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_exist.py new file mode 100644 index 00000000..014ef8d9 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_exist.py @@ -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, + }, + ) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_other_lines.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_other_lines.py new file mode 100644 index 00000000..076c37dc --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_other_lines.py @@ -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 diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/verify_consolidated.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/verify_consolidated.py new file mode 100644 index 00000000..2a00a41c --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/verify_consolidated.py @@ -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, + }, + ) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/task.py b/backend/api/v1/modules/a76/invoices/exports/process/task.py new file mode 100644 index 00000000..65bda45a --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/task.py @@ -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() diff --git a/backend/api/v1/modules/a76/invoices/imports/balance/__init__.py b/backend/api/v1/modules/a76/invoices/imports/balance/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/invoices/imports/balance/create_balance_entries.py b/backend/api/v1/modules/a76/invoices/imports/balance/create_balance_entries.py new file mode 100644 index 00000000..e19d9a9a --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/balance/create_balance_entries.py @@ -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 diff --git a/backend/api/v1/modules/a76/invoices/imports/balance/void_balance_entries.py b/backend/api/v1/modules/a76/invoices/imports/balance/void_balance_entries.py new file mode 100644 index 00000000..45bb113c --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/balance/void_balance_entries.py @@ -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 diff --git a/backend/api/v1/modules/a76/invoices/imports/process/main_process.py b/backend/api/v1/modules/a76/invoices/imports/process/main_process.py index cfadf0dd..df03b322 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/main_process.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/main_process.py @@ -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() \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py b/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py index 1e96b84a..c1c47b7f 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py @@ -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") diff --git a/backend/api/v1/modules/a76/invoices/imports/process/routes.py b/backend/api/v1/modules/a76/invoices/imports/process/routes.py index cc65bc06..66a00121 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/routes.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/routes.py @@ -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) diff --git a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_rule_octave.py b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_rule_octave.py index 3f19cd68..431070f5 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_rule_octave.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_rule_octave.py @@ -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, diff --git a/backend/api/v1/modules/a76/invoices/imports/process/task.py b/backend/api/v1/modules/a76/invoices/imports/process/task.py index 67c58de8..b269e129 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/task.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/task.py @@ -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() diff --git a/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py b/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py index 8f71e2b2..f39e51ef 100644 --- a/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py +++ b/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py @@ -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 diff --git a/backend/api/v1/modules/a76/invoices/imports/revert/task.py b/backend/api/v1/modules/a76/invoices/imports/revert/task.py index 039baba7..fbf2b056 100644 --- a/backend/api/v1/modules/a76/invoices/imports/revert/task.py +++ b/backend/api/v1/modules/a76/invoices/imports/revert/task.py @@ -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() diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index f0edde67..e6aee827 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -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 diff --git a/backend/api/v1/modules/a76/items/exports/validators/calculations.py b/backend/api/v1/modules/a76/items/exports/validators/calculations.py index 76dfa6a3..26b73c85 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/calculations.py +++ b/backend/api/v1/modules/a76/items/exports/validators/calculations.py @@ -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: diff --git a/backend/api/v1/modules/a76/items/exports/validators/common.py b/backend/api/v1/modules/a76/items/exports/validators/common.py index 31ace4a5..0012e7bf 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/common.py +++ b/backend/api/v1/modules/a76/items/exports/validators/common.py @@ -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( diff --git a/backend/api/v1/modules/a76/items/exports/validators/create.py b/backend/api/v1/modules/a76/items/exports/validators/create.py index 8ab754f8..9c19d6bf 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/create.py +++ b/backend/api/v1/modules/a76/items/exports/validators/create.py @@ -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: diff --git a/backend/api/v1/modules/a76/items/exports/validators/update.py b/backend/api/v1/modules/a76/items/exports/validators/update.py index e4f6c121..4f8e2c9d 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/update.py +++ b/backend/api/v1/modules/a76/items/exports/validators/update.py @@ -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: diff --git a/backend/api/v1/modules/a76/items/imports/validators/common.py b/backend/api/v1/modules/a76/items/imports/validators/common.py index ce5723e6..b1d0dc2f 100644 --- a/backend/api/v1/modules/a76/items/imports/validators/common.py +++ b/backend/api/v1/modules/a76/items/imports/validators/common.py @@ -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( diff --git a/backend/api/v1/modules/a76/items/series/models.py b/backend/api/v1/modules/a76/items/series/models.py index 9c9133d7..3adda326 100644 --- a/backend/api/v1/modules/a76/items/series/models.py +++ b/backend/api/v1/modules/a76/items/series/models.py @@ -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,11 +16,10 @@ 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 - import_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURAIMPO - import_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEAIMPO + 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) diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index bb93666d..f656d23c 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -167,7 +167,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} diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py index 3c52da6c..54095fdf 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py @@ -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()) diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/common/fk_loader.py b/backend/api/v1/modules/a76/layouts_csv/parts/common/fk_loader.py index f173a0eb..04f33435 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/common/fk_loader.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/common/fk_loader.py @@ -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]: diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/parts/validators/common.py index ef48142f..0b0f8411 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/validators/common.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/validators/common.py @@ -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 { diff --git a/backend/api/v1/modules/a76/parts/dto.py b/backend/api/v1/modules/a76/parts/dto.py index e84aa70e..d069a856 100644 --- a/backend/api/v1/modules/a76/parts/dto.py +++ b/backend/api/v1/modules/a76/parts/dto.py @@ -1,13 +1,13 @@ 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 # --- 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) @@ -143,11 +143,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 diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py index e5d53263..e00286cb 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py @@ -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""" diff --git a/backend/api/v1/modules/public/reference_data/router.py b/backend/api/v1/modules/public/reference_data/router.py index 6200d14a..fb4a322f 100644 --- a/backend/api/v1/modules/public/reference_data/router.py +++ b/backend/api/v1/modules/public/reference_data/router.py @@ -17,7 +17,6 @@ from .material_types.routes import router as material_types_router from .payment_methods.routes import router as payment_methods_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 @@ -81,11 +80,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", diff --git a/backend/api/v1/modules/public/reference_data/sectors/dto.py b/backend/api/v1/modules/public/reference_data/sectors/dto.py deleted file mode 100644 index e97e8a1d..00000000 --- a/backend/api/v1/modules/public/reference_data/sectors/dto.py +++ /dev/null @@ -1,9 +0,0 @@ -from pydantic import BaseModel, ConfigDict, Field - - -class SectorDTO(BaseModel): - key: str = Field(..., min_length=1, max_length=8) - description: str - authorized: bool - - model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/sectors/models.py b/backend/api/v1/modules/public/reference_data/sectors/models.py deleted file mode 100644 index 6c581cf1..00000000 --- a/backend/api/v1/modules/public/reference_data/sectors/models.py +++ /dev/null @@ -1,23 +0,0 @@ -from core.database import Base -from sqlalchemy import Boolean, PrimaryKeyConstraint, SmallInteger, String -from sqlalchemy.orm import Mapped, mapped_column - - -class Sector(Base): - __tablename__ = "sectors" # GSectores - __table_args__ = ( - PrimaryKeyConstraint("key", name="sectors_pkey"), - {"schema": "public", "extend_existing": True}, # opcional - ) - - key: Mapped[str] = mapped_column( - String(8), nullable=False) # clave del sector - description: Mapped[str] = mapped_column( - String(150), nullable=False - ) # descripción oficial (en español) - authorized: Mapped[bool] = mapped_column( - Boolean - ) # True = autorizado, False = no autorizado - - def __repr__(self): - return f"" diff --git a/backend/api/v1/modules/public/reference_data/sectors/routes.py b/backend/api/v1/modules/public/reference_data/sectors/routes.py deleted file mode 100644 index da38ac7d..00000000 --- a/backend/api/v1/modules/public/reference_data/sectors/routes.py +++ /dev/null @@ -1,56 +0,0 @@ - -from typing import Any, Dict, Optional - -from core.database import get_core_db -from core.security import get_current_user -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import or_ -from sqlalchemy.orm import Session - -from .dto import SectorDTO -from .models import Sector - -router = APIRouter(prefix="/sectors") - - -@router.get("/", response_model=Dict[str, Any]) -def list_sectors( - page: int = Query(1, ge=1, description="Número de página"), - page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), - search: Optional[str] = Query(None, description="Término de búsqueda"), - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - skip = (page - 1) * page_size - query = db.query(Sector) - - if search: - search_filter = or_( - Sector.key.ilike(f"%{search}%"), - Sector.description.ilike(f"%{search}%") - ) - query = query.filter(search_filter) - - total = query.count() - # Add deterministic sort order - query = query.order_by(Sector.key) - items = query.offset(skip).limit(page_size).all() - - return { - "items": [SectorDTO.model_validate(obj) for obj in items], - "total": total, - "page": page, - "page_size": page_size, - } - - -@router.get("/{key}", response_model=SectorDTO) -def get_sector( - key: str, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - obj = db.query(Sector).filter(Sector.key == key).first() - if not obj: - raise HTTPException(status_code=404, detail="Not found") - return obj diff --git a/backend/api/v1/modules/public/reference_data/sectors/seed.py b/backend/api/v1/modules/public/reference_data/sectors/seed.py deleted file mode 100644 index 9eb39401..00000000 --- a/backend/api/v1/modules/public/reference_data/sectors/seed.py +++ /dev/null @@ -1,55 +0,0 @@ -seed = [ - ("I", "INDUSTRIA ELECTRICA", "0"), - ("II", "INDUSTRIA ELECTRONICA", "0"), - ( - "IIa", - "PARA LOS BIENES A QUE SE REFIERE LA FRACCION II, INCISO a) o b), DE ARTICULO 4to DE ESTE DECRETO.", - "0", - ), - ( - "IIb", - "PARA LOS BIENES A QUE SE REFIERE LA FRACCION II, INCISO b), DE ARTICULO 4to DE ESTE DECRETO.", - "0", - ), - ("III", "INDUSTRIA DEL MUEBLE", "0"), - ("IV", "INDUSTRIA DEL JUGUETE, JUEGOS DE RECREO Y ARTICULOS DEPORTIVOS", "0"), - ("IX", "INDUSTRIA DE MAQUINARIA AGRICOLA", "0"), - ("V", "INDUSTRIA DEL CALZADO", "0"), - ("VI", "INDUSTRIA MINERA Y METALURGICA", "0"), - ("VII", "INDUSTRIA DE BIENES DE CAPITAL", "0"), - ("VIII", "INDUSTRIA FOTOGRAFICA", "0"), - ("X", "INDUSTRIAS DIVERSAS", "0"), - ("XI", "INDUSTRIA QUIMICA", "0"), - ("XII", "INDUSTRIAS DE MANUFACTURAS DEL CAUCHO Y PLASTICOS", "0"), - ("XIII", "INDUSTRIA SIDERURGICA", "0"), - ("XIV", "INDUSTRIA DE PRODUCTOS FARMOQUIMICOS, MEDICAMENTOS Y EQUIPO MEDICO", "0"), - ("XIX", "INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", "0"), - ("XIXa", "INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", "0"), - ("XIXb", "INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", "0"), - ( - "XV", - "INDUSTRIA DEL TRANSPORTE, EXCEPTO EL SECTOR DE LA INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", - "0", - ), - ( - "XVa", - "INDUSTRIA DEL TRANSPORTE, EXCEPTO EL SECTOR DE LA INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES.", - "0", - ), - ( - "XVb", - "INDUSTRIA DEL TRANSPORTE, EXCEPTO EL SECTOR DE LA INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES.", - "0", - ), - ("XVI", "INDUSTRIA DEL PAPEL Y CARTON", "0"), - ("XVII", "INDUSTRIA DE LA MADERA", "0"), - ("XVIII", "INDUSTRIA DEL CUERO Y PIELES", "0"), - ("XX", "INDUSTRIA TEXTIL Y DE LA CONFECCION", "0"), - ("XXa", "INDUSTRIA TEXTIL Y DE LA CONFECCION", "0"), - ("XXb", "INDUSTRIA TEXTIL Y DE LA CONFECCION", "0"), - ("XXc", "INDUSTRIA TEXTIL Y DE LA CONFECCION", "0"), - ("XXd", "INDUSTRIA TEXTIL Y DE LA CONFECCION", "0"), - ("XXe", "INDUSTRIA TEXTIL Y DE LA CONFECCION", "0"), - ("XXI", "INDUSTRIA DE CHOCOLATES, DULCES Y SIMILARES", "0"), - ("XXII", "INDUSTRIA DEL CAFE", "0"), -] diff --git a/backend/api/v1/modules/public/reference_data/sectors/test_sectors.py b/backend/api/v1/modules/public/reference_data/sectors/test_sectors.py deleted file mode 100644 index 872ea295..00000000 --- a/backend/api/v1/modules/public/reference_data/sectors/test_sectors.py +++ /dev/null @@ -1,40 +0,0 @@ -import pytest -from api.v1.modules.public.reference_data.sectors.routes import router -from fastapi import FastAPI -from fastapi.testclient import TestClient - -app = FastAPI() -app.include_router(router) -client = TestClient(app) - - -@pytest.mark.usefixtures("client", "access_token") -def test_list_sectors(client, access_token): - headers = {"Authorization": f"Bearer {access_token}"} - response = client.get("/sectors/", headers=headers) - assert response.status_code == 200 - assert "items" in response.json() - assert "page" in response.json() - assert "page_size" in response.json() - - -@pytest.mark.usefixtures("client", "access_token") -def test_get_sector_not_found(client, access_token): - headers = {"Authorization": f"Bearer {access_token}"} - response = client.get("/sectors/invalid_key", headers=headers) - assert response.status_code == 404 - - -def test_create_sector_forbidden(): - response = client.post("/sectors/", json={"key": "TST", "description": "Test"}) - assert response.status_code in (403, 405, 404) - - -def test_update_sector_forbidden(): - response = client.put("/sectors/TST", json={"key": "TST", "description": "Test"}) - assert response.status_code in (403, 405, 404) - - -def test_delete_sector_forbidden(): - response = client.delete("/sectors/TST") - assert response.status_code in (403, 405, 404) diff --git a/backend/core/error_handlers.py b/backend/core/error_handlers.py index e4527184..7a68ccb7 100644 --- a/backend/core/error_handlers.py +++ b/backend/core/error_handlers.py @@ -75,6 +75,11 @@ _FIELD_LABELS: Dict[str, str] = { "city": "Ciudad", "state": "Estado", "country": "País", + # Partes (A76) + "unit_cost": "Costo Unitario", + "unit_weight": "Peso Unitario", + "sector": "Sector", + "fraction_type": "Tipo de tarifa", } _FIELD_PATTERN_MESSAGES: Dict[str, str] = { @@ -85,16 +90,31 @@ _FIELD_PATTERN_MESSAGES: Dict[str, str] = { "email": "El correo electrónico no tiene un formato válido. Ejemplo: usuario@dominio.com.", "phone": "El teléfono solo puede contener dígitos, espacios y los símbolos: +, -, (, ).", "contact": "El nombre de contacto contiene caracteres no permitidos. Use solo letras, números y puntuación básica.", + # Partes (A76) + "sector": "El Sector solo puede contener números, máximo 8 dígitos (sin espacios ni caracteres especiales).", } def _friendly_message(field_key: str, error_type: str) -> str: """Devuelve un mensaje de error legible en español según el campo y tipo de error.""" + if error_type in ("greater_than_equal",): + if field_key in ("unit_cost", "unit_weight"): + return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' no puede ser negativo." + return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' debe ser mayor o igual a 0." if error_type in ("string_pattern_mismatch", "value_error"): return _FIELD_PATTERN_MESSAGES.get( field_key, f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor con formato inválido.", ) + if error_type in ("decimal_parsing", "decimal_type", "float_parsing", "float_type", "int_parsing", "int_type"): + return ( + f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' debe ser numérico. " + "Si no aplica, déjelo vacío." + ) + if error_type in ("literal_error",): + if field_key == "fraction_type": + return "El campo 'Tipo de tarifa' es inválido. Seleccione una opción predefinida." + return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene una opción inválida." if error_type == "string_too_long": return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' excede la longitud máxima permitida." if error_type == "string_too_short": diff --git a/backend/main.py b/backend/main.py index 08987b2e..b5c0f325 100644 --- a/backend/main.py +++ b/backend/main.py @@ -24,7 +24,7 @@ from api.v1.modules.public.reference_data.pedimento_regimens.models import Regim from api.v1.modules.public.reference_data.code_pedimento_regimens.models import ( CodePedimentoRegimen, ) -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.states.models import State from api.v1.modules.public.reference_data.transport_modes.models import TransportMode from api.v1.modules.public.reference_data.transport_types.models import TransportType @@ -221,7 +221,6 @@ from api.v1.modules.public.reference_data.pedimento_codes.models import Pediment from api.v1.modules.public.reference_data.pedimento_regimens.models import ( RegimenPedimento, ) -from api.v1.modules.public.reference_data.sectors.models import Sector from api.v1.modules.public.reference_data.states.models import State from api.v1.modules.public.reference_data.transport_modes.models import TransportMode from api.v1.modules.public.reference_data.transport_types.models import TransportType diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index f63dc394..df8eb253 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -134,7 +134,7 @@ export interface FaLineItem { subitem_number?: number; // Special flags - download?: boolean; + discharge?: boolean; own_equipment?: boolean; omit_annex31?: boolean; diff --git a/frontend/src/lib/api/dashboard/general_catalogs/sectors.ts b/frontend/src/lib/api/dashboard/general_catalogs/sectors.ts index b92acd2f..52fecd72 100644 --- a/frontend/src/lib/api/dashboard/general_catalogs/sectors.ts +++ b/frontend/src/lib/api/dashboard/general_catalogs/sectors.ts @@ -1,11 +1,14 @@ - import { api } from '$lib/api'; -import type { ApiResponse } from '$lib/api'; export interface Sector { + id: number; key: string; description: string; authorized: boolean; + company_id: number; + tenant_id: number; + created_at?: string; + updated_at?: string; } export interface SectorListResponse { @@ -18,18 +21,20 @@ export interface SectorListResponse { export async function getSectors( page = 1, pageSize = 50, + companyId: number, search?: string ): Promise { const params = new URLSearchParams({ page: page.toString(), - page_size: pageSize.toString() + page_size: pageSize.toString(), + company_id: companyId.toString() }); if (search) { - params.append('search', search); + params.append('key', search); } - const response = await api.get(`/v1/public/reference_data/sectors/?${params.toString()}`); + const response = await api.get(`/v1/a76/sectors/?${params.toString()}`); if (!response.data) throw new Error('Error fetching sectors'); return response.data; } diff --git a/frontend/src/lib/api/dashboard/reference_data/sectors.ts b/frontend/src/lib/api/dashboard/reference_data/sectors.ts index 409c9ef0..d36a26eb 100644 --- a/frontend/src/lib/api/dashboard/reference_data/sectors.ts +++ b/frontend/src/lib/api/dashboard/reference_data/sectors.ts @@ -1,13 +1,17 @@ /** - * API Client para Sectors - * Gestiona las operaciones CRUD para los sectores + * API Client para Sectors (a76 — tenant-scoped) */ import { api } from '$lib/api'; export interface Sector { + id: number; key: string; description: string; - authorized: number; + authorized: boolean; + company_id: number; + tenant_id: number; + created_at?: string; + updated_at?: string; } export interface SectorListResponse { @@ -20,60 +24,45 @@ export interface SectorListResponse { export interface CreateSectorData { key: string; description: string; - authorized: number; + authorized: boolean; } export interface UpdateSectorData { key?: string; description?: string; - authorized?: number; + authorized?: boolean; } -/** - * API para Sectors - */ export const sectorsApi = { - /** - * Lista todos los sectores con paginación - * @param page - Número de página (por defecto 1) - * @param pageSize - Tamaño de página (por defecto 50) - */ - list: (page = 1, pageSize = 50) => - api.get( - // CORREGIDO: Slash antes del '?' - `/v1/public/reference_data/sectors/?page=${page}&page_size=${pageSize}` - ), + list: (page = 1, pageSize = 50, companyId: number, search?: string) => { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString() + }); + if (search) params.append('key', search); + return api.get(`/v1/a76/sectors/?${params.toString()}`); + }, - /** - * Obtiene un sector por key - * @param key - Clave del sector - */ - get: (key: string) => - // CORREGIDO: Slash final - api.get(`/v1/public/reference_data/sectors/${key}/`), + get: (id: number, companyId: number) => + api.get(`/v1/a76/sectors/${id}/?company_id=${companyId}`), - /** - * Crea un nuevo sector - * @param data - Datos del sector a crear - */ - create: (data: CreateSectorData) => - // CORREGIDO: Slash final - api.post('/v1/public/reference_data/sectors/', data), + getByKey: (key: string, companyId: number) => { + const params = new URLSearchParams({ + page: '1', + page_size: '1', + company_id: companyId.toString(), + key + }); + return api.get(`/v1/a76/sectors/?${params.toString()}`); + }, - /** - * Actualiza un sector existente - * @param key - Clave del sector a actualizar - * @param data - Datos a actualizar - */ - update: (key: string, data: UpdateSectorData) => - // CORREGIDO: Slash final después de la variable - api.put(`/v1/public/reference_data/sectors/${key}/`, data), + create: (data: CreateSectorData, companyId: number) => + api.post(`/v1/a76/sectors/?company_id=${companyId}`, data), - /** - * Elimina un sector - * @param key - Clave del sector a eliminar - */ - delete: (key: string) => - // CORREGIDO: Slash final después de la variable - api.delete(`/v1/public/reference_data/sectors/${key}/`) -}; \ No newline at end of file + update: (id: number, data: UpdateSectorData, companyId: number) => + api.put(`/v1/a76/sectors/${id}/?company_id=${companyId}`, data), + + delete: (id: number, companyId: number) => + api.delete(`/v1/a76/sectors/${id}/?company_id=${companyId}`) +}; diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index 9c7876a2..2ce54185 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -1,3628 +1,3719 @@ - - -
-
-
-
- -

{title}

- - {isEdit ? 'Editar' : 'Nueva'} - -
-

- {#if formType === 'both'} - - AMBOS - Gestión Inventario + Activo Fijo - - {:else if formType === 'fa'} - - SCAF - Gestión de Activo Fijo - - {:else} - - SCAI - Gestión de Inventario - - {/if} -

-
-
- - - {#if error} -
- ⚠️ {error} -
- {/if} - -
- {#if formType === 'fa'} -
{ - e.preventDefault(); - handleSubmit(); - }} - class="space-y-6" - > - - - -
- -
-
- -
- - -
-
-
- -
-
- - (showClientModal = true)} - class="cursor-pointer pl-9 transition-colors hover:bg-muted/50" - placeholder="Seleccione un cliente..." - /> -
- -
-
-
- -
-
- -