Merge branch 'development' into feature/SSR-views

This commit is contained in:
2026-02-13 08:13:35 -06:00
123 changed files with 12052 additions and 1047 deletions

View File

@@ -67,9 +67,6 @@ class FaLineItem(Base, TenantScopedMixin, TimestampMixin):
# Subitems
is_subitem: Mapped[Optional[bool]] = mapped_column(Boolean) # ESSUBPARTIDA
contains_subitems: Mapped[Optional[bool]] = mapped_column(Boolean) # CONTIENESUBP
includes_subitems: Mapped[Optional[bool]] = mapped_column(
Boolean
) # INCUYESUBPARTIDAS
subitem_number: Mapped[Optional[int]] = mapped_column(Integer) # SUBPARTIDA
# Special flags

View File

@@ -39,7 +39,7 @@ class Company(Base, TimestampMixin):
# Programa
program: Mapped[Optional[str]] = mapped_column(String(7))
program_number: Mapped[Optional[str]] = mapped_column(String(40))
prosec: Mapped[Optional[int]] = mapped_column(SmallInteger)
prosec: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20))
# Sectores
@@ -61,9 +61,9 @@ class Company(Base, TimestampMixin):
# Configuración básica
logo: Mapped[Optional[str]] = mapped_column(String(255))
has_express_line: Mapped[Optional[str]] = mapped_column(String(2), default="N")
has_express_line: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false")
order_format_type: Mapped[Optional[str]] = mapped_column(String(19))
is_service_company: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
is_service_company: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false")
client_name: Mapped[Optional[str]] = mapped_column(String(300))
subassembly_mode: Mapped[Optional[str]] = mapped_column(String(7))
@@ -77,9 +77,11 @@ class Company(Base, TimestampMixin):
scaf_readonly: Mapped[Optional[int]] = mapped_column(SmallInteger)
parts_replacement: Mapped[Optional[int]] = mapped_column(SmallInteger)
activate_facmexame: Mapped[Optional[int]] = mapped_column(SmallInteger)
part_reference: Mapped[Optional[int]] = mapped_column(SmallInteger)
part_reference: Mapped[Optional[int]] = mapped_column(SmallInteger)
part_reference: Mapped[Optional[int]] = mapped_column(SmallInteger)
international_firm: Mapped[Optional[int]] = mapped_column(SmallInteger)
seventh_amendment: Mapped[Optional[bool]] = mapped_column(
Boolean
) # Septima enimenda (FinalContadorAElectronico)
# Configuraciones simples
ftp_key: Mapped[Optional[str]] = mapped_column(String(10))

View File

@@ -0,0 +1,32 @@
from datetime import datetime
from typing import Optional
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, String, Integer, Numeric, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class HistoricalTariffFraction(Base):
"""
Historical tariff fractions catalog.
Maps to SQL Server table: GFraccionesHistorico
"""
__tablename__ = "historical_tariff_fractions"
__table_args__ = {"schema": "a76"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, nullable=False)
historical_fraction: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
unit_of_measure_code: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.unit_of_measure_customs.code"), nullable=True)
country: Mapped[Optional[str]] = mapped_column(ForeignKey("public.countries.m3_key"), nullable=True)
fraction_type: Mapped[Optional[str]] = mapped_column(String(7), nullable=True)
sector: Mapped[Optional[str]] = mapped_column(String(5), nullable=True)
import_tax_rate: Mapped[Optional[Decimal]] = mapped_column(Numeric(7, 2), nullable=True)
export_tax_rate: Mapped[Optional[Decimal]] = mapped_column(Numeric(7, 2), nullable=True)
publication_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
is_immex: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
normal_temporality: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
services_temporality: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
certified_temporality: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
by_log: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
end_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)

View File

@@ -0,0 +1,52 @@
from fastapi import APIRouter
from .company import router as company_router
from .exchange_rate.routes import router as exchange_rate_router
from .identifiers.routes import router as identifiers_router
from .packages.routes import router as package_router
from .ports.routes import router as ports_router
from .fractions.tariff_fractions.routes import router as tariff_fractions_router
from .fractions.us_tariff_fractions.routes import router as us_tariff_fractions_router
from .depreciation_catalog.routes import router as depreciation_catalog_router
from .fda_catalog.routes import router as fda_catalog_router
from .seal.routes import router as seal_router
from .units_of_measure.routes import router as units_of_measure_router
from .concepts.routes import router as concepts_router
from .customs_broker_concepts.routes import router as customs_broker_concepts_router
from .classification_concepts.routes import router as classification_concepts_router
from .unit_conversions.routes import router as unit_conversions_router
from .equivalencies.routes import router as equivalencies_router
from .multi_currency_types.routes import router as multi_currency_types_router
from .inpc.routes import router as inpc_router
from .legends.routes import router as legends_router
from .signatures.routes import router as signatures_router
from .error_catalogs.routes import router as error_catalogs_router
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
router = APIRouter()
router.include_router(company_router, tags=["a76 / company"])
router.include_router(package_router)
router.include_router(ports_router)
router.include_router(tariff_fractions_router)
router.include_router(us_tariff_fractions_router)
router.include_router(depreciation_catalog_router)
router.include_router(fda_catalog_router)
router.include_router(seal_router, tags=["a76 / seal"])
router.include_router(units_of_measure_router)
router.include_router(identifiers_router)
router.include_router(exchange_rate_router, tags=["a76 / exchange_rate"])
router.include_router(concepts_router)
router.include_router(customs_broker_concepts_router)
router.include_router(classification_concepts_router)
router.include_router(unit_conversions_router)
router.include_router(equivalencies_router)
router.include_router(multi_currency_types_router)
router.include_router(inpc_router)
router.include_router(legends_router)
router.include_router(signatures_router)
router.include_router(error_catalogs_router)
router.include_router(doda_router)
router.include_router(prevalidators_router)
router.include_router(electronic_notices_router)

View File

@@ -1,3 +1,4 @@
from typing import Optional
from core.exceptions import ErrorCollector
from .. import models
from sqlalchemy.orm import Session
@@ -8,21 +9,80 @@ def invoice_exists(
invoice_number: str,
tenant_id: int,
company_id: int,
errors: ErrorCollector
) -> bool:
invoice_exists = (
db.query(models.InvoiceHeader.id)
errors: Optional[ErrorCollector],
):
invoice = (
db.query(models.InvoiceHeader)
.filter(
models.InvoiceHeader.invoice_number == invoice_number,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
)
.first()
.first()
)
if invoice_exists:
errors.add_duplicate_error(
if invoice:
if errors:
errors.add_duplicate_error(
"invoice_number",
invoice_number,
f"Ya existe una factura con el número '{invoice_number}'",
)
)
return invoice
return None
def invoice_exists_by_id(
db: Session,
invoice_id: str,
tenant_id: int,
company_id: int,
errors: Optional[ErrorCollector],
):
invoice = (
db.query(models.InvoiceHeader)
.filter(
models.InvoiceHeader.id == invoice_id,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
)
.first()
)
if invoice:
if errors:
errors.add_duplicate_error(
"invoice_id",
invoice_id,
f"Ya existe una factura con el número '{invoice_id}'",
)
return invoice
return None
def invoice_updated(
db: Session,
invoice_id: str,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
) -> bool:
is_updated = (
db.query(models.InvoiceHeader.is_updated)
.filter(
models.InvoiceHeader.id == invoice_id,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
)
.scalar()
)
if is_updated:
errors.add_error(
field="invoice_number",
message=f"La factura con el número '{invoice_id}' ya ha sido actualizada y no se puede modificar.",
solution="Capturar otro número de Factura de Importación Temporal o Desactualizar la factura.",
code="INVOICE_UPDATED",
value=invoice_id,
)
return True
return False

View File

@@ -0,0 +1,38 @@
from sqlalchemy import func
from core.exceptions import ErrorCollector
from ..line_items import models
from sqlalchemy.orm import Session
def item_exists(
db: Session,
item_line: int,
tenant_id: int,
company_id: int
):
item_exists = (
db.query(models.LineItem.id)
.filter(
models.LineItem.line_number == item_line,
models.LineItem.tenant_id == tenant_id,
models.LineItem.company_id == company_id,
)
.scalar()
)
if item_exists:
return item_exists
return None
def count_items(
db: Session,
invoice_id: int,
tenant_id: int,
company_id: int
):
count = db.query(func.count()).select_from(models.Item).filter(
models.Item.invoice_id == invoice_id,
models.Item.tenant_id == tenant_id,
models.Item.company_id == company_id,
).scalar()
return count

View File

@@ -0,0 +1,155 @@
import asyncio
from datetime import datetime
from typing import Optional, Tuple
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import HistoricalTariffFraction
from api.v1.modules.sitar.tlcs import TLCSService
from api.v1.modules.sitar.prosec import ProsecService
from api.v1.modules.sitar.fracciones import FraccionesService
from sqlalchemy.orm import Session
from sqlalchemy import exists
from core.exceptions import ErrorCollector
def search_historical_fraction(db: Session, fraction: str, country: str, fraction_type: str, sector: str, invoice_date: Optional[datetime], errors: Optional[ErrorCollector]) -> Tuple[Optional[str], float]:
"""Search for historical fraction data
This corresponds to BUSCA_FRACCION_HISTORICA in original Clarion code
Returns:
Tuple of (rate_im, adv_impo)
"""
# Placeholder for historical search
historical_exists = db.query(exists().where(HistoricalTariffFraction.historical_fraction == fraction)).scalar()
if not historical_exists:
errors.add_error(
field=f"fraction, country, fraction_type, sector",
message=f"La fraccion {fraction}, con pais {country}, con preferencia {fraction_type} y sector {sector} no existe.",
solution=["Revisar que si exista la preferencia para esta fracción en caso de ser historico, registrarlo en el catálogo de fracciones historicas."],
code="HISTORICAL_FRACTION_NOT_FOUND",
)
else:
historical_exists = db.query(HistoricalTariffFraction).filter(
HistoricalTariffFraction.historical_fraction == fraction,
HistoricalTariffFraction.country == country,
HistoricalTariffFraction.fraction_type == fraction_type,
HistoricalTariffFraction.sector == sector,
HistoricalTariffFraction.publication_date <= invoice_date,
).first()
return historical_exists.import_tax_rate, historical_exists.import_tax_rate
def search_fraction_preference(
db: Session,
country: str,
fraccion: str,
fraction_type: str,
sector: Optional[str] = None,
invoice_date: Optional[datetime] = None,
errors: Optional[ErrorCollector] = None
) -> Tuple[Optional[str], float]:
"""Search fraction preference and return rate_im and adv_impo
Args:
country: Country code
fraction_type: Type of fraction (e.g., 'TLCS', 'PROSEC')
company: Company object with configuration
fraccion: Tariff fraction code to search
sector: Sector code (required for PROSEC searches)
Returns:
Tuple of (rate_im, adv_impo) where:
- rate_im: Tax rate as string (e.g., "EXE", "5.0%")
- adv_impo: Numeric ad valorem rate
"""
rate_im = None
adv_impo = 0.0
fraccion_8 = fraccion[:8]
country_group = "USA" if country == "MEX" else country
"""" TLCS Search """
if fraction_type.upper() == "TLCS":
try:
# Get TLCS service instance
tlcs_service = TLCSService.get_instance()
# Fetch TLCS data using the service
tlcs_data = asyncio.run(
tlcs_service.search(fraccion=fraccion_8, pais=country_group, limit=100)
)
if not tlcs_data:
rate_im, adv_impo = search_historical_fraction(db=db, fraction=fraccion_8, country=country, fraction_type=fraction_type, sector=sector, invoice_date=invoice_date, errors=errors)
else:
first_record = tlcs_data[0]
rate_im = first_record.TASATXT
adv_impo = float(first_record.TASA1NUM or 0.0)
except Exception as e:
print(f"Error fetching Fracciones data from SITAR API: {e}")
""" ALADI Search """
if fraction_type.upper() == "ALADI":
rate_im, adv_impo = search_historical_fraction(db=db, fraction=fraccion_8, country=country, fraction_type=fraction_type, sector=sector, invoice_date=invoice_date, errors=errors)
""" PROSEC Search """
if fraction_type.upper() == "PROSEC":
try:
# Búsqueda en API de PROSEC (TARIFA_AS..sProsec)
prosec_service = ProsecService.get_instance()
# Buscar primero con ARTICULO = '4to'
prosec_data = asyncio.run(
prosec_service.search(
fraccion=fraccion_8, sector=sector, articulo="4to", limit=100
)
)
# Si no se encuentra con '4to', buscar con '5to'
if not prosec_data:
prosec_data = asyncio.run(
prosec_service.search(
fraccion=fraccion_8, sector=sector, articulo="5to", limit=100
)
)
if not prosec_data:
rate_im, adv_impo = search_historical_fraction(db=db, fraction=fraccion_8, country=country, fraction_type=fraction_type, sector=sector, invoice_date=invoice_date, errors=errors)
else:
first_record = prosec_data[0]
rate_im = first_record.TASATXT
adv_impo = float(first_record.TASANUM or 0.0)
except Exception as e:
print(f"Error fetching Fracciones data from SITAR API: {e}")
""" General search fallback """
if fraction_type.upper() == "GENERAL":
# Búsqueda en API de Fracciones (sFracciones)
try:
fracciones_service = FraccionesService.get_instance()
# Extraer fracción (primeros 8 caracteres) e nico (caracteres 9-10)
nico = fraccion[8:10] if len(fraccion) >= 10 else None
# Buscar por fracción e histórico
fracciones_data = asyncio.run(
fracciones_service.search(
fraccion=fraccion_8, nico=nico, limit=100
)
)
if not fracciones_data:
rate_im, adv_impo = search_historical_fraction(db=db, fraction=fraccion_8, country=country, fraction_type=fraction_type, sector=sector, invoice_date=invoice_date, errors=errors)
else:
# Se encontraron registros, tomar el primero
first_record = fracciones_data[0]
rate_im = first_record.ADVIMPOTXT # AdvImpoTxt
adv_impo = float(first_record.ADVIMPONUM or 0.0)
except Exception as e:
print(f"Error fetching Fracciones data from SITAR API: {e}")
return rate_im, adv_impo

View File

