diff --git a/archivo.txt b/archivo.txt new file mode 100644 index 00000000..e69de29b diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index 430401b2..6d8cc4b0 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -44,11 +44,14 @@ from api.v1.modules.public.reference_data.pedimento_codes.seed import ( from api.v1.modules.public.reference_data.pedimento_regimens.seed import ( seed as pedimento_regimens_seed, ) -from api.v1.modules.public.reference_data.sectors.seed import seed as sectors_seed +from api.v1.modules.a76.general_catalogs.sectors.seed import seed as sectors_seed from api.v1.modules.public.reference_data.states.seed import seed as states_seed from api.v1.modules.public.reference_data.transport_modes.seed import ( seed as transport_modes_seed, ) +from api.v1.modules.public.reference_data.pedimento_transport_catalog.seed import ( + seed as pedimento_transport_catalog_seed, +) from api.v1.modules.public.reference_data.transport_types.seed import ( seed as transport_types_seed, ) @@ -104,6 +107,28 @@ def upgrade() -> None: return "NULL" return f"'{str(val).replace(chr(39), chr(39)*2)}'" + op.execute( + """ + CREATE TABLE IF NOT EXISTS public.pedimento_transport_catalog ( + code VARCHAR(3) NOT NULL, + transport_en VARCHAR(80) NOT NULL, + transport_es VARCHAR(120) NOT NULL, + payment_date_code VARCHAR(1) NOT NULL, + CONSTRAINT pedimento_transport_catalog_pkey PRIMARY KEY (code), + CONSTRAINT pedimento_transport_catalog_payment_date_code_chk + CHECK (payment_date_code IN ('E','P')) + ); + """ + ) + op.execute( + """ + ALTER TABLE IF EXISTS a76.pedimento_transport_means + ALTER COLUMN entry_exit TYPE VARCHAR(3), + ALTER COLUMN arrival TYPE VARCHAR(3), + ALTER COLUMN departure TYPE VARCHAR(3); + """ + ) + # --- SEEDS PUBLIC (Tablas base) --- # Seeds values_pc = ", ".join( @@ -274,19 +299,8 @@ def upgrade() -> None: """ ) - values_sectors = ", ".join( - [ - f"('{key}', '{desc.replace(chr(39), chr(39)*2)}', '{authorized}')" - for key, desc, authorized in sectors_seed - ] - ) - op.execute( - f""" - INSERT INTO public.sectors (key, description, authorized) VALUES - {values_sectors} - ON CONFLICT (key) DO NOTHING; - """ - ) + # Sectors se siembran por compañía en _seed_company_data + # (a76.sectors requiere tenant_id/company_id — no aplica en seed global) values_tm = ", ".join( [ @@ -302,6 +316,20 @@ def upgrade() -> None: """ ) + values_ptc = ", ".join( + [ + f"('{code}', '{en.replace(chr(39), chr(39)*2)}', '{es.replace(chr(39), chr(39)*2)}', '{pdc}')" + for code, en, es, pdc in pedimento_transport_catalog_seed + ] + ) + op.execute( + f""" + INSERT INTO public.pedimento_transport_catalog (code, transport_en, transport_es, payment_date_code) VALUES + {values_ptc} + ON CONFLICT (code) DO NOTHING; + """ + ) + values_tt = ", ".join( [ f"('{code}', '{desc.replace(chr(39), chr(39)*2)}')" @@ -500,6 +528,16 @@ def upgrade() -> None: def downgrade() -> None: """Downgrade schema.""" + op.execute( + """ + ALTER TABLE IF EXISTS a76.pedimento_transport_means + ALTER COLUMN entry_exit TYPE VARCHAR(2), + ALTER COLUMN arrival TYPE VARCHAR(2), + ALTER COLUMN departure TYPE VARCHAR(2); + """ + ) + + op.drop_table("pedimento_transport_catalog", schema="public") op.drop_table("us_tariff_fractions", schema="a76") op.drop_table("historical_tariff_fractions", schema="a76") op.drop_table("canadian_tariff_fractions", schema="a76") @@ -507,7 +545,7 @@ def downgrade() -> None: op.drop_table("transport_types", schema="public") op.drop_table("trailer_types", schema="public") op.drop_table("transport_modes", schema="public") - op.drop_table("sectors", schema="public") + op.drop_table("sectors", schema="a76") op.drop_table("payment_methods", schema="public") op.drop_table("material_types", schema="public") op.drop_table("invoice_types", schema="public") 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/clients_and_providers/dto.py b/backend/api/v1/modules/a76/clients_and_providers/dto.py index 292d9e41..e461addd 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/dto.py +++ b/backend/api/v1/modules/a76/clients_and_providers/dto.py @@ -6,7 +6,9 @@ Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS from decimal import Decimal from typing import List, Literal, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator + +from .validators import is_valid_rfc, is_valid_tax_id # DTOs para dirección @@ -54,7 +56,6 @@ class ClientProviderProgramsDTO(BaseModel): manufacturer_id: Optional[str] = Field( None, max_length=25, description="Manufacturer ID" ) - tax_id: Optional[str] = Field(None, max_length=30, description="Tax ID") broker: Optional[str] = Field(None, max_length=6, description="Broker") import_broker: Optional[str] = Field( None, max_length=6, description="Import broker" @@ -116,7 +117,6 @@ class ClientProviderCreateDTO(BaseModel): ) position: Optional[str] = Field(None, max_length=30, description="Position") incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm") - is_national_provider: Optional[bool] = None is_active: Optional[bool] = Field(None, description="Enabled/Disabled status") # Nested DTOs @@ -127,6 +127,24 @@ class ClientProviderCreateDTO(BaseModel): None, description="Programs information" ) + @model_validator(mode="after") + def validate_rfc_or_tax_id_format(self): + rfc = self.rfc + if not rfc or not (rfc := (rfc or "").strip()): + return self + proc = (self.type_nat_foreign or "N").strip().upper()[:1] + if proc == "E": + if not is_valid_tax_id(rfc): + raise ValueError( + "El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres." + ) + else: + if not is_valid_rfc(rfc): + raise ValueError( + "El RFC no tiene el formato correcto. Ejemplo: XAXX010101000." + ) + return self + class Config: from_attributes = True @@ -157,7 +175,6 @@ class ClientProviderUpdateDTO(BaseModel): ) position: Optional[str] = Field(None, max_length=30, description="Position") incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm") - is_national_provider: Optional[bool] = None is_active: Optional[bool] = Field(None, description="Enabled/Disabled status") # Nested DTOs @@ -168,6 +185,24 @@ class ClientProviderUpdateDTO(BaseModel): None, description="Programs information" ) + @model_validator(mode="after") + def validate_rfc_or_tax_id_format(self): + rfc = self.rfc + if not rfc or not (rfc := (rfc or "").strip()): + return self + proc = (self.type_nat_foreign or "N").strip().upper()[:1] + if proc == "E": + if not is_valid_tax_id(rfc): + raise ValueError( + "El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres." + ) + else: + if not is_valid_rfc(rfc): + raise ValueError( + "El RFC no tiene el formato correcto. Ejemplo: XAXX010101000." + ) + return self + class Config: from_attributes = True @@ -189,7 +224,6 @@ class ClientProviderResponseDTO(BaseModel): responsible: Optional[str] = None position: Optional[str] = None incoterm: Optional[str] = None - is_national_provider: Optional[bool] = None is_active: Optional[bool] = None tenant_id: int company_id: int diff --git a/backend/api/v1/modules/a76/clients_and_providers/models.py b/backend/api/v1/modules/a76/clients_and_providers/models.py index db5544ca..6333449d 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/models.py +++ b/backend/api/v1/modules/a76/clients_and_providers/models.py @@ -45,6 +45,7 @@ class ClientProvider(Base, TenantScopedMixin, TimestampMixin): type_nat_foreign: Mapped[Optional[str]] = mapped_column(String(1)) # TIPO NACIONAL/EXTRANJERO name: Mapped[Optional[str]] = mapped_column(String(256)) short_name: Mapped[Optional[str]] = mapped_column(String(10)) + # Identificador fiscal único: RFC (nacional) o TAX-ID (extranjero); no usar programs.tax_id para lo mismo rfc: Mapped[Optional[str]] = mapped_column(String(30)) curp: Mapped[Optional[str]] = mapped_column(String(19)) client_or_provider: Mapped[ClientOrProviderEnum] = mapped_column(PgEnum(ClientOrProviderEnum, name="entity_client_or_provider", create_type=True, native_enum=True),nullable=False) @@ -55,7 +56,6 @@ class ClientProvider(Base, TenantScopedMixin, TimestampMixin): responsible: Mapped[Optional[str]] = mapped_column(String(80)) position: Mapped[Optional[str]] = mapped_column(String(30)) incoterm: Mapped[Optional[str]] = mapped_column(String(19)) - is_national_provider: Mapped[Optional[bool]] = mapped_column(Boolean) is_active: Mapped[Optional[bool]] = mapped_column(Boolean) # Relationships @@ -140,7 +140,6 @@ class ClientProviderPrograms(Base, TenantScopedMixin, TimestampMixin): prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20)) secon_auth_date: Mapped[Optional[int]] = mapped_column(Integer) manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25)) - tax_id: Mapped[Optional[str]] = mapped_column(String(30)) broker: Mapped[Optional[str]] = mapped_column(String(6)) import_broker: Mapped[Optional[str]] = mapped_column(String(6)) transfer_key: Mapped[Optional[str]] = mapped_column(String(8)) diff --git a/backend/api/v1/modules/a76/clients_and_providers/service.py b/backend/api/v1/modules/a76/clients_and_providers/service.py index aaf76471..14276427 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/service.py +++ b/backend/api/v1/modules/a76/clients_and_providers/service.py @@ -185,6 +185,7 @@ class ClientProviderService: db_address = ClientProviderAddress( client_id=client.id, tenant_id=tenant_id, + company_id=company_id, **client_data.address.model_dump(exclude_unset=True), ) db.add(db_address) @@ -199,6 +200,7 @@ class ClientProviderService: db_programs = ClientProviderPrograms( client_id=client.id, tenant_id=tenant_id, + company_id=company_id, **client_data.programs.model_dump(exclude_unset=True), ) db.add(db_programs) diff --git a/backend/api/v1/modules/a76/clients_and_providers/validators.py b/backend/api/v1/modules/a76/clients_and_providers/validators.py new file mode 100644 index 00000000..523bec74 --- /dev/null +++ b/backend/api/v1/modules/a76/clients_and_providers/validators.py @@ -0,0 +1,27 @@ +""" +Validadores de formato para RFC (Nacional) y TAX-ID (Extranjero) en clientes y proveedores. +""" + +import re + +# Formato RFC México: 3-4 letras (A-Z, &, Ñ), 6 dígitos (fecha), 3 caracteres homoclave. Ej: XAXX010101000 +RFC_PATTERN = re.compile(r"^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$", re.IGNORECASE) + +# TAX-ID extranjero: 2 dígitos, guión, resto alfanumérico. Ej: 12-3456789 (EIN US). Total máx 30. +TAX_ID_PATTERN = re.compile(r"^\d{2}-[A-Z0-9]{1,27}$", re.IGNORECASE) + + +def is_valid_rfc(value: str) -> bool: + """Valida formato RFC mexicano. Acepta cadena vacía/None como inválida (no opcional aquí).""" + if not value or not isinstance(value, str): + return False + normalized = value.strip().upper() + return bool(normalized and RFC_PATTERN.match(normalized)) + + +def is_valid_tax_id(value: str) -> bool: + """Valida formato TAX-ID (extranjero): 2 dígitos, guión y resto alfanumérico. Ej: 12-3456789.""" + if not value or not isinstance(value, str): + return False + normalized = value.strip() + return bool(normalized and len(normalized) <= 30 and TAX_ID_PATTERN.match(normalized)) 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/identifiers/dto.py b/backend/api/v1/modules/a76/general_catalogs/identifiers/dto.py index 9432a023..e65af32e 100644 --- a/backend/api/v1/modules/a76/general_catalogs/identifiers/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/identifiers/dto.py @@ -32,6 +32,7 @@ class IdentifierDetailBase(BaseModel): part_line: Optional[int] = Field(None, description="Part Line") identifier_code: Optional[str] = Field( None, max_length=2, description="Identifier Code") + item_line_id: Optional[int] = Field(None, description="Item Line ID") module: Optional[str] = Field(None, max_length=20, description="Module") complement1: Optional[str] = Field( None, max_length=50, description="Complement 1") diff --git a/backend/api/v1/modules/a76/general_catalogs/identifiers/models.py b/backend/api/v1/modules/a76/general_catalogs/identifiers/models.py index daabe759..d81e700b 100644 --- a/backend/api/v1/modules/a76/general_catalogs/identifiers/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/identifiers/models.py @@ -1,10 +1,13 @@ -from typing import Optional +from typing import Optional, TYPE_CHECKING from sqlalchemy import Integer, String, UniqueConstraint, ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base +if TYPE_CHECKING: + from api.v1.modules.a76.items.models import LineItem + class Identifier(Base, TenantScopedMixin, TimestampMixin): __tablename__ = "identifiers" __table_args__ = ( @@ -44,6 +47,8 @@ class IdentifierDetail(Base, TenantScopedMixin, TimestampMixin): Integer, nullable=True) # LINEAPARTIDA identifier_code: Mapped[Optional[str]] = mapped_column( String(2), ForeignKey("a76.identifiers.code"), nullable=True) # ID + item_line_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("a76.item_lines.id"), nullable=True) module: Mapped[Optional[str]] = mapped_column( String(20), nullable=True) # MODULO complement1: Mapped[Optional[str]] = mapped_column( @@ -54,3 +59,4 @@ class IdentifierDetail(Base, TenantScopedMixin, TimestampMixin): String(50), nullable=True) # COMPLEMENTO3 identifier: Mapped["Identifier"] = relationship(back_populates="details") + line: Mapped[Optional["LineItem"]] = relationship(back_populates="identifiers") 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..0ea95e59 --- /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, lines, errors) + review_exchange_rate(db, invoice, errors) + assign_values(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors) + + review_qty_vs_weight(lines, invoice.logistics.weight_type.upper(), errors) + + review_unit_cost(lines, errors) + total_qty, total_net_weight = limit_weight(lines) + total_value = limit_value(lines) + review_qty_series(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors) + + # QUIERE_DESCARGAR → LLENA_QUEUE_SALDOS → COMPARA_SALDOS + to_discharge = collect_lines_to_discharge(db, invoice, lines, errors) + + fill_available_balances(db, invoice, to_discharge, errors) + compare_balances(db, invoice, to_discharge, errors) + verify_consolidated(db, invoice, to_discharge, errors) + + finalize_invoice_with_discharge(db, invoice, lines, errors, to_discharge) + + +# --------------------------------------------------------------------------- +# Proceso principal +# --------------------------------------------------------------------------- + +def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str) -> dict: + """ + Proceso principal para actualizar facturas de exportación. + + Flujo (porta la rutina principal del legacy SCAII – Facturas de Exportación): + + 1. Validaciones previas (pre_validators) + 2. TODO: Compartir parámetros generales (QSisGen / GEmpresa) + 3. TODO: Compartir parámetros de exportación (QSisExpo) según EsCambioRegimen + 4. TODO: Validar permisos de usuario (GUsuarios / GNivelesSeguridad) + 5. TODO: Iniciar transacción SQL (BEGIN TRAN) + 6. Verificar que existan partidas + 7. TODO: Obtener tipo de cambio según SisGen:CalValBaseTCPedExpo + (TCPED desde la fecha de pago del pedimento, o TCFAC desde la factura) + 8. TODO: Validar que la factura no exista ya en Importaciones Definitivas (si GeneraID='S') + 9. CASE invoice_type → ejecutar sub-proceso específico por tipo: + - NODES : sin descarga + - AFIJO / DONAC / SCRAP : con descarga + lógica de CambioRegimen opcional + - REEXP / VEMEX : con descarga + revisión de procedencia DEF + 10. Si hay errores: rollback implícito (raise) + Si no hay errores: COMMIT y marcar factura como procesada + """ + errors = ErrorCollector() + + # --- Paso 1: Validaciones previas ---------------------------------------- + lines = pre_validators(db, invoice, tenant_id, company_id, errors) + errors.raise_if_errors() + + # --- Paso 2-4: Parámetros generales, parámetros expo y permisos ---------- + # TODO: Compartir QSisGen / GEmpresa + # TODO: Compartir QSisExpo (EsCambioRegimen = 'S' → SisExp:EsCambioRegimen = 'CR') + # TODO: Validar permisos usuario (GUsuarios / GNivelesSeguridad) + + # --- Paso 5: Iniciar transacción ----------------------------------------- + # TODO: BEGIN TRAN (en el legacy: GSQLFile{PROP:SQL} = 'BEGIN TRAN') + + # --- Paso 6: Verificar que existan partidas ------------------------------ + if not lines: + errors.add_error( + field="items", + message="Esta Factura no tiene partidas.", + solution=["Capturar al menos una partida a la factura."], + code="NO_ITEMS_FOUND", + ) + errors.raise_if_errors() + + # --- Paso 7: Tipo de cambio ---------------------------------------------- + # TODO: Si SisGen:CalValBaseTCPedExpo = 1: + # invoice.which_exchange_rate = 'TCPED' + # Buscar pedimento (EqiPed:Pedimento = EqiFex:PedimentoExpo) + # Buscar GTipoCambio por EqiPed:Fecha_Pago + # exchange_rate = GenTC:Valor + # Else: + # invoice.which_exchange_rate = 'TCFAC' + # exchange_rate = invoice.financials.exchange_rate + + # --- Paso 8: Validar que la factura no exista en ImportDef --------------- + # TODO: Si invoice.generate_id = True: + # Buscar en QFacImpDef por invoice.invoice_number + # Si ya existe → agregar error + + # --- Paso 9: Sub-proceso por tipo de factura ----------------------------- + invoice_type = invoice.invoice_type + + if invoice_type == "NODES": + # Sin descarga de saldos + assign_no_discharges_items(lines, errors) + assign_no_discharges_series(db, lines, errors) + review_class(db, lines, errors) + review_exchange_rate(db, invoice, errors) + assign_values(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors) + + review_qty_vs_weight(lines, invoice.logistics.weight_type.upper(), errors) + + review_unit_cost(lines, errors) + review_qty_series(db, invoice, lines, tenant_id, company_id, errors) + total_qty, total_net_weight = limit_weight(lines) + total_value = limit_value(lines) + + finalize_invoice_no_discharge(db, invoice, lines, errors) + + elif invoice_type == "AFIJO": + if invoice.compliance_mx and invoice.compliance_mx.is_regime_change: + review_origin_procedure(db, invoice, lines, "TEM", errors) + if invoice.generate_id and invoice.generate_desc_parties == "Todas": + def_inv = generate_definitive_import(db, invoice, errors) + if def_inv: + generate_definitive_import_all_lines(db, invoice, def_inv, errors) + + _process_with_discharge(db, invoice, lines, errors) + + elif invoice_type == "DONAC": + _process_with_discharge(db, invoice, lines, errors) + + elif invoice_type == "SCRAP": + if invoice.compliance_mx and invoice.compliance_mx.is_regime_change: + review_origin_procedure(db, invoice, lines, "TEM", errors) + if invoice.generate_id and invoice.generate_desc_parties == "Todas": + def_inv = generate_definitive_import(db, invoice, errors) + if def_inv: + generate_definitive_import_all_lines(db, invoice, def_inv, errors) + + _process_with_discharge(db, invoice, lines, errors) + + elif invoice_type == "REEXP": + review_origin_procedure(db, invoice, lines, "DEF", errors) + _process_with_discharge(db, invoice, lines, errors) + + elif invoice_type == "VEMEX": + review_origin_procedure(db, invoice, lines, "DEF", errors) + _process_with_discharge(db, invoice, lines, errors) + + else: + errors.add_error( + field="invoice_type", + message=f"{invoice_type} no es un Tipo de Factura válido, llamar al proveedor del Sistema SCAII.", + solution=["Verificar el tipo de factura de exportación."], + code="INVALID_INVOICE_TYPE", + value=invoice_type, + ) + + # --- Paso 10: Commit / Rollback ------------------------------------------ + errors.raise_if_errors() + + # TODO: COMMIT TRAN (en el legacy: gSQLFile{PROP:SQL} = 'COMMIT TRAN') + # TODO: GBitacora('ACTUALIZAR FACTURA', invoice.invoice_number) + + # invoice.status and totals are set inside finalize_invoice_no_discharge / termina_ac_o_lp_normal + db.flush() + + return {"status": "success", "invoice_id": str(invoice.id)} 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..8f1140f0 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py @@ -0,0 +1,122 @@ +from sqlalchemy import func +from sqlalchemy.orm import Session, joinedload +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.general_catalogs.fractions.warning_fractions.models import WarningFraction +from api.v1.modules.a76.clients_and_providers.models import ClientProvider +from core.exceptions import ErrorCollector + +def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector): + if invoice.status == InvoiceStatus.PROCESSED: + errors.add_error( + "status", + "La factura ya fue procesada y no puede volver a actualizarse. Desactualícela primero.", + solution=["Use el botón 'Desactualizar' antes de volver a procesar la factura."], + code="ALREADY_PROCESSED", + value=invoice.status, + ) + errors.raise_if_errors() + return + + if not invoice.invoice_date: + errors.add_required_error("invoice_date") + + if invoice.invoice_type != "VEMEX": + if not invoice.document_type: + errors.add_required_error("document_type") + + if not invoice.compliance_mx.provider_id: + errors.add_required_error("compliance_mx.provider_id") + + if not invoice.compliance_mx.sold_to_id: + errors.add_required_error("compliance_mx.sold_to_id") + + if not invoice.compliance_mx.shipped_to_id: + errors.add_required_error("compliance_mx.shipped_to_id") + else: + shipped_to_exists = db.query(ClientProvider).filter( + ClientProvider.id == invoice.compliance_mx.shipped_to_id, + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ).first() + if not shipped_to_exists: + errors.add_error( + field="compliance_mx.shipped_to_id", + message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.", + solution=["Verifica el ID del Destinatario", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.shipped_to_id, + ) + if not shipped_to_exists.address or not shipped_to_exists.address.country: + errors.add_error( + field="compliance_mx.shipped_to_id", + message="El Destinatario no tiene capturado el pais.", + solution=["Captura el pais de envío del Destinatario", "Revisa el catálogo"], + code="MISSING_COUNTRY", + value=invoice.compliance_mx.shipped_to_id, + ) + + if invoice.invoice_type != "VEMEX": + if not invoice.compliance_mx.customs_broker_id: + errors.add_required_error("compliance_mx.customs_broker_id") + + + if not invoice.financials.exchange_rate or invoice.financials.exchange_rate <= 0: + errors.add_range_error( + "financials.exchange_rate", + min_value=0.0001, + ) + + if not invoice.financials.currency: + errors.add_required_error("El Tipo de Moneda esta vacio no se puede actualizar") + elif invoice.financials.currency == "manual" and not invoice.financials.currency_type: + errors.add_required_error("financials.currency_type") + + #TODO: SSISGEN: Seguridad Ejemplo en: BrowseQFacImp + + # 2.- Existe tipo de cambio para la factura seleccionada + #TODO: SSISGEN: VALIDACION DEL TIPO DE CAMBIO EN BASE A LA FECHA DE PAGO DEL PEDIMENTO. + + # 3.- Validacion que deber de existir un pedimento cuando es requerido + if not invoice.compliance_mx.is_pedimento_pending and not invoice.compliance_mx.pedimento_id: + errors.add_required_error("compliance_mx.pedimento_number") + + # 4.- Verificacion de que existan partidas para la factura, si no hay partidas no se puede procesar + item_count = ( + db.query(func.count(LineItem.id)) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == invoice.tenant_id, + LineItem.company_id == invoice.company_id, + ) + .scalar() + ) + if item_count == 0: + errors.add_error( + field="items", + message="La factura no tiene partidas capturadas.", + solution=["Capture al menos una partida antes de procesar la factura."], + code="NO_ITEMS_FOUND", + ) + + # Advertencias para las fracciones y su horario + lines = ( + db.query(LineItem) + .options(joinedload(LineItem.fa_data)) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .all() + ) + + return lines + + + + + + + + \ 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..0a9ec622 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/compare_balances.py @@ -0,0 +1,165 @@ +""" +COMPARA_SALDOS_POR_FACTURA +Compares the discharge queue (QueADescargar) against the available PEPS lots +(QSaldoActual) and distributes the quantity to discharge across the available +lots, updating ``entry.quantity_used`` and ``lot.available_qty`` accordingly. + +Also validates that the unit of measure on the export line matches the one +on the import lot. + +If after consuming all available lots a discharge entry still has remaining +quantity, no explicit error is raised here — the caller (compare_balances) +detects this and reports an insufficient-balance error. + +Legacy mapping +-------------- +QADesc:Cantidad → entry.quantity +QADesc:CantUsada → entry.quantity_used +QSaldo:Cantidad → lot.available_qty (net balance from ledger) +QSaldo:CantUsada → lot_used (tracked locally; lots are mutated in-place) +QADesc:UniMed → entry.unit_of_measure +QSaldo:UniMed → resolved from import line (stored on AvailableLot via uom) +""" + +from decimal import Decimal +from typing import List, Optional + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector +from .discharge_types import AvailableLot, DownloadEntry + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +def _resolve_import_uom(db: Session, import_item_line_id: int) -> Optional[str]: + """ + Returns the unit-of-measure code of the import line (QSaldo:UniMed). + Equivalent to EqiPim:UnidadMedida resolved via the import LineItem. + """ + from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure + + row = db.execute( + select(LineItem.unit_of_measure).where(LineItem.id == import_item_line_id) + ).scalar_one_or_none() + + if row is None: + return None + + uom = db.get(UnitOfMeasure, row) + return uom.code if uom else None + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def compare_balances( + db: Session, + export_invoice: InvoiceHeader, + to_discharge: List[DownloadEntry], + errors: ErrorCollector, +) -> None: + """ + COMPARA_SALDOS_POR_FACTURA + For each discharge entry distributes the quantity to discharge across the + available PEPS lots attached to the entry by ``fill_available_balances``. + + Mutates ``entry.quantity_used`` and ``lot.available_qty`` in-place. + After this call, ``entry.quantity_used`` should equal ``entry.quantity`` + for every entry; if not, there is insufficient balance. + + Parameters + ---------- + db : active SQLAlchemy session + export_invoice : the export invoice being processed + to_discharge : list of DownloadEntry objects populated by + fill_available_balances (entry.available_lots must be set) + errors : shared error collector + """ + # Sort mirrors Clarion: + # Sort(QueADescargar, -Procedencia, FacturaImpo, LineaImpo) + # Sort(QSaldoActual, -Procedencia, FacturaImpo, LineaImpo) + sorted_entries = sorted( + to_discharge, + key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line), + reverse=True, + ) + + for entry in sorted_entries: + if not entry.available_lots: + # No lots were found for this entry — balance check will catch it + continue + + # ── Validate unit of measure matches the import lot ─────────────────── + first_lot = entry.available_lots[0] + import_uom = _resolve_import_uom(db, first_lot.import_item_line_id) + + if import_uom and entry.unit_of_measure and import_uom != entry.unit_of_measure: + errors.add_error( + field=f"line[{entry.export_line}].unit_of_measure", + message=( + f"La U.M.: '{entry.unit_of_measure}' de la partida: {entry.export_line} " + f"es diferente a la U.M: '{import_uom}' registrada en importación." + ), + solution=["Revisar la Partida de Exportación y cambiar la Unidad de Medida."], + code="UOM_MISMATCH", + value={ + "export_line": entry.export_line, + "export_uom": entry.unit_of_measure, + "import_uom": import_uom, + }, + ) + continue + + # ── Distribute quantity across available lots (PEPS order) ──────────── + # Lots are already ordered by order_peps (oldest first) from + # fill_available_balances; sort defensively here too. + sorted_lots: List[AvailableLot] = sorted( + entry.available_lots, key=lambda lot: lot.order_peps + ) + + for lot in sorted_lots: + remaining_entry = entry.quantity - entry.quantity_used + remaining_lot = lot.available_qty + + if remaining_entry <= 0: + break # Entry fully satisfied + + if remaining_lot <= 0: + continue # Lot exhausted — try next + + # Consume as much as possible from this lot + consume = min(remaining_entry, remaining_lot) + + entry.quantity_used += consume + lot.available_qty -= consume + lot.consumed_qty += consume + + # ── Check if the entry was fully satisfied ──────────────────────────── + if entry.quantity_used < entry.quantity: + shortage = entry.quantity - entry.quantity_used + errors.add_error( + field=f"line[{entry.export_line}].quantity", + message=( + f"Saldo insuficiente para la partida: {entry.export_line}. " + f"Se requieren {entry.quantity} y solo hay {entry.quantity_used} disponibles " + f"(faltan {shortage})." + ), + solution=[ + "Verificar el saldo disponible de la Factura de Importación.", + "Reducir la cantidad a descargar.", + ], + code="INSUFFICIENT_BALANCE", + value={ + "export_line": entry.export_line, + "required": str(entry.quantity), + "available": str(entry.quantity_used), + "shortage": str(shortage), + }, + ) 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..e8dbca22 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/discharge_types.py @@ -0,0 +1,73 @@ +""" +Shared dataclasses for the export invoice discharge process. + +Kept in a standalone module (no local imports) so that +download_balance_collector, review_series_exist, review_series_other_lines +and fill_available_balances can all import from here without circular deps. +""" + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import List, Optional + + +@dataclass +class AvailableLot: + """ + One PEPS lot available for discharge — equivalent to one QSaldoActual record. + + Fields + ------ + import_item_line_id : a76.item_lines.id of the import line (the lot) + import_invoice_id : a76.invoice_header.id of the import invoice + part_number_id : denormalized from the import line + available_qty : net balance available (QSaldo:Cantidad); mutated by + compare_balances() as quantity is distributed + consumed_qty : how much was actually taken from this lot by + compare_balances(); used by register_discharge_ledger + to create the exact BalanceMovement amount + value_me : USD value of the full lot (for proportional calc) + value_mn : MXN value of the full lot (for proportional calc) + order_peps : PEPS ordering key — lower = older = consumed first + """ + import_item_line_id: int + import_invoice_id: int + part_number_id: Optional[int] + available_qty: Decimal + value_me: Optional[Decimal] + value_mn: Optional[Decimal] + order_peps: int + consumed_qty: Decimal = field(default_factory=Decimal) + + +@dataclass +class DownloadEntry: + """ + Represents one export line that will be discharged from inventory. + Equivalent to the QADesc (QueADescargar) record in the legacy system. + + Fields + ------ + origin_procedure : TipoMovImpo – 'TEM' (temporal) | 'DEF' (definitiva) + export_line : LineaExpo – line number on the export invoice + part_number : NumParte – part number code + class_code : Clase – class code + quantity : CantExpo – quantity to discharge + quantity_used : CantUsada – amount already consumed (starts at 0) + unit_of_measure : UniMed – unit of measure code + import_invoice : FacturaImpo – source import invoice number + import_line : LineaImpo – source import line number + line_item_id : internal DB id of the LineItem (for series lookups) + available_lots : PEPS lots attached by fill_available_balances + """ + origin_procedure: str + export_line: int + part_number: str + class_code: str + quantity: Decimal + unit_of_measure: str + import_invoice: str + import_line: int + line_item_id: int + quantity_used: Decimal = field(default_factory=Decimal) + available_lots: List[AvailableLot] = field(default_factory=list) 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..091442f4 --- /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.fa_data and line.fa_data.discharge] + + if not discharge_lines: + return to_discharge + + # Shared across all lines — accumulates series keys to detect duplicates + # between lines (equivalent to QueueSeries in the Clarion) + seen_series: Set[Tuple] = set() + + for line in discharge_lines: + origin_procedure = ( + line.customs.origin_procedure + if line.customs and line.customs.origin_procedure + else "" + ) + part_number = "" + if line.part_info: + part_number = line.part_info.part_number or "" + + class_code = "" + if line.class_info: + class_code = line.class_info.class_code or "" + + quantity = ( + line.quantity.quantity or Decimal(0) + if line.quantity + else Decimal(0) + ) + + uom_code = "" + if line.unit_of_measure_info: + uom_code = line.unit_of_measure_info.code or "" + + import_invoice_number = "" + import_line_number = 0 + if line.fa_data: + import_invoice_number = line.fa_data.search_invoice or "" + import_line_number = line.fa_data.search_line or 0 + + entry = DownloadEntry( + origin_procedure=origin_procedure, + export_line=line.line_number, + part_number=part_number, + class_code=class_code, + quantity=quantity, + quantity_used=Decimal(0), + unit_of_measure=uom_code, + import_invoice=import_invoice_number, + import_line=import_line_number, + line_item_id=line.id, + ) + to_discharge.append(entry) + + review_series_exist(db, invoice, line, entry, errors) + review_series_other_lines(db, invoice, line, entry, seen_series, errors) + + return to_discharge 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..eec6f686 --- /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.PENDING: + errors.add_error( + field=f"line[{entry.export_line}].import_invoice", + message=f"La Factura de Importación: '{entry.import_invoice}' está Desactualizada.", + solution=["Actualizar la factura de Importación."], + code="IMPORT_INVOICE_UNPROCESSED", + value=entry.import_invoice, + ) + continue + + # Import date must not be later than export date + imp_date: datetime.date = ( + import_invoice.invoice_date.date() + if hasattr(import_invoice.invoice_date, "date") + else import_invoice.invoice_date + ) + if imp_date > export_date: + errors.add_error( + field=f"line[{entry.export_line}].import_invoice", + message=( + f"La Factura de Importación: '{entry.import_invoice}' tiene una Fecha Mayor " + f"a la Fecha de Descarga." + ), + solution=[ + f"Seleccionar otra factura de Importación con Fecha Anterior al " + f"{export_date.strftime('%d/%m/%Y')}." + ], + code="IMPORT_INVOICE_DATE_AFTER_EXPORT", + value={"import_date": str(imp_date), "export_date": str(export_date)}, + ) + continue + + # ── 2. Validate import line ─────────────────────────────────────────── + import_line = _fetch_import_line( + db, + import_invoice.id, + entry.import_line, + export_invoice.tenant_id, + export_invoice.company_id, + ) + + if import_line is None: + errors.add_error( + field=f"line[{entry.export_line}].import_line", + message=( + f"La Factura de Importación: '{entry.import_invoice}' " + f"con Línea: {entry.import_line} no existe." + ), + solution=["Seleccionar otra Línea de Importación a Descargar."], + code="IMPORT_LINE_NOT_FOUND", + value={"import_invoice": entry.import_invoice, "import_line": entry.import_line}, + ) + continue + + # ── 3. Compute net available balance from ledger (as of export date) ─── + # Equivalent to: CantImpo - CantRetornadaTemp - CantRetornada (general) + # then CALCULA_SALDO_FECHA_EXPO (only exits on or before export_date). + available = _net_balance_for_lot(db, import_line.id, export_date) + if available <= 0: + # No balance — skip this lot (equivalent to Clarion CYCLE) + continue + + # ── 4. Build AvailableLot and attach to entry ───────────────────────── + fin = import_line.financial + lot = AvailableLot( + import_item_line_id=import_line.id, + import_invoice_id=import_invoice.id, + part_number_id=import_line.part_number_id, + available_qty=available, + value_me=Decimal(str(fin.value_usd or 0)) if fin else None, + value_mn=Decimal(str(fin.value_mxn or 0)) if fin else None, + order_peps=_peps_order_for_lot(db, import_line.id), + ) + entry.available_lots.append(lot) 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..6aea9c0a --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py @@ -0,0 +1,284 @@ +""" +finalize_invoice_no_discharge / finalize_invoice_with_discharge +(TERMINA_AC_O_LP_NODES / TERMINA_AC_O_LP_NORMAL) + +Last step of export invoice processing. Both variants: + 1. TODO: DO REVISACLASESHABILITADAS + 2. Validate SisExp quantity / weight / value limits (min and max). + 3. If no errors: assign invoice-level totals and mark as PROCESSED. + +The "with_discharge" variant additionally: + 4. DO GENERAIMPODEFINITIVA (if is_regime_change and generate_id) + 5. DO REGISTRA_DESCARGA_IMPORTACION (update returned qty/value on import lines) + 6. DO REGISTRA_DESCARGA_SERIES (flag import series as exported) + +The legacy 'Of LP' branch (print-preview / progress-bar UI) is not ported. +""" + +import datetime +from decimal import Decimal +from typing import TYPE_CHECKING, List + +from sqlalchemy.orm import Session + +from .register_import_discharge import register_import_discharge +from .register_discharge_series import register_discharge_series +from .register_discharge_ledger import register_discharge_ledger + +if TYPE_CHECKING: + from .discharge_types import DownloadEntry + +from api.v1.modules.a76.classes.models import Class +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector +from .review_limits import limit_weight, limit_value +from .generate_definitive_import import generate_definitive_import + + +# --------------------------------------------------------------------------- +# REVISACLASESHABILITADAS +# --------------------------------------------------------------------------- + +def _review_enabled_classes( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + errors: ErrorCollector, +) -> None: + """ + REVISACLASESHABILITADAS + Verifies that every line item's class is active (not disabled). + + Clarion: loops QEqeMaq for the invoice, fetches QClaAct by class code, + errors if HabilitaDeshabilitaClase = 1 → Python: Class.is_active = False. + """ + for line in lines: + if not line.class_id: + continue + cls: Class | None = db.get(Class, line.class_id) + if cls is not None and cls.is_active is False: + errors.add_error( + field=f"line[{line.line_number}].class", + message=( + f"La Clase: '{cls.class_code}' esta desactivada, " + "no se pueden hacer movimientos." + ), + solution=["Seleccionar una clase activa."], + code="CLASS_DISABLED", + ) + + +# --------------------------------------------------------------------------- +# SisExp limit checks (shared by both public functions) +# --------------------------------------------------------------------------- + +def _validate_sisexp_limits( + invoice: InvoiceHeader, + total_qty: Decimal, + total_net_weight: Decimal, + total_value: Decimal, + errors: ErrorCollector, +) -> None: + """ + Validates invoice totals against the SisExp min/max limit parameters. + + TODO: Read actual SisExp parameters from the tenant system-config model. + Until then all limits default to 0 (= disabled) so no checks fire. + + Clarion names → Python (TODO): + SisExp:CantLimiteMin / SisExp:CantLimite → qty min / max + SisExp:PesoLimiteMin / SisExp:PesoLimite → weight min / max + SisExp:ValorLimiteMin / SisExp:ValorLimite → value min / max + """ + # TODO: load from SisExp tenant config + cant_limite_min: Decimal = Decimal(0) + cant_limite: Decimal = Decimal(0) + peso_limite_min: Decimal = Decimal(0) + peso_limite: Decimal = Decimal(0) + valor_limite_min: Decimal = Decimal(0) + valor_limite: Decimal = Decimal(0) + + solution = ["Consulte a su Administrador de sistema para parametrizar la factura."] + code = "PAR.EXPO" + + if cant_limite_min != 0 and cant_limite_min > total_qty: + errors.add_error( + field="invoice.total_quantity", + message=( + f"La cantidad total de la factura: {total_qty} " + f"no supera a la cantidad mínima parametrizada: {cant_limite_min}." + ), + solution=solution, code=code, + ) + if cant_limite != 0 and cant_limite < total_qty: + errors.add_error( + field="invoice.total_quantity", + message=( + f"La cantidad total de la factura: {total_qty} " + f"excede a la cantidad máxima parametrizada: {cant_limite}." + ), + solution=solution, code=code, + ) + if peso_limite_min != 0 and peso_limite_min > total_net_weight: + errors.add_error( + field="invoice.net_weight", + message=( + f"El Peso Neto total de la factura: {total_net_weight} " + f"no supera el Peso mínimo parametrizado: {peso_limite_min}." + ), + solution=solution, code=code, + ) + if peso_limite != 0 and peso_limite < total_net_weight: + errors.add_error( + field="invoice.net_weight", + message=( + f"El Peso Neto total de la factura: {total_net_weight} " + f"excede el Peso máximo parametrizado: {peso_limite}." + ), + solution=solution, code=code, + ) + if valor_limite_min != 0 and valor_limite_min > total_value: + errors.add_error( + field="invoice.total_value", + message=( + f"El Valor total de la factura: {total_value} " + f"no supera el Valor mínimo parametrizado: {valor_limite_min}." + ), + solution=solution, code=code, + ) + if valor_limite != 0 and valor_limite < total_value: + errors.add_error( + field="invoice.total_value", + message=( + f"El Valor total de la factura: {total_value} " + f"excede el Valor máximo parametrizado: {valor_limite}." + ), + solution=solution, code=code, + ) + + +# --------------------------------------------------------------------------- +# DO ASIGNA_VALORES_FACTURA +# --------------------------------------------------------------------------- + +def _assign_invoice_totals( + invoice: InvoiceHeader, + lines: List[LineItem], +) -> None: + """ + DO ASIGNA_VALORES_FACTURA + Aggregates line-level values (MN, ME, qty, packages, net/gross weight) + and writes the totals to the invoice header, then marks it as PROCESSED. + + Clarion equivalent: + SELECT SUM(ValorExpoMN), SUM(ValorExpoME), SUM(CantExpo), + SUM(CantBultos), SUM(PesoNeto), SUM(PesoBruto) + FROM QEqeMaq WHERE Consecutivo = + + TODO: SisGen:CalValBaseTCPedExpo = 1 → invoice.financials.exchange_rate = Loc:TipoCambio + TODO: SisGen:CalValBaseTCPedExpo = 1 → + invoice.process_log = 'Se Actualizó con el Tipo de Cambio de la Fecha de Pago de Pedimento.' + TODO: SisGen:ActSeguridad = 1 → invoice.updated_by = current_user + """ + total_value_mn = Decimal(0) + total_value_me = Decimal(0) + total_qty = Decimal(0) + total_packages = 0 + total_net_weight = Decimal(0) + total_gross_weight = Decimal(0) + + for line in lines: + if line.financial: + total_value_mn += Decimal(str(line.financial.value_mxn or 0)) + total_value_me += Decimal(str(line.financial.value_usd or 0)) + if line.quantity: + total_qty += line.quantity.quantity or Decimal(0) + total_packages += line.quantity.package_quantity or 0 + total_net_weight += line.quantity.net_weight or Decimal(0) + total_gross_weight += line.quantity.gross_weight or Decimal(0) + + if invoice.financials is not None: + invoice.financials.value_mn = float(total_value_mn) + invoice.financials.value_me = float(total_value_me) + invoice.financials.total_quantity = float(total_qty) + invoice.financials.total_packages = total_packages + invoice.financials.net_weight = float(total_net_weight) + invoice.financials.gross_weight = float(total_gross_weight) + + invoice.party_count = len([l for l in lines if not (l.fa_data and l.fa_data.is_subitem)]) + invoice.updated_date = datetime.date.today() + invoice.status = InvoiceStatus.PROCESSED + + +# --------------------------------------------------------------------------- +# Public entry points +# --------------------------------------------------------------------------- + +def finalize_invoice_no_discharge( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + errors: ErrorCollector, +) -> None: + """ + TERMINA_AC_O_LP_NODES + Finalizes a NODES-type export invoice (no inventory discharge). + + Flow: + 1. Verify all line classes are active (REVISACLASESHABILITADAS). + 2. Validate SisExp limits (qty / weight / value). + 3. If no errors: write invoice totals and set status = PROCESSED. + """ + _review_enabled_classes(db, invoice, lines, errors) + + total_qty, total_net_weight = limit_weight(lines) + total_value = limit_value(lines) + + _validate_sisexp_limits(invoice, total_qty, total_net_weight, total_value, errors) + + if not errors.has_errors(): + _assign_invoice_totals(invoice, lines) + + +def finalize_invoice_with_discharge( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + errors: ErrorCollector, + to_discharge: List["DownloadEntry"] | None = None, +) -> None: + """ + TERMINA_AC_O_LP_NORMAL + Finalizes a discharge-type export invoice (AFIJO / DONAC / SCRAP / REEXP / VEMEX). + + Flow: + 1. Verify all line classes are active (REVISACLASESHABILITADAS). + 2. Validate SisExp limits (qty / weight / value). + 3. If no errors: + a. DO GENERAIMPODEFINITIVA (only if is_regime_change and generate_id) + b. TODO: DO REGISTRA_DESCARGA_IMPORTACION (write a24 discharge movements) + c. TODO: DO REGISTRA_DESCARGA_SERIES (write series discharge records) + d. Write invoice totals and set status = PROCESSED. + + Note: the legacy 'Of LP' branch (print-preview UI) is not ported. + """ + _review_enabled_classes(db, invoice, lines, errors) + + total_qty, total_net_weight = limit_weight(lines) + total_value = limit_value(lines) + + _validate_sisexp_limits(invoice, total_qty, total_net_weight, total_value, errors) + + if not errors.has_errors(): + if invoice.compliance_mx and invoice.compliance_mx.is_regime_change and invoice.generate_id: + generate_definitive_import(db, invoice, errors) + + if to_discharge: + # Write BalanceMovement (CONSUMPTION) + DischargeHeader + DischargeDetail + register_discharge_ledger(db, invoice, to_discharge) + # Update quantity_returned / value_returned on the import lines + register_import_discharge(db, invoice, to_discharge) + register_discharge_series(db, invoice, to_discharge) + + _assign_invoice_totals(invoice, lines) 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_ledger.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_ledger.py new file mode 100644 index 00000000..9f86550f --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_ledger.py @@ -0,0 +1,239 @@ +""" +register_discharge_ledger +========================= +Creates the full Annex-24 discharge record for one export invoice: + + 1. ONE DischargeHeader (one per export event) + 2. N BalanceMovement rows (type=CONSUMPTION, one per lot consumed) + 3. N DischargeDetail rows (one per export-line × import-lot pair), + each referencing its BalanceMovement (design rule 3) + +Design rules from a24.balance_movement (preserved here): + 1. NEVER update existing balance_movement rows — only INSERT. + 2. Balance = SUM of movements. + 3. Every DischargeDetail.movement_id MUST reference a BalanceMovement row. + 4. order_peps = movement.id (set after flush, globally monotonic). +""" + +import datetime +import logging +from decimal import Decimal +from typing import List, Optional + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a24.balance_movements.models import BalanceMovement, MovementType +from api.v1.modules.a24.discharges.models import ( + DischargeDetail, + DischargeHeader, + DischargeStatus, + DischargeType, +) +from api.v1.modules.a76.invoices.models import InvoiceHeader as A76InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from .discharge_types import DownloadEntry, AvailableLot + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _discharge_type_for_invoice(invoice: InvoiceHeader) -> DischargeType: + mapping = { + "AFIJO": DischargeType.TEMPORARY, + "DONAC": DischargeType.TEMPORARY, + "SCRAP": DischargeType.WASTE_SCRAP, + "REEXP": DischargeType.DEFINITIVE, + "VEMEX": DischargeType.DEFINITIVE, + } + return mapping.get(invoice.invoice_type or "", DischargeType.TEMPORARY) + + +def _export_date(invoice: InvoiceHeader) -> datetime.date: + d = invoice.invoice_date + return d.date() if hasattr(d, "date") else d + + +def _proportional_value( + consume: Decimal, + lot_consumed_total: Decimal, + lot_value: Optional[Decimal], +) -> Optional[Decimal]: + """Returns the proportional value for *consume* units out of *lot_consumed_total*.""" + if not lot_value or lot_consumed_total <= 0: + return None + return (consume / lot_consumed_total) * lot_value + + +def _proportional_qty(consume: Decimal, base_qty: Optional[Decimal], base_total: Optional[Decimal]) -> Optional[Decimal]: + """ + Proratea un valor (peso/valor) en proporción a lo consumido. + - consume: cantidad consumida del lote + - base_qty: valor total del lote (ej. peso neto total del lote) + - base_total: cantidad total del lote (ej. quantity del lote) + """ + if base_qty is None: + return None + if base_total is None or base_total <= 0: + return None + return (consume / base_total) * base_qty + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def register_discharge_ledger( + db: Session, + export_invoice: InvoiceHeader, + to_discharge: List[DownloadEntry], +) -> Optional[DischargeHeader]: + """ + Persists the complete Annex-24 discharge record for *export_invoice*. + + Expects that compare_balances() has already run and populated + ``lot.consumed_qty`` for every lot that was drawn from. + + Returns the created DischargeHeader, or None if nothing was discharged. + """ + # Only process entries that actually consumed something + active = [e for e in to_discharge if e.quantity_used > Decimal(0)] + if not active: + return None + + op_date = _export_date(export_invoice) + discharge_type = _discharge_type_for_invoice(export_invoice) + + # Caches to avoid N+1 queries in loops + export_line_cache: dict[int, LineItem] = {} + import_line_cache: dict[int, LineItem] = {} + import_invoice_number_cache: dict[int, str] = {} + + # ── 1. DischargeHeader ──────────────────────────────────────────────── + header = DischargeHeader( + tenant_id=export_invoice.tenant_id, + company_id=export_invoice.company_id, + source_invoice_id=export_invoice.id, + discharge_type=discharge_type, + status=DischargeStatus.APPLIED, + discharge_date=op_date, + ) + db.add(header) + db.flush() # get header.id + + total_movements = 0 + + for entry in active: + export_line_id: Optional[int] = entry.line_item_id + + export_line_obj: Optional[LineItem] = None + if export_line_id: + export_line_obj = export_line_cache.get(export_line_id) + if export_line_obj is None: + export_line_obj = db.get(LineItem, export_line_id) + if export_line_obj is not None: + export_line_cache[export_line_id] = export_line_obj + + # Only iterate lots that were actually consumed + consumed_lots: List[AvailableLot] = [ + lot for lot in entry.available_lots if lot.consumed_qty > Decimal(0) + ] + + for lot in consumed_lots: + consume = lot.consumed_qty + + # Load import-line object for denormalized customs/weights fields + import_line_obj = import_line_cache.get(lot.import_item_line_id) + if import_line_obj is None: + import_line_obj = db.get(LineItem, lot.import_item_line_id) + if import_line_obj is not None: + import_line_cache[lot.import_item_line_id] = import_line_obj + + # Import invoice number (for origin_import_invoice in DischargeDetail) + origin_import_invoice: Optional[str] = None + if lot.import_invoice_id: + origin_import_invoice = import_invoice_number_cache.get(lot.import_invoice_id) + if origin_import_invoice is None: + inv = db.get(A76InvoiceHeader, lot.import_invoice_id) + origin_import_invoice = inv.invoice_number if inv else None + if origin_import_invoice: + import_invoice_number_cache[lot.import_invoice_id] = origin_import_invoice + + # ── 2. BalanceMovement (CONSUMPTION) ────────────────────────── + # Proportional value: consume / lot_consumed_total × lot_value + # lot_consumed_total == consume for single-lot entries (most cases) + value_me = _proportional_value(consume, consume, lot.value_me) + value_mn = _proportional_value(consume, consume, lot.value_mn) + + movement = BalanceMovement( + tenant_id=export_invoice.tenant_id, + company_id=export_invoice.company_id, + import_invoice_id=lot.import_invoice_id, + import_item_line_id=lot.import_item_line_id, + part_number_id=lot.part_number_id, + movement_type=MovementType.CONSUMPTION, + quantity=consume, + value_me=value_me, + value_mn=value_mn, + source_invoice_id=export_invoice.id, + source_item_line_id=export_line_id, + order_peps=0, # placeholder — set after flush (rule 4) + operation_date=op_date, + notes=( + f"Descarga por factura de exportación " + f"{export_invoice.invoice_number}" + ), + ) + db.add(movement) + db.flush() # get movement.id + movement.order_peps = movement.id # rule 4: monotonic + + # ── 3. DischargeDetail ───────────────────────────────────────── + # Denormalized fields expected by reports: + imp_cust = import_line_obj.customs if import_line_obj else None + imp_qty = import_line_obj.quantity if import_line_obj else None + imp_total_qty = imp_qty.quantity if imp_qty else None + + net_weight = _proportional_qty(consume, imp_qty.net_weight if imp_qty else None, imp_total_qty) + gross_weight = _proportional_qty(consume, imp_qty.gross_weight if imp_qty else None, imp_total_qty) + + detail = DischargeDetail( + tenant_id=export_invoice.tenant_id, + company_id=export_invoice.company_id, + discharge_header_id=header.id, + export_item_line_id=export_line_id, + import_item_line_id=lot.import_item_line_id, + movement_id=movement.id, + quantity_discharged=consume, + unit_of_measure=entry.unit_of_measure or None, + value_me=value_me, + value_mn=value_mn, + net_weight=net_weight, + gross_weight=gross_weight, + tariff_fraction=imp_cust.fraction if imp_cust else None, + fraction_type=imp_cust.fraction_type if imp_cust else None, + ad_valorem=imp_cust.advalorem if imp_cust else None, + country_of_origin=imp_cust.origin_country if imp_cust else None, + sector=imp_cust.sector if imp_cust else None, + procedence=entry.origin_procedure or None, + part_number=entry.part_number or None, + export_part_number=( + export_line_obj.part_info.part_number + if export_line_obj and export_line_obj.part_info and export_line_obj.part_info.part_number + else None + ), + origin_import_invoice=origin_import_invoice, + ) + db.add(detail) + total_movements += 1 + + logger.info( + "register_discharge_ledger: invoice=%s header_id=%s details=%d", + export_invoice.invoice_number, + header.id, + total_movements, + ) + return header 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/models.py b/backend/api/v1/modules/a76/items/models.py index c966c459..c7bc18eb 100644 --- a/backend/api/v1/modules/a76/items/models.py +++ b/backend/api/v1/modules/a76/items/models.py @@ -4,7 +4,7 @@ SQLAlchemy v2 - Annex 24 Compliance """ from datetime import datetime -from typing import Optional, TYPE_CHECKING +from typing import Optional, List, TYPE_CHECKING from core.database import Base from decimal import Decimal from sqlalchemy import Boolean, Date, String, Integer, Numeric, SmallInteger, ForeignKey @@ -25,6 +25,10 @@ if TYPE_CHECKING: from api.v1.modules.a76.parts.models import Part from api.v1.modules.a76.invoices.models import InvoiceHeader +# Imported at runtime so SQLAlchemy's mapper registry can resolve the class name +# used in the relationship string below. +from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail + # ============================================================================ # CORE ENTITIES # ============================================================================ @@ -216,10 +220,14 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin): ) fa_data: Mapped[Optional["FaLineItem"]] = relationship( "FaLineItem", - back_populates="master_info", cascade="all, delete-orphan", uselist=False, ) + identifiers: Mapped[List["IdentifierDetail"]] = relationship( + "IdentifierDetail", + back_populates="line", + cascade="all, delete-orphan", + ) part_info: Mapped[Optional["Part"]] = relationship( "Part", foreign_keys=[part_number_id], diff --git a/backend/api/v1/modules/a76/items/routes.py b/backend/api/v1/modules/a76/items/routes.py index ee1c9e14..65f559bf 100644 --- a/backend/api/v1/modules/a76/items/routes.py +++ b/backend/api/v1/modules/a76/items/routes.py @@ -3,7 +3,8 @@ API Endpoints for Items management Handles CRUD operations for Item with one-to-many relationships to LineItems """ -from typing import Dict, Any, Optional +import datetime +from typing import Dict, Any, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Path, status from sqlalchemy.orm import Session @@ -191,6 +192,39 @@ async def get_items_by_invoice( skip=skip, limit=limit ) +@router.get("/invoice/{invoice_id}/items-with-balance", response_model=List[dict]) +async def get_items_with_balance( + invoice_id: int = Path(..., description="Import Invoice ID"), + company_id: int = Query(..., description="Company ID"), + as_of_date: Optional[datetime.date] = Query( + None, + description=( + "Cut-off date for balance calculation. Only consumptions on or " + "before this date are subtracted (CALCULA_SALDO_FECHA_EXPO logic)." + ), + ), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Returns every line of the given import invoice with its available balance + from the a24.balance_movement ledger. + + Each item in the response includes: + - id, line_number, part_number, class_code, unit_of_measure_code + - quantity : original imported quantity + - available_balance : net balance still available for export discharge + - has_balance : true when available_balance > 0 + + Use ``as_of_date`` to restrict consumption movements to a specific date + (pass the export invoice date so that future discharges are not counted). + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = ItemService() + return service.get_lines_with_balance(db, invoice_id, tenant_id, company_id, as_of_date) + + +# ============================================================================ # STATISTICS & UTILITIES # ============================================================================ diff --git a/backend/api/v1/modules/a76/items/schemas.py b/backend/api/v1/modules/a76/items/schemas.py index a752701e..099a8571 100644 --- a/backend/api/v1/modules/a76/items/schemas.py +++ b/backend/api/v1/modules/a76/items/schemas.py @@ -37,6 +37,12 @@ from .line_references.schemas import ( LineReferenceResponse, ) +from api.v1.modules.a76.general_catalogs.identifiers.dto import ( + IdentifierDetailCreate, + IdentifierDetailUpdate, + IdentifierDetailResponse, +) +from api.v1.modules.a76.classes.models import Class from api.v1.modules.a24.fa.fa_item_lines.dto import ( FaLineItemCreateDTO, FaLineItemUpdateDTO, @@ -238,6 +244,9 @@ class LineItemCreate(LineItemBase): series: Optional[list[SerieCreate]] = Field( None, description="Series data for this line (multiple per line)" ) + identifiers: Optional[list[IdentifierDetailCreate]] = Field( + None, description="Identifiers for this line" + ) class LineItemUpdate(LineItemBase): @@ -267,6 +276,9 @@ class LineItemUpdate(LineItemBase): series: Optional[list[SerieUpdate]] = Field( None, description="Series data for this line (replace all)" ) + identifiers: Optional[list[IdentifierDetailUpdate]] = Field( + None, description="Identifiers for this line" + ) class LineItemResponse(LineItemBase): @@ -295,6 +307,7 @@ class LineItemResponse(LineItemBase): reference: Optional[LineReferenceResponse] = None fa_data: Optional[FaLineItemResponseDTO] = None series: Optional[list[SerieResponse]] = None + identifiers: Optional[list[IdentifierDetailResponse]] = None # Fields populated from relationships class_code: Optional[str] = None diff --git a/backend/api/v1/modules/a76/items/series/models.py b/backend/api/v1/modules/a76/items/series/models.py index 6801b43a..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,9 +16,11 @@ class Serie(Base, TenantScopedMixin, TimestampMixin): serial_numbers: Mapped[Optional[str]] = mapped_column(String(50)) # SERIEEXPO model: Mapped[Optional[str]] = mapped_column(String(50)) # MODELOEXPO sub_model: Mapped[Optional[str]] = mapped_column(String(50)) # SUBMODELOEXPO - brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCA - expo_brad: Mapped[Optional[str]] = mapped_column(String(50)) # MARCAEXPO + brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCAEXPO number_id: Mapped[Optional[str]] = mapped_column(String(25)) # NUMIDEXPO + discharge: Mapped[Optional[bool]] = mapped_column(Boolean) # MARCA + serie_row: Mapped[Optional[int]] = mapped_column(Integer) # LINEASERIEIMPO <-- IN CASE OF EXPO + image_path: Mapped[Optional[str]] = mapped_column(String(255)) # PATH DE IMAGEN (MEX) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/items/series/schemas.py b/backend/api/v1/modules/a76/items/series/schemas.py index e967a1d2..53d947d7 100644 --- a/backend/api/v1/modules/a76/items/series/schemas.py +++ b/backend/api/v1/modules/a76/items/series/schemas.py @@ -10,6 +10,9 @@ class SerieBase(BaseModel): brand: Optional[str] = Field(None, max_length=50, description="Brand (MARCA)") expo_brad: Optional[str] = Field(None, max_length=50, description="Expo brand (MARCAEXPO)") number_id: Optional[str] = Field(None, max_length=25, description="Number ID (NUMIDEXPO)") + import_invoice: Optional[str] = Field(None, max_length=15, description="Import invoice (FACTURAIMPO)") + import_line: Optional[int] = Field(None, description="Import line (LINEAIMPO)") + image_path: Optional[str] = Field(None, max_length=255, description="Image path (IMAGEPATHMEX)") class SerieCreate(SerieBase): diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index 4f3cb2d3..dbbab8e3 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -12,10 +12,12 @@ After refactoring: LineItem is the main entity, representing a single line item There is no intermediate Item entity anymore. Each LineItem belongs directly to an InvoiceHeader. """ +import datetime import logging +from decimal import Decimal from typing import Optional, List, Tuple from fastapi import HTTPException -from sqlalchemy import and_, or_ +from sqlalchemy import and_, case, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, joinedload @@ -40,6 +42,8 @@ from .models import LineItem from .series.models import Serie from api.v1.modules.a76.invoices.models import InvoiceHeader from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail +from api.v1.modules.a24.balance_movements.models import BalanceMovement, NEGATIVE_MOVEMENTS logger = logging.getLogger(__name__) @@ -166,7 +170,7 @@ class ItemService: # FA data uses line.id as primary key if line_data.fa_data: fa_dict = line_data.fa_data.model_dump( - exclude_unset=True, exclude={"line_item_id"} + exclude_unset=True, exclude={"line_item_id", "includes_subitems"} ) fa_dict.update( {"id": line.id, "tenant_id": tenant_id, "company_id": company_id} @@ -195,6 +199,26 @@ class ItemService: serie_dict["row"] = 1 db.add(Serie(**serie_dict)) + # Identifier Detail data + if hasattr(line_data, "identifiers") and line_data.identifiers: + id_list = ( + line_data.identifiers + if isinstance(line_data.identifiers, list) + else [line_data.identifiers] + ) + for d in id_list: + id_dict = ( + d.model_dump(exclude_unset=True) + if hasattr(d, "model_dump") + else (dict(d) if isinstance(d, dict) else {}) + ) + if not id_dict: + continue + id_dict["item_line_id"] = line.id + id_dict["tenant_id"] = tenant_id + id_dict["company_id"] = company_id + db.add(IdentifierDetail(**id_dict)) + @staticmethod def _attach_series(db: Session, item: LineItem) -> None: """Query and attach all Serie rows for this item as a list.""" @@ -206,6 +230,16 @@ class ItemService: ) item.series = list(series) + @staticmethod + def _attach_identifiers(db: Session, item: LineItem) -> None: + """Query and attach all IdentifierDetail rows for this item.""" + identifiers = ( + db.query(IdentifierDetail) + .filter(IdentifierDetail.item_line_id == item.id) + .all() + ) + item.identifiers = list(identifiers) + @staticmethod def get_by_id( db: Session, item_id: int, tenant_id: int, company_id: int @@ -232,6 +266,7 @@ class ItemService: ) if result: ItemService._attach_series(db, result) + ItemService._attach_identifiers(db, result) return result @staticmethod @@ -285,6 +320,7 @@ class ItemService: items = query.offset(skip).limit(limit).all() for item in items: ItemService._attach_series(db, item) + ItemService._attach_identifiers(db, item) return items, total @staticmethod @@ -318,6 +354,7 @@ class ItemService: items = query.offset(skip).limit(limit).all() for item in items: ItemService._attach_series(db, item) + ItemService._attach_identifiers(db, item) return items, total @staticmethod @@ -338,10 +375,11 @@ class ItemService: errors.raise_if_errors("Error al crear el item - invoice_id es requerido") invoice = invoice_exists_by_id( - db, item_data.invoice_id, tenant_id, company_id, errors + db, item_data.invoice_id, tenant_id, company_id, None ) if not invoice: + errors.add_error("invoice_id", "La factura no existe", code="NOT_FOUND", value=str(item_data.invoice_id)) errors.raise_if_errors("Error al encontra la factura para el item") if not invoice_processed(db, item_data.invoice_id, tenant_id, company_id, errors): errors.raise_if_errors("Error al crear el item - la factura ya fue actualizada, no se pueden agregar items") @@ -443,6 +481,7 @@ class ItemService: db.commit() db.refresh(db_item) ItemService._attach_series(db, db_item) + ItemService._attach_identifiers(db, db_item) return db_item except IntegrityError as e: @@ -476,9 +515,10 @@ class ItemService: errors = ErrorCollector() invoice = invoice_exists_by_id( - db, item_data.invoice_id, tenant_id, company_id, errors + db, item_data.invoice_id, tenant_id, company_id, None ) if not invoice: + errors.add_error("invoice_id", "La factura no existe", code="NOT_FOUND", value=str(item_data.invoice_id)) errors.raise_if_errors("Error al encontra la factura para el item") # Lock invoice @@ -591,6 +631,7 @@ class ItemService: ).delete() db.query(FaLineItem).filter(FaLineItem.id == db_item.id).delete() db.query(Serie).filter(Serie.line_item_id == db_item.id).delete() + db.query(IdentifierDetail).filter(IdentifierDetail.item_line_id == db_item.id).delete() db.flush() # Create new nested data @@ -604,6 +645,7 @@ class ItemService: db.commit() db.refresh(db_item) ItemService._attach_series(db, db_item) + ItemService._attach_identifiers(db, db_item) return db_item except HTTPException: @@ -647,3 +689,125 @@ class ItemService: db.rollback() logger.error(f"Error deleting item: {e}") raise HTTPException(status_code=500, detail="Error deleting item") + + @staticmethod + def get_lines_with_balance( + db: Session, + invoice_id: int, + tenant_id: int, + company_id: int, + as_of_date: Optional[datetime.date] = None, + ) -> List[dict]: + """ + Returns every line of an import invoice together with its current + available balance calculated from the a24.balance_movement ledger. + + Lines with balance <= 0 are included but marked as unavailable so + the frontend can grey them out / disable them. + + Parameters + ---------- + as_of_date : optional cut-off date. Only negative movements + (consumptions, etc.) on or before this date are counted, + mirroring the CALCULA_SALDO_FECHA_EXPO Clarion logic. + If None, all movements are counted (no date restriction). + """ + lines: List[LineItem] = ( + db.query(LineItem) + .filter( + LineItem.invoice_id == invoice_id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .options( + joinedload(LineItem.quantity), + joinedload(LineItem.description), + joinedload(LineItem.part_info), + joinedload(LineItem.class_info), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.fa_data), + joinedload(LineItem.invoice), + ) + .order_by(LineItem.line_number) + .all() + ) + + result = [] + for line in lines: + available_balance = ItemService._compute_balance(db, line.id, as_of_date) + qty = line.quantity + desc = line.description + fa = line.fa_data + inv = line.invoice + + # Count subitems (lines that reference this line as parent via subitem_number) + subitem_count = 0 + if fa and fa.contains_subitems: + from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem as FaModel + subitem_count = ( + db.query(func.count(LineItem.id)) + .join(FaModel, FaModel.id == LineItem.id) + .filter( + LineItem.invoice_id == invoice_id, + LineItem.tenant_id == tenant_id, + FaModel.is_subitem == True, + FaModel.subitem_number == line.line_number, + ) + .scalar() or 0 + ) + + result.append({ + "id": line.id, + "line_number": line.line_number, + # Invoice info + "invoice_number": inv.invoice_number if inv else None, + "invoice_date": inv.invoice_date.isoformat() if inv and inv.invoice_date else None, + "invoice_status": inv.status if inv and inv.status else None, + # Part / class + "part_number": line.part_info.part_number if line.part_info else None, + "class_code": line.class_info.class_code if line.class_info else None, + "description_spanish": desc.description_spanish if desc else None, + "unit_of_measure_code": line.unit_of_measure_info.code if line.unit_of_measure_info else None, + # Quantities + "quantity": float(qty.quantity) if qty and qty.quantity is not None else None, + "quantity_returned_temp": float(qty.quantity_returned_temp) if qty and qty.quantity_returned_temp is not None else None, + "quantity_returned": float(qty.quantity_returned) if qty and qty.quantity_returned is not None else None, + # Balance + "available_balance": float(available_balance), + "has_balance": available_balance > Decimal(0), + # FA / subitem info + "is_subitem": fa.is_subitem if fa else None, + "contains_subitems": fa.contains_subitems if fa else None, + "subitem_count": subitem_count, + }) + return result + + @staticmethod + def _compute_balance( + db: Session, + item_line_id: int, + as_of_date: Optional[datetime.date], + ) -> Decimal: + """Net available balance for one import line from the ledger.""" + sign_expr = case( + (BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), Decimal(-1)), + else_=Decimal(1), + ) + if as_of_date is not None: + date_filter = case( + ( + BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), + BalanceMovement.operation_date <= as_of_date, + ), + else_=True, + ) + else: + date_filter = True # type: ignore[assignment] + + result = db.execute( + select(func.sum(sign_expr * BalanceMovement.quantity)).where( + BalanceMovement.import_item_line_id == item_line_id, + date_filter, + ) + ).scalar() + return Decimal(str(result or 0)) diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py b/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py index f4d9d2e5..cc6009bd 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py @@ -12,6 +12,22 @@ from ..common.cell_value import cell_to_str # Valores que indican que la primera fila es cabecera (primera columna normalizada) FIRST_COLUMN_HEADER_VALUES = ("CLAVE CLASE", "CLASE") +_ENCODING_FALLBACKS: Tuple[str, ...] = ("utf-8-sig", "utf-8", "cp1252", "latin-1") + + +def _read_text_sample(file_path: str, sample_bytes: int = 2048) -> str: + with open(file_path, "rb") as f: + raw = f.read(sample_bytes) + last_err: Optional[Exception] = None + for enc in _ENCODING_FALLBACKS: + try: + return raw.decode(enc) + except Exception as e: + last_err = e + if last_err: + raise last_err + return "" + def detect_headers_or_data( file_path: str, @@ -26,8 +42,15 @@ def detect_headers_or_data( - Si no -> has_header=False, fieldnames=TEMPLATE_DOWNLOAD_HEADERS (la primera fila es dato). """ try: - with open(file_path, "r", encoding=encoding) as f: - sample = f.read(2048) + # `encoding` se mantiene por compatibilidad; si falla, hacemos fallback para CSVs tipo Excel (cp1252/latin-1). + if encoding and encoding.lower() not in ("auto", "detect"): + try: + with open(file_path, "r", encoding=encoding) as f: + sample = f.read(2048) + except Exception: + sample = _read_text_sample(file_path, sample_bytes=2048) + else: + sample = _read_text_sample(file_path, sample_bytes=2048) except Exception: return None, True lines = sample.splitlines() diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py index 42f41ae9..06f50b76 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py @@ -3,11 +3,16 @@ Validadores reutilizables para import CSV de clientes y proveedores. Paridad Clarion: procedencia E/N, tipo C/P/A, clave máx 8, SECON, Prosec, Vinculación, Es Empresa Certificada, Transformador/SubMaq, desfase. """ +import re from typing import Dict, Any, Optional from api.v1.modules.a76.clients_and_providers.models import ClientOrProviderEnum RFC_MAX = 30 + +# Formato RFC México: 3-4 letras, 6 dígitos, 3 homoclave. TAX-ID: 2 dígitos, guión, resto. Ej: 12-3456789. +RFC_PATTERN = re.compile(r"^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$", re.IGNORECASE) +TAX_ID_PATTERN = re.compile(r"^\d{2}-[A-Z0-9]{1,27}$", re.IGNORECASE) NAME_MAX = 256 SHORT_NAME_MAX = 10 # Clarion: Col C máx 8 caracteres @@ -29,6 +34,36 @@ def check_required_max(row: Dict[str, Any], col: str, max_len: int, line_num: in return None +def check_rfc_format(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]: + """Col E (RFC): si tiene valor, debe cumplir formato RFC México.""" + val = (row.get("RFC") or "").strip() + if not val: + return None + if not RFC_PATTERN.match(val.upper()): + return { + "line": line_num, + "col": "RFC", + "msg": "El formato del RFC es inválido.", + "solution": "Capturar en la columna E un RFC con formato válido (ej. XAXX010101000).", + } + return None + + +def check_tax_id_format(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]: + """Col E (TAX-ID): 2 dígitos, guión y resto alfanumérico. Ej: 12-3456789. Máx 30 caracteres.""" + val = (row.get("RFC") or "").strip() + if not val: + return None + if len(val) > 30 or not TAX_ID_PATTERN.match(val): + return { + "line": line_num, + "col": "RFC", + "msg": "El formato del TAX-ID es inválido.", + "solution": "Capturar en la columna E un TAX-ID con formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres.", + } + return None + + def check_max_length(row: Dict[str, Any], col: str, max_len: int, line_num: int) -> Optional[Dict[str, Any]]: val = (row.get(col) or "").strip() if not val: diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/mappers.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/mappers.py index c281f814..be64f05b 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/mappers.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/mappers.py @@ -43,7 +43,6 @@ MAX_LEN = { "prosec_authorization": 20, "secon_authorization": 20, "manufacturer_id": 25, - "tax_id_programs": 30, "broker": 6, "import_broker": 6, "transfer_key": 8, @@ -85,11 +84,15 @@ def row_to_client_provider_data( """ Mapea fila normalizada a datos para ClientProvider, ClientProviderAddress y ClientProviderPrograms. Devuelve (cp_data, address_data_or_none, programs_data_or_none). - Para compatibilidad con Clarion: se requiere RFC o SHORT_NAME para considerar la fila válida. + Identificador fiscal unificado: solo se guarda en ClientProvider.rfc (RFC o TAX-ID según procedencia). + Se toma de columna RFC (E); si viene vacía y hay TAX_ID_PROGRAMS (AB), se usa esa para la misma columna rfc. """ - rfc = _str_or_none(row_norm.get("RFC"), MAX_LEN["rfc"]) + rfc_col = _str_or_none(row_norm.get("RFC"), MAX_LEN["rfc"]) + tax_id_programs_col = _str_or_none(row_norm.get("TAX_ID_PROGRAMS"), MAX_LEN["rfc"]) + # Una sola columna: identificador fiscal en cp.rfc (nacional=RFC, extranjero=TAX-ID) + rfc_unified = rfc_col or tax_id_programs_col short_name = _str_or_none(row_norm.get("SHORT_NAME"), MAX_LEN["short_name"]) - if not rfc and not short_name: + if not rfc_unified and not short_name: return ({}, None, None) client_or_provider = parse_client_or_provider(row_norm.get("TIPO")) or ClientOrProviderEnum.BOTH @@ -110,7 +113,7 @@ def row_to_client_provider_data( cp_data = { "tenant_id": tenant_id, "company_id": company_id, - "rfc": rfc or None, + "rfc": rfc_unified or None, "name": _str_or_none(row_norm.get("NOMBRE"), MAX_LEN["name"]), "short_name": short_name, "curp": _str_or_none(row_norm.get("CURP"), MAX_LEN["curp"]), @@ -124,7 +127,6 @@ def row_to_client_provider_data( "position": _str_or_none(row_norm.get("POSICION"), MAX_LEN["position"]), "incoterm": _str_or_none(row_norm.get("INCOTERM"), MAX_LEN["incoterm"]), "is_active": parse_active(row_norm.get("ACTIVO")), - "is_national_provider": True if procedencia == "N" else (False if procedencia == "E" else None), } # Address @@ -174,12 +176,12 @@ def row_to_client_provider_data( tipo_prog or num_prog or fecha_secon is not None or prosec_val or num_aut_prosec or is_certified or reg_cert or _str_or_none(row_norm.get("MANUFACTURER_ID")) - or _str_or_none(row_norm.get("TAX_ID_PROGRAMS")) or _str_or_none(row_norm.get("BROKER_EXPO")) or _str_or_none(row_norm.get("BROKER_IMPO")) or _str_or_none(row_norm.get("CLAVE_TRANSFER")) or applied_proportion is not None ): + # Identificador fiscal solo en ClientProvider.rfc (columna única) programs_data = { "program": tipo_prog[:7] if tipo_prog else None, "program_number": num_prog, @@ -190,7 +192,6 @@ def row_to_client_provider_data( "is_certified_company": is_certified, "certified_company_registry": reg_cert, "manufacturer_id": _str_or_none(row_norm.get("MANUFACTURER_ID"), MAX_LEN["manufacturer_id"]), - "tax_id": _str_or_none(row_norm.get("TAX_ID_PROGRAMS"), MAX_LEN["tax_id_programs"]), "broker": _str_or_none(row_norm.get("BROKER_EXPO"), MAX_LEN["broker"]), "import_broker": _str_or_none(row_norm.get("BROKER_IMPO"), MAX_LEN["import_broker"]), "transfer_key": _str_or_none(row_norm.get("CLAVE_TRANSFER"), MAX_LEN["transfer_key"]), diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py index 12b311a6..60e03c2b 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py @@ -13,6 +13,8 @@ from ..common.common_validators import ( CURP_MAX, check_required_max, check_max_length, + check_rfc_format, + check_tax_id_format, check_tipo_client_provider, check_procedencia, check_short_name_max_clarion, @@ -161,8 +163,23 @@ def validate_row_client_provider( if err: return err - # Compatibilidad: RFC requerido y longitudes (como antes) + # Identificador fiscal requerido: Nacional = RFC, Extranjero = TAX-ID (mismo campo "RFC" en layout). + procedencia = (row.get("PROCEDENCIA") or "").strip().upper()[:1] err = check_required_max(row, "RFC", RFC_MAX, line_num) + if err: + if procedencia == "E": + err = { + "line": line_num, + "col": "RFC", + "msg": "Requerido (TAX-ID)", + "solution": "Capturar el TAX-ID del cliente/proveedor extranjero en la columna E (RFC/TAX-ID).", + } + return err + # Validar formato según procedencia: RFC (N) o TAX-ID (E). + if procedencia == "N": + err = check_rfc_format(row, line_num) + elif procedencia == "E": + err = check_tax_id_format(row, line_num) if err: return err err = check_max_length(row, "NOMBRE", NAME_MAX, line_num) diff --git a/backend/api/v1/modules/a76/layouts_csv/common/csv_reader.py b/backend/api/v1/modules/a76/layouts_csv/common/csv_reader.py index 24359188..33060545 100644 --- a/backend/api/v1/modules/a76/layouts_csv/common/csv_reader.py +++ b/backend/api/v1/modules/a76/layouts_csv/common/csv_reader.py @@ -5,7 +5,7 @@ Si se pasa headerless_first_cell_values, se detecta si la primera fila es cabece """ import csv import io -from typing import Iterator, Tuple, Dict, Any, Optional, List, Set +from typing import Iterator, Tuple, Dict, Any, Optional, List, Set, Sequence def _normalize_empty_headers(headers: List[str]) -> List[str]: @@ -21,6 +21,32 @@ def _normalize_empty_headers(headers: List[str]) -> List[str]: return result +_ENCODING_FALLBACKS: Sequence[str] = ("utf-8-sig", "utf-8", "cp1252", "latin-1") + + +def _detect_text_encoding( + file_path: str, + encodings: Sequence[str] = _ENCODING_FALLBACKS, + sample_bytes: int = 8192, +) -> str: + """ + Detecta encoding por prueba de decodificación en un sample en binario. + Nota: latin-1 decodifica cualquier byte; por eso debe ir al final. + """ + with open(file_path, "rb") as f: + raw = f.read(sample_bytes) + last_err: Optional[Exception] = None + for enc in encodings: + try: + raw.decode(enc) + return enc + except Exception as e: + last_err = e + if last_err: + raise last_err + return "utf-8-sig" + + def iter_csv_rows( file_path: str, fieldnames: Optional[List[str]] = None, @@ -34,7 +60,8 @@ def iter_csv_rows( (quitando BOM, strip, upper) está en headerless_first_cell_values, se trata como dato y se usan fieldnames. headerless_second_cell_key_pattern se ignora si no se usa (reservado para otros layouts). """ - with open(file_path, "r", encoding="utf-8-sig") as f: + encoding = _detect_text_encoding(file_path) + with open(file_path, "r", encoding=encoding) as f: sample = f.read(2048) f.seek(0) try: @@ -91,6 +118,7 @@ def iter_csv_rows( def count_csv_rows(file_path: str, has_header: bool = True) -> int: """Cuenta filas del CSV. Si has_header=True (por defecto), no cuenta la cabecera.""" - with open(file_path, "r", encoding="utf-8-sig") as f: + encoding = _detect_text_encoding(file_path) + with open(file_path, "r", encoding=encoding) as f: total_lines = sum(1 for _ in f) return total_lines if not has_header else max(0, total_lines - 1) 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/layouts_csv/pedmientos/common/fk_loader.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/common/fk_loader.py index cb55fefe..e72ded53 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/common/fk_loader.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/common/fk_loader.py @@ -35,7 +35,7 @@ def load_pedimentos_fk_sets( valid_clave_regimen_tipo, # (pedimento_code, regimen_code, type_code) valid_aduana_seccion, # customs_code existing_pedimento_keys, # key strings para actualizar - valid_anexo22_claves, # stub vacío hasta tener catálogo + valid_anexo22_claves, # catálogo de transporte Anexo 22 (pedimento_transport_catalog.code) valid_patentes, # CustomsBroker.license (tenant/company) short_name_to_id, # short_name normalizado (upper) -> client id (primera aparición gana) """ @@ -48,6 +48,9 @@ def load_pedimentos_fk_sets( from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento + from api.v1.modules.public.reference_data.pedimento_transport_catalog.models import ( + PedimentoTransportCatalog, + ) valid_client_ids: Set[int] = set() valid_regimes: Set[str] = set() @@ -96,6 +99,10 @@ def load_pedimentos_fk_sets( for cs in session.query(CustomsSection).all(): valid_aduana_seccion.add(cs.customs_code.strip()) + for tm in session.query(PedimentoTransportCatalog.code).all(): + if (tm[0] or "").strip(): + valid_anexo22_claves.add((tm[0] or "").strip().upper()) + for cb in ( session.query(CustomsBroker) .filter( diff --git a/backend/api/v1/modules/a76/parts/dto.py b/backend/api/v1/modules/a76/parts/dto.py index 486b41e9..aa241e22 100644 --- a/backend/api/v1/modules/a76/parts/dto.py +++ b/backend/api/v1/modules/a76/parts/dto.py @@ -1,14 +1,14 @@ from datetime import datetime from decimal import Decimal -from typing import List, Optional +from typing import List, Optional, Literal from pydantic import BaseModel, Field, ConfigDict from api.v1.modules.a24.inv.inv_aphis.dto import InvPartAphisGeneralDTO # --- SUB-DTO: DATOS ADUANALES (FaData) --- class FaDataDTO(BaseModel): - origin_country: Optional[str] = None - sector: Optional[str] = None - fraction_type: Optional[str] = None + origin_country: Optional[str] = Field(default=None, pattern=r"^[A-Z]{3}$") + sector: Optional[str] = Field(default=None, pattern=r"^[A-Za-z0-9]{1,8}$") + fraction_type: Optional[Literal["GENERAL", "PROSEC", "ALADI", "TLCS"]] = None model_config = ConfigDict(from_attributes=True) @@ -154,11 +154,11 @@ class PartBase(BaseModel): part_class: Optional[str] = None unit_of_measure: Optional[str] = "PZ" - unit_cost: Optional[Decimal] = None + unit_cost: Optional[Decimal] = Field(default=None, ge=0) currency_key: Optional[str] = None currency_type: Optional[str] = None - unit_weight: Optional[Decimal] = None + unit_weight: Optional[Decimal] = Field(default=None, ge=0) weight_type: Optional[str] = None fraction: Optional[str] = None diff --git a/backend/api/v1/modules/a76/pedmientos/catalog_service.py b/backend/api/v1/modules/a76/pedmientos/catalog_service.py index cc0dd1ab..d283f2a6 100644 --- a/backend/api/v1/modules/a76/pedmientos/catalog_service.py +++ b/backend/api/v1/modules/a76/pedmientos/catalog_service.py @@ -8,6 +8,9 @@ from api.v1.modules.public.reference_data.customs_sections.models import Customs from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen from api.v1.modules.public.reference_data.transport_types.models import TransportType from api.v1.modules.public.reference_data.transport_modes.models import TransportMode +from api.v1.modules.public.reference_data.pedimento_transport_catalog.models import ( + PedimentoTransportCatalog, +) # Import A76 Services from api.v1.modules.a76.customs_brokers.services import CustomsBrokerService @@ -19,6 +22,9 @@ from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSec from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO +from api.v1.modules.public.reference_data.pedimento_transport_catalog.dto import ( + PedimentoTransportCatalogDTO, +) from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO from .dtos.pedimentos import PedimentosResponse @@ -71,6 +77,16 @@ class PedimentoCatalogService: except Exception as e: print(f"Error fetching transport_modes: {e}") + try: + response.pedimento_transport_catalog = [ + PedimentoTransportCatalogDTO.model_validate(obj) + for obj in db.query(PedimentoTransportCatalog) + .order_by(PedimentoTransportCatalog.code.asc()) + .all() + ] + except Exception as e: + print(f"Error fetching pedimento_transport_catalog: {e}") + # Helper to fetch tenant/company specific data def fetch_tenant_data(): # Customs Brokers diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_transport_means.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_transport_means.py index ba86131a..d769822f 100644 --- a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_transport_means.py +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_transport_means.py @@ -10,27 +10,27 @@ class PedimentoTransportMeansBase(BaseModel): pedimento_id: Optional[int] = Field(None, description="Pedimento ID") tenant_id: Optional[int] = Field(None, description="Tenant ID") destination: Optional[int] = Field(None, description="Destination") - entry_exit: Optional[str] = Field(None, max_length=2, description="Entry/exit") - arrival: str = Field(..., max_length=2, description="Arrival") - departure: str = Field(..., max_length=2, description="Departure") + entry_exit: Optional[str] = Field(None, max_length=3, description="Entry/exit") + arrival: str = Field(..., max_length=3, description="Arrival") + departure: str = Field(..., max_length=3, description="Departure") class PedimentoTransportMeansCreate(BaseModel): """Schema for creating a new Pedimento Transport Means - pedimento_id and tenant_id are set by backend""" destination: Optional[int] = Field(None, description="Destination") - entry_exit: Optional[str] = Field(None, max_length=2, description="Entry/exit") - arrival: str = Field(..., max_length=2, description="Arrival") - departure: str = Field(..., max_length=2, description="Departure") + entry_exit: Optional[str] = Field(None, max_length=3, description="Entry/exit") + arrival: str = Field(..., max_length=3, description="Arrival") + departure: str = Field(..., max_length=3, description="Departure") class PedimentoTransportMeansUpdate(BaseModel): """Schema for updating a Pedimento Transport Means""" destination: Optional[int] = None - entry_exit: Optional[str] = Field(None, max_length=2) - arrival: Optional[str] = Field(None, max_length=2) - departure: Optional[str] = Field(None, max_length=2) + entry_exit: Optional[str] = Field(None, max_length=3) + arrival: Optional[str] = Field(None, max_length=3) + departure: Optional[str] = Field(None, max_length=3) class PedimentoTransportMeansResponse(PedimentoTransportMeansBase): diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_transport_means.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_transport_means.py index 271f69d4..a1702aa2 100644 --- a/backend/api/v1/modules/a76/pedmientos/models/pedimento_transport_means.py +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_transport_means.py @@ -39,9 +39,9 @@ class PedimentoTransportMeans(Base, TenantScopedMixin, TimestampMixin): pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) destination: Mapped[int] = mapped_column(SmallInteger) - entry_exit: Mapped[str] = mapped_column(String(2)) - arrival: Mapped[str] = mapped_column(String(2)) - departure: Mapped[str] = mapped_column(String(2)) + entry_exit: Mapped[str] = mapped_column(String(3)) + arrival: Mapped[str] = mapped_column(String(3)) + departure: Mapped[str] = mapped_column(String(3)) pedimento: Mapped["Pedimentos"] = relationship( "Pedimentos", back_populates="pedimento_transport_means" diff --git a/backend/api/v1/modules/a76/pedmientos/schemas.py b/backend/api/v1/modules/a76/pedmientos/schemas.py index 8eec714e..e68d7a9e 100644 --- a/backend/api/v1/modules/a76/pedmientos/schemas.py +++ b/backend/api/v1/modules/a76/pedmientos/schemas.py @@ -13,6 +13,9 @@ from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO +from api.v1.modules.public.reference_data.pedimento_transport_catalog.dto import ( + PedimentoTransportCatalogDTO, +) from .dtos.pedimentos import PedimentosResponse @@ -26,6 +29,7 @@ class PedimentoCatalogsResponse(BaseModel): clients: List[ClientProviderResponseDTO] = [] transport_types: List[TransportTypeDTO] = [] transport_modes: List[TransportModeDTO] = [] + pedimento_transport_catalog: List[PedimentoTransportCatalogDTO] = [] class PedimentoCreationResponse(PedimentoCatalogsResponse): diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py index 3671361f..2d3b3f4f 100644 --- a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py @@ -136,12 +136,8 @@ class AvisoConsolidadoExportacionService: if client_obj: # Fetch Address c_addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == target_client_id).first() - # Fetch Fiscal Data (RFC) - c_prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == target_client_id).first() - - c_rfc = "" - if c_prog and c_prog.tax_id: c_rfc = c_prog.tax_id - elif hasattr(client_obj, 'rfc'): c_rfc = client_obj.rfc + # Identificador fiscal único en ClientProvider.rfc (RFC o TAX-ID) + c_rfc = getattr(client_obj, "rfc", "") or "" c_dir_str = "DOMICILIO NO REGISTRADO" if c_addr: diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py index 82e0c897..b35c8bc1 100644 --- a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py @@ -127,11 +127,7 @@ class ConsolidadoImportacionMexService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=( - prog.tax_id - if (prog and prog.tax_id) - else (getattr(main, "rfc", "") or "") - ), + tax_id=getattr(main, "rfc", "") or "", programa="IMMEX" if (prog and prog.program) else "", autorizacion=prog.program_number if prog else "", prosec=( diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py index 17369185..a3949d07 100644 --- a/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py @@ -82,7 +82,7 @@ class ConsolidadoImportacionMexService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), + tax_id=getattr(main, "rfc", "") or "", programa="IMMEX" if (prog and prog.program) else "", autorizacion=prog.program_number if prog else "", prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index 1163644e..185589d0 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -159,11 +159,7 @@ class FacturaImportacionMexService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=( - prog.tax_id - if (prog and prog.tax_id) - else (getattr(main, "rfc", "") or "") - ), + tax_id=getattr(main, "rfc", "") or "", programa="IMMEX" if (prog and prog.program) else "", autorizacion=prog.program_number if prog else "", prosec=( diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py index b61c2c87..192dab8a 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py @@ -82,7 +82,7 @@ class FacturaImportacionMexService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), + tax_id=getattr(main, "rfc", "") or "", programa="IMMEX" if (prog and prog.program) else "", autorizacion=prog.program_number if prog else "", prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py index 951a3135..9bd7c40d 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py @@ -153,11 +153,7 @@ class FacturaImportacionUsaService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "USA") if addr else "USA", - tax_id=( - prog.tax_id - if (prog and prog.tax_id) - else (getattr(main, "rfc", "") or "") - ), + tax_id=getattr(main, "rfc", "") or "", programa="IMMEX" if (prog and prog.program) else "", autorizacion=prog.program_number if prog else "", prosec=( diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py index 7238e369..8a74bbb3 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py @@ -103,7 +103,7 @@ class PackingListService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), + tax_id=getattr(main, "rfc", "") or "", programa="IMMEX" if (prog and prog.program) else "", autorizacion=prog.program_number if prog else "", prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py index e6ab3db8..77954f96 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py @@ -108,22 +108,24 @@ class DatabaseHelper: if not client_code: return {"name": None, "rfc": None, "tax_id": None} - client_type = 'PROVIDER' if is_supplier else 'CLIENT' + # In our schema, enum values are lowercase: 'client', 'provider', 'both' + client_type = 'provider' if is_supplier else 'client' try: sql = text(""" - SELECT cp.name, cp.rfc, cpp.tax_id + SELECT cp.name, cp.rfc FROM a76.clients_and_providers cp - LEFT JOIN a76.clients_and_providers_programs cpp ON cpp.client_id = cp.id - WHERE cp.id = :client_code AND cp.client_or_provider = :client_type + WHERE cp.id = :client_code + AND (cp.client_or_provider = :client_type OR cp.client_or_provider = 'both') """) result = db.execute(sql, {"client_code": client_code, "client_type": client_type}).fetchone() if result: + # Unified identifier: cp.rfc contains either RFC (national) or TAX-ID (foreign) return { "name": result[0], "rfc": result[1], - "tax_id": result[2] + "tax_id": result[1], } else: logger.debug(f"Client {client_code} not found as {client_type}") 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/a76/reports/movements/saldos/csv_utils.py b/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py index ddc10f80..9ea3bac1 100644 --- a/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py +++ b/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py @@ -227,7 +227,7 @@ BASE_SELECT = """ COALESCE(icm.vucem_operation_num,'') AS "C41", COALESCE(cl.material_key,'') AS "C42", CONCAT(ped.year,'-',ped.customs_office,'-',ped.license,'-',ped.pedimento_number) AS "C43", - '' AS "C44", + COALESCE(ptc.payment_date_code, 'P') AS "C44", COALESCE(ilc.octave_fraction,'') AS "C45", '' AS "C47", COALESCE(ped.pedimento_code,'') AS "C48", @@ -243,6 +243,8 @@ BASE_JOINS = """ JOIN a76.invoice_compliance_mx icm ON icm.invoice_id = ih.id LEFT JOIN a76.pedimentos ped ON ped.id = icm.pedimento_id LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN a76.pedimento_transport_means ptm ON ptm.pedimento_id = ped.id + LEFT JOIN public.pedimento_transport_catalog ptc ON ptc.code = ptm.entry_exit LEFT JOIN a76.classes cl ON cl.id = il.class_id LEFT JOIN a76.item_line_quantities ilq ON ilq.item_line_id = il.id LEFT JOIN a76.item_line_financials ilf ON ilf.item_line_id = il.id @@ -440,20 +442,21 @@ def _build_row( peso_usado = (cant_ret * peso_neto / cant_orig) if cant_orig != 0 else Decimal(0) peso_saldo = peso_neto - peso_usado - # TIPO DE CAMBIO — per Clarion logic: - # Si TipoPedimentoTransporteE IN ('4','1','98E') → usar Fecha_Inicio, else Fecha_Pago + # TIPO DE CAMBIO: + # C44 now carries payment_date_code from pedimento_transport_catalog: + # E => Fecha_Inicio, P => Fecha_Pago. # If explicitly "invoice_date", we use invoice_date (C11) instead. tc = Decimal(1) fecha_pago = row.get("C7") fecha_inicio = row.get("C9") fecha_factura= row.get("C11") - transport_type = str(row.get("C44") or "") + payment_date_code = str(row.get("C44") or "").upper() tc_fecha_display = None # TIPO DE CAMBIO tc_fecha = None if use_fp: - tc_fecha = fecha_inicio if transport_type in ("1", "4", "98E") else fecha_pago + tc_fecha = fecha_inicio if payment_date_code == "E" else fecha_pago else: tc_fecha = fecha_factura diff --git a/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/dto.py b/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/dto.py new file mode 100644 index 00000000..5289649a --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/dto.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoTransportCatalogDTO(BaseModel): + code: str = Field(..., min_length=1, max_length=3) + transport_en: str + transport_es: str + payment_date_code: str = Field(..., min_length=1, max_length=1) + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/models.py b/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/models.py new file mode 100644 index 00000000..95554872 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/models.py @@ -0,0 +1,26 @@ +from core.database import Base +from sqlalchemy import CheckConstraint, PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column + + +class PedimentoTransportCatalog(Base): + __tablename__ = "pedimento_transport_catalog" + __table_args__ = ( + PrimaryKeyConstraint("code", name="pedimento_transport_catalog_pkey"), + CheckConstraint( + "payment_date_code IN ('E', 'P')", + name="pedimento_transport_catalog_payment_date_code_chk", + ), + {"schema": "public", "extend_existing": True}, + ) + + code: Mapped[str] = mapped_column(String(3), nullable=False) + transport_en: Mapped[str] = mapped_column(String(80), nullable=False) + transport_es: Mapped[str] = mapped_column(String(120), nullable=False) + payment_date_code: Mapped[str] = mapped_column(String(1), nullable=False) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/routes.py b/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/routes.py new file mode 100644 index 00000000..a8667238 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/routes.py @@ -0,0 +1,93 @@ +from typing import Any, Dict + +from core.database import get_core_db +from core.security import get_current_user +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from .dto import PedimentoTransportCatalogDTO +from .models import PedimentoTransportCatalog + +router = APIRouter(prefix="/pedimento-transport-catalog") + + +@router.get("/", response_model=Dict[str, Any]) +async def list_pedimento_transport_catalog( + page: int = Query(1, ge=1, description="Numero de pagina"), + page_size: int = Query(100, ge=1, le=200, description="Tamano de pagina"), + db: Session = Depends(get_core_db), +): + skip = (page - 1) * page_size + query = db.query(PedimentoTransportCatalog).order_by(PedimentoTransportCatalog.code.asc()) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [PedimentoTransportCatalogDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } + + +@router.get("/{code}", response_model=PedimentoTransportCatalogDTO) +async def get_pedimento_transport_catalog(code: str, db: Session = Depends(get_core_db)): + obj = ( + db.query(PedimentoTransportCatalog) + .filter(PedimentoTransportCatalog.code == code) + .first() + ) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + return obj + + +@router.post("/", response_model=PedimentoTransportCatalogDTO, status_code=201) +async def create_pedimento_transport_catalog( + data: PedimentoTransportCatalogDTO, + db: Session = Depends(get_core_db), + user=Depends(get_current_user), +): + obj = PedimentoTransportCatalog(**data.model_dump()) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +@router.put("/{code}", response_model=PedimentoTransportCatalogDTO) +async def update_pedimento_transport_catalog( + code: str, + data: PedimentoTransportCatalogDTO, + db: Session = Depends(get_core_db), + user=Depends(get_current_user), +): + obj = ( + db.query(PedimentoTransportCatalog) + .filter(PedimentoTransportCatalog.code == code) + .first() + ) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + for field, value in data.model_dump().items(): + setattr(obj, field, value) + db.commit() + db.refresh(obj) + return obj + + +@router.delete("/{code}", status_code=204) +async def delete_pedimento_transport_catalog( + code: str, + db: Session = Depends(get_core_db), + user=Depends(get_current_user), +): + obj = ( + db.query(PedimentoTransportCatalog) + .filter(PedimentoTransportCatalog.code == code) + .first() + ) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + db.delete(obj) + db.commit() + return None diff --git a/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/seed.py b/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/seed.py new file mode 100644 index 00000000..2742e590 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/pedimento_transport_catalog/seed.py @@ -0,0 +1,16 @@ +seed = [ + ("1", "MARITIME", "MARITIMO", "E"), + ("2", "DOUBLE-TRACK RAIL", "FERROVIARIO DE DOBLE VIA", "P"), + ("3", "ROAD-RAIL", "CARRETERO-FERROVIARIO", "P"), + ("4", "AIR", "AEREO.", "E"), + ("5", "POSTAL", "POSTAL.", "P"), + ("6", "RAIL", "FERROVIARIO.", "P"), + ("7", "ROAD", "CARRETERO.", "P"), + ("8", "PIPELINE", "TUBERIA.", "P"), + ("10", "CABLE", "CABLES.", "P"), + ("11", "DUCT", "DUCTOS.", "P"), + ("12", "PEDESTRIAN", "PEATONAL.", "P"), + ("98", "NOT DECLARED TRANSPORT MODE", "NO SE DECLARA MEDIO DE TRANSPORTE", "P"), + ("98E", "NOT DECLARED TRANSPORT MODE", "NO SE DECLARA MEDIO DE TRANSPORTE", "E"), + ("99", "OTHERS", "OTROS.", "P"), +] diff --git a/backend/api/v1/modules/public/reference_data/router.py b/backend/api/v1/modules/public/reference_data/router.py index f9b09072..62efddc6 100644 --- a/backend/api/v1/modules/public/reference_data/router.py +++ b/backend/api/v1/modules/public/reference_data/router.py @@ -19,9 +19,9 @@ from .invoice_types.routes import router as invoice_types_router from .license_exceptions.routes import router as license_exceptions_router from .material_types.routes import router as material_types_router from .payment_methods.routes import router as payment_methods_router +from .pedimento_transport_catalog.routes import router as pedimento_transport_catalog_router from .pedimento_codes.routes import router as pedimento_codes_router from .pedimento_regimens.routes import router as pedimento_regimens_router -from .sectors.routes import router as sectors_router from .states.routes import router as states_router from .trailer_types.routes import router as trailer_types_router from .transport_modes.routes import router as transport_modes_router @@ -37,6 +37,11 @@ router.include_router( prefix="/reference_data", tags=["public / reference_data / agency_tariff_codes"], ) +router.include_router( + pedimento_transport_catalog_router, + prefix="/reference_data", + tags=["public / reference_data / pedimento_transport_catalog"], +) router.include_router( pedimento_codes_router, prefix="/reference_data", @@ -95,11 +100,6 @@ router.include_router( prefix="/reference_data", tags=["public / reference_data / valuation_methods"], ) -router.include_router( - sectors_router, - prefix="/reference_data", - tags=["public / public / reference_data / sectors"], -) router.include_router( transport_modes_router, prefix="/reference_data", 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/celery_app.py b/backend/core/celery_app.py index 1df5980b..1ab2efb7 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -64,6 +64,7 @@ celery_app.conf.update( "api.v1.modules.core.help_center.tasks", "api.v1.modules.a76.invoices.imports.process.task", "api.v1.modules.a76.invoices.imports.revert.task", + "api.v1.modules.a76.invoices.exports.process.task", ] # Ruta al módulo donde están las tareas ) 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 34fce2d2..570f7230 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,6 +17,9 @@ from api.v1.modules.public.reference_data.incoterms.models import Incoterm from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType from api.v1.modules.public.reference_data.material_types.models import MaterialType from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod +from api.v1.modules.public.reference_data.pedimento_transport_catalog.models import ( + PedimentoTransportCatalog, +) # Orden: PedimentoCode y RegimenPedimento antes de CodePedimentoRegimen para que # SQLAlchemy resuelva los nombres en relationship() al configurar el mapper from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode @@ -24,7 +27,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,11 +224,13 @@ from api.v1.modules.public.reference_data.incoterms.models import Incoterm from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType from api.v1.modules.public.reference_data.material_types.models import MaterialType from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod +from api.v1.modules.public.reference_data.pedimento_transport_catalog.models import ( + PedimentoTransportCatalog, +) from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode from api.v1.modules.public.reference_data.pedimento_regimens.models import ( RegimenPedimento, ) -from api.v1.modules.public.reference_data.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 @@ -305,6 +310,7 @@ def register_audit(): InvoiceType, MaterialType, PaymentMethod, + PedimentoTransportCatalog, PedimentoCode, RegimenPedimento, Sector, diff --git a/frontend/src/lib/api/dashboard/a76/clients-providers.ts b/frontend/src/lib/api/dashboard/a76/clients-providers.ts index 3b904f29..ea7c2026 100644 --- a/frontend/src/lib/api/dashboard/a76/clients-providers.ts +++ b/frontend/src/lib/api/dashboard/a76/clients-providers.ts @@ -25,7 +25,6 @@ export interface ClientProviderPrograms { secon_auth_date?: number | null; // YYYYMMDD prosec?: number | null; manufacturer_id?: string | null; - tax_id?: string | null; ctpat_svi?: string | null; is_certified_company?: string | null; } @@ -45,8 +44,6 @@ export interface ClientProvider { responsible?: string | null; position?: string | null; - // Booleanos (Coincidiendo con la BD) - is_national_provider?: boolean | null; is_active?: boolean; // Relaciones Anidadas diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/identifiers.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/identifiers.ts index 98660069..15be7cc2 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/identifiers.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/identifiers.ts @@ -35,6 +35,33 @@ export interface IdentifierListResponse { pages: number; } +export interface IdentifierDetail { + id: number; + invoice_consecutive: number | null; + part_line: number | null; + identifier_code: string | null; + module: string | null; + complement1: string | null; + complement2: string | null; + complement3: string | null; + item_line_id: number | null; + company_id: number; + tenant_id: number; +} + +export interface IdentifierDetailCreate { + invoice_consecutive?: number | null; + part_line?: number | null; + identifier_code?: string | null; + module?: string | null; + complement1?: string | null; + complement2?: string | null; + complement3?: string | null; + item_line_id?: number | null; +} + +export interface IdentifierDetailUpdate extends Partial { } + export async function getIdentifiers( page = 1, pageSize = 50, @@ -71,4 +98,29 @@ export async function deleteIdentifier( companyId: number ): Promise> { return await api.delete(`/v1/a76/identifiers/${id}/?company_id=${companyId}`); +} + +/** + * API for Identifier Details + */ +export async function createIdentifierDetail( + data: IdentifierDetailCreate, + companyId: number +): Promise> { + return await api.post(`/v1/a76/identifiers/details/?company_id=${companyId}`, data); +} + +export async function updateIdentifierDetail( + id: number, + data: IdentifierDetailUpdate, + companyId: number +): Promise> { + return await api.put(`/v1/a76/identifiers/details/${id}/?company_id=${companyId}`, data); +} + +export async function deleteIdentifierDetail( + id: number, + companyId: number +): Promise> { + return await api.delete(`/v1/a76/identifiers/details/${id}/?company_id=${companyId}`); } \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/unit-conversions.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/unit-conversions.ts index 7ece8a8e..1a977195 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/unit-conversions.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/unit-conversions.ts @@ -76,6 +76,7 @@ export async function updateUnitConversion( } export async function deleteUnitConversion(id: number, companyId: number): Promise { - const response = await api.delete(`/v1/a76/unit-conversions/${id}/?company_id=${companyId}`); + // Backend DELETE route is defined without trailing slash: /unit-conversions/{id} + const response = await api.delete(`/v1/a76/unit-conversions/${id}?company_id=${companyId}`); if (response.error) throw new Error(response.error); } \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index 1c7f2a75..8c5b7326 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -88,6 +88,10 @@ export interface LineReferences { serie_id?: number; } +import type { + IdentifierDetail +} from './general_catalogs/identifiers'; + export interface Serie { id?: number; line_item_id?: number; @@ -98,6 +102,9 @@ export interface Serie { brand?: string; expo_brad?: string; number_id?: string; + import_invoice?: string; + import_line?: number; + image_path?: string; } export interface FaLineItem { @@ -127,7 +134,7 @@ export interface FaLineItem { subitem_number?: number; // Special flags - download?: boolean; + discharge?: boolean; own_equipment?: boolean; omit_annex31?: boolean; @@ -207,6 +214,7 @@ export interface Item { reference?: LineReferences; fa_data?: FaLineItem; // Fixed Asset specific data series?: Serie[]; // Series data (multiple per line) + identifiers?: IdentifierDetail[]; // Identifiers for this line } export interface ItemListResponse { @@ -291,5 +299,52 @@ export const itemsApi = { company_id: companyId.toString() }); return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`); + }, + + /** + * Lista las líneas de una factura de importación con su saldo disponible. + * Solo las líneas con has_balance = true tienen mercancía disponible para descarga. + * + * @param invoiceId - ID de la factura de importación + * @param companyId - ID de la empresa + * @param asOfDate - Fecha corte opcional (ISO: "YYYY-MM-DD"). + * Pasa la fecha de la factura de exportación para que + * los consumos futuros no se descuenten del saldo. + */ + listByInvoiceWithBalance: ( + invoiceId: number, + companyId: number, + asOfDate?: string + ) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + if (asOfDate) params.append('as_of_date', asOfDate); + return api.get( + `/v1/a76/items/invoice/${invoiceId}/items-with-balance?${params.toString()}` + ); } }; + +export interface ImportLineWithBalance { + id: number; + line_number: number; + // Invoice info + invoice_number?: string; + invoice_date?: string; + invoice_status?: string; + // Part / class + part_number?: string; + class_code?: string; + description_spanish?: string; + unit_of_measure_code?: string; + // Quantities + quantity?: number; + quantity_returned_temp?: number; + quantity_returned?: number; + // Balance + available_balance: number; + has_balance: boolean; + // FA / subitem + is_subitem?: boolean; + contains_subitems?: boolean; + subitem_count?: number; +} 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/clients_and_providers/columns.ts b/frontend/src/lib/components/dashboard/clients_and_providers/columns.ts index b6dc5529..a779f66a 100644 --- a/frontend/src/lib/components/dashboard/clients_and_providers/columns.ts +++ b/frontend/src/lib/components/dashboard/clients_and_providers/columns.ts @@ -17,7 +17,7 @@ export function createColumns(onSuccess) { }, { accessorKey: "rfc", - header: "RFC", + header: "RFC / TAX-ID", cell: ({ row }) => { const snippet = createRawSnippet((getData) => { const { val } = getData(); diff --git a/frontend/src/lib/components/dashboard/clients_and_providers/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/clients_and_providers/create-edit-dialog.svelte index 811cba86..469de742 100644 --- a/frontend/src/lib/components/dashboard/clients_and_providers/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/clients_and_providers/create-edit-dialog.svelte @@ -21,21 +21,26 @@ rfc: "", name: "", curp: "", - residence_country: "", - domicile_fiscal: "", - foreign_tax_id: "", + type_nat_foreign: "N", client_or_provider: "client", is_active: true, // Address fields - street: "", + streets: "", + exterior_number: "", + interior_number: "", neighborhood: "", + municipality: "", city: "", state: "", country: "", - zip_code: "", - // Programs fields - program_code: "", - authorization_date: "" + postal_code: "", + email: "", + phone: "", + contact: "", + // Programs fields (subset) + program: "", + program_number: "", + manufacturer_id: "" }); let loading = $state(false); @@ -48,43 +53,59 @@ rfc: item.rfc, name: item.name, curp: item.curp || "", - residence_country: item.residence_country || "", - domicile_fiscal: item.domicile_fiscal || "", - foreign_tax_id: item.foreign_tax_id || "", + type_nat_foreign: item.type_nat_foreign || "N", client_or_provider: item.client_or_provider || "client", is_active: item.is_active ?? true, - street: item.address?.street || "", + streets: item.address?.streets || "", + exterior_number: item.address?.exterior_number || "", + interior_number: item.address?.interior_number || "", neighborhood: item.address?.neighborhood || "", + municipality: item.address?.municipality || "", city: item.address?.city || "", state: item.address?.state || "", country: item.address?.country || "", - zip_code: item.address?.zip_code || "", - program_code: item.programs?.program_code || "", - authorization_date: item.programs?.authorization_date || "" + postal_code: item.address?.postal_code || "", + email: item.address?.email || "", + phone: item.address?.phone || "", + contact: item.address?.contact || "", + program: item.programs?.program || "", + program_number: item.programs?.program_number || "", + manufacturer_id: item.programs?.manufacturer_id || "" }; } else { formData = { rfc: "", name: "", curp: "", - residence_country: "", - domicile_fiscal: "", - foreign_tax_id: "", + type_nat_foreign: "N", client_or_provider: "client", is_active: true, - street: "", + streets: "", + exterior_number: "", + interior_number: "", neighborhood: "", + municipality: "", city: "", state: "", country: "", - zip_code: "", - program_code: "", - authorization_date: "" + postal_code: "", + email: "", + phone: "", + contact: "", + program: "", + program_number: "", + manufacturer_id: "" }; } }); const isEditing = $derived(!!item); + const isForeign = $derived((formData.type_nat_foreign || "N").toUpperCase() === "E"); + + // Validación de formato: RFC (Nacional) o TAX-ID (Extranjero) + const RFC_REGEX = /^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/i; + // TAX-ID: 2 dígitos, guión, resto alfanumérico. Ej: 12-3456789. Máx 30 caracteres. + const TAX_ID_REGEX = /^\d{2}-[A-Z0-9]{1,27}$/i; async function handleSubmit(e: Event) { e.preventDefault(); @@ -94,6 +115,19 @@ return; } + const rfcVal = (formData.rfc || "").trim(); + if (isForeign) { + if (!TAX_ID_REGEX.test(rfcVal)) { + error = "El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres."; + return; + } + } else { + if (!RFC_REGEX.test(rfcVal)) { + error = "El RFC no tiene el formato correcto. Ejemplo: XAXX010101000."; + return; + } + } + loading = true; error = null; @@ -104,23 +138,27 @@ rfc: formData.rfc, name: formData.name, curp: formData.curp || null, - residence_country: formData.residence_country || null, - domicile_fiscal: formData.domicile_fiscal || null, - foreign_tax_id: formData.foreign_tax_id || null, client_or_provider: formData.client_or_provider as "client" | "provider" | "both" | null, + type_nat_foreign: (formData.type_nat_foreign || null) as any, is_active: formData.is_active, address: { - street: formData.street || null, + streets: formData.streets || null, + exterior_number: formData.exterior_number || null, + interior_number: formData.interior_number || null, neighborhood: formData.neighborhood || null, + municipality: formData.municipality || null, city: formData.city || null, state: formData.state || null, country: formData.country || null, - zip_code: formData.zip_code || null, + postal_code: formData.postal_code || null, + email: formData.email || null, + phone: formData.phone || null, + contact: formData.contact || null, }, programs: { - program_code: formData.program_code || null, - authorization_date: formData.authorization_date || null, - + program: formData.program || null, + program_number: formData.program_number || null, + manufacturer_id: formData.manufacturer_id || null, } }; response = await clientsProvidersApi.update(item.id, companyStore.activeCompany.id, payload); @@ -129,22 +167,27 @@ rfc: formData.rfc, name: formData.name, curp: formData.curp || null, - residence_country: formData.residence_country || null, - domicile_fiscal: formData.domicile_fiscal || null, - foreign_tax_id: formData.foreign_tax_id || null, client_or_provider: formData.client_or_provider as "client" | "provider" | "both" | null, + type_nat_foreign: (formData.type_nat_foreign || null) as any, is_active: formData.is_active, address: { - street: formData.street || null, + streets: formData.streets || null, + exterior_number: formData.exterior_number || null, + interior_number: formData.interior_number || null, neighborhood: formData.neighborhood || null, + municipality: formData.municipality || null, city: formData.city || null, state: formData.state || null, country: formData.country || null, - zip_code: formData.zip_code || null, + postal_code: formData.postal_code || null, + email: formData.email || null, + phone: formData.phone || null, + contact: formData.contact || null, }, programs: { - program_code: formData.program_code || null, - authorization_date: formData.authorization_date || null, + program: formData.program || null, + program_number: formData.program_number || null, + manufacturer_id: formData.manufacturer_id || null, } }; response = await clientsProvidersApi.create(companyStore.activeCompany.id, payload); @@ -182,19 +225,24 @@ rfc: "", name: "", curp: "", - residence_country: "", - domicile_fiscal: "", - foreign_tax_id: "", + type_nat_foreign: "N", client_or_provider: "client", is_active: true, - street: "", + streets: "", + exterior_number: "", + interior_number: "", neighborhood: "", + municipality: "", city: "", state: "", country: "", - zip_code: "", - program_code: "", - authorization_date: "" + postal_code: "", + email: "", + phone: "", + contact: "", + program: "", + program_number: "", + manufacturer_id: "" }; error = null; } @@ -226,14 +274,27 @@

Información Básica

-
+
- + + +
+
+ @@ -292,48 +353,45 @@
- -
-

Información Fiscal

- -
- - -
- -
- - -
-
-

Dirección

- +
+
+
+ + +
+ +
+ + +
+
+
@@ -347,10 +405,10 @@
- +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +

Programas

-
+
- +
- + +
+ +
+ +
diff --git a/frontend/src/lib/components/dashboard/clients_and_providers/data-table-actions.svelte b/frontend/src/lib/components/dashboard/clients_and_providers/data-table-actions.svelte index 03550b05..291753a7 100644 --- a/frontend/src/lib/components/dashboard/clients_and_providers/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/clients_and_providers/data-table-actions.svelte @@ -2,7 +2,7 @@ import EllipsisIcon from "@lucide/svelte/icons/ellipsis"; import { Button } from "$lib/components/ui/button/index.js"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js"; - import type { ClientProvider } from "./columns.js"; + import type { ClientProvider } from "$lib/api/dashboard/a76/clients-providers"; import DetailsDialog from "./details-dialog.svelte"; import DeleteDialog from "./delete-dialog.svelte"; import { clientsProvidersApi } from "$lib/api/dashboard/a76/clients-providers"; @@ -29,6 +29,9 @@ navigator.clipboard.writeText(item.rfc); } + const isForeign = (cp: ClientProvider) => + (cp.type_nat_foreign || "N").toUpperCase() === "E"; + function handleViewDetails() { showDetailsDialog = true; } @@ -77,7 +80,7 @@ Copiar ID - Copiar RFC + Copiar {isForeign(item) ? 'TAX-ID' : 'RFC'} diff --git a/frontend/src/lib/components/dashboard/clients_and_providers/delete-dialog.svelte b/frontend/src/lib/components/dashboard/clients_and_providers/delete-dialog.svelte index f0b8d48c..3cfe2b5f 100644 --- a/frontend/src/lib/components/dashboard/clients_and_providers/delete-dialog.svelte +++ b/frontend/src/lib/components/dashboard/clients_and_providers/delete-dialog.svelte @@ -51,6 +51,9 @@ } open = newOpen; } + + const isForeign = (cp: ClientProvider) => + (cp.type_nat_foreign || "N").toUpperCase() === "E"; @@ -66,7 +69,7 @@ {item.id}
- RFC: + {item ? (isForeign(item) ? 'TAX-ID:' : 'RFC:') : 'RFC:'} {item.rfc}
diff --git a/frontend/src/lib/components/dashboard/clients_and_providers/details-dialog.svelte b/frontend/src/lib/components/dashboard/clients_and_providers/details-dialog.svelte index fe478312..3b86bd06 100644 --- a/frontend/src/lib/components/dashboard/clients_and_providers/details-dialog.svelte +++ b/frontend/src/lib/components/dashboard/clients_and_providers/details-dialog.svelte @@ -15,6 +15,9 @@ function handleOpenChange(newOpen: boolean) { open = newOpen; } + + const isForeign = (cp: ClientProvider) => + (cp.type_nat_foreign || "N").toUpperCase() === "E"; @@ -39,7 +42,9 @@
- RFC + + {isForeign(item) ? "TAX-ID" : "RFC"} + {item.rfc}
@@ -63,7 +68,7 @@

Clasificación

-
+
Tipo {#if item.client_or_provider === 'client'} @@ -82,6 +87,13 @@ No especificado {/if}
+ +
+ Procedencia + + {isForeign(item) ? "Extranjero" : "Nacional"} + +
Estado @@ -100,44 +112,16 @@
- -
-

Información Fiscal

- - {#if item.residence_country} -
- País de Residencia - {item.residence_country} -
- {/if} - - {#if item.domicile_fiscal} -
- Domicilio Fiscal - {item.domicile_fiscal} -
- {/if} - - {#if item.foreign_tax_id} -
- ID Fiscal Extranjero - {item.foreign_tax_id} -
- {/if} - - -
- {#if item.address}

Dirección

- {#if item.address.street} + {#if item.address.streets}
- Calle - {item.address.street} + Calles + {item.address.streets}
{/if} @@ -149,15 +133,22 @@
{/if} - {#if item.address.zip_code} + {#if item.address.postal_code}
Código Postal - {item.address.zip_code} + {item.address.postal_code}
{/if}
+ {#if item.address.municipality} +
+ Municipio + {item.address.municipality} +
+ {/if} + {#if item.address.city}
Ciudad @@ -179,6 +170,29 @@ {item.address.country}
{/if} + + {#if item.address.email || item.address.phone || item.address.contact} +
+ {#if item.address.email} +
+ Email + {item.address.email} +
+ {/if} + {#if item.address.phone} +
+ Teléfono + {item.address.phone} +
+ {/if} + {#if item.address.contact} +
+ Contacto + {item.address.contact} +
+ {/if} +
+ {/if}
@@ -190,17 +204,27 @@

Programas

- {#if item.programs.program_code} + {#if item.programs.program}
- Código de Programa - {item.programs.program_code} + Programa + {item.programs.program}
{/if} - {#if item.programs.authorization_date} -
- Fecha de Autorización - {new Date(item.programs.authorization_date).toLocaleDateString()} + {#if item.programs.program_number || item.programs.manufacturer_id} +
+ {#if item.programs.program_number} +
+ Número + {item.programs.program_number} +
+ {/if} + {#if item.programs.manufacturer_id} +
+ Manufacturer ID + {item.programs.manufacturer_id} +
+ {/if}
{/if}
diff --git a/frontend/src/lib/components/dashboard/general_catalogs/unit_conversions/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/general_catalogs/unit_conversions/create-edit-dialog.svelte index 1a6911c8..0d6c303a 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/unit_conversions/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/unit_conversions/create-edit-dialog.svelte @@ -3,6 +3,8 @@ import * as Dialog from '$lib/components/ui/dialog'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; + import UnitMeasureSelectorDialog from '$lib/components/dashboard/goods/modales/unit-measure-dialog.svelte'; + import { FolderSearch, Scale } from 'lucide-svelte'; import { createUnitConversion, updateUnitConversion, @@ -36,6 +38,21 @@ let loading = $state(false); let error = $state(null); + let showFromUomModal = $state(false); + let showToUomModal = $state(false); + let wasOpen = $state(false); + + function resetForm() { + formData = { + from_unit_code: '', + to_unit_code: '', + conversion_factor: '' + }; + error = null; + loading = false; + showFromUomModal = false; + showToUomModal = false; + } $effect(() => { if (conversion) { @@ -53,6 +70,14 @@ } }); + $effect(() => { + // Reset each time the dialog is opened in create mode + if (open && !wasOpen && !isEdit) { + resetForm(); + } + wasOpen = open; + }); + async function handleSubmit() { loading = true; error = null; @@ -114,24 +139,60 @@
- +
+
+ + (showFromUomModal = true)} + class="cursor-pointer pl-9 font-mono" + placeholder="Seleccione..." + disabled={loading} + required + /> +
+ +
- +
+
+ + (showToUomModal = true)} + class="cursor-pointer pl-9 font-mono" + placeholder="Seleccione..." + disabled={loading} + required + /> +
+ +
@@ -160,3 +221,17 @@ + + { + formData.from_unit_code = u.code; + }} +/> + + { + formData.to_unit_code = u.code; + }} +/> diff --git a/frontend/src/lib/components/dashboard/general_catalogs/unit_conversions/data-table-actions.svelte b/frontend/src/lib/components/dashboard/general_catalogs/unit_conversions/data-table-actions.svelte index 05c6410e..b020111a 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/unit_conversions/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/unit_conversions/data-table-actions.svelte @@ -5,6 +5,7 @@ import { companyStore } from "$lib/stores/company.svelte"; import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte'; import CreateEditDialog from "./create-edit-dialog.svelte"; + import { toast } from "svelte-sonner"; let { conversion, @@ -23,6 +24,9 @@ } if (!companyStore.activeCompany) { + toast.error('Selecciona una compañía', { + description: 'No se puede eliminar sin una compañía activa.' + }); return; } @@ -30,11 +34,13 @@ try { await deleteUnitConversion(conversion.id, companyStore.activeCompany.id); + toast.success('Conversión eliminada'); if (onSuccess) { onSuccess(); } } catch (e) { const errorMsg = e instanceof Error ? e.message : 'Error desconocido al eliminar el registro'; + toast.error('No se pudo eliminar', { description: errorMsg }); } finally { loading = false; } diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index 321b04fe..2ce54185 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -1,3624 +1,3719 @@ - - -
-
-
-
- -

{title}

- - {isEdit ? 'Editar' : 'Nueva'} - -
-
-
-
- - - {#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..." - /> -
- -
-
-
- -
-
- - -
- +
+ + + + Asset Number + Num. Factura + Línea + Imagen + Acciones + + + + {#if lineItem.series && lineItem.series.length > 0} + {#each lineItem.series as asset, index} + + {asset.number_id} + {asset.import_invoice || '-'} + {asset.import_line || '-'} + + {#if asset.image_path} +
+ +
+ {:else} + - + {/if} +
+ +
+ + +
+
+
+ {/each} + {:else} + + + No hay activos registrados. + + + {/if} +
+
+
+ + {:else} + +
+ + Tabla de Identificadores + + +
+ +
+ +
+ + + + Clave + Compl. 1 + Compl. 2 + Compl. 3 + Acciones + + + + {#if lineItem.identifiers && lineItem.identifiers.length > 0} + {#each lineItem.identifiers as idDetail, index} + + {idDetail.identifier_code} + {idDetail.complement1 || '-'} + {idDetail.complement2 || '-'} + {idDetail.complement3 || '-'} + +
+ + +
+
+
+ {/each} + {:else} + + + No hay identificadores registrados. + + + {/if} +
+
+
+
+ {/if} +
+ + + + + + {isEditing ? 'Editar' : 'Insertar'} Identificador + + Ingrese los detalles del identificador para esta partida. + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + +
+
+ + + + + + {isEditing ? 'Editar' : 'Insertar'} Asset Tag (Etiquetado) + + Detalles del etiquetado de activos para Compras Mexicanas. + + + +
+ +
+
+ +

{currentMexAsset.import_invoice || '-'}

+
+
+ +

{currentMexAsset.import_line || '-'}

+
+
+ + +
+
+ + +
+
+ +
+
+ {currentMexAsset.image_path || 'Ningún archivo seleccionado'} +
+ +
+
+
+
+ + + + + +
+
+ + diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-labeling.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-labeling.svelte index 3e28e03e..bc9d3eb9 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-labeling.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-labeling.svelte @@ -1,32 +1,262 @@ -
- Labeling - -
-
- - -
-
- - -
-
+
+ {#if visibility.showLabelingLeftSection} + +
+ Labeling & Valuation + + {#if visibility.showLabelingStandard} +
+
+ + +
+
+ + +
+
+ {/if} -
- - -
-
+
+ {#if visibility.showLabelingEnhanced} + {#if visibility.showLabelingQuantity} +
+ + +
+ {/if} + {#if visibility.showLabelingValuationValue} +
+ + +
+ {/if} + {/if} +
+ + {#if visibility.showLabelingEnhanced} + {#if visibility.showLabelingValuationMethod} +
+ +
+ + +
+
+ {/if} + + {#if visibility.showUsageReason} +
+ + +
+ {/if} + {/if} + + + {#if visibility.showLabelingObservations} +
+ + +
+ {/if} +
+ {/if} + + {#if visibility.showLabelingEnhanced} + +
+ Assets / Series + +
+ +
+ +
+ + + + # + Asset Num + Factura + Línea + Acc + + + + {#each lineItem.series || [] as asset, i} + + {asset.row || i+1} + {asset.number_id || '-'} + {asset.import_invoice || '-'} + {asset.import_line || '-'} + +
+ + +
+
+
+ {:else} + + + No hay activos. + + + {/each} +
+
+
+ + {#if selectedAssetIndex !== null} +
+
Editar #{editingAsset.row}
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ {/if} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/valuation-method-selector.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/valuation-method-selector.svelte new file mode 100644 index 00000000..25a41a7f --- /dev/null +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/valuation-method-selector.svelte @@ -0,0 +1,108 @@ + + + + + + Seleccionar Método de Valoración + + Busca y selecciona un método de valoración de la lista. + + + +
+
+ + +
+
+ +
+ {#if loading} +
+ +
+ {:else} + + + + Clave + Descripción + + + + {#each filteredMethods as method} + handleSelect(method)} + > + {method.key} + {method.description} + + {:else} + + + No se encontraron métodos de valoración. + + + {/each} + + + {/if} +
+
+
diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte index 88b31a85..e41e2ae1 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte @@ -4,6 +4,7 @@ import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; import * as Select from '$lib/components/ui/select'; + import { getLocale } from '$lib/paraglide/runtime'; import { getExchangeRateByDate } from '$lib/api/dashboard/a76/exchange-rate'; import { companyStore } from '$lib/stores/company.svelte'; import type { Pedimento } from '$lib/api/dashboard/a76/pedimentos'; @@ -12,8 +13,6 @@ import type { CustomsBroker } from '$lib/api/dashboard/a76/customs-brokers'; import type { ClientProvider } from '$lib/api/dashboard/a76/clients-providers'; import type { CodePedimentoRegimen } from '$lib/api/dashboard/reference_data/code_pedimento_regimens'; - import type { TransportType } from '$lib/api/dashboard/reference_data/transport_types'; - import type { TransportMode } from '$lib/api/dashboard/reference_data/transport_modes'; import IdentificadoresTabForm from './identifiers-tab-form.svelte'; import ExchangeRateDialog from '$lib/components/dashboard/exchange_rate/create-edit-dialog.svelte'; import { Calendar, Clock } from 'lucide-svelte'; @@ -29,6 +28,13 @@ import { shortcutStore } from '$lib/stores/shortcut-store'; import { focusStore, interactionMode } from '$lib/stores/focus-store'; + type PedimentoTransportCatalog = { + code: string; + transport_en: string; + transport_es: string; + payment_date_code: 'E' | 'P' | string; + }; + let { pedimento, formData = $bindable(), @@ -38,8 +44,7 @@ customsBrokers = [], clients = [], codePedimentoRegimens = [], - transportTypes = [], - transportModes = [], + pedimentoTransportCatalog = [], isActive = false }: { pedimento: Pedimento | null; @@ -50,8 +55,7 @@ customsBrokers?: CustomsBroker[]; clients?: ClientProvider[]; codePedimentoRegimens?: CodePedimentoRegimen[]; - transportTypes?: TransportType[]; - transportModes?: TransportMode[]; + pedimentoTransportCatalog?: PedimentoTransportCatalog[]; isActive?: boolean; } = $props(); @@ -352,27 +356,53 @@ let lastFetchedDate: string | null = null; let lastCompanyId: number | null = null; - // Obtener automáticamente el tipo de cambio cuando cambie la fecha de entrada + function getTransportByCode(code: string | null | undefined): PedimentoTransportCatalog | undefined { + if (!code) return undefined; + return pedimentoTransportCatalog.find((m) => m.code === code); + } + + function getTransportLabel(mode: PedimentoTransportCatalog | undefined): string { + if (!mode) return ''; + const locale = getLocale(); + return locale === 'en' ? mode.transport_en : mode.transport_es; + } + + function getEffectiveExchangeDate(): string | null { + const entryMethod = getTransportByCode(formData?.pedimento_transport_means?.entry_exit); + const paymentCode = (entryMethod?.payment_date_code || 'E').toUpperCase(); + if (paymentCode === 'P') { + return formData?.payment_date || null; + } + return formData?.entry_date || null; + } + + function getEffectiveDateLabel(): string { + const entryMethod = getTransportByCode(formData?.pedimento_transport_means?.entry_exit); + const paymentCode = (entryMethod?.payment_date_code || 'E').toUpperCase(); + return paymentCode === 'P' ? 'fecha de pago' : 'fecha de entrada'; + } + + // Obtener automáticamente el tipo de cambio cuando cambie la fecha efectiva $effect(() => { - const entryDate = formData?.entry_date; + const effectiveDate = getEffectiveExchangeDate(); const companyId = companyStore.activeCompany?.id; // Solo ejecutar si los valores clave cambiaron if ( formData && - entryDate && + effectiveDate && companyId && - (entryDate !== lastFetchedDate || companyId !== lastCompanyId) + (effectiveDate !== lastFetchedDate || companyId !== lastCompanyId) ) { - lastFetchedDate = entryDate; + lastFetchedDate = effectiveDate; lastCompanyId = companyId; - getExchangeRateByDate(entryDate, companyId) + getExchangeRateByDate(effectiveDate, companyId) .then((usdRate) => { if (usdRate && formData) { formData.exchange_rate = usdRate.value; } else { - console.warn('⚠️ [TIPO CAMBIO] No encontrado para fecha:', entryDate); + console.warn('⚠️ [TIPO CAMBIO] No encontrado para fecha:', effectiveDate); } }) .catch((err) => { @@ -397,13 +427,14 @@ ]; export async function checkPaymentDateRate(date: string): Promise { - if (!date || !companyStore.activeCompany?.id) return true; + const effectiveDate = date || getEffectiveExchangeDate(); + if (!effectiveDate || !companyStore.activeCompany?.id) return true; try { - const rate = await getExchangeRateByDate(date, companyStore.activeCompany.id); + const rate = await getExchangeRateByDate(effectiveDate, companyStore.activeCompany.id); if (!rate) { // Abrir modal preventivamente - missingExchangeRateDate = date; + missingExchangeRateDate = effectiveDate; showExchangeRateDialog = true; return false; } @@ -411,7 +442,7 @@ } catch (error) { console.error('Error checking payment date rate:', error); // Si hay error de red, asumimos que falta para forzar reintento/captura segura - missingExchangeRateDate = date; + missingExchangeRateDate = effectiveDate; showExchangeRateDialog = true; return false; } @@ -568,11 +599,16 @@ id="exchange_rate" type="text" value={formData.exchange_rate ? Number(formData.exchange_rate).toFixed(6) : ''} - placeholder="Se obtiene automáticamente de la fecha de entrada" + placeholder={`Se obtiene automáticamente de la ${getEffectiveDateLabel()}`} readonly disabled class="cursor-not-allowed bg-muted" /> +

+ Tipo de fecha para TC: {getEffectiveDateLabel() === 'fecha de pago' + ? 'FECHA PAGO' + : 'FECHA ENTRADA'} +

@@ -829,18 +865,16 @@ > - {transportModes.find((m) => m.key === formData.pedimento_transport_means.entry_exit) - ?.name || - transportTypes.find( - (t) => t.transport_code === formData.pedimento_transport_means.entry_exit - )?.description || + {getTransportLabel( + getTransportByCode(formData.pedimento_transport_means.entry_exit) + ) || formData.pedimento_transport_means.entry_exit || 'Seleccionar...'} - {#each transportModes as mode} - {mode.key} - {mode.name} + {#each pedimentoTransportCatalog as mode} + {mode.code} - {getTransportLabel(mode)} {/each} @@ -856,18 +890,14 @@ > - {transportModes.find((m) => m.key === formData.pedimento_transport_means.arrival) - ?.name || - transportTypes.find( - (t) => t.transport_code === formData.pedimento_transport_means.arrival - )?.description || + {getTransportLabel(getTransportByCode(formData.pedimento_transport_means.arrival)) || formData.pedimento_transport_means.arrival || 'Seleccionar...'} - {#each transportModes as mode} - {mode.key} - {mode.name} + {#each pedimentoTransportCatalog as mode} + {mode.code} - {getTransportLabel(mode)} {/each} @@ -883,18 +913,14 @@ > - {transportModes.find((m) => m.key === formData.pedimento_transport_means.departure) - ?.name || - transportTypes.find( - (t) => t.transport_code === formData.pedimento_transport_means.departure - )?.description || + {getTransportLabel(getTransportByCode(formData.pedimento_transport_means.departure)) || formData.pedimento_transport_means.departure || 'Seleccionar...'} - {#each transportModes as mode} - {mode.key} - {mode.name} + {#each pedimentoTransportCatalog as mode} + {mode.code} - {getTransportLabel(mode)} {/each} diff --git a/frontend/src/lib/components/dashboard/reference_data/sectors/columns.ts b/frontend/src/lib/components/dashboard/reference_data/sectors/columns.ts index 5bf863f0..9392d3aa 100644 --- a/frontend/src/lib/components/dashboard/reference_data/sectors/columns.ts +++ b/frontend/src/lib/components/dashboard/reference_data/sectors/columns.ts @@ -4,9 +4,12 @@ import { createRawSnippet } from "svelte"; import DataTableActions from "./data-table-actions.svelte"; export type Sector = { + id: number; key: string; description: string; - authorized: number; + authorized: boolean; + company_id: number; + tenant_id: number; }; export function createColumns(onSuccess?: () => void): ColumnDef[] { @@ -42,9 +45,9 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { accessorKey: "authorized", header: "Autorizado", cell: ({ row }) => { - const authSnippet = createRawSnippet<[{ authorized: number }]>((getAuth) => { + const authSnippet = createRawSnippet<[{ authorized: boolean }]>((getAuth) => { const { authorized } = getAuth(); - const badge = authorized === 1 + const badge = authorized ? '' : 'No'; return { diff --git a/frontend/src/lib/components/dashboard/reference_data/sectors/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/reference_data/sectors/create-edit-dialog.svelte index 0d9aff0c..1da14dbf 100644 --- a/frontend/src/lib/components/dashboard/reference_data/sectors/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/reference_data/sectors/create-edit-dialog.svelte @@ -4,6 +4,7 @@ import { Input } from "$lib/components/ui/input"; import { Label } from "$lib/components/ui/label"; import { sectorsApi, type Sector, type CreateSectorData, type UpdateSectorData } from "$lib/api/dashboard/reference_data/sectors"; + import { companyStore } from '$lib/stores/company.svelte'; let { open = $bindable(false), @@ -18,7 +19,7 @@ let formData = $state({ key: "", description: "", - authorized: 0 + authorized: false }); let loading = $state(false); @@ -36,7 +37,7 @@ formData = { key: "", description: "", - authorized: 0 + authorized: false }; } }); @@ -48,6 +49,13 @@ loading = true; error = null; + const companyId = companyStore.activeCompany?.id; + if (!companyId) { + error = 'No hay empresa activa seleccionada'; + loading = false; + return; + } + try { let response; if (isEditing && item) { @@ -56,14 +64,14 @@ description: formData.description, authorized: formData.authorized }; - response = await sectorsApi.update(item.key, payload); + response = await sectorsApi.update(item.id, payload, companyId); } else { const payload: CreateSectorData = { key: formData.key, description: formData.description, authorized: formData.authorized }; - response = await sectorsApi.create(payload); + response = await sectorsApi.create(payload, companyId); } if (response.error) { @@ -95,11 +103,10 @@ function handleOpenChange(newOpen: boolean) { if (!newOpen) { - // Limpiar form al cerrar formData = { key: "", description: "", - authorized: 0 + authorized: false }; error = null; } @@ -159,9 +166,9 @@ (formData.authorized = 1)} + value="true" + checked={formData.authorized === true} + onchange={() => (formData.authorized = true)} disabled={loading} class="h-4 w-4 border-gray-300 text-primary focus:ring-primary" /> @@ -171,16 +178,15 @@ (formData.authorized = 0)} + value="false" + checked={formData.authorized === false} + onchange={() => (formData.authorized = false)} disabled={loading} class="h-4 w-4 border-gray-300 text-primary focus:ring-primary" /> No
-

1 = Autorizado, 0 = No autorizado

diff --git a/frontend/src/lib/components/dashboard/reference_data/sectors/delete-dialog.svelte b/frontend/src/lib/components/dashboard/reference_data/sectors/delete-dialog.svelte index 2acdbf35..9e92fb82 100644 --- a/frontend/src/lib/components/dashboard/reference_data/sectors/delete-dialog.svelte +++ b/frontend/src/lib/components/dashboard/reference_data/sectors/delete-dialog.svelte @@ -3,6 +3,7 @@ import * as AlertDialog from "$lib/components/ui/alert-dialog"; import { Badge } from "$lib/components/ui/badge"; import { sectorsApi, type Sector } from "$lib/api/dashboard/reference_data/sectors"; + import { companyStore } from '$lib/stores/company.svelte'; let { open = $bindable(false), @@ -20,11 +21,17 @@ async function handleDelete() { if (!item) return; + const companyId = companyStore.activeCompany?.id; + if (!companyId) { + error = 'No hay empresa activa seleccionada'; + return; + } + loading = true; error = null; try { - const response = await sectorsApi.delete(item.key); + const response = await sectorsApi.delete(item.id, companyId); if (response.error) { error = response.error; @@ -70,9 +77,9 @@
Autorizado: - - {item.authorized === 1 ? "Sí" : "No"} - + + {item.authorized ? "Sí" : "No"} +
{/if} diff --git a/frontend/src/lib/components/dashboard/reference_data/sectors/details-dialog.svelte b/frontend/src/lib/components/dashboard/reference_data/sectors/details-dialog.svelte index a796446e..9ff9b71c 100644 --- a/frontend/src/lib/components/dashboard/reference_data/sectors/details-dialog.svelte +++ b/frontend/src/lib/components/dashboard/reference_data/sectors/details-dialog.svelte @@ -50,9 +50,9 @@
Autorizado - - {item.authorized === 1 ? "Sí" : "No"} - + + {item.authorized ? "Sí" : "No"} +
diff --git a/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte b/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte index dffe17af..e24cb234 100644 --- a/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte @@ -5,6 +5,7 @@ import * as Table from '$lib/components/ui/table'; import { Search, Loader2, Factory } from 'lucide-svelte'; import { sectorsApi, type Sector } from '$lib/api/dashboard/reference_data/sectors'; + import { companyStore } from '$lib/stores/company.svelte'; import { toast } from 'svelte-sonner'; // --- PROPS --- @@ -97,7 +98,13 @@ } try { - const response = await sectorsApi.list(page, pageSize); + const companyId = companyStore.activeCompany?.id; + if (!companyId) { + toast.error('No hay empresa activa seleccionada'); + hasMore = false; + return; + } + const response = await sectorsApi.list(page, pageSize, companyId, searchTerm || undefined); if (response.error) { toast.error(`Error: ${response.error}`); @@ -105,18 +112,10 @@ return; } - let newItems = response.data?.items || []; - totalItems = response.data?.total || 0; + const newItems = response.data?.items || []; + totalItems = response.data?.total || 0; - if (searchTerm) { - newItems = newItems.filter( - (item) => - item.description.toLowerCase().includes(searchTerm.toLowerCase()) || - item.key.toLowerCase().includes(searchTerm.toLowerCase()) - ); - } - - if (isInitial) { + if (isInitial) { items = newItems; } else { items = [...items, ...newItems]; diff --git a/frontend/src/lib/components/ui/select/select-root.svelte b/frontend/src/lib/components/ui/select/select-root.svelte index 40f3b2dc..62bc3d59 100644 --- a/frontend/src/lib/components/ui/select/select-root.svelte +++ b/frontend/src/lib/components/ui/select/select-root.svelte @@ -5,11 +5,21 @@ import { selectSearchContextKey, type SelectSearchContext } from './select-search-context'; import { type WithoutChild } from '$lib/utils.js'; + // SelectPrimitive.RootProps is a discriminated union (single | multiple). + // Spreading a discriminated union collapses conflicting members (e.g. onValueChange) to `never`. + // We widen the props type so callers can pass either variant without hitting `never`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type SelectRootProps = Omit, 'value' | 'onValueChange'> & { + type?: 'single' | 'multiple'; + value?: string | string[]; + onValueChange?: (value: any) => void; + }; + let { children, value = $bindable(), ...restProps - }: WithoutChild = $props(); + }: SelectRootProps = $props(); let open = $state(false); const query = writable(''); diff --git a/frontend/src/lib/config/invoice-item-visibility.ts b/frontend/src/lib/config/invoice-item-visibility.ts index 283660af..d040537d 100644 --- a/frontend/src/lib/config/invoice-item-visibility.ts +++ b/frontend/src/lib/config/invoice-item-visibility.ts @@ -31,6 +31,26 @@ export interface InvoiceItemVisibility { showContinuationConsiderA31: boolean; /** Continuación tab: Extra Description in Spanish. */ showContinuationExtraDescription: boolean; + /** Etiquetado tab: Quantity, Valuation, Assets Table. */ + showLabelingEnhanced: boolean; + /** Identificadores tab: Special Assets table for MEX. */ + showMexicanIdEnhanced: boolean; + /** Etiquetado tab visibility. */ + showLabelingTab: boolean; + /** Etiquetado tab: Usage Reason field. */ + showUsageReason: boolean; + /** Etiquetado tab: Standard fields (Label No, Type, Observations). */ + showLabelingStandard: boolean; + /** Etiquetado tab: Quantity field. */ + showLabelingQuantity: boolean; + /** Etiquetado tab: Observations field. */ + showLabelingObservations: boolean; + /** Etiquetado tab: Valuation field (Valor Det). */ + showLabelingValuationValue: boolean; + /** Etiquetado tab: Valuation Method selector. */ + showLabelingValuationMethod: boolean; + /** Etiquetado tab: Whole left section (Legend + all fields). */ + showLabelingLeftSection: boolean; } const defaultVisibility: InvoiceItemVisibility = { @@ -50,7 +70,17 @@ const defaultVisibility: InvoiceItemVisibility = { showContinuationOwnOmitAnnex: true, showContinuationLotEntry: true, showContinuationConsiderA31: true, - showContinuationExtraDescription: true + showContinuationExtraDescription: true, + showLabelingEnhanced: false, + showMexicanIdEnhanced: false, + showLabelingTab: true, + showUsageReason: false, + showLabelingStandard: true, + showLabelingQuantity: false, + showLabelingObservations: true, + showLabelingValuationValue: false, + showLabelingValuationMethod: true, + showLabelingLeftSection: true }; function normalizeInvoiceType(invoiceType?: string | null): string { @@ -106,8 +136,27 @@ export function getVisibility( const expVisibility: InvoiceItemVisibility = { ...defaultVisibility, showExportLinkToImportBlock: true, - showExportValuationFields: true + showExportValuationFields: true, + showLabelingEnhanced: true, + showUsageReason: true, + showLabelingValuationValue: true, + showLabelingStandard: false, + showLabelingObservations: false, + showLabelingQuantity: false }; + + // CHECK FOR EXPORT REPAIR (REP / REPAR) + const upInvoiceType = normalizeInvoiceType(invoiceType); + if (upInvoiceType === 'REP' || upInvoiceType === 'REPAR') { + return { + ...expVisibility, + showLabelingValuationMethod: false, + showLabelingValuationValue: false, + showUsageReason: false, + showLabelingLeftSection: false + }; + } + // Per-type overrides can be added here (e.g. hide valuation for SCRAP). switch (exportType) { case 'NODES': @@ -126,14 +175,24 @@ export function getVisibility( return { ...defaultVisibility, showCrTrackingHeader: false, - showFdaFcc: false + showFdaFcc: false, + showLabelingEnhanced: true, + showLabelingQuantity: true, + showLabelingObservations: true, + showLabelingValuationValue: true }; case 'CR': return { ...defaultVisibility, showEighthRule: false, - showValuationFields: true + showIdentifiersTab: true, + showValuationFields: true, + showUsageReason: true, + showLabelingEnhanced: true, + showLabelingQuantity: false, + showLabelingStandard: false, + showLabelingObservations: false }; case 'REP': @@ -154,7 +213,13 @@ export function getVisibility( showContinuationOwnOmitAnnex: false, showContinuationLotEntry: false, showContinuationConsiderA31: false, - showContinuationExtraDescription: true + showContinuationExtraDescription: true, + showLabelingEnhanced: true, + showLabelingQuantity: true, + showLabelingStandard: true, + showLabelingObservations: false, + showLabelingValuationValue: false, + showLabelingValuationMethod: true }; case 'MEX': @@ -165,7 +230,9 @@ export function getVisibility( showEighthRule: false, showFdaFcc: false, showCertificateOfOrigin: false, - showIdentifiersTab: false, + showIdentifiersTab: true, + showMexicanIdEnhanced: true, + showLabelingTab: false, showContinuationIgi: false, showContinuationLocation: true, showContinuationMilitary: false, diff --git a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte index f38de0c8..48bbd5c1 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte @@ -118,6 +118,12 @@ selectedItem = item; } + function taxIdOrRfcLabel(cp: ClientProvider | null): string { + if (!cp) return 'RFC'; + const proc = (cp.type_nat_foreign || 'N').toUpperCase(); + return proc === 'E' ? 'TAX-ID' : 'RFC'; + } + function handleEdit() { if (selectedItem) goto(`/dashboard/clients_and_providers/edit/${selectedItem.id}`); } @@ -182,7 +188,7 @@

Filtros

- Busque por nombre, RFC o tipo + Busque por nombre, RFC/TAX-ID o tipo
@@ -195,10 +201,10 @@ />
- + e.key === 'Enter' && handleSearch()} /> @@ -249,7 +255,7 @@ # - RFC + RFC / TAX-ID Nombre Tipo Estatus @@ -352,6 +358,7 @@ {selectedItem?.name || '---'}
+ {taxIdOrRfcLabel(selectedItem)}: {selectedItem?.rfc || ''}
@@ -434,10 +441,6 @@ Número {selectedItem.programs.program_number || '-'}
-
- TAX ID - {selectedItem.programs.tax_id || '-'} -
{/if} diff --git a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte index 991a2507..0a10a0b0 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte @@ -73,7 +73,6 @@ responsible: '', position: '', is_active: true, - is_national_provider: false, streets: '', exterior_number: '', @@ -94,7 +93,6 @@ authorization_date_str: '', // String para input date prosec: '', manufacturer_id: '', - tax_id: '', ctpat_svi: '', is_certified_company: false }); @@ -152,7 +150,6 @@ responsible: item.responsible || '', position: item.position || '', is_active: !!item.is_active, - is_national_provider: !!item.is_national_provider, streets: addr.streets || '', exterior_number: addr.exterior_number || '', @@ -172,7 +169,6 @@ authorization_date_str: intDateToString(prog.secon_auth_date), prosec: prog.prosec ? String(prog.prosec) : '', manufacturer_id: prog.manufacturer_id || '', - tax_id: prog.tax_id || '', ctpat_svi: prog.ctpat_svi || '', is_certified_company: prog.is_certified_company === '1' }; @@ -185,6 +181,11 @@ } } + // Validación de formato: RFC (Nacional) o TAX-ID (Extranjero) + const RFC_REGEX = /^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/i; + // TAX-ID: 2 dígitos, guión, resto alfanumérico. Ej: 12-3456789. Máx 30 caracteres. + const TAX_ID_REGEX = /^\d{2}-[A-Z0-9]{1,27}$/i; + // --- ENVÍO DE DATOS --- async function handleSubmit() { if (!companyStore.activeCompany) { @@ -193,11 +194,27 @@ return; } if (!formData.rfc.trim() || !formData.name.trim()) { - error = 'RFC y Nombre obligatorios'; + error = 'Identificador fiscal (RFC/TAX-ID) y Nombre son obligatorios'; toast.error(error); return; } + const rfcVal = formData.rfc.trim(); + const isForeign = (formData.type_nat_foreign || 'N').toUpperCase() === 'E'; + if (isForeign) { + if (!TAX_ID_REGEX.test(rfcVal)) { + error = 'El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres.'; + toast.error(error); + return; + } + } else { + if (!RFC_REGEX.test(rfcVal)) { + error = 'El RFC no tiene el formato correcto. Ejemplo: XAXX010101000.'; + toast.error(error); + return; + } + } + loading = true; error = null; @@ -213,7 +230,6 @@ responsible: clean(formData.responsible), position: clean(formData.position), is_active: formData.is_active, - is_national_provider: formData.is_national_provider, address: { streets: clean(formData.streets), @@ -236,7 +252,6 @@ secon_auth_date: stringDateToInt(formData.authorization_date_str), prosec: clean(formData.prosec), manufacturer_id: clean(formData.manufacturer_id), - tax_id: clean(formData.tax_id), ctpat_svi: clean(formData.ctpat_svi), is_certified_company: formData.is_certified_company ? '1' : '0' } @@ -333,12 +348,12 @@
- +
@@ -659,19 +674,6 @@
-
- - -
-
- -
- -

Marcar si es un proveedor nacional

-
-
diff --git a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.server.ts b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.server.ts index 2e923b7d..e8cbfed3 100644 --- a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.server.ts +++ b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.server.ts @@ -41,6 +41,7 @@ export const load: PageServerLoad = async ({ params, cookies, fetch }) => { codePedimentoRegimens: [], transportTypes: [], transportModes: [], + pedimentoTransportCatalog: [], error: 'Error al cargar catálogos. Verifique la conexión con el backend.' }; } @@ -57,7 +58,8 @@ export const load: PageServerLoad = async ({ params, cookies, fetch }) => { clients: data.clients || [], codePedimentoRegimens: data.code_pedimento_regimens || [], transportTypes: data.transport_types || [], - transportModes: data.transport_modes || [] + transportModes: data.transport_modes || [], + pedimentoTransportCatalog: data.pedimento_transport_catalog || [] }; } catch (e) { console.error('❌ Error loading new pedimento data:', e); @@ -73,6 +75,7 @@ export const load: PageServerLoad = async ({ params, cookies, fetch }) => { codePedimentoRegimens: [], transportTypes: [], transportModes: [], + pedimentoTransportCatalog: [], error: 'Error al cargar catálogos. Verifique la conexión con el backend.' }; } @@ -114,7 +117,8 @@ export const load: PageServerLoad = async ({ params, cookies, fetch }) => { clients: data.clients || [], codePedimentoRegimens: data.code_pedimento_regimens || [], transportTypes: data.transport_types || [], - transportModes: data.transport_modes || [] + transportModes: data.transport_modes || [], + pedimentoTransportCatalog: data.pedimento_transport_catalog || [] }; } catch (e) { console.error('Error loading pedimento:', e); diff --git a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte index 8f6c6825..6a868691 100644 --- a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte @@ -56,6 +56,12 @@ import type { CodePedimentoRegimen } from '$lib/api/dashboard/reference_data/code_pedimento_regimens'; import type { TransportType } from '$lib/api/dashboard/reference_data/transport_types'; import type { TransportMode } from '$lib/api/dashboard/reference_data/transport_modes'; + type PedimentoTransportCatalog = { + code: string; + transport_en: string; + transport_es: string; + payment_date_code: 'E' | 'P' | string; + }; // Get sidebar context const sidebar = useSidebar(); @@ -71,6 +77,7 @@ codePedimentoRegimens?: CodePedimentoRegimen[]; transportTypes?: TransportType[]; transportModes?: TransportMode[]; + pedimentoTransportCatalog?: PedimentoTransportCatalog[]; user?: any; companies?: any[]; authenticated?: boolean; @@ -153,6 +160,18 @@ } } + function getExchangeDateForPedimento(formData: any): { date: string | null; label: string } { + const catalog = (data.pedimentoTransportCatalog || []) as PedimentoTransportCatalog[]; + const entryMethod = catalog.find( + (item) => item.code === formData?.pedimento_transport_means?.entry_exit + ); + const paymentDateCode = (entryMethod?.payment_date_code || 'E').toUpperCase(); + if (paymentDateCode === 'P') { + return { date: formData?.payment_date || null, label: 'fecha de pago' }; + } + return { date: formData?.entry_date || null, label: 'fecha de entrada' }; + } + // ID del pedimento let pedimentoId = $state(data.pedimentoId ?? null); @@ -395,11 +414,10 @@ saving = true; try { - // Verificar tipo de cambio antes de guardar si hay instancia del tab general y hay fecha de pago - if (generalTabInstance && generalFormData?.payment_date) { - const rateExists = await generalTabInstance.checkPaymentDateRate( - generalFormData.payment_date - ); + // Verificar tipo de cambio según catalogo de transporte (E/P) + if (generalTabInstance && generalFormData) { + const exchangeRef = getExchangeDateForPedimento(generalFormData); + const rateExists = await generalTabInstance.checkPaymentDateRate(exchangeRef.date || ''); if (!rateExists) { saving = false; // Asegurar que se muestre el tab general @@ -432,8 +450,9 @@ } } - // Validar tipo de cambio en create y update + // Validar tipo de cambio en create y update segun fecha efectiva del metodo de transporte if (generalFormData) { + const exchangeRef = getExchangeDateForPedimento(generalFormData); const rate = generalFormData.exchange_rate; if ( rate === null || @@ -443,11 +462,11 @@ ) { saving = false; activeTab = 'general'; - const date = generalFormData.entry_date || ''; + const date = exchangeRef.date || ''; toast.error( Number(rate) <= 0 && rate !== null && rate !== undefined - ? 'El tipo de cambio debe ser mayor a 0. Registra el tipo de cambio para la fecha de entrada.' - : 'No hay tipo de cambio registrado para la fecha de entrada. Por favor, regístralo antes de guardar.' + ? `El tipo de cambio debe ser mayor a 0. Registra el tipo de cambio para la ${exchangeRef.label}.` + : `No hay tipo de cambio registrado para la ${exchangeRef.label}. Por favor, regístralo antes de guardar.` ); if (date) { missingExchangeRateDate = date; @@ -1129,8 +1148,7 @@ customsBrokers={data.customsBrokers} clients={data.clients} codePedimentoRegimens={data.codePedimentoRegimens} - transportTypes={data.transportTypes} - transportModes={data.transportModes} + pedimentoTransportCatalog={data.pedimentoTransportCatalog} isActive={activeTab === 'general'} /> diff --git a/frontend/src/routes/dashboard/reference_data/sectors/+page.server.ts b/frontend/src/routes/dashboard/reference_data/sectors/+page.server.ts index a637f91c..979bc44d 100644 --- a/frontend/src/routes/dashboard/reference_data/sectors/+page.server.ts +++ b/frontend/src/routes/dashboard/reference_data/sectors/+page.server.ts @@ -2,11 +2,10 @@ import type { PageServerLoad } from './$types'; import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { - // Esperar a que el layout padre valide/refresque el token await parent(); - + const { accessToken } = getAuthTokens(cookies); - + if (!accessToken) { return { error: 'No authenticated', @@ -18,13 +17,33 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { } try { - // Obtener parámetros de paginación de la URL const page = parseInt(url.searchParams.get('page') || '1'); const pageSize = parseInt(url.searchParams.get('page_size') || '50'); - // Usar authenticatedFetch para manejar automáticamente el refresh de tokens + const parentData = await parent(); + const cookieCompanyId = cookies.get('active_company_id'); + const companyId = cookieCompanyId + ? parseInt(cookieCompanyId) + : parentData.companies?.[0]?.id; + + if (!companyId) { + return { + error: 'No se encontró una compañía seleccionada', + items: [], + total: 0, + page, + page_size: pageSize + }; + } + + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString() + }); + const response = await authenticatedFetch( - `v1/public/reference_data/sectors?page=${page}&page_size=${pageSize}`, + `v1/a76/sectors/?${params.toString()}`, {}, cookies, fetch @@ -37,12 +56,12 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { statusText: response.statusText, error: errorText }); - + return { error: `Error ${response.status}: ${response.statusText}`, items: [], total: 0, - page: page, + page, page_size: pageSize }; } diff --git a/frontend/src/routes/dashboard/reference_data/sectors/+page.svelte b/frontend/src/routes/dashboard/reference_data/sectors/+page.svelte index 77bc8ec2..f944f18f 100644 --- a/frontend/src/routes/dashboard/reference_data/sectors/+page.svelte +++ b/frontend/src/routes/dashboard/reference_data/sectors/+page.svelte @@ -1,6 +1,7 @@