@@ -1,158 +1,344 @@
"""
Funciones helper compartidas para validaciones de items.
"""
from sqlalchemy import exists
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.common.common_validators import invoice_exists_by_id
from core.exceptions import ErrorCollector
from typing import Optional
from sqlalchemy import func
from ....common.fractions import search_fraction_preference
from ....common.common_validators import item_exists
from ....models import Item
from ....line_items.models import LineItem
from ....line_customs.models import FractionType, LineCustom
from api.v1.modules.a76.items.schemas import LineItemCreate
from api.v1.modules.a76.invoices.models import InvoiceHeader
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.public.reference_data.valuation_methods.models import (
ValuationMethod,
)
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.general_catalogs.company.models import Company
def validate_catalog_reference(
def validate_common(
db: Session,
model_class,
id_value: Optional[int],
field_name: str,
line: LineItemCreate,
invoice_id: int, # Para creación, se pasa directamente; para update, se consulta del item
tenant_id: int,
company_id: Optional[int],
company_id: int,
errors: ErrorCollector,
error_message: str = None
) -> bool:
"""
Valida que una referencia a catálogo exista en la base de datos.
Args:
db: Sesión de base de datos
model_class: Clase del modelo SQLAlchemy a consultar
id_value: ID a validar
field_name: Nombre del campo para el error
tenant_id: ID del tenant
company_id: ID de la compañía (opcional)
errors: Colector de errores
error_message: Mensaje personalizado de error
Returns:
True si existe, False si no
"""
if not id_value:
return False
query = db.query(model_class).filter(
model_class.id == id_value,
model_class.tenant_id == tenant_id
line_number: int,
):
# Para updates, line.item_id existe; para creates, es None
item_header = None
if line.item_id:
item_header = db.query(Item).filter(Item.id == line.item_id).first()
if item_header:
invoice_id = item_header.invoice_id
invoice: InvoiceHeader = invoice_exists_by_id(
db, invoice_id, tenant_id, company_id, errors
)
# Agregar filtro de company_id si el modelo lo tiene y se proporciona
if company_id and hasattr(model_class, 'company_id'):
query = query.filter(model_class.company_id == company_id)
exists = query.first() is not None
if not exists:
msg = error_message or f"El valor {id_value} no existe en el catálogo"
line_item: LineItem = item_exists(
db, line.line_number, tenant_id, company_id
)
fecha_factura = invoice.invoice_date if invoice else None
fraction = None
class_ = db.query(Class).filter(Class.id == line.class_id).first()
if not class_:
errors.add_error(
field=field_name,
message=msg,
solution="Selecciona un valor válido del catálogo",
code="NOT_FOUND"
field=f"line[{line_number}].class_id",
message="La clase especificada no existe.",
solution=["Darla de alta en el catalogo de clases."],
code="CLASS_NOT_FOUND",
)
return exists
def validate_positive_value(
value: Optional[float],
field_name: str,
errors: ErrorCollector,
required: bool = True,
allow_zero: bool = False
) -> bool:
"""
Valida que un valor numérico sea positivo.
Args:
value: Valor a validar
field_name: Nombre del campo para el error
errors: Colector de errores
required: Si el campo es obligatorio
allow_zero: Si se permite el valor cero
Returns:
True si es válido, False si no
"""
if value is None:
if required:
else:
if not line.unit_of_measure and not class_.unit_of_measure:
errors.add_error(
field=field_name,
message=f"El campo {field_name} es obligatorio",
solution="Proporciona un valor válido",
code="REQUIRED"
field=f"line[{line_number}].unit_of_measure",
message="La unidad de medida es obligatoria para la clase especificada.",
solution=["Proporciona una unidad de medida valida."],
code="UNIT_OF_MEASURE_REQUIRED",
)
return False
return True
if allow_zero and value == 0:
return True
if value <= 0:
if not line.customs.fraction:
if not line_item:
if not class_.fraction:
errors.add_error(
field=f"line[{line_number}].customs.fraction",
message="La fracción arancelaria es obligatoria para la clase especificada.",
solution=["Proporciona una fracción arancelaria valida."],
code="FRACTION_REQUIRED",
)
else:
fraction = class_.fraction
else:
if not line.customs.fraction:
if not class_.fraction:
errors.add_error(
field=f"line[{line_number}].customs.fraction",
message="La fracción arancelaria es obligatoria para la clase especificada.",
solution=["Proporciona una fracción arancelaria valida."],
code="FRACTION_REQUIRED",
)
else:
fraction = class_.fraction
else:
if line_item:
fraction = line.customs.fraction
if not line.description.description_spanish and not class_.description_es:
errors.add_error(
field=f"line[{line_number}].description.description_spanish",
message="La descripción en español es obligatoria para la clase especificada.",
solution=["Proporciona una descripción en español valida."],
code="DESCRIPTION_SPANISH_REQUIRED",
)
if not line.description.description_english and not class_.description_en:
errors.add_error(
field=f"line[{line_number}].description.description_english",
message="La descripción en inglés es obligatoria para la clase especificada.",
solution=["Proporciona una descripción en inglés valida."],
code="DESCRIPTION_ENGLISH_REQUIRED",
)
if line.quantity.quantity and line.quantity.quantity <= 0:
errors.add_error(
field=field_name,
message=f"El campo {field_name} debe ser mayor a cero",
solution="Proporciona un valor positivo",
code="INVALID_VALUE"
field=f"line[{line_number}].quantity.quantity",
message="La cantidad debe ser mayor a cero.",
solution=["Proporciona una cantidad valida."],
code="QUANTITY_MUST_BE_GREATER_THAN_ZERO",
)
return False
return True
def normalize_yes_no_value(value: Optional[str]) -> Optional[str]:
"""
Normaliza valores SI/NO a formato estándar.
Args:
value: Valor a normalizar (SI, NO, S, N)
Returns:
'SI' o 'NO', o None si el valor es None
"""
if not value:
return None
val = value.upper().strip()
if val in ['SI', 'S']:
return 'SI'
elif val in ['NO', 'N']:
return 'NO'
return value # Retornar original si no coincide
def validate_string_not_empty(
value: Optional[str],
field_name: str,
errors: ErrorCollector,
required: bool = True
) -> bool:
"""
Valida que un string no esté vacío.
Args:
value: Valor a validar
field_name: Nombre del campo para el error
errors: Colector de errores
required: Si el campo es obligatorio
Returns:
True si es válido, False si no
"""
if value is None or (isinstance(value, str) and not value.strip()):
if required:
errors.add_error(
field=field_name,
message=f"El campo {field_name} no puede estar vacío",
solution="Proporciona un valor válido",
code="REQUIRED"
if line.unit_of_measure:
um = (
db.query(func.count(UnitOfMeasure.id))
.filter(
UnitOfMeasure.id == line.unit_of_measure,
UnitOfMeasure.tenant_id == tenant_id,
UnitOfMeasure.company_id == company_id,
)
.scalar()
)
if um == 0:
errors.add_error(
field=f"line[{line_number}].unit_of_measure",
message="La unidad de medida especificada no existe.",
solution=["Proporciona una unidad de medida valida."],
code="UNIT_OF_MEASURE_NOT_FOUND",
)
if line.quantity.package_id:
package = (
db.query(func.count(Package.id))
.filter(
Package.id == line.quantity.package_id,
Package.tenant_id == tenant_id,
Package.company_id == company_id,
)
.scalar()
)
if package == 0:
errors.add_error(
field=f"line[{line_number}].quantity.package_id",
message="El paquete especificado no existe.",
solution=["Proporciona un paquete valido."],
code="PACKAGE_NOT_FOUND",
)
if not line.quantity.package_quantity:
errors.add_error(
field=f"line[{line_number}].quantity.package_quantity",
message="La cantidad de paquetes es obligatoria cuando se proporciona el paquete.",
solution=["Proporciona una cantidad de paquetes valida."],
code="PACKAGE_QUANTITY_REQUIRED",
)
if line.quantity.package_quantity <= 0:
errors.add_error(
field=f"line[{line_number}].quantity.package_quantity",
message="La cantidad de paquetes debe ser mayor a cero.",
solution=["Proporciona una cantidad de paquetes valida."],
code="PACKAGE_QUANTITY_MUST_BE_GREATER_THAN_ZERO",
)
else:
if line.quantity.package_quantity and line.quantity.package_quantity > 0:
errors.add_error(
field=f"line[{line_number}].quantity.package_id",
message="El paquete es obligatorio cuando se proporciona la cantidad de paquetes.",
solution=["Proporciona un paquete valido."],
code="PACKAGE_ID_REQUIRED",
)
country = None
fraction_type = None
sector = None
if fraction:
fraction = line.customs.fraction if line.customs.fraction else fraction
country = line.customs.origin_country
if line_item:
country = (
line_item.customs.fraction if line_item.customs.origin_country else country
)
fraction_type = line.customs.fraction_type.upper()
if line_item:
fraction_type = (
line_item.customs.fraction_type
if line_item.customs.fraction_type
else fraction_type
)
sector = line.customs.sector
if line_item:
sector = line_item.customs.sector if line_item.customs.sector else sector
country_m3 = db.query(Country.m3_key).filter(Country.m3_key == country).scalar()
if not country_m3:
country_m3 = (
db.query(Country.m3_key).filter(Country.ame_key == country).scalar()
)
country = country_m3
if not country:
errors.add_error(
field=f"line[{line_number}].customs.origin_country",
message="El país de origen especificado no existe.",
solution=["Proporciona un país de origen valido."],
code="ORIGIN_COUNTRY_NOT_FOUND",
)
else:
if fraction_type.strip().upper() not in vars(FractionType).values():
valid_types = [
v
for k, v in vars(FractionType).items()
if not k.startswith("_") and isinstance(v, str)
]
errors.add_error(
field=f"line[{line_number}].customs.fraction_type",
message="El tipo de fracción especificado no es válido.",
solution=[
f"Proporciona un tipo de fracción válido. Valores permitidos: {', '.join(valid_types)}"
],
code="FRACTION_TYPE_INVALID",
value=fraction_type,
)
else:
if fraction_type.strip().upper() == FractionType.PROSEC and not sector:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message="El sector es obligatorio cuando el tipo de fracción es 'PROSEC'.",
solution=["Proporciona un sector valido."],
code="SECTOR_REQUIRED_FOR_PROSEC",
)
elif fraction_type.strip().upper() != FractionType.PROSEC and sector:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message="El sector solo es aplicable cuando el tipo de fracción es 'PROSEC'.",
solution=[
"Elimina el sector o cambia el tipo de fracción a 'PROSEC'."
],
code="SECTOR_ONLY_FOR_PROSEC",
)
elif fraction_type.strip().upper() == FractionType.PROSEC and sector:
sector_db: Sector = (
db.query(Sector).filter(Sector.key == sector).scalar()
)
if sector_db:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message="El sector especificado no existe.",
solution=["Proporciona un sector valido."],
code="SECTOR_NOT_FOUND",
)
else:
if not sector_db.authorized:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message="El sector especificado no está autorizado.",
solution=["Proporciona un sector autorizado."],
code="SECTOR_NOT_AUTHORIZED",
)
company_db = db.query(Company).filter(Company.id == company_id).first()
if not company_db.prosec:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message=" La empresa no cuenta con autorización PROSEC.",
solution=[
"Accese a los datos de la empresa y selecione la opción Pertenece al Programa de Promoción Sectorial y capture el número de permiso PROSEC."
],
code="COMPANY_NOT_AUTHORIZED_FOR_PROSEC",
)
if fraction:
search_fraction_preference(
db=db,
country=country,
fraccion=fraction,
fraction_type=fraction_type,
sector=sector,
invoice_date=fecha_factura,
errors=errors,
)
if line.customs.american_fraction:
american_fraction_exists = db.query(
exists().where(
LineCustom.american_fraction == line.customs.american_fraction
)
).scalar()
if not american_fraction_exists:
errors.add_error(
field=f"line[{line_number}].customs.american_fraction",
message="La fracción americana especificada no existe.",
solution=["Proporciona una fracción americana valida."],
code="AMERICAN_FRACTION_NOT_FOUND",
)
if item_header and item_header.order:
if len(item_header.order) > 20:
errors.add_error(
field=f"item.order",
message="El campo orden no debe exceder los 20 caracteres.",
solution=["Proporciona un valor valido para el campo orden."],
code="ORDER_EXCEEDS_MAX_LENGTH",
)
unit_of_measure = line.unit_of_measure or (
class_.unit_of_measure if class_ else None
)
if unit_of_measure == "PZA" and line.quantity.quantity % 1 != 0:
errors.add_error(
field=f"line[{line_number}].quantity.quantity",
message="La cantidad debe ser un número entero cuando la unidad de medida es PZA.",
solution=["Proporciona una cantidad entera."],
code="QUANTITY_MUST_BE_INTEGER_FOR_PIECES",
)
if line.valuation_method:
valuation_method_exists = db.query(
exists().where(ValuationMethod.key == line.valuation_method)
).scalar()
if not valuation_method_exists:
errors.add_error(
field=f"line[{line_number}].valuation_method",
message="El método de valoración especificado no existe.",
solution=["Proporciona un método de valoración valido."],
code="VALUATION_METHOD_NOT_FOUND",
)
if line.part_number_id:
part_exists = db.query(exists().where(Part.id == line.part_number_id)).scalar()
if not part_exists:
errors.add_error(
field=f"line[{line_number}].part_number_id",
message="El número de parte especificado no existe.",
solution=["Proporciona un número de parte valido."],
code="PART_NUMBER_NOT_FOUND",
)
return False
return True
return True

View File

@@ -1,135 +1,354 @@
"""
Validaciones para creación de items vía API.
"""
from decimal import Decimal
from sqlalchemy import func, exists
from sqlalchemy.orm import Session
from ....common.common_validators import count_items
from core.exceptions import ErrorCollector
from api.v1.modules.a76.items.line_items.schemas import LineItemCreate
from .common import validate_string_not_empty, validate_positive_value
from ....line_items.models import LineItem
from ....line_financials.models import LineFinancial
from ....line_financials.schemas import LineFinancialCreate
from ....line_quantities.models import LineQuantity
from ....line_quantities.schemas import LineQuantityCreate
from ....line_customs.models import LineCustom
from ....line_customs.schemas import LineCustomCreate
from ....line_descriptions.models import LineDescription
from ....line_descriptions.schemas import LineDescriptionCreate
from ....line_references.models import LineReference
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from ....models import Item
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from .common import validate_common
def validate_create(
db: Session,
line: LineItemCreate,
line, # LineItemCreate schema (Pydantic)
invoice_id: int, # Passed from service
tenant_id: int,
company_id: int,
errors: ErrorCollector,
) -> None:
line_number: int,
):
"""
Validaciones para crear LineItems vía API (actualmente en uso).
Validates and calculates fields for a new line item before DB creation.
Works with Pydantic schemas, modifying them in-place.
Args:
db: Sesión de base de datos
line: Datos del line item
tenant_id: ID del tenant
company_id: ID de la compañía
errors: Colector de errores
line: LineItemCreate schema with nested data (financial, quantity, customs, etc.)
invoice_id: ID of the invoice this line belongs to
fa_data: FaLineItemCreateDTO or None (None for INV system)
"""
# 1. Validar line_number
if not line.line_number:
errors.add_error(
field="line_number",
message="El número de línea es obligatorio",
solution="Proporciona un número de línea válido",
code="REQUIRED",
)
# 2. Validar class_id
if not line.class_id:
errors.add_error(
field="class_id",
message="Clase (ID) es obligatorio",
solution="Selecciona una clasificación válida del catálogo",
code="REQUIRED",
)
# 4. Validar unit_of_measure
if not line.unit_of_measure:
errors.add_error(
field="unit_of_measure",
message="U.M. es obligatorio",
solution="Proporciona una unidad de medida válida",
code="REQUIRED",
)
# 5. Validar quantity.quantity
if not line.quantity:
errors.add_error(
field="quantity",
message="Quantity es obligatorio",
solution="Proporciona una cantidad válida",
code="REQUIRED",
)
else:
# Validar con nombre amigable
if line.quantity.quantity is None or line.quantity.quantity <= 0:
errors.add_error(
field="quantity.quantity",
message="Quantity debe ser mayor a cero",
solution="Proporciona una cantidad válida",
code=(
"INVALID_VALUE"
if line.quantity.quantity is not None
else "REQUIRED"
),
)
# 6. Validar financial.unit_cost
# Inicializar nested schemas si no existen (para poder validar y modificar)
if not line.financial:
errors.add_error(
field="financial",
message="Unit Cost es obligatorio",
solution="Proporciona el costo unitario del item",
code="REQUIRED",
)
else:
has_cost = (
line.financial.unit_cost_usd
or line.financial.unit_cost_mxn
or line.financial.unit_cost_capture
)
if not has_cost:
errors.add_error(
field="financial.unit_cost",
message="Unit Cost es obligatorio",
solution="Proporciona al menos un costo unitario (USD, MXN o captura)",
code="REQUIRED",
line.financial = LineFinancialCreate()
if not line.quantity:
line.quantity = LineQuantityCreate()
if not line.customs:
line.customs = LineCustomCreate()
if not line.description:
line.description = LineDescriptionCreate()
# Access fa_data safely
fa_data = getattr(line, "fa_data", None)
# Required field validations
if not line.class_id:
errors.add_required_error(field=f"line[{line_number}].class_id")
if not line.quantity.quantity or line.quantity.quantity <= 0:
errors.add_required_error(field=f"line[{line_number}].quantity.quantity")
# TODO: Añadir validacion SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf <-- de la tabla de preferencias de el sistema
# if SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf == False:
if fa_data and not fa_data.is_subitem:
if (
not line.financial.unit_cost_capture
or line.financial.unit_cost_capture <= 0
):
errors.add_required_error(
field=f"line[{line_number}].financial.unit_cost_capture"
)
# 7. Validar description.description_spanish
if line.description:
if not line.quantity.net_weight or line.quantity.net_weight <= 0:
errors.add_required_error(field=f"line[{line_number}].quantity.net_weight")
if not line.customs.origin_country:
errors.add_required_error(field=f"line[{line_number}].customs.origin_country")
if not line.customs.fraction_type:
errors.add_required_error(field=f"line[{line_number}].customs.fraction_type")
# FA-specific validations
if fa_data:
if (
not line.description.description_spanish
or not line.description.description_spanish.strip()
fa_data.is_subitem and fa_data.contains_subitems
) and not fa_data.subitem_number:
errors.add_required_error(
field=f"line[{line_number}].fa_data.subitem_number"
)
# Validar que si es un subitem, existe un item principal correspondiente
if (
fa_data.is_subitem
and fa_data.subitem_number
and fa_data.subitem_number != 0
):
principal_item_exists = db.query(
exists().where(
(LineItem.id == FaLineItem.id)
& (LineItem.item_id == Item.id)
& (Item.invoice_id == invoice_id)
& (LineItem.line_number == line_number)
& (FaLineItem.is_subitem == False)
& (FaLineItem.contains_subitems == True)
& (LineItem.tenant_id == tenant_id)
& (LineItem.company_id == company_id)
)
).scalar()
if not principal_item_exists:
errors.add_error(
field=f"line[{line_number}]",
message=f"No existe un item principal registrado para esta linea {line_number} con subitem {fa_data.subitem_number}",
solution=[
"Registrar el item principal correspondiente a esta linea antes de registrar subitems."
],
code="SUBITEM_WITHOUT_PRINCIPAL_ITEM",
)
if fa_data.is_subitem and (
fa_data.subitem_number == 0 or not fa_data.subitem_number
):
errors.add_error(
field="description.description_spanish",
message="Description in Spanish es obligatorio",
solution="Proporciona una descripción del item en español",
code="REQUIRED",
field=f"line[{line_number}]",
message=f"El número de subitem no puede ser 0 si la línea es un subitem.",
solution=["Asignar un número de subitem mayor a 0 para esta línea."],
code="SUBITEM_NUMBER_INVALID",
)
else:
errors.add_error(
field="description.description_spanish",
message="Description in Spanish es obligatorio",
solution="Proporciona una descripción del item en español",
code="REQUIRED",
validate_common(db, line, invoice_id, tenant_id, company_id, errors, line_number)
if not errors.has_errors():
# Obtener la factura para acceder a tipo de cambio, moneda y peso
invoice: InvoiceHeader = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
# 8. Validar customs.origin_country
if not line.customs or not line.customs.origin_country:
errors.add_error(
field="customs.origin_country",
message="País de Origen es obligatorio",
solution="Selecciona el país de origen del item",
code="REQUIRED"
if not invoice or not invoice.financials or not invoice.logistics:
errors.add_error(
field=f"line[{line_number}]",
message="No se pudo obtener información de la factura",
solution=[
"Verificar que la factura existe y tiene datos financieros y logísticos"
],
code="INVOICE_DATA_MISSING",
)
return
# Obtener la clase para valores por defecto
class_info: Class = (
db.query(Class)
.filter(
Class.id == line.class_id,
Class.tenant_id == tenant_id,
Class.company_id == company_id,
)
.first()
)
# 9. Validar customs.fraction_type
if not line.customs or not line.customs.fraction_type:
errors.add_error(
field="customs.fraction_type",
message="Tipo de Tarifa es obligatorio",
solution="Selecciona el tipo de tarifa (GENERAL, PROSEC, ALADI, TLCS)",
code="REQUIRED"
)
# ==========================================
# ASIGNAR TIPO DE CAMBIO
# ==========================================
exchange_rate = invoice.financials.exchange_rate or Decimal("1.0")
# ==========================================
# ASIGNAR UNIDAD DE MEDIDA
# ==========================================
# Si no se proporcionó unidad de medida, usar la de la clase
if not line.unit_of_measure and class_info:
line.unit_of_measure = class_info.unit_of_measure
# ==========================================
# ASIGNAR TIPOS DE MONEDA Y CALCULAR COSTOS
# ==========================================
currency_type = invoice.financials.currency_type
unit_cost_capture = line.financial.unit_cost_capture or Decimal("0")
# Calcular costos según tipo de moneda
if currency_type == "USD" or currency_type == "ME": # Moneda Extranjera (ME)
line.financial.unit_cost_capture = unit_cost_capture
line.financial.unit_cost_usd = unit_cost_capture
line.financial.unit_cost_mxn = unit_cost_capture * exchange_rate
elif currency_type == "MXN" or currency_type == "MN": # Moneda Nacional (MN)
line.financial.unit_cost_capture = unit_cost_capture
line.financial.unit_cost_usd = (
unit_cost_capture / exchange_rate if exchange_rate else Decimal("0")
)
line.financial.unit_cost_mxn = unit_cost_capture
# Si es otro tipo de moneda, dejamos el costo como está
# ==========================================
# VALIDAR Y CONVERTIR PESOS NETOS
# ==========================================
invoice_weight_type = invoice.logistics.weight_type # 'kgs' o 'lbs'
quantity = line.quantity.quantity or Decimal("0")
net_weight_input = line.quantity.net_weight or Decimal("0")
# Determinar si la unidad de medida es de peso
unit_is_kgs = line.unit_of_measure and line.unit_of_measure.upper() == "KGS"
unit_is_lbs = line.unit_of_measure and line.unit_of_measure.upper() == "LB"
# Calcular peso neto en kilogramos (estándar interno)
if unit_is_kgs:
if invoice_weight_type == "kgs":
line.quantity.net_weight = quantity
else: # invoice en libras
line.quantity.net_weight = quantity * Decimal("2.204624")
elif unit_is_lbs:
if invoice_weight_type == "kgs":
line.quantity.net_weight = quantity / Decimal("2.204624")
else: # invoice en libras
line.quantity.net_weight = quantity
else:
# Otra unidad de medida - usar peso capturado y convertir si es necesario
if invoice_weight_type == "kgs":
# El peso capturado está en kilos
line.quantity.net_weight = net_weight_input
else:
# El peso capturado está en libras, convertir a kilos
line.quantity.net_weight = net_weight_input / Decimal("2.204624")
# ==========================================
# CALCULAR PESO BRUTO
# ==========================================
gross_weight_input = line.quantity.gross_weight
package_quantity = line.quantity.package_quantity or 0
package_weight_unit = Decimal("0")
# Obtener peso unitario del bulto si existe
if line.quantity.package_key:
package: Package = (
db.query(Package)
.filter(
Package.key == line.quantity.package_key,
Package.tenant_id == tenant_id,
Package.company_id == company_id,
)
.first()
)
if package and package.weight_unit:
package_weight_unit = package.weight_unit
# Si no se proporcionó peso bruto, calcularlo
if not gross_weight_input or gross_weight_input == 0:
if invoice_weight_type == "kgs":
line.quantity.gross_weight = line.quantity.net_weight + (
package_weight_unit * package_quantity
)
else: # libras
line.quantity.gross_weight = line.quantity.net_weight + (
(package_weight_unit * Decimal("2.204624")) * package_quantity
)
else:
# Convertir peso bruto capturado según tipo de factura
if invoice_weight_type == "kgs":
line.quantity.gross_weight = gross_weight_input
else: # libras
line.quantity.gross_weight = gross_weight_input / Decimal("2.204624")
# ==========================================
# VALIDAR PESO BRUTO < PESO NETO
# ==========================================
if line.quantity.gross_weight < line.quantity.net_weight:
line.quantity.gross_weight = line.quantity.net_weight + (
package_weight_unit * package_quantity
)
# ==========================================
# ASIGNAR DESCRIPCIÓN DE BULTOS
# ==========================================
if package_quantity and package_quantity > 0 and line.quantity.package_key:
package: Package = (
db.query(Package)
.filter(
Package.key == line.quantity.package_key,
Package.tenant_id == tenant_id,
Package.company_id == company_id,
)
.first()
)
if package:
line.quantity.package_description = package.description_es
else:
line.quantity.package_quantity = 0
line.quantity.package_key = None
line.quantity.package_description = None
# ==========================================
# ASIGNAR FRACCIÓN AMERICANA POR DEFECTO
# ==========================================
if not line.customs.american_fraction and class_info and class_info.us_fraction:
line.customs.american_fraction = class_info.us_fraction
# Buscar el advalorem de la fracción americana
if line.customs.american_fraction:
us_fraction: USTariffFraction = (
db.query(USTariffFraction)
.filter(
USTariffFraction.code == line.customs.american_fraction,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.first()
)
if us_fraction:
# Si el tipo es 'ME' (Moneda Extranjera), usar costo fijo
# De lo contrario, usar ad valorem
if us_fraction.type_code == "foreign":
line.customs.advalorem_american = us_fraction.fixed_cost
else:
line.customs.advalorem_american = us_fraction.ad_valorem
# ==========================================
# ASIGNAR DESCRIPCIONES POR DEFECTO
# ==========================================
if not line.description.description_spanish and class_info:
line.description.description_spanish = class_info.description_es
if not line.description.description_english and class_info:
line.description.description_english = class_info.description_en
# ==========================================
# NORMALIZAR CAMPOS DE TEXTO
# ==========================================
# Convertir a mayúsculas campos que lo requieran
if line.description.brand:
line.description.brand = line.description.brand.upper().strip()
if line.description.model:
line.description.model = line.description.model.upper().strip()
# ==========================================
# ASIGNAR VALORES POR DEFECTO DE IMPUESTOS
# ==========================================
# Si no se especificó pago de impuesto, tomar de preferencias del sistema (SisImp)
# TODO: Implementar lectura de preferencias del sistema
# Por ahora dejamos None si no se proporcionó
# Si no se especificó forma de pago, tomar de preferencias del sistema
# TODO: Implementar lectura de preferencias del sistema
# Si no se especificó método de valoración, tomar de preferencias del sistema
# TODO: Implementar lectura de preferencias del sistema

View File

@@ -1,246 +1,202 @@
"""
Validaciones para actualización de items vía API.
"""
from decimal import Decimal
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.common.common_validators import invoice_exists
from core.exceptions import ErrorCollector
from api.v1.modules.a76.items.line_items.schemas import LineItemUpdate
from .common import validate_string_not_empty, validate_positive_value
from ....line_items.models import LineItem
from ....models import Item
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from .common import validate_common
def validate_update(
db: Session,
line: LineItemUpdate,
line: LineItem,
existing_line: LineItem,
invoice_id: int, # Passed from service
tenant_id: int,
company_id: int,
errors: ErrorCollector,
invoice_id: int = None,
) -> None:
line_number: int,
):
"""
Validaciones para actualizar LineItems vía API.
Incluye todas las validaciones de negocio de Clarion.
Args:
db: Sesión de base de datos
line: Datos del line item a actualizar
tenant_id: ID del tenant
company_id: ID de la compañía
errors: Colector de errores
invoice_id: ID de la factura asociada (opcional, para validar subpartidas)
Validar y procesar actualización parcial de línea de importación temporal.
Si un campo no se proporciona, se mantiene el valor existente.
"""
# 1. Validar line_number si se proporciona
if line.line_number is not None and not line.line_number:
errors.add_error(
field="line_number",
message="El número de línea no puede estar vacío",
solution="Proporciona un número de línea válido",
code="REQUIRED",
validate_common(db, line, invoice_id, tenant_id, company_id, errors, line_number)
if not errors.has_errors():
# Obtener la factura para acceder a tipo de cambio, moneda y peso
invoice: InvoiceHeader = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id
)
.first()
)
# 2. Validar class_id si se proporciona
if line.class_id is not None and not line.class_id:
errors.add_error(
field="class_id",
message="Clase (ID) no puede estar vacío",
solution="Selecciona una clasificación válida del catálogo",
code="REQUIRED",
)
if not invoice or not invoice.financials or not invoice.logistics:
errors.add_error(
field=f"line[{line_number}]",
message="No se pudo obtener información de la factura",
solution=[
"Verificar que la factura existe y tiene datos financieros y logísticos"
],
code="INVOICE_DATA_MISSING",
)
return
# 4. Validar unit_of_measure si se proporciona
if line.unit_of_measure is not None and not line.unit_of_measure:
errors.add_error(
field="unit_of_measure",
message="U.M. no puede estar vacío",
solution="Proporciona una unidad de medida válida",
code="REQUIRED",
)
# ==========================================
# ACTUALIZACIÓN PARCIAL DE CAMPOS
# Si no se proporciona, mantener valor existente
# ==========================================
# 5. Validar cantidad si se proporciona
if line.quantity:
# Si se proporciona el objeto quantity, validar que quantity.quantity sea válido
if line.quantity.quantity is not None:
if line.quantity.quantity <= 0:
errors.add_error(
field="quantity.quantity",
message="Quantity debe ser mayor a cero",
solution="Proporciona una cantidad válida",
code="INVALID_VALUE",
)
# Tipo de cambio de la factura
exchange_rate = invoice.financials.exchange_rate or Decimal("1.0")
# Unidad de medida
if not line.unit_of_measure:
line.unit_of_measure = existing_line.unit_of_measure
# Costo unitario
if line.financial.unit_cost_capture is None:
line.financial.unit_cost_capture = existing_line.financial.unit_cost_capture
# Convertir peso neto si se proporcionó
invoice_weight_type = invoice.logistics.weight_type
if line.quantity.net_weight is not None:
# Se proporcionó nuevo peso neto, convertir según tipo
net_weight_input = line.quantity.net_weight
if invoice_weight_type == "kgs":
line.quantity.net_weight = net_weight_input
else: # libras, convertir a kilos
line.quantity.net_weight = net_weight_input / Decimal("2.204624")
else:
# Si se proporciona quantity pero quantity.quantity es None, es requerido
errors.add_error(
field="quantity.quantity",
message="Quantity es obligatorio",
solution="Proporciona una cantidad mayor a 0",
code="REQUIRED",
)
# Mantener peso existente
line.quantity.net_weight = existing_line.quantity.net_weight
# 6. Validar peso neto si se proporciona
if line.quantity and line.quantity.net_weight is not None:
if line.quantity.net_weight <= 0:
errors.add_error(
field="quantity.net_weight",
message="Net Weight debe ser mayor a cero",
solution="Proporciona un peso neto válido",
code="INVALID_VALUE",
)
# Convertir peso bruto si se proporcionó
if line.quantity.gross_weight is not None:
gross_weight_input = line.quantity.gross_weight
# 7. Validar costo unitario si se proporciona financial (excepto subpartidas)
if line.financial:
is_subitem = line.fa_data and line.fa_data.is_subitem if line.fa_data else False
if invoice_weight_type == "kgs":
line.quantity.gross_weight = gross_weight_input
else: # libras, convertir a kilos
line.quantity.gross_weight = gross_weight_input / Decimal("2.204624")
else:
# Mantener peso existente
line.quantity.gross_weight = existing_line.quantity.gross_weight
if not is_subitem:
has_cost = (
line.financial.unit_cost_usd
or line.financial.unit_cost_mxn
or line.financial.unit_cost_capture
)
if not has_cost:
errors.add_error(
field="financial.unit_cost",
message="Unit Cost es obligatorio",
solution="Proporciona al menos un costo unitario (USD, MXN o captura)",
code="REQUIRED",
)
# Validar que sean positivos
if line.financial.unit_cost_usd is not None:
if line.financial.unit_cost_usd <= 0:
errors.add_error(
field="financial.unit_cost_usd",
message="Unit Cost (USD) debe ser mayor a cero",
solution="Proporciona un costo unitario válido",
code="INVALID_VALUE",
)
if line.financial.unit_cost_mxn is not None:
if line.financial.unit_cost_mxn <= 0:
errors.add_error(
field="financial.unit_cost_mxn",
message="Unit Cost (MXN) debe ser mayor a cero",
solution="Proporciona un costo unitario válido",
code="INVALID_VALUE",
)
if line.financial.unit_cost_capture is not None:
if line.financial.unit_cost_capture <= 0:
errors.add_error(
field="financial.unit_cost_capture",
message="Unit Cost (Captura) debe ser mayor a cero",
solution="Proporciona un costo unitario válido",
code="INVALID_VALUE",
)
# Cantidad de bultos
if line.quantity.package_quantity is None:
line.quantity.package_quantity = existing_line.quantity.package_quantity
# 8. Validar datos aduanales si se proporcionan
if line.customs:
# Validar país de origen (OBLIGATORIO)
if line.customs.origin_country is not None:
if not line.customs.origin_country:
errors.add_error(
field="customs.origin_country",
message="País de Origen es obligatorio",
solution="Selecciona el país de origen del item",
code="REQUIRED"
)
# Validar tipo de tarifa (OBLIGATORIO)
if line.customs.fraction_type is not None:
if not line.customs.fraction_type:
errors.add_error(
field="customs.fraction_type",
message="Tipo de Tarifa es obligatorio",
solution="Selecciona el tipo de tarifa (GENERAL, PROSEC, ALADI, TLCS)",
code="REQUIRED"
)
# Validar preferencia arancelaria
if line.customs.preference is not None and not line.customs.preference:
errors.add_error(
field="customs.preference",
message="La preferencia arancelaria no puede estar vacía",
solution="Selecciona la preferencia arancelaria",
code="REQUIRED",
)
# Clave de bultos
if not line.quantity.package_key:
line.quantity.package_key = existing_line.quantity.package_key
# Validar formato de pago de impuestos
if line.customs.tax_paid:
val_tax = line.customs.tax_paid.upper()
if val_tax not in ["SI", "NO", "S", "N"]:
errors.add_error(
field="customs.tax_paid",
message="El valor de pago de impuesto debe ser SI/NO o S/N",
solution="Proporciona un valor válido: SI, NO, S o N",
code="INVALID_VALUE",
)
# País de origen
if not line.customs.origin_country:
line.customs.origin_country = existing_line.customs.origin_country
# Validar forma de pago si existe
if line.customs.payment_form:
from api.v1.modules.a76.general_catalogs.forms_of_payment.models import (
PaymentForm,
)
# Fracción arancelaria
if not line.customs.fraction:
line.customs.fraction = existing_line.customs.fraction
payment = (
db.query(PaymentForm)
# Tipo de fracción
if not line.customs.fraction_type:
line.customs.fraction_type = existing_line.customs.fraction_type
# Sector
if not line.customs.sector:
line.customs.sector = existing_line.customs.sector
# Fracción americana y su advalorem
if line.customs.american_fraction:
# Se proporcionó nueva fracción americana, buscar su advalorem
us_fraction: USTariffFraction = (
db.query(USTariffFraction)
.filter(
PaymentForm.code == line.customs.payment_form,
PaymentForm.tenant_id == tenant_id,
USTariffFraction.code == line.customs.american_fraction,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.first()
)
if not payment:
errors.add_error(
field="customs.payment_form",
message=f"La forma de pago '{line.customs.payment_form}' no es válida",
solution="Selecciona una forma de pago válida del catálogo",
code="INVALID_VALUE",
)
# 9. Validar descripción en español (OBLIGATORIA)
if line.description and hasattr(line.description, 'description_spanish'):
if line.description.description_spanish is not None:
if not line.description.description_spanish.strip():
errors.add_error(
field="description.description_spanish",
message="Descripción en Español es obligatoria",
solution="Proporciona una descripción del item en español",
code="REQUIRED"
)
# 10. Validar subpartidas si se actualizan
if line.fa_data and line.fa_data.is_subitem:
# Es subpartida, debe tener partida principal
if not line.fa_data.main_line_id:
errors.add_error(
field="fa_data.main_line_id",
message="La subpartida debe tener asignada una partida principal",
solution="Selecciona la partida principal de esta subpartida",
code="REQUIRED",
)
elif invoice_id:
# Validar que la partida principal exista en la misma factura
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.items.models import Item
if us_fraction:
if us_fraction.type_code == "ME":
line.customs.advalorem_american = us_fraction.fixed_cost
else:
line.customs.advalorem_american = us_fraction.ad_valorem
else:
# Mantener fracción americana existente
line.customs.american_fraction = existing_line.customs.american_fraction
line.customs.advalorem_american = existing_line.customs.advalorem_american
parent = (
db.query(LineItem)
.join(LineItem.item)
.filter(
LineItem.line_number == line.fa_data.main_line_id,
Item.invoice_id == invoice_id,
LineItem.company_id == company_id,
)
.first()
# Orden de compra
if not line.reference.purchase_order:
line.reference.purchase_order = existing_line.reference.purchase_order
# Descripciones
if not line.description.description_spanish:
line.description.description_spanish = (
existing_line.description.description_spanish
)
if not parent:
errors.add_error(
field="fa_data.main_line_id",
message=f"La partida principal {line.fa_data.main_line_id} no existe en esta factura",
solution="Verifica el número de la partida principal",
code="NOT_FOUND",
)
elif parent.fa_data and parent.fa_data.is_subitem:
errors.add_error(
field="fa_data.main_line_id",
message="La partida principal no puede ser otra subpartida",
solution="Selecciona una partida normal como principal",
code="INVALID_VALUE",
)
if not line.description.description_english:
line.description.description_english = (
existing_line.description.description_english
)
if not line.description.extra_description:
line.description.extra_description = (
existing_line.description.extra_description
)
# Marca y modelo
if line.description.brand:
line.description.brand = line.description.brand.upper().strip()
else:
line.description.brand = existing_line.description.brand
if line.description.model:
line.description.model = line.description.model.upper().strip()
else:
line.description.model = existing_line.description.model
# Subpartidas (si aplica)
# TODO: Implementar lógica de subpartidas si Loc:LevantarSubpartidas = 'S'
# Número de parte
if not line.part_number:
line.part_number = existing_line.part_number
# Pago de impuesto
if line.tax_payment is None:
line.tax_payment = existing_line.tax_payment
# Forma de pago
if not line.payment_method:
line.payment_method = existing_line.payment_method
# Método de valoración
if not line.valuation_method:
if existing_line.valuation_method:
line.valuation_method = existing_line.valuation_method
# else: TODO: Tomar de SisImp:MetValor (preferencias del sistema)
# Número de entrada
if not line.description.entry_number:
line.description.entry_number = existing_line.description.entry_number
# Lote
if not line.description.lot:
line.description.lot = existing_line.description.lot

View File

@@ -6,6 +6,13 @@ from core.database import Base
if TYPE_CHECKING:
from ..line_items.models import LineItem
class FractionType:
"""Enumeration for fraction types"""
GENERAL = "GENERAL"
PROSEC = "PROSEC"
ALADI = "ALADI"
TLCS = "TLCS"
class LineCustom(Base):
"""
@@ -22,7 +29,7 @@ class LineCustom(Base):
# Tariff/Customs Classifications
fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCION / FRACCIONIMPO / FRACCIONEXPO
fraction_type: Mapped[Optional[str]] = mapped_column(String(7)) # TIPOFRACCION / TIPOFRACCIONIMPO / TIPOFRACCIONEXPO
fraction_type: Mapped[Optional[FractionType]] = mapped_column(String(7)) # TIPOFRACCION / TIPOFRACCIONIMPO / TIPOFRACCIONEXPO
american_fraction: Mapped[Optional[str]] = mapped_column(String(16)) # FRACCIONAMERICANA
alternate_fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCIONALTERNA
reference_fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCIONREFERENCIA

View File

@@ -46,14 +46,21 @@ class LineItemBase(BaseModel):
model_config = ConfigDict(populate_by_name=True)
item_id: int = Field(..., description="ID of the parent item")
line_number: int = Field(..., description="Line number")
# Part identification
part_number_id: Optional[int] = Field(
None, description="Part number", alias="part_number", serialization_alias="part_number_id"
None,
description="Part number",
alias="part_number",
serialization_alias="part_number_id",
)
component_part_number_id: Optional[int] = Field(
None, description="Component part number", alias="component_part_number", serialization_alias="component_part_number_id"
None,
description="Component part number",
alias="component_part_number",
serialization_alias="component_part_number_id",
)
class_id: Optional[int] = Field(None, description="Class code")
@@ -181,6 +188,12 @@ class LineItemBase(BaseModel):
class LineItemCreate(LineItemBase):
"""Schema for creating line item with all nested data"""
# Override base fields - estos se asignan automáticamente en el service
item_id: Optional[int] = Field(
None, description="ID of the parent item (auto-assigned)"
)
line_number: Optional[int] = Field(None, description="Line number (auto-assigned)")
financial: Optional[LineFinancialCreate] = Field(
None, description="Financial data for this line"
)
@@ -204,6 +217,8 @@ class LineItemCreate(LineItemBase):
class LineItemUpdate(LineItemBase):
"""Schema for updating line item with all nested data"""
# Override base fields - todos opcionales en updates
item_id: Optional[int] = Field(None, description="ID of the parent item")
line_number: Optional[int] = Field(None, description="Line number")
financial: Optional[LineFinancialUpdate] = Field(
None, description="Financial data for this line"

View File

@@ -16,6 +16,10 @@ from sqlalchemy import and_, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
from api.v1.modules.a76.invoices.common.common_validators import (
invoice_exists_by_id,
invoice_updated,
)
from core.exceptions import ErrorCollector
from .imports.temporary.validators.create import validate_create
from .imports.temporary.validators.update import validate_update
@@ -32,8 +36,6 @@ from .line_references.models import LineReference
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from .models import Item
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.company.models import Company
logger = logging.getLogger(__name__)
@@ -43,6 +45,101 @@ class ItemService:
Service for managing Items and related entities with tenant/company isolation
"""
@staticmethod
def _get_next_line_number(db: Session, invoice_id: int) -> int:
"""Calculate the next line_number for a given invoice based on database."""
from sqlalchemy import func
max_line = (
db.query(func.max(LineItem.line_number))
.join(Item, LineItem.item_id == Item.id)
.filter(Item.invoice_id == invoice_id)
.scalar()
)
return 1 if max_line is None else max_line + 1
@staticmethod
def _renumber_all_invoice_lines(db: Session, invoice_id: int) -> None:
"""Renumber all line_items for a given invoice to be consecutive (1, 2, 3, ...)."""
items = db.query(Item).filter(Item.invoice_id == invoice_id).all()
all_lines = [line for item in items for line in item.lines]
all_lines.sort(key=lambda x: x.line_number if x.line_number else 0)
for idx, line in enumerate(all_lines, start=1):
line.line_number = idx
@staticmethod
def _lock_invoice(
db: Session,
invoice_id: int,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
) -> Optional[InvoiceHeader]:
"""Lock invoice to prevent concurrent modifications. Returns locked invoice or adds error."""
try:
invoice = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.with_for_update()
.first()
)
if not invoice:
errors.add_error(
field="invoice_id",
message="La factura no existe o no se pudo bloquear",
code="LOCK_FAILED",
value=str(invoice_id),
)
return invoice
except Exception as e:
logger.error(f"Error locking invoice {invoice_id}: {e}")
errors.add_error(
field="invoice_id",
message="Error al intentar bloquear la factura",
code="LOCK_ERROR",
)
return None
@staticmethod
def _create_line_nested_data(
db: Session, line: LineItem, line_data, tenant_id: int, company_id: int
) -> None:
"""Create all nested data for a line item."""
nested_models = [
(line_data.financial, LineFinancial),
(line_data.quantity, LineQuantity),
(line_data.customs, LineCustom),
(line_data.description, LineDescription),
(line_data.reference, LineReference),
]
for data, model_class in nested_models:
if data:
nested_dict = (
data.model_dump(exclude_unset=True)
if hasattr(data, "model_dump")
else data.model_dump()
)
nested_dict["item_line_id"] = line.id
db.add(model_class(**nested_dict))
# 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"}
)
fa_dict.update(
{"id": line.id, "tenant_id": tenant_id, "company_id": company_id}
)
db.add(FaLineItem(**fa_dict))
@staticmethod
def get_by_id(
db: Session, item_id: int, tenant_id: int, company_id: int
@@ -108,7 +205,7 @@ class ItemService:
search_term = f"%{filters['search']}%"
query = query.filter(
or_(
Item.invoice_number.ilike(search_term),
Item.invoice_id.ilike(search_term),
Item.reference_number.ilike(search_term),
Item.order.ilike(search_term),
Item.guide_number.ilike(search_term),
@@ -163,78 +260,62 @@ class ItemService:
errors = ErrorCollector()
# Validar que la factura exista y no esté actualizada (si viene invoice_id)
invoice = None
if item_data.invoice_id:
invoice = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == item_data.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
if not item_data.invoice_id:
errors.add_required_error(field="invoice_id")
errors.raise_if_errors("Error al crear el item")
if not invoice:
errors.add_error(
field="invoice_id",
message="La factura especificada no existe",
code="NOT_FOUND",
value=str(item_data.invoice_id),
)
if not invoice_exists_by_id(
db, item_data.invoice_id, tenant_id, company_id, errors
):
errors.raise_if_errors("Error al crear el item")
if not invoice_updated(db, item_data.invoice_id, tenant_id, company_id, errors):
errors.raise_if_errors("Error al crear el item")
# Lock invoice and pre-calculate line_numbers
if not ItemService._lock_invoice(
db, item_data.invoice_id, tenant_id, company_id, errors
):
errors.raise_if_errors("Error al crear el item")
line_numbers = []
if item_data.lines:
starting_line = ItemService._get_next_line_number(db, item_data.invoice_id)
line_numbers = [starting_line + i for i in range(len(item_data.lines))]
# Validar cada line item que se va a crear
if item_data.lines:
for idx, line_data in enumerate(item_data.lines):
# Convertir a LineItemCreate para validar
line_create = LineItemCreate(**line_data.model_dump())
line_number = line_numbers[idx] # Usar el line_number calculado
validate_create(db, line_create, tenant_id, company_id, errors)
validate_create(
db,
line_data, # Schema Pydantic completo
item_data.invoice_id, # invoice_id
tenant_id,
company_id,
errors,
line_number,
)
# Validaciones adicionales específicas del negocio
if line_data.fa_data and line_data.fa_data.is_subitem is None:
errors.add_required_error(
field=f"lines[{line_number}].fa_data.is_subitem"
)
if line_data.fa_data and line_data.fa_data.subitem_number is None:
errors.add_required_error(
field=f"lines[{line_number}].fa_data.subitem_number"
)
# Validar apóstrofes en número de parte
if line_data.part_number_id and "'" in str(line_data.part_number_id):
errors.add_error(
field=f"lines[{idx}].part_number",
field=f"lines[{line_number}].part_number",
message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos",
code="WARNING_APOSTROPHE",
)
# Validar tipo de partida
if hasattr(line_data, "item_type"):
tipo_partida = line_data.item_type
if tipo_partida and tipo_partida not in ["N", "S"]:
errors.add_error(
field=f"lines[{idx}].item_type",
message=f"Tipo de partida debe ser 'N' (Normal) o 'S' (Subpartida), recibido: '{tipo_partida}'",
code="INVALID_ITEM_TYPE",
value=str(tipo_partida),
)
# Si es subpartida (S), debe tener partida principal
if tipo_partida == "S":
if (
not hasattr(line_data, "main_line_id")
or not line_data.main_line_id
):
errors.add_error(
field=f"lines[{idx}].main_line_id",
message="Las subpartidas (tipo 'S') deben tener una partida principal",
code="MISSING_MAIN_LINE",
)
# Validar que el line_number sea consecutivo (si se especifica)
if hasattr(line_data, "line_number") and line_data.line_number:
expected_line = idx + 1
if line_data.line_number != expected_line:
errors.add_error(
field=f"lines[{idx}].line_number",
message=f"Número de línea esperado: {expected_line}, recibido: {line_data.line_number}",
code="INVALID_LINE_SEQUENCE",
value=str(line_data.line_number),
)
# Si hay errores, lanzar excepción ANTES de intentar crear
errors.raise_if_errors("Error al crear el item")
@@ -254,14 +335,6 @@ class ItemService:
# Create line items if provided
for idx, line_data in enumerate(lines_data):
# Extract nested data from line
financial_data = line_data.financial
quantity_data = line_data.quantity
customs_data = line_data.customs
description_data = line_data.description
reference_data = line_data.reference
fa_data = line_data.fa_data
line_dict = line_data.model_dump(
exclude={
"financial",
@@ -272,66 +345,35 @@ class ItemService:
"fa_data",
}
)
line_dict["item_id"] = db_item.id
line_dict["tenant_id"] = tenant_id
line_dict["company_id"] = company_id
line_dict.update(
{
"item_id": db_item.id,
"tenant_id": tenant_id,
"company_id": company_id,
"line_number": (
line_numbers[idx]
if line_numbers
else ItemService._get_next_line_number(
db, item_data.invoice_id
)
),
}
)
# Map schema field names to model field names
if "part_number_id" in line_dict:
line_dict["part_number"] = line_dict.pop("part_number_id")
if "component_part_number_id" in line_dict:
line_dict["component_part_number"] = line_dict.pop("component_part_number_id")
line_dict["part_number"] = line_dict.pop("part_number_id", None)
line_dict["component_part_number"] = line_dict.pop(
"component_part_number_id", None
)
# Create line item
db_line = LineItem(**line_dict)
db.add(db_line)
db.flush() # Get the line ID
db.flush()
# Create financial data if provided
if financial_data:
financial_dict = financial_data.model_dump()
financial_dict["item_line_id"] = db_line.id
db_financial = LineFinancial(**financial_dict)
db.add(db_financial)
# Create quantity data if provided
if quantity_data:
quantity_dict = quantity_data.model_dump()
quantity_dict["item_line_id"] = db_line.id
db_quantity = LineQuantity(**quantity_dict)
db.add(db_quantity)
# Create customs data if provided
if customs_data:
customs_dict = customs_data.model_dump()
customs_dict["item_line_id"] = db_line.id
db_customs = LineCustom(**customs_dict)
db.add(db_customs)
# Create description data if provided
if description_data:
description_dict = description_data.model_dump()
description_dict["item_line_id"] = db_line.id
db_description = LineDescription(**description_dict)
db.add(db_description)
# Create reference data if provided
if reference_data:
reference_dict = reference_data.model_dump()
reference_dict["item_line_id"] = db_line.id
db_reference = LineReference(**reference_dict)
db.add(db_reference)
# Create FA data if provided
if fa_data:
fa_dict = fa_data.model_dump(
exclude={"line_item_id"}
) # Exclude line_item_id from DTO
fa_dict["id"] = db_line.id # FA table uses same ID as line item
fa_dict["tenant_id"] = tenant_id
fa_dict["company_id"] = company_id
db_fa = FaLineItem(**fa_dict)
db.add(db_fa)
# Create all nested data
ItemService._create_line_nested_data(
db, db_line, line_data, tenant_id, company_id
)
db.commit()
db.refresh(db_item)
@@ -367,39 +409,25 @@ class ItemService:
# Validaciones con ErrorCollector
errors = ErrorCollector()
# Si se está actualizando el invoice_id, validar la factura
invoice = None
if item_data.invoice_id:
invoice = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == item_data.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
# Lock invoice
invoice_id_to_lock = (
item_data.invoice_id if item_data.invoice_id else db_item.invoice_id
)
if not ItemService._lock_invoice(
db, invoice_id_to_lock, tenant_id, company_id, errors
):
errors.raise_if_errors("Error al actualizar el item")
if not invoice:
errors.add_error(
field="invoice_id",
message="La factura especificada no existe",
code="NOT_FOUND",
value=str(item_data.invoice_id),
)
else:
# Si no se está actualizando invoice_id, obtener la factura actual por invoice_id
from api.v1.modules.a76.invoices.models import InvoiceHeader
invoice = (
db.query(InvoiceHeader)
.filter(InvoiceHeader.id == db_item.invoice_id)
.first()
)
# Pre-calcular line_numbers para cada línea (en update, las líneas se renumeran desde 1)
line_numbers = []
if item_data.lines:
line_numbers = [i + 1 for i in range(len(item_data.lines))]
# Validar cada line item que se va a actualizar
if item_data.lines:
for idx, line_data in enumerate(item_data.lines):
line_number = line_numbers[idx] # Usar el line_number calculado
# Si el line tiene ID, es actualización; si no, es creación
if hasattr(line_data, "id") and line_data.id:
# Buscar el line item existente
@@ -408,13 +436,28 @@ class ItemService:
None,
)
if existing_line:
# Convertir a LineItemUpdate para validar
line_update = LineItemUpdate(**line_data.model_dump())
validate_update(db, line_update, tenant_id, company_id, errors)
# Validar update con línea existente
validate_update(
db,
line_data, # Schema de update
existing_line, # LineItem existente en DB
invoice_id_to_lock, # invoice_id
tenant_id,
company_id,
errors,
line_number,
)
else:
# Es un nuevo line item, validar como creación
line_create = LineItemCreate(**line_data.model_dump())
validate_create(db, line_create, tenant_id, company_id, errors)
validate_create(
db,
line_data, # Schema Pydantic completo
invoice_id_to_lock, # invoice_id
tenant_id,
company_id,
errors,
line_number,
)
# Validaciones adicionales específicas del negocio
# (Aplican tanto para crear como actualizar)
@@ -422,7 +465,7 @@ class ItemService:
# Validar apóstrofes en número de parte
if line_data.part_number_id and "'" in str(line_data.part_number_id):
errors.add_error(
field=f"lines[{idx}].part_number",
field=f"lines[{line_number}].part_number",
message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos",
solution=None,
code="WARNING_APOSTROPHE",
@@ -433,7 +476,7 @@ class ItemService:
tipo_partida = line_data.item_type
if tipo_partida and tipo_partida not in ["N", "S"]:
errors.add_error(
field=f"lines[{idx}].item_type",
field=f"lines[{line_number}].item_type",
message=f"Tipo de partida debe ser 'N' (Normal) o 'S' (Subpartida), recibido: '{tipo_partida}'",
solution=None,
code="INVALID_ITEM_TYPE",
@@ -447,24 +490,12 @@ class ItemService:
or not line_data.main_line_id
):
errors.add_error(
field=f"lines[{idx}].main_line_id",
field=f"lines[{line_number}].main_line_id",
message="Las subpartidas (tipo 'S') deben tener una partida principal",
solution=None,
code="MISSING_MAIN_LINE",
)
# Validar que el line_number sea consecutivo (si se especifica)
if hasattr(line_data, "line_number") and line_data.line_number:
expected_line = idx + 1
if line_data.line_number != expected_line:
errors.add_error(
field=f"lines[{idx}].line_number",
message=f"Número de línea esperado: {expected_line}, recibido: {line_data.line_number}",
solution=None,
code="INVALID_LINE_SEQUENCE",
value=str(line_data.line_number),
)
# Si hay errores, lanzar excepción ANTES de actualizar
errors.raise_if_errors("Error al actualizar el item")
@@ -486,15 +517,7 @@ class ItemService:
db.flush()
# Create new lines
for line_data in lines_data:
# Extract nested data from line
financial_data = line_data.financial
quantity_data = line_data.quantity
customs_data = line_data.customs
description_data = line_data.description
reference_data = line_data.reference
fa_data = line_data.fa_data
for idx, line_data in enumerate(lines_data):
line_dict = line_data.model_dump(
exclude={
"financial",
@@ -506,57 +529,32 @@ class ItemService:
},
exclude_unset=True,
)
line_dict["item_id"] = db_item.id
line_dict["tenant_id"] = tenant_id
line_dict["company_id"] = company_id
line_dict.update(
{
"item_id": db_item.id,
"tenant_id": tenant_id,
"company_id": company_id,
"line_number": idx + 1,
}
)
# Map schema field names to model field names
if "part_number_id" in line_dict:
line_dict["part_number"] = line_dict.pop("part_number_id")
if "component_part_number_id" in line_dict:
line_dict["component_part_number"] = line_dict.pop("component_part_number_id")
line_dict["part_number"] = line_dict.pop("part_number_id", None)
line_dict["component_part_number"] = line_dict.pop(
"component_part_number_id", None
)
db_line = LineItem(**line_dict)
db.add(db_line)
db.flush()
# Create nested data if provided
if financial_data is not None:
financial_dict = financial_data.model_dump(exclude_unset=True)
financial_dict["item_line_id"] = db_line.id
db.add(LineFinancial(**financial_dict))
# Create all nested data
ItemService._create_line_nested_data(
db, db_line, line_data, tenant_id, company_id
)
if quantity_data is not None:
quantity_dict = quantity_data.model_dump(exclude_unset=True)
quantity_dict["item_line_id"] = db_line.id
db.add(LineQuantity(**quantity_dict))
if customs_data is not None:
customs_dict = customs_data.model_dump(exclude_unset=True)
customs_dict["item_line_id"] = db_line.id
db.add(LineCustom(**customs_dict))
if description_data is not None:
description_dict = description_data.model_dump(
exclude_unset=True
)
description_dict["item_line_id"] = db_line.id
db.add(LineDescription(**description_dict))
if reference_data is not None:
reference_dict = reference_data.model_dump(exclude_unset=True)
reference_dict["item_line_id"] = db_line.id
db.add(LineReference(**reference_dict))
# Create FA data if provided
if fa_data is not None:
fa_dict = fa_data.model_dump(
exclude_unset=True, exclude={"line_item_id"}
)
fa_dict["id"] = db_line.id # FA table uses same ID as line item
fa_dict["tenant_id"] = tenant_id
fa_dict["company_id"] = company_id
db.add(FaLineItem(**fa_dict))
# Renumber all lines for this invoice to ensure consecutive numbering
ItemService._renumber_all_invoice_lines(db, db_item.invoice_id)
db.commit()
db.refresh(db_item)
@@ -582,7 +580,20 @@ class ItemService:
if not db_item:
return False
invoice_id = db_item.invoice_id
errors = ErrorCollector()
# Lock the invoice
if not ItemService._lock_invoice(
db, invoice_id, tenant_id, company_id, errors
):
raise HTTPException(
status_code=404, detail="Invoice not found or could not be locked"
)
db.delete(db_item)
db.flush()
ItemService._renumber_all_invoice_lines(db, invoice_id)
db.commit()
return True

View File

@@ -36,7 +36,7 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
# --- MODELO DE UNIDADES DE MEDIDA ---
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
@@ -50,6 +50,10 @@ from .schemas import (
FacturaImportacionCompleta,
)
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
class ConsolidadoImportacionMexService:
def __init__(self):
@@ -515,11 +519,7 @@ class ConsolidadoImportacionMexService:
.filter(InvoiceHeader.id.in_(target_invoice_ids))
.all()
)
invoice_map = {inv.id: inv for inv in invoices_list}
from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import (
USTariffFraction,
)
invoice_map = {inv.id: inv for inv in invoices_list}
for line in lines:
qty = (

View File

@@ -30,7 +30,7 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
# --- SCHEMAS ---
from ...mex.schemas import (
@@ -345,7 +345,7 @@ class ConsolidadoImportacionMexService:
invoices_list = db.query(InvoiceHeader).filter(InvoiceHeader.id.in_(target_invoice_ids)).all()
invoice_map = {inv.id: inv for inv in invoices_list}
from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction
for line in lines:
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()

View File

@@ -32,7 +32,7 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
# --- MODELO DE UNIDADES DE MEDIDA ---
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure

View File

@@ -30,7 +30,7 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
# --- SCHEMAS ---
from ...mex.schemas import (

View File

@@ -30,7 +30,7 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
# --- SCHEMAS ---
# Reuse schemas from neighbor package as they fit the same data structure

View File

@@ -31,7 +31,7 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
# --- SCHEMAS ---
from .schemas import (

View File

@@ -8,6 +8,7 @@ from fastapi import APIRouter
from .customs_brokers.routes import router as customs_broker_router
# Importar routers de módulos
from .general_catalogs.router import router as general_catalogs_router
from .invoices.routes import router as invoices_router
from .items.routes import router as items_router
from .classes import router as classes_router
@@ -18,33 +19,10 @@ from .general_catalogs.company import router as company_router
from .country_rule_oct.routes import router as country_rule_oct_router
from .transportation.drivers.routes import router as drivers_router
from .doc_types_dig.routes import router as doc_types_dig_router
from .general_catalogs.exchange_rate.routes import router as exchange_rate_router
from .general_catalogs.identifiers.routes import router as identifiers_router
from .fraction_rule_octave.routes import router as fraction_rule_octave_router
from .general_catalogs.packages.routes import router as package_router
from .general_catalogs.ports.routes import router as ports_router
from .general_catalogs.tariff_fractions.routes import router as tariff_fractions_router
from .general_catalogs.us_tariff_fractions.routes import router as us_tariff_fractions_router
from .general_catalogs.depreciation_catalog.routes import router as depreciation_catalog_router
from .general_catalogs.fda_catalog.routes import router as fda_catalog_router
from .parts import router as parts_router
from .pedmientos.router import router as pedimentos_router
from .permission_rule_oct.routes import router as permission_rule_oct_router
from .general_catalogs.seal.routes import router as seal_router
from .general_catalogs.units_of_measure.routes import router as units_of_measure_router
from .general_catalogs.concepts.routes import router as concepts_router
from .general_catalogs.customs_broker_concepts.routes import router as customs_broker_concepts_router
from .general_catalogs.classification_concepts.routes import router as classification_concepts_router
from .general_catalogs.unit_conversions.routes import router as unit_conversions_router
from .general_catalogs.equivalencies.routes import router as equivalencies_router
from .general_catalogs.multi_currency_types.routes import router as multi_currency_types_router
from .general_catalogs.inpc.routes import router as inpc_router
from .general_catalogs.legends.routes import router as legends_router
from .general_catalogs.signatures.routes import router as signatures_router
from .general_catalogs.error_catalogs.routes import router as error_catalogs_router
from .general_catalogs.doda.routes import router as doda_router
from .general_catalogs.prevalidators.routes import router as prevalidators_router
from .general_catalogs.electronic_notices.routes import router as electronic_notices_router
from .transportation.trailers.routes import router as trailers_router
from .transportation.transporters.routes import router as transporters_router
from .transportation.vehicles.routes import router as vehicles_router
@@ -66,63 +44,25 @@ from .manifests.manifiesto_anexo.routes import router as manifest_anexos_router
router = APIRouter()
# Registrar módulos
router.include_router(general_catalogs_router, prefix="/a76", tags=["a76 / general_catalogs"])
router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"])
router.include_router(items_router, prefix="/a76", tags=["a76 / items"])
router.include_router(invoice_settings_router)
router.include_router(item_presets_router, prefix="/a76/item-presets", tags=["a76 / item_presets"])
router.include_router(pedimentos_router, prefix="/a76")
router.include_router(
client_and_provider_router, prefix="/a76", tags=["a76 / clients_and_providers"]
)
router.include_router(company_router, prefix="/a76", tags=["a76 / company"])
router.include_router(client_and_provider_router, prefix="/a76", tags=["a76 / clients_and_providers"])
router.include_router(classes_router, prefix="/a76/classes", tags=["a76 / classes"])
router.include_router(parts_router, prefix="/a76", tags=["a76 / parts"])
router.include_router(
permission_rule_oct_router, prefix="/a76", tags=["a76 / permission_rule_oct"]
)
router.include_router(package_router, prefix="/a76")
router.include_router(ports_router, prefix="/a76")
router.include_router(tariff_fractions_router, prefix="/a76")
router.include_router(us_tariff_fractions_router, prefix="/a76")
router.include_router(depreciation_catalog_router, prefix="/a76")
router.include_router(fda_catalog_router, prefix="/a76")
router.include_router(seal_router, prefix="/a76", tags=["a76 / seal"])
router.include_router(units_of_measure_router, prefix="/a76")
router.include_router(
fraction_rule_octave_router, prefix="/a76", tags=["a76 / fraction_rule_octave"]
)
router.include_router(identifiers_router, prefix="/a76")
router.include_router(
country_rule_oct_router, prefix="/a76", tags=["a76 / country_rule_oct"]
)
router.include_router(exchange_rate_router, prefix="/a76",
tags=["a76 / exchange_rate"])
router.include_router(permission_rule_oct_router, prefix="/a76", tags=["a76 / permission_rule_oct"])
router.include_router(fraction_rule_octave_router, prefix="/a76", tags=["a76 / fraction_rule_octave"])
router.include_router(country_rule_oct_router, prefix="/a76", tags=["a76 / country_rule_oct"])
router.include_router(trailers_router, prefix="/a76/transportation", tags=["a76 / trailers"])
router.include_router(
customs_broker_router, prefix="/a76", tags=["a76 / customs_broker"]
)
router.include_router(customs_broker_router, prefix="/a76", tags=["a76 / customs_broker"])
router.include_router(doc_types_dig_router, prefix="/a76", tags=["a76 / document_types_digitization"])
router.include_router(drivers_router, prefix="/a76", tags=["a76 / drivers"])
router.include_router(transporters_router, prefix="/a76",
tags=["a76 / transporters"])
router.include_router(transporters_router, prefix="/a76", tags=["a76 / transporters"])
router.include_router(vehicles_router, prefix="/a76/transportation", tags=["a76 / vehicles"])
# Registrar catálogos generales adicionales
router.include_router(concepts_router, prefix="/a76")
router.include_router(customs_broker_concepts_router, prefix="/a76")
router.include_router(classification_concepts_router, prefix="/a76")
router.include_router(unit_conversions_router, prefix="/a76")
router.include_router(equivalencies_router, prefix="/a76")
router.include_router(multi_currency_types_router, prefix="/a76")
router.include_router(inpc_router, prefix="/a76")
router.include_router(legends_router, prefix="/a76")
router.include_router(signatures_router, prefix="/a76")
router.include_router(error_catalogs_router, prefix="/a76")
router.include_router(doda_router, prefix="/a76")
router.include_router(prevalidators_router, prefix="/a76")
router.include_router(electronic_notices_router, prefix="/a76")
# Registrar router de tipos de material públicos
router.include_router(
material_types_router,

View File

@@ -1,5 +1,5 @@
from core.database import Base
from sqlalchemy import PrimaryKeyConstraint, SmallInteger, String
from sqlalchemy import Boolean, PrimaryKeyConstraint, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column
@@ -15,9 +15,9 @@ class Sector(Base):
description: Mapped[str] = mapped_column(
String(150), nullable=False
) # descripción oficial (en español)
authorized: Mapped[SmallInteger] = mapped_column(
SmallInteger
) # 1 = autorizado, 0 = no autorizado
authorized: Mapped[bool] = mapped_column(
Boolean
) # True = autorizado, False = no autorizado
def __repr__(self):
return f"<Sector(key={self.key}, description={self.description}, authorized={self.authorized})>"

View File

@@ -0,0 +1,320 @@
# SITAR API Module
Módulo reorganizado para integración con la API externa de SITAR (Sistema de Información de Aranceles).
## Estructura
Cada recurso ahora tiene su propia carpeta con separación de responsabilidades:
```
backend/api/v1/modules/sitar/
├── common/ # Servicio base compartido
│ ├── __init__.py
│ └── base_service.py # SitarAPIBaseService con autenticación
├── tlcs/ # TLCS (Tratados de Libre Comercio)
│ ├── __init__.py
│ ├── schemas.py # TLCSResponse
│ ├── service.py # TLCSService
│ └── router.py # Endpoints FastAPI
├── fracciones/ # Fracciones arancelarias mexicanas
├── fracciones_usa/ # Fracciones USA
├── regulaciones/ # Regulaciones y restricciones
├── prosec/ # PROSEC
├── precios_estimados/ # Precios estimados
├── fundamentos_tlc/ # Fundamentos TLC
├── aladi2/ # ALADI2
├── cuotas2/ # Cuotas compensatorias
├── cupos/ # Cupos de importación
├── fracciones_anteriores/ # Historial de fracciones
├── informacion_general/ # Información general
├── ieps/ # IEPS
├── noms/ # Normas Oficiales Mexicanas
├── rcg2/ # Reglas de Carácter General
├── reit/ # REIT
├── requisito_previo/ # Requisitos previos
├── vehiculos_marcas/ # Marcas de vehículos
├── vehiculos_modelos/ # Modelos de vehículos
├── __init__.py # Exporta todos los servicios
└── main_router.py # Router principal con todos los endpoints
```
## Uso del Servicio
### Desde cualquier parte del código
Cada recurso tiene su propio servicio **singleton** que maneja automáticamente la autenticación:
```python
from api.v1.modules.sitar.tlcs import TLCSService
from api.v1.modules.sitar.fracciones import FraccionesService
# Obtener instancia singleton
tlcs_service = TLCSService.get_instance()
# Buscar TLCS (async)
tlcs_data = await tlcs_service.search(
fraccion="84716001",
pais="USA",
limit=100
)
# Procesar resultados
for tlcs in tlcs_data:
print(f"Tasa: {tlcs.TASATXT}, País: {tlcs.PAIS}")
```
### Desde código síncrono
Si estás en contexto síncrono, usa `asyncio.run()`:
```python
import asyncio
from api.v1.modules.sitar.tlcs import TLCSService
tlcs_service = TLCSService.get_instance()
tlcs_data = asyncio.run(tlcs_service.search(fraccion="84716001", pais="USA"))
```
## Métodos Disponibles
### TLCS (Tratados de Libre Comercio)
- `search_tlcs(fraccion, pais, nico, skip, limit)` - Buscar TLCS
- `get_tlcs_by_id(sysid, fraccion)` - Obtener TLCS por ID
### Fracciones Arancelarias
- `search_fracciones(fraccion, nico, skip, limit)` - Buscar fracciones mexicanas
- `get_fraccion_by_id(sysid)` - Obtener fracción por ID
- `search_fracciones_usa(fraccion, skip, limit)` - Buscar fracciones USA
- `get_fraccion_usa_by_id(consecutivo)` - Obtener fracción USA por ID
- `search_fracciones_anteriores(fraccion_actual, fraccion_anterior, skip, limit)` - Buscar historial de fracciones
- `get_fracciones_anteriores_by_id(sysid)` - Obtener historial por ID
### Regulaciones y Restricciones
- `search_regulaciones(fraccion, nico, skip, limit)` - Buscar regulaciones
- `get_regulacion_by_id(sysid)` - Obtener regulación por ID
- `search_noms(fraccion, pais, nico, skip, limit)` - Buscar Normas Oficiales Mexicanas
- `get_noms_by_id(sysid)` - Obtener NOM por ID
- `search_requisito_previo(fraccion, nico, skip, limit)` - Buscar requisitos previos
- `get_requisito_previo_by_id(sysid)` - Obtener requisito previo por ID
### PROSEC y Programas de Promoción
- `search_prosec(fraccion, nico, skip, limit)` - Buscar PROSEC
- `get_prosec_by_id(sysid)` - Obtener PROSEC por ID
- `search_reit(fraccion, nico, skip, limit)` - Buscar REIT
- `get_reit_by_id(sysid)` - Obtener REIT por ID
### Precios e Impuestos
- `search_precios_estimados(fraccion, nico, skip, limit)` - Buscar precios estimados
- `get_precio_estimado_by_id(sysid)` - Obtener precio estimado por ID
- `search_ieps(fraccion, nico, skip, limit)` - Buscar IEPS
- `get_ieps_by_id(consecutivo)` - Obtener IEPS por ID
### Fundamentos y Acuerdos
- `search_fundamentos_tlc(fraccion, nico, tipat_only, skip, limit)` - Buscar fundamentos TLC
- `get_fundamento_tlc_by_id(sysid)` - Obtener fundamento por ID
- `search_aladi2(fraccion, pais, nico, skip, limit)` - Buscar ALADI2
- `get_aladi2_by_id(sysid)` - Obtener ALADI2 por ID
### Cuotas y Cupos
- `search_cuotas2(fraccion, pais, nico, skip, limit)` - Buscar cuotas compensatorias
- `get_cuotas2_by_id(sysid)` - Obtener cuota por ID
- `search_cupos(fraccion, nico, skip, limit)` - Buscar cupos de importación
- `get_cupos_by_id(sysid)` - Obtener cupo por ID
### Reglas de Carácter General
- `search_rcg2(fraccion, nico, skip, limit)` - Buscar RCG2
- `get_rcg2_by_id(sysid)` - Obtener RCG2 por ID
### Información General
- `search_informacion_general(fraccion, nico, skip, limit)` - Buscar información general
- `get_informacion_general_by_id(sysid)` - Obtener información general por ID
### Vehículos
- `search_vehiculos_marcas(fraccion, marca, skip, limit)` - Buscar marcas de vehículos
- `get_vehiculos_marcas_by_id(sysid)` - Obtener marca por ID
- `search_vehiculos_modelos(fraccion, marca, modelo, skip, limit)` - Buscar modelos de vehículos
- `get_vehiculos_modelos_by_id(sysid)` - Obtener modelo por ID
## Configuración
Requiere las siguientes variables de entorno:
```bash
SITAR_API_URL=https://api.sitar.example.com
SITAR_API_USER=your_username
SITAR_API_PASSWORD=your_password
```
## Servicios Disponibles
Cada módulo sigue el mismo patrón con métodos `search()` y `get_by_id()`:
### TLCS - `TLCSService`
```python
from api.v1.modules.sitar.tlcs import TLCSService
service = TLCSService.get_instance()
await service.search(fraccion="84716001", pais="USA", nico=None, skip=0, limit=100)
await service.get_by_id(sysid=123, fraccion="84716001")
```
### Fracciones - `FraccionesService`
```python
from api.v1.modules.sitar.fracciones import FraccionesService
service = FraccionesService.get_instance()
await service.search(fraccion="84716001", nico=None, skip=0, limit=100)
await service.get_by_id(sysid=123)
```
### Fracciones USA - `FraccionesUSAService`
```python
from api.v1.modules.sitar.fracciones_usa import FraccionesUSAService
service = FraccionesUSAService.get_instance()
await service.search(fraccion="84716001", skip=0, limit=100)
await service.get_by_id(consecutivo=123)
```
### Otros servicios disponibles:
- **RegulacionesService** - Regulaciones y restricciones
- **ProsecService** - PROSEC (Programa de Promoción Sectorial)
- **PreciosEstimadosService** - Precios estimados
- **FundamentosTLCService** - Fundamentos de tratados de libre comercio
- **Aladi2Service** - ALADI2 (Asociación Latinoamericana de Integración)
- **Cuotas2Service** - Cuotas compensatorias
- **CuposService** - Cupos de importación
- **FraccionesAnterioresService** - Historial de fracciones
- **InformacionGeneralService** - Información general de fracciones
- **IepsService** - IEPS (Impuesto Especial sobre Producción y Servicios)
- **NomsService** - Normas Oficiales Mexicanas
- **Rcg2Service** - Reglas de Carácter General
- **ReitService** - Registro de Empresas de Industria Terminal
- **RequisitoPrevioService** - Requisitos previos
- **VehiculosMarcasService** - Marcas de vehículos
- **VehiculosModelosService** - Modelos de vehículos
```### Desde endpoints asíncronos
```python
from fastapi import APIRouter, HTTPException
from api.v1.modules.sitar.tlcs import TLCSService
router = APIRouter()
@router.get("/tariff/{fraccion}")
async def get_tariff_info(fraccion: str, country: str = "USA"):
"""Endpoint que consulta información arancelaria"""
try:
tlcs_service = TLCSService.get_instance()
data = await tlcs_service.search(fraccion=fraccion, pais=country)
return {"success": True, "data": data}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
```
## Arquitectura
### Servicio Base Compartido
Todos los servicios heredan de `SitarAPIBaseService` que maneja:
- Autenticación con token caching
- Renovación automática de tokens
- Manejo de errores HTTP
- Timeout configurable
### Singleton Pattern
Cada servicio usa el patrón singleton para:
- Compartir la conexión HTTP
- Reutilizar tokens de autenticación
- Evitar múltiples instancias
### Separación de Responsabilidades
- **schemas.py**: Modelos Pydantic para validación de datos
- **service.py**: Lógica de negocio y llamadas a API
- **router.py**: Endpoints FastAPI (opcional)
- **__init__.py**: Exportaciones públicas
## Notas Importantes
1. **Autenticación**: El token se cachea y renueva automáticamente
2. **Timeouts**: Configurado a 10 segundos por defecto
3. **Límites**: Máximo 1000 registros por consulta
4. **Errores**: Todos los servicios lanzan excepciones httpx en caso de error
5. **Async/Sync**: Los servicios son async, usa `asyncio.run()` en código síncrono
```
Esto expondrá endpoints como:
- `GET /api/v1/sitar/tlcs/` - Buscar TLCS
- `GET /api/v1/sitar/fracciones/` - Buscar fracciones
- etc.
## Ejemplo Completo
```python
from api.v1.modules.sitar import SitarAPIService
async def get_tariff_info(fraccion: str, country: str):
"""Obtener información arancelaria completa"""
sitar = SitarAPIService.get_instance()
try:
# Buscar TLCS
tlcs = await sitar.search_tlcs(fraccion=fraccion, pais=country)
if tlcs:
first_tlcs = tlcs[0]
return {
"rate": first_tlcs.TASATXT,
"adv_impo": float(first_tlcs.TASA1NUM or 0.0),
"country": first_tlcs.PAIS
}
# Si no hay TLCS, buscar fracción general
fracciones = await sitar.search_fracciones(fraccion=fraccion)
if fracciones:
first_frac = fracciones[0]
return {
"rate": first_frac.ADVIMPOTXT,
"adv_impo": float(first_frac.ADVIMPONUM or 0.0),
"description": first_frac.DESCRIPCION
}
return None
except Exception as e:
print(f"Error: {e}")
return None
```
## Manejo de Errores
El servicio lanza excepciones `httpx.HTTPError` en caso de error:
```python
import httpx
try:
tlcs = await sitar.search_tlcs(fraccion="invalid")
except httpx.HTTPStatusError as e:
print(f"HTTP Error: {e.response.status_code}")
except httpx.RequestError as e:
print(f"Request Error: {e}")
except Exception as e:
print(f"General Error: {e}")
```

View File

@@ -0,0 +1,90 @@
"""
SITAR API Module
Módulo reorganizado para integración con la API externa de SITAR.
Cada recurso tiene su propia carpeta con schemas, service y router.
Estructura:
backend/api/v1/modules/sitar/
├── common/ # Base service compartido
├── tlcs/ # TLCS (Tratados de Libre Comercio)
├── fracciones/ # Fracciones arancelarias mexicanas
├── fracciones_usa/ # Fracciones arancelarias USA
├── regulaciones/ # Regulaciones y restricciones
├── prosec/ # PROSEC
├── precios_estimados/ # Precios estimados
├── fundamentos_tlc/ # Fundamentos TLC
├── aladi2/ # ALADI2
├── cuotas2/ # Cuotas compensatorias
├── cupos/ # Cupos de importación
├── fracciones_anteriores/ # Historial de fracciones
├── informacion_general/ # Información general
├── ieps/ # IEPS
├── noms/ # Normas Oficiales Mexicanas
├── rcg2/ # Reglas de Carácter General
├── reit/ # REIT
├── requisito_previo/ # Requisitos previos
├── vehiculos_marcas/ # Marcas de vehículos
└── vehiculos_modelos/ # Modelos de vehículos
Uso:
# Importar servicios específicos
from api.v1.modules.sitar.tlcs import TLCSService
from api.v1.modules.sitar.fracciones import FraccionesService
# Usar servicios
tlcs_service = TLCSService.get_instance()
data = await tlcs_service.search(fraccion="84716001")
# Importar routers para FastAPI
from api.v1.modules.sitar.tlcs import router as tlcs_router
from api.v1.modules.sitar.fracciones import router as fracciones_router
app.include_router(tlcs_router, prefix="/api/v1/sitar/tlcs", tags=["sitar-tlcs"])
"""
from .common import SitarAPIBaseService
# Import all services for convenient access
from .tlcs import TLCSService
from .fracciones import FraccionesService
from .fracciones_usa import FraccionesUSAService
from .regulaciones import RegulacionesService
from .prosec import ProsecService
from .precios_estimados import PreciosEstimadosService
from .fundamentos_tlc import FundamentosTLCService
from .aladi2 import Aladi2Service
from .cuotas2 import Cuotas2Service
from .cupos import CuposService
from .fracciones_anteriores import FraccionesAnterioresService
from .informacion_general import InformacionGeneralService
from .ieps import IepsService
from .noms import NomsService
from .rcg2 import Rcg2Service
from .reit import ReitService
from .requisito_previo import RequisitoPrevioService
from .vehiculos_marcas import VehiculosMarcasService
from .vehiculos_modelos import VehiculosModelosService
__all__ = [
"SitarAPIBaseService",
"TLCSService",
"FraccionesService",
"FraccionesUSAService",
"RegulacionesService",
"ProsecService",
"PreciosEstimadosService",
"FundamentosTLCService",
"Aladi2Service",
"Cuotas2Service",
"CuposService",
"FraccionesAnterioresService",
"InformacionGeneralService",
"IepsService",
"NomsService",
"Rcg2Service",
"ReitService",
"RequisitoPrevioService",
"VehiculosMarcasService",
"VehiculosModelosService",
]

View File

@@ -0,0 +1,7 @@
"""Aladi2 Module"""
from .schemas import Aladi2Response
from .service import Aladi2Service
from .router import router
__all__ = ["Aladi2Response", "Aladi2Service", "router"]

View File

@@ -0,0 +1,37 @@
"""Aladi2 Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query
from fastapi import Depends
from core.security import get_current_user
from .service import Aladi2Service
from .schemas import Aladi2Response
router = APIRouter()
@router.get("/", response_model=List[Aladi2Response])
async def search(
fraccion: Optional[str] = Query(None),
pais: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = Aladi2Service.get_instance()
return await service.search(
fraccion=fraccion, pais=pais, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sysid}", response_model=Aladi2Response)
async def get_by_id(sysid: int, current_user: dict = Depends(get_current_user)):
try:
return await Aladi2Service.get_instance().get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,24 @@
"""ALADI2 Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class Aladi2Response(BaseModel):
"""ALADI2 (Asociación Latinoamericana de Integración)"""
FRACCION: Optional[str] = Field(None, max_length=10)
ACUERDO: Optional[str] = Field(None, max_length=49)
PAIS: Optional[str] = Field(None, max_length=3)
TASATXT: Optional[str] = Field(None, max_length=19)
TASANUM: Optional[str] = None
TASACALCULADA: Optional[str] = Field(None, max_length=19)
TIPOTASA: Optional[int] = None
DOF: Optional[str] = Field(None, max_length=8)
NOTAS: Optional[str] = Field(None, max_length=499)
OBSERVACIONES: Optional[str] = None
NICO: Optional[str] = Field(None, max_length=2)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,37 @@
"""Aladi2 Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import Aladi2Response
class Aladi2Service(SitarAPIBaseService):
_instance: Optional["Aladi2Service"] = None
@classmethod
def get_instance(cls) -> "Aladi2Service":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
pais: Optional[str] = None,
nico: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[Aladi2Response]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if pais:
params["pais"] = pais
if nico:
params["nico"] = nico
data = await self._make_request("GET", "/api/v1/aladi2/", params=params)
return [Aladi2Response(**item) for item in data]
async def get_by_id(self, sysid: int) -> Aladi2Response:
data = await self._make_request("GET", f"/api/v1/aladi2/{sysid}")
return Aladi2Response(**data)

View File

@@ -0,0 +1,5 @@
"""SITAR API Common Module"""
from .base_service import SitarAPIBaseService
__all__ = ["SitarAPIBaseService"]

View File

@@ -0,0 +1,104 @@
"""
SITAR API Base Service
Base service class for SITAR API authentication and HTTP requests.
All specific resource services inherit from this.
"""
import os
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
import httpx
class SitarAPIBaseService:
"""Base service for SITAR API integration with authentication"""
_token: Optional[str] = None
_token_expires: Optional[datetime] = None
def __init__(self):
"""Initialize base service with API credentials"""
self.base_url = os.getenv("SITAR_API_URL")
self.username = os.getenv("SITAR_API_USER")
self.password = os.getenv("SITAR_API_PASSWORD")
self.timeout = 10.0
if not all([self.base_url, self.username, self.password]):
raise ValueError(
"Missing SITAR API configuration. "
"Set SITAR_API_URL, SITAR_API_USER, and SITAR_API_PASSWORD environment variables."
)
async def _get_token(self) -> str:
"""
Get authentication token, refreshing if necessary
Returns:
str: Bearer token for API authentication
Raises:
httpx.HTTPError: If authentication fails
"""
# Return cached token if still valid
if self._token and self._token_expires and datetime.now() < self._token_expires:
return self._token
# Authenticate and get new token
login_url = f"{self.base_url}/fractions/api/v1/auth/login"
payload = {"username": self.username, "password": self.password}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(login_url, json=payload)
response.raise_for_status()
data = response.json()
self._token = data.get("access_token") or data.get("token")
if not self._token:
raise ValueError("No token received from SITAR API")
# Set token expiration (assume 1 hour if not specified)
self._token_expires = datetime.now() + timedelta(hours=1)
return self._token
async def _make_request(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None,
) -> Any:
"""
Make authenticated request to SITAR API
Args:
method: HTTP method (GET, POST, etc.)
endpoint: API endpoint path
params: Query parameters
json_data: JSON body data
Returns:
JSON response data
Raises:
httpx.HTTPError: If request fails
"""
token = await self._get_token()
url = f"{self.base_url}/fractions/{endpoint}"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.request(
method=method,
url=url,
params=params,
json=json_data,
headers=headers,
)
response.raise_for_status()
return response.json()

View File

@@ -0,0 +1,7 @@
"""Cuotas2 Module"""
from .schemas import Cuotas2Response
from .service import Cuotas2Service
from .router import router
__all__ = ["Cuotas2Response", "Cuotas2Service", "router"]

View File

@@ -0,0 +1,35 @@
"""Cuotas2 Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import Cuotas2Service
from .schemas import Cuotas2Response
router = APIRouter()
@router.get("/", response_model=List[Cuotas2Response])
async def search(
fraccion: Optional[str] = Query(None),
pais: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = Cuotas2Service.get_instance()
return await service.search(
fraccion=fraccion, pais=pais, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sysid}", response_model=Cuotas2Response)
async def get_by_id(sysid: int, current_user: dict = Depends(get_current_user)):
try:
return await Cuotas2Service.get_instance().get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,30 @@
"""Cuotas2 Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class Cuotas2Response(BaseModel):
"""Cuotas compensatorias"""
FRACCION: Optional[str] = Field(None, max_length=10)
PRODUCTO: Optional[str] = Field(None, max_length=300)
PAIS: Optional[str] = Field(None, max_length=3)
DOF: Optional[str] = Field(None, max_length=8)
DOF2: Optional[str] = Field(None, max_length=149)
EMPRESA: Optional[str] = Field(None, max_length=100)
INCOTERM: Optional[str] = Field(None, max_length=10)
DOLARES: Optional[str] = None
CLAVEMONEDA: Optional[str] = Field(None, max_length=3)
CLAVEUM: Optional[str] = Field(None, max_length=2)
PORCENTAJE: Optional[str] = None
DESCRIPCIONDOLARES: Optional[str] = Field(None, max_length=50)
NOTAS: Optional[str] = None
ESTATUS: Optional[str] = Field(None, max_length=19)
NUMERORESOLUCION: Optional[int] = None
RESOLUCION: Optional[str] = Field(None, max_length=150)
NICO: Optional[str] = Field("", max_length=2)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,37 @@
"""Cuotas2 Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import Cuotas2Response
class Cuotas2Service(SitarAPIBaseService):
_instance: Optional["Cuotas2Service"] = None
@classmethod
def get_instance(cls) -> "Cuotas2Service":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
pais: Optional[str] = None,
nico: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[Cuotas2Response]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if pais:
params["pais"] = pais
if nico:
params["nico"] = nico
data = await self._make_request("GET", "/api/v1/cuotas2/", params=params)
return [Cuotas2Response(**item) for item in data]
async def get_by_id(self, sysid: int) -> Cuotas2Response:
data = await self._make_request("GET", f"/api/v1/cuotas2/{sysid}")
return Cuotas2Response(**data)

View File

@@ -0,0 +1,7 @@
"""Cupos Module"""
from .schemas import CuposResponse
from .service import CuposService
from .router import router
__all__ = ["CuposResponse", "CuposService", "router"]

View File

@@ -0,0 +1,34 @@
"""Cupos Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import CuposService
from .schemas import CuposResponse
router = APIRouter()
@router.get("/", response_model=List[CuposResponse])
async def search(
fraccion: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = CuposService.get_instance()
return await service.search(
fraccion=fraccion, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sysid}", response_model=CuposResponse)
async def get_by_id(sysid: int, current_user: dict = Depends(get_current_user)):
try:
return await CuposService.get_instance().get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,25 @@
"""Cupos Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class CuposResponse(BaseModel):
"""Cupos de importación"""
FRACCION: Optional[str] = Field(None, max_length=10)
NUMEROCUPO: Optional[int] = None
DOF: Optional[str] = Field(None, max_length=8)
PERMISO: Optional[str] = Field(None, max_length=2)
CONDICION: Optional[str] = Field(None, max_length=199)
VIGENCIA: Optional[str] = Field(None, max_length=8)
PAIS: Optional[str] = Field(None, max_length=5)
OBSERVACIONES: Optional[str] = Field(None, max_length=5000)
ADVIMPO: Optional[str] = Field(None, max_length=20)
ADVEXPO: Optional[str] = Field(None, max_length=20)
TIPOOPERACION: Optional[int] = None
NICO: Optional[str] = Field("", max_length=2)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,34 @@
"""Cupos Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import CuposResponse
class CuposService(SitarAPIBaseService):
_instance: Optional["CuposService"] = None
@classmethod
def get_instance(cls) -> "CuposService":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[CuposResponse]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if nico:
params["nico"] = nico
data = await self._make_request("GET", "/api/v1/cupos/", params=params)
return [CuposResponse(**item) for item in data]
async def get_by_id(self, sysid: int) -> CuposResponse:
data = await self._make_request("GET", f"/api/v1/cupos/{sysid}")
return CuposResponse(**data)

View File

@@ -0,0 +1,7 @@
"""Fracciones Module"""
from .schemas import FraccionesResponse
from .service import FraccionesService
from .router import router
__all__ = ["FraccionesResponse", "FraccionesService", "router"]

View File

@@ -0,0 +1,43 @@
"""Fracciones Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import FraccionesService
from .schemas import FraccionesResponse
router = APIRouter()
@router.get("/", response_model=List[FraccionesResponse])
async def search_fracciones(
fraccion: Optional[str] = Query(None, description="Fracción arancelaria"),
nico: Optional[str] = Query(None, description="NICO"),
skip: int = Query(0, ge=0, description="Registros a saltar"),
limit: int = Query(100, ge=1, le=1000, description="Máximo de registros"),
current_user: dict = Depends(get_current_user),
):
"""Search Mexican tariff fractions"""
try:
service = FraccionesService.get_instance()
return await service.search(
fraccion=fraccion, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Error fetching Fracciones data: {str(e)}"
)
@router.get("/{sysid}", response_model=FraccionesResponse)
async def get_fraccion_by_id(
sysid: int, current_user: dict = Depends(get_current_user)
):
"""Get single Fraccion record by SYSID"""
try:
service = FraccionesService.get_instance()
return await service.get_by_id(sysid)
except Exception as e:
raise HTTPException(
status_code=404, detail=f"Fraccion record not found: {str(e)}"
)

View File

@@ -0,0 +1,38 @@
"""Fracciones Schemas"""
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, Field
class FraccionesResponse(BaseModel):
"""Fracciones arancelarias mexicanas"""
FRACCION: Optional[str] = Field(None, max_length=10)
FRACCIONPUNTO: Optional[str] = Field(None, max_length=10)
DESCRIPCION: Optional[str] = None
UMCLAVE: Optional[str] = Field(None, max_length=2)
UMABREVIACION: Optional[str] = Field(None, max_length=4)
ADVIMPOTXT: Optional[str] = Field(None, max_length=19)
ADVIMPONUM: Optional[Decimal] = None
TIPOTASAADVIMPO: Optional[int] = None
ADVEXPOTXT: Optional[str] = Field(None, max_length=19)
ADVEXPONUM: Optional[Decimal] = None
TIPOTASAADVEXPO: Optional[int] = None
TASAIVAFRANJA: Optional[Decimal] = None
TASAIVAINTERIOR: Optional[Decimal] = None
TASAISAN: Optional[Decimal] = None
ARANCELMIXTO: Optional[str] = Field(None, max_length=1)
TASAMIXTA: Optional[Decimal] = None
DOF: Optional[str] = Field(None, max_length=8)
NOTAS: Optional[str] = None
HISTORICO: Optional[str] = None
ARANCELESPECIFICO: Optional[str] = Field(None, max_length=1)
TIPOVEHICULO: Optional[str] = Field(None, max_length=1)
APLICAISAN: Optional[str] = Field(None, max_length=1)
APLICAIEPS: Optional[str] = Field(None, max_length=1)
NIVEL: Optional[int] = None
NICO: Optional[str] = Field(None, max_length=14)
class Config:
from_attributes = True

View File

@@ -0,0 +1,40 @@
"""Fracciones Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import FraccionesResponse
class FraccionesService(SitarAPIBaseService):
"""Service for Fracciones operations"""
_instance: Optional["FraccionesService"] = None
@classmethod
def get_instance(cls) -> "FraccionesService":
"""Get singleton instance"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesResponse]:
"""Search Mexican tariff fractions"""
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if nico:
params["nico"] = nico
data = await self._make_request("GET", "/api/v1/fracciones/", params=params)
return [FraccionesResponse(**item) for item in data]
async def get_by_id(self, sysid: int) -> FraccionesResponse:
"""Get single Fraccion record by SYSID"""
data = await self._make_request("GET", f"/api/v1/fracciones/{sysid}")
return FraccionesResponse(**data)

View File

@@ -0,0 +1,7 @@
"""FraccionesAnteriores Module"""
from .schemas import FraccionesAnterioresResponse
from .service import FraccionesAnterioresService
from .router import router
__all__ = ["FraccionesAnterioresResponse", "FraccionesAnterioresService", "router"]

View File

@@ -0,0 +1,37 @@
"""FraccionesAnteriores Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import FraccionesAnterioresService
from .schemas import FraccionesAnterioresResponse
router = APIRouter()
@router.get("/", response_model=List[FraccionesAnterioresResponse])
async def search(
fraccion_actual: Optional[str] = Query(None),
fraccion_anterior: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = FraccionesAnterioresService.get_instance()
return await service.search(
fraccion_actual=fraccion_actual,
fraccion_anterior=fraccion_anterior,
skip=skip,
limit=limit,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sysid}", response_model=FraccionesAnterioresResponse)
async def get_by_id(sysid: int, current_user: dict = Depends(get_current_user)):
try:
return await FraccionesAnterioresService.get_instance().get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,15 @@
"""Fracciones Anteriores Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class FraccionesAnterioresResponse(BaseModel):
"""Fracciones arancelarias anteriores (histórico)"""
FRACCIONACTUAL: Optional[str] = Field(None, max_length=10)
FRACCIONANTERIOR: Optional[str] = Field(None, max_length=10)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,36 @@
"""FraccionesAnteriores Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import FraccionesAnterioresResponse
class FraccionesAnterioresService(SitarAPIBaseService):
_instance: Optional["FraccionesAnterioresService"] = None
@classmethod
def get_instance(cls) -> "FraccionesAnterioresService":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion_actual: Optional[str] = None,
fraccion_anterior: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesAnterioresResponse]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion_actual:
params["fraccion_actual"] = fraccion_actual
if fraccion_anterior:
params["fraccion_anterior"] = fraccion_anterior
data = await self._make_request(
"GET", "/api/v1/fracciones-anteriores/", params=params
)
return [FraccionesAnterioresResponse(**item) for item in data]
async def get_by_id(self, sysid: int) -> FraccionesAnterioresResponse:
data = await self._make_request("GET", f"/api/v1/fracciones-anteriores/{sysid}")
return FraccionesAnterioresResponse(**data)

View File

@@ -0,0 +1,7 @@
"""Fracciones USA Module"""
from .schemas import FraccionesUSAResponse
from .service import FraccionesUSAService
from .router import router
__all__ = ["FraccionesUSAResponse", "FraccionesUSAService", "router"]

View File

@@ -0,0 +1,41 @@
"""Fracciones USA Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import FraccionesUSAService
from .schemas import FraccionesUSAResponse
router = APIRouter()
@router.get("/", response_model=List[FraccionesUSAResponse])
async def search_fracciones_usa(
fraccion: Optional[str] = Query(None, description="Fracción arancelaria USA"),
skip: int = Query(0, ge=0, description="Registros a saltar"),
limit: int = Query(100, ge=1, le=1000, description="Máximo de registros"),
current_user: dict = Depends(get_current_user),
):
"""Search USA tariff fractions"""
try:
service = FraccionesUSAService.get_instance()
return await service.search(fraccion=fraccion, skip=skip, limit=limit)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Error fetching USA Fracciones data: {str(e)}"
)
@router.get("/{consecutivo}", response_model=FraccionesUSAResponse)
async def get_fraccion_usa_by_id(
consecutivo: int,
current_user: dict = Depends(get_current_user),
):
"""Get single USA Fraccion record by CONSECUTIVO"""
try:
service = FraccionesUSAService.get_instance()
return await service.get_by_id(consecutivo)
except Exception as e:
raise HTTPException(
status_code=404, detail=f"USA Fraccion record not found: {str(e)}"
)

View File

@@ -0,0 +1,24 @@
"""Fracciones USA Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class FraccionesUSAResponse(BaseModel):
"""USA tariff fractions"""
FRACCION_SIN_PUNTO: Optional[str] = None
FRACCION_CON_PUNTO: Optional[str] = None
FRACCION_MOSTRAR: Optional[str] = None
ESPECIFICO: Optional[str] = None
NIVEL: Optional[str] = None
DESCRIPCION: Optional[str] = None
UNIDADCANTIDAD: Optional[str] = None
TARIFA1: Optional[str] = None
TLC: Optional[str] = None
TARIFA2: Optional[str] = None
NOTAS: Optional[str] = None
CONSECUTIVO: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,37 @@
"""Fracciones USA Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import FraccionesUSAResponse
class FraccionesUSAService(SitarAPIBaseService):
"""Service for USA Fracciones operations"""
_instance: Optional["FraccionesUSAService"] = None
@classmethod
def get_instance(cls) -> "FraccionesUSAService":
"""Get singleton instance"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesUSAResponse]:
"""Search USA tariff fractions"""
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
data = await self._make_request("GET", "/api/v1/fracciones-usa/", params=params)
return [FraccionesUSAResponse(**item) for item in data]
async def get_by_id(self, consecutivo: int) -> FraccionesUSAResponse:
"""Get single USA Fraccion record by CONSECUTIVO"""
data = await self._make_request("GET", f"/api/v1/fracciones-usa/{consecutivo}")
return FraccionesUSAResponse(**data)

View File

@@ -0,0 +1,7 @@
"""Fundamentos TLC Module"""
from .schemas import FundamentosTLCResponse
from .service import FundamentosTLCService
from .router import router
__all__ = ["FundamentosTLCResponse", "FundamentosTLCService", "router"]

View File

@@ -0,0 +1,37 @@
"""Fundamentos TLC Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import FundamentosTLCService
from .schemas import FundamentosTLCResponse
router = APIRouter()
@router.get("/", response_model=List[FundamentosTLCResponse])
async def search_fundamentos(
fraccion: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
tipat_only: bool = Query(False),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = FundamentosTLCService.get_instance()
return await service.search(
fraccion=fraccion, nico=nico, tipat_only=tipat_only, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sysid}", response_model=FundamentosTLCResponse)
async def get_fundamento_by_id(
sysid: int, current_user: dict = Depends(get_current_user)
):
try:
return await FundamentosTLCService.get_instance().get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,27 @@
"""Fundamentos TLC Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class FundamentosTLCResponse(BaseModel):
"""Fundamentos de tratados de libre comercio"""
FRACCION: Optional[str] = Field(None, max_length=8)
PAIS: Optional[str] = Field(None, max_length=3)
ACUERDO: Optional[str] = Field(None, max_length=5)
TASA1TXT: Optional[str] = Field(None, max_length=19)
TASA1NUM: Optional[str] = None
TASA2TXT: Optional[str] = Field(None, max_length=19)
TASA2NUM: Optional[str] = None
FUNDAMENTO1: Optional[str] = None
FUNDAMENTO2: Optional[str] = None
PERIODO: Optional[str] = Field(None, max_length=299)
MODALIDAD: Optional[str] = Field(None, max_length=999)
DOF: Optional[str] = Field(None, max_length=8)
NOTAS: Optional[str] = Field(None, max_length=20)
NICO: Optional[str] = Field("", max_length=2)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,40 @@
"""Fundamentos TLC Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import FundamentosTLCResponse
class FundamentosTLCService(SitarAPIBaseService):
_instance: Optional["FundamentosTLCService"] = None
@classmethod
def get_instance(cls) -> "FundamentosTLCService":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
tipat_only: bool = False,
skip: int = 0,
limit: int = 100,
) -> List[FundamentosTLCResponse]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if nico:
params["nico"] = nico
endpoint = (
"/api/v1/fundamentos-tlc/tipat"
if tipat_only
else "/api/v1/fundamentos-tlc/"
)
data = await self._make_request("GET", endpoint, params=params)
return [FundamentosTLCResponse(**item) for item in data]
async def get_by_id(self, sysid: int) -> FundamentosTLCResponse:
data = await self._make_request("GET", f"/api/v1/fundamentos-tlc/{sysid}")
return FundamentosTLCResponse(**data)

View File

@@ -0,0 +1,7 @@
"""Ieps Module"""
from .schemas import IepsResponse
from .service import IepsService
from .router import router
__all__ = ["IepsResponse", "IepsService", "router"]

View File

@@ -0,0 +1,34 @@
"""Ieps Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import IepsService
from .schemas import IepsResponse
router = APIRouter()
@router.get("/", response_model=List[IepsResponse])
async def search(
fraccion: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = IepsService.get_instance()
return await service.search(
fraccion=fraccion, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{consecutivo}", response_model=IepsResponse)
async def get_by_id(consecutivo: int, current_user: dict = Depends(get_current_user)):
try:
return await IepsService.get_instance().get_by_id(consecutivo)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,20 @@
"""IEPS Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class IepsResponse(BaseModel):
"""IEPS (Impuesto Especial sobre Producción y Servicios)"""
FRACCION_SIN_PUNTO: Optional[str] = Field(None, max_length=10)
FRACCION_CON_PUNTO: Optional[str] = Field(None, max_length=10)
FUNDAMENTO: Optional[str] = Field(None, max_length=1000)
CONDICION: Optional[str] = Field(None, max_length=5000)
TASA: Optional[str] = None
TASAESPECIFICO: Optional[str] = None
NICO: Optional[str] = Field("", max_length=2)
CONSECUTIVO: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,34 @@
"""Ieps Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import IepsResponse
class IepsService(SitarAPIBaseService):
_instance: Optional["IepsService"] = None
@classmethod
def get_instance(cls) -> "IepsService":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[IepsResponse]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if nico:
params["nico"] = nico
data = await self._make_request("GET", "/api/v1/ieps/", params=params)
return [IepsResponse(**item) for item in data]
async def get_by_id(self, consecutivo: int) -> IepsResponse:
data = await self._make_request("GET", f"/api/v1/ieps/{consecutivo}")
return IepsResponse(**data)

View File

@@ -0,0 +1,7 @@
"""InformacionGeneral Module"""
from .schemas import InformacionGeneralResponse
from .service import InformacionGeneralService
from .router import router
__all__ = ["InformacionGeneralResponse", "InformacionGeneralService", "router"]

View File

@@ -0,0 +1,34 @@
"""InformacionGeneral Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import InformacionGeneralService
from .schemas import InformacionGeneralResponse
router = APIRouter()
@router.get("/", response_model=List[InformacionGeneralResponse])
async def search(
fraccion: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = InformacionGeneralService.get_instance()
return await service.search(
fraccion=fraccion, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sysid}", response_model=InformacionGeneralResponse)
async def get_by_id(sysid: int, current_user: dict = Depends(get_current_user)):
try:
return await InformacionGeneralService.get_instance().get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,21 @@
"""Información General Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class InformacionGeneralResponse(BaseModel):
"""Información general de fracciones"""
OBSERVACIONES: Optional[str] = Field(None, max_length=1000)
NOTAOBSERVACIONES: Optional[str] = Field(None, max_length=1000)
FRACCION: Optional[str] = Field(None, max_length=10)
TIPOOPERACION: Optional[int] = None
ORDEN: Optional[int] = None
LEYENDA: Optional[str] = None
NOTA: Optional[str] = None
NICO: Optional[str] = Field("", max_length=2)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,36 @@
"""InformacionGeneral Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import InformacionGeneralResponse
class InformacionGeneralService(SitarAPIBaseService):
_instance: Optional["InformacionGeneralService"] = None
@classmethod
def get_instance(cls) -> "InformacionGeneralService":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[InformacionGeneralResponse]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if nico:
params["nico"] = nico
data = await self._make_request(
"GET", "/api/v1/informacion-general/", params=params
)
return [InformacionGeneralResponse(**item) for item in data]
async def get_by_id(self, sysid: int) -> InformacionGeneralResponse:
data = await self._make_request("GET", f"/api/v1/informacion-general/{sysid}")
return InformacionGeneralResponse(**data)

View File

@@ -0,0 +1,7 @@
"""Noms Module"""
from .schemas import NomsResponse
from .service import NomsService
from .router import router
__all__ = ["NomsResponse", "NomsService", "router"]

View File

@@ -0,0 +1,35 @@
"""Noms Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import NomsService
from .schemas import NomsResponse
router = APIRouter()
@router.get("/", response_model=List[NomsResponse])
async def search(
fraccion: Optional[str] = Query(None),
pais: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = NomsService.get_instance()
return await service.search(
fraccion=fraccion, pais=pais, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sysid}", response_model=NomsResponse)
async def get_by_id(sysid: int, current_user: dict = Depends(get_current_user)):
try:
return await NomsService.get_instance().get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,27 @@
"""NOMs Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class NomsResponse(BaseModel):
"""Normas Oficiales Mexicanas"""
FRACCION: Optional[str] = Field("", max_length=10)
IMPORTACION: Optional[str] = Field("", max_length=1)
EXPORTACION: Optional[str] = Field("", max_length=1)
PERMISO: Optional[str] = Field("", max_length=19)
ACUERDO: Optional[str] = Field("", max_length=100)
CONDICION: Optional[str] = Field("")
FUNDAMENTO: Optional[str] = Field("")
DOF: Optional[str] = Field("", max_length=8)
FORMATONOM: Optional[str] = Field("", max_length=100)
INSTRUCCIONES: Optional[str] = Field("", max_length=100)
CRITERIO: Optional[str] = Field("", max_length=254)
COMPLEMENTO: Optional[str] = Field("", max_length=999)
NICO: Optional[str] = Field("", max_length=2)
PAIS: Optional[str] = Field("", max_length=3)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,37 @@
"""Noms Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import NomsResponse
class NomsService(SitarAPIBaseService):
_instance: Optional["NomsService"] = None
@classmethod
def get_instance(cls) -> "NomsService":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
pais: Optional[str] = None,
nico: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[NomsResponse]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if pais:
params["pais"] = pais
if nico:
params["nico"] = nico
data = await self._make_request("GET", "/api/v1/noms/", params=params)
return [NomsResponse(**item) for item in data]
async def get_by_id(self, sysid: int) -> NomsResponse:
data = await self._make_request("GET", f"/api/v1/noms/{sysid}")
return NomsResponse(**data)

View File

@@ -0,0 +1,7 @@
"""Precios Estimados Module"""
from .schemas import PreciosEstimados2Response
from .service import PreciosEstimadosService
from .router import router
__all__ = ["PreciosEstimados2Response", "PreciosEstimadosService", "router"]

View File

@@ -0,0 +1,34 @@
"""Precios Estimados Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import PreciosEstimadosService
from .schemas import PreciosEstimados2Response
router = APIRouter()
@router.get("/", response_model=List[PreciosEstimados2Response])
async def search_precios(
fraccion: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = PreciosEstimadosService.get_instance()
return await service.search(
fraccion=fraccion, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sysid}", response_model=PreciosEstimados2Response)
async def get_precio_by_id(sysid: int, current_user: dict = Depends(get_current_user)):
try:
return await PreciosEstimadosService.get_instance().get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,18 @@
"""Precios Estimados Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class PreciosEstimados2Response(BaseModel):
"""Precios Estimados"""
FRACCION: Optional[str] = Field(None, max_length=10)
UMC: Optional[str] = Field(None, max_length=2)
PRECIO: Optional[str] = None
DOF: Optional[str] = Field(None, max_length=8)
NICO: Optional[str] = Field("", max_length=2)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,36 @@
"""Precios Estimados Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import PreciosEstimados2Response
class PreciosEstimadosService(SitarAPIBaseService):
_instance: Optional["PreciosEstimadosService"] = None
@classmethod
def get_instance(cls) -> "PreciosEstimadosService":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[PreciosEstimados2Response]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if nico:
params["nico"] = nico
data = await self._make_request(
"GET", "/api/v1/precios-estimados2/", params=params
)
return [PreciosEstimados2Response(**item) for item in data]
async def get_by_id(self, sysid: int) -> PreciosEstimados2Response:
data = await self._make_request("GET", f"/api/v1/precios-estimados2/{sysid}")
return PreciosEstimados2Response(**data)

View File

@@ -0,0 +1,7 @@
"""PROSEC Module"""
from .schemas import ProsecResponse
from .service import ProsecService
from .router import router
__all__ = ["ProsecResponse", "ProsecService", "router"]

View File

@@ -0,0 +1,42 @@
"""PROSEC Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import ProsecService
from .schemas import ProsecResponse
router = APIRouter()
@router.get("/", response_model=List[ProsecResponse])
async def search_prosec(
fraccion: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
sector: Optional[str] = Query(None),
articulo: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = ProsecService.get_instance()
return await service.search(
fraccion=fraccion,
nico=nico,
sector=sector,
articulo=articulo,
skip=skip,
limit=limit,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
@router.get("/{sysid}", response_model=ProsecResponse)
async def get_prosec_by_id(sysid: int, current_user: dict = Depends(get_current_user)):
try:
service = ProsecService.get_instance()
return await service.get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=f"Not found: {str(e)}")

View File

@@ -0,0 +1,21 @@
"""PROSEC Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class ProsecResponse(BaseModel):
"""PROSEC (Programa de Promoción Sectorial)"""
FRACCION: Optional[str] = Field(None, max_length=10)
PRODUCTO: Optional[str] = Field(None, max_length=999)
TASA: Optional[str] = Field(None, max_length=19)
SECTOR: Optional[str] = Field(None, max_length=2)
ANEXO: Optional[str] = Field(None, max_length=19)
DOF: Optional[str] = Field(None, max_length=8)
NOTAS: Optional[str] = Field(None, max_length=5000)
NICO: Optional[str] = Field("", max_length=2)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,43 @@
"""PROSEC Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import ProsecResponse
class ProsecService(SitarAPIBaseService):
"""Service for PROSEC operations"""
_instance: Optional["ProsecService"] = None
@classmethod
def get_instance(cls) -> "ProsecService":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
sector: Optional[str] = None,
articulo: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[ProsecResponse]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if nico:
params["nico"] = nico
if sector:
params["sector"] = sector
if articulo:
params["articulo"] = articulo
data = await self._make_request("GET", "/api/v1/prosec/", params=params)
return [ProsecResponse(**item) for item in data]
async def get_by_id(self, sysid: int) -> ProsecResponse:
data = await self._make_request("GET", f"/api/v1/prosec/{sysid}")
return ProsecResponse(**data)

View File

@@ -0,0 +1,7 @@
"""Rcg2 Module"""
from .schemas import Rcg2Response
from .service import Rcg2Service
from .router import router
__all__ = ["Rcg2Response", "Rcg2Service", "router"]

View File

@@ -0,0 +1,34 @@
"""Rcg2 Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import Rcg2Service
from .schemas import Rcg2Response
router = APIRouter()
@router.get("/", response_model=List[Rcg2Response])
async def search(
fraccion: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = Rcg2Service.get_instance()
return await service.search(
fraccion=fraccion, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sysid}", response_model=Rcg2Response)
async def get_by_id(sysid: int, current_user: dict = Depends(get_current_user)):
try:
return await Rcg2Service.get_instance().get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,26 @@
"""RCG2 Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class Rcg2Response(BaseModel):
"""Reglas de Carácter General"""
FRACCION: Optional[str] = Field(None, max_length=10)
ANEXO: Optional[str] = Field(None, max_length=19)
SECTOR: Optional[str] = None
REGIMEN: Optional[str] = None
CONDICION: Optional[str] = None
ADUANAS: Optional[str] = Field(None, max_length=1000)
DOF: Optional[str] = Field(None, max_length=8)
ARCHIVOCRITERIO: Optional[str] = Field(None, max_length=1000)
TIPO: Optional[str] = Field(None, max_length=1)
IMPO: Optional[int] = None
EXPO: Optional[int] = None
WEB_DOCUMENTO_ID: Optional[int] = None
NICO: Optional[str] = Field("", max_length=2)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,34 @@
"""Rcg2 Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import Rcg2Response
class Rcg2Service(SitarAPIBaseService):
_instance: Optional["Rcg2Service"] = None
@classmethod
def get_instance(cls) -> "Rcg2Service":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[Rcg2Response]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if nico:
params["nico"] = nico
data = await self._make_request("GET", "/api/v1/rcg2/", params=params)
return [Rcg2Response(**item) for item in data]
async def get_by_id(self, sysid: int) -> Rcg2Response:
data = await self._make_request("GET", f"/api/v1/rcg2/{sysid}")
return Rcg2Response(**data)

View File

@@ -0,0 +1,7 @@
"""Regulaciones Module"""
from .schemas import RegulacionesResponse
from .service import RegulacionesService
from .router import router
__all__ = ["RegulacionesResponse", "RegulacionesService", "router"]

View File

@@ -0,0 +1,37 @@
"""Regulaciones Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import RegulacionesService
from .schemas import RegulacionesResponse
router = APIRouter()
@router.get("/", response_model=List[RegulacionesResponse])
async def search_regulaciones(
fraccion: Optional[str] = Query(None, description="Fracción arancelaria"),
nico: Optional[str] = Query(None, description="NICO"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = RegulacionesService.get_instance()
return await service.search(
fraccion=fraccion, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
@router.get("/{sysid}", response_model=RegulacionesResponse)
async def get_regulacion_by_id(
sysid: int, current_user: dict = Depends(get_current_user)
):
try:
service = RegulacionesService.get_instance()
return await service.get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=f"Not found: {str(e)}")

View File

@@ -0,0 +1,19 @@
"""Regulaciones Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class RegulacionesResponse(BaseModel):
"""Regulaciones y restricciones"""
FRACCION: Optional[str] = Field(None, max_length=10)
PERMISO: Optional[str] = Field(None, max_length=2)
ACUERDO: Optional[str] = Field(None, max_length=15)
CLAVE: Optional[str] = Field(None, max_length=10)
DESCRIPCION: Optional[str] = None
NICO: Optional[str] = Field("", max_length=2)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,37 @@
"""Regulaciones Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import RegulacionesResponse
class RegulacionesService(SitarAPIBaseService):
"""Service for Regulaciones operations"""
_instance: Optional["RegulacionesService"] = None
@classmethod
def get_instance(cls) -> "RegulacionesService":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[RegulacionesResponse]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if nico:
params["nico"] = nico
data = await self._make_request("GET", "/api/v1/regulaciones/", params=params)
return [RegulacionesResponse(**item) for item in data]
async def get_by_id(self, sysid: int) -> RegulacionesResponse:
data = await self._make_request("GET", f"/api/v1/regulaciones/{sysid}")
return RegulacionesResponse(**data)

View File

@@ -0,0 +1,7 @@
"""Reit Module"""
from .schemas import ReitResponse
from .service import ReitService
from .router import router
__all__ = ["ReitResponse", "ReitService", "router"]

View File

@@ -0,0 +1,34 @@
"""Reit Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import ReitService
from .schemas import ReitResponse
router = APIRouter()
@router.get("/", response_model=List[ReitResponse])
async def search(
fraccion: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = ReitService.get_instance()
return await service.search(
fraccion=fraccion, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sysid}", response_model=ReitResponse)
async def get_by_id(sysid: int, current_user: dict = Depends(get_current_user)):
try:
return await ReitService.get_instance().get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,25 @@
"""REIT Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class ReitResponse(BaseModel):
"""Registro de Empresas de Industria Terminal"""
FRACCION: Optional[str] = Field(None, max_length=10)
ARTICULO: Optional[str] = Field(None, max_length=50)
FUNDAMENTO: Optional[str] = Field(None, max_length=1500)
ACUERDO: Optional[str] = Field(None, max_length=500)
PERMISO: Optional[str] = Field(None, max_length=2)
DOF: Optional[str] = Field(None, max_length=8)
DOCUMENTO: Optional[str] = Field(None, max_length=200)
TEMPORALIDAD: Optional[int] = None
TEMPORALIDADSERVICIO: Optional[int] = None
TEMPORALIDADCERTIFICADA: Optional[int] = None
WEB_DOCUMENTO_ID: Optional[int] = None
NICO: Optional[str] = Field("", max_length=2)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,34 @@
"""Reit Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import ReitResponse
class ReitService(SitarAPIBaseService):
_instance: Optional["ReitService"] = None
@classmethod
def get_instance(cls) -> "ReitService":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[ReitResponse]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if nico:
params["nico"] = nico
data = await self._make_request("GET", "/api/v1/reit/", params=params)
return [ReitResponse(**item) for item in data]
async def get_by_id(self, sysid: int) -> ReitResponse:
data = await self._make_request("GET", f"/api/v1/reit/{sysid}")
return ReitResponse(**data)

View File

@@ -0,0 +1,7 @@
"""RequisitoPrevio Module"""
from .schemas import RequisitoPrevioResponse
from .service import RequisitoPrevioService
from .router import router
__all__ = ["RequisitoPrevioResponse", "RequisitoPrevioService", "router"]

View File

@@ -0,0 +1,34 @@
"""RequisitoPrevio Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, Depends
from core.security import get_current_user
from .service import RequisitoPrevioService
from .schemas import RequisitoPrevioResponse
router = APIRouter()
@router.get("/", response_model=List[RequisitoPrevioResponse])
async def search(
fraccion: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = RequisitoPrevioService.get_instance()
return await service.search(
fraccion=fraccion, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sysid}", response_model=RequisitoPrevioResponse)
async def get_by_id(sysid: int, current_user: dict = Depends(get_current_user)):
try:
return await RequisitoPrevioService.get_instance().get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

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