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

This commit is contained in:
2026-03-17 09:30:11 -05:00
33 changed files with 979 additions and 122 deletions

View File

@@ -26,7 +26,6 @@ import datetime
from decimal import Decimal
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
from ..discharges.models import DischargeDetail
from sqlalchemy import (
BigInteger,

View File

@@ -23,7 +23,7 @@ class UnitOfMeasureAmericanBase(BaseModel):
class UnitOfMeasureCustomsBase(BaseModel):
code: str = Field(..., max_length=10, description="Customs Code")
description: Optional[str] = Field(None, max_length=50)
scaii_unit_code: Optional[str] = Field(None, max_length=5)
a76_unit_code: Optional[str] = Field(None, max_length=5, description="Unidad SCAII")
class UnitOfMeasureBase(BaseModel):
@@ -86,7 +86,7 @@ class UnitOfMeasureAmericanUpdate(BaseModel):
class UnitOfMeasureCustomsUpdate(BaseModel):
code: Optional[str] = Field(None, max_length=10)
description: Optional[str] = Field(None, max_length=50)
scaii_unit_code: Optional[str] = Field(None, max_length=5)
a76_unit_code: Optional[str] = Field(None, max_length=5)
class UnitOfMeasureUpdate(BaseModel):

View File

@@ -1,24 +1,24 @@
seed = [
("1", "Kilo"),
("2", "Gramo"),
("3", "Metro Lineal"),
("4", "Metro Cuadrado"),
("5", "Metro Cubico"),
("6", "Pieza"),
("7", "Cabeza"),
("8", "Litro"),
("9", "Par"),
("10", "Kilowatt"),
("11", "Millar"),
("12", "Juego"),
("13", "Kilowatt/Hora"),
("14", "Tonelada"),
("15", "Barril"),
("16", "Gramo Neto"),
("17", "Decenas"),
("18", "Cientos"),
("19", "Decenas"),
("20", "Caja"),
("21", "Botella"),
("22", "Carat"),
("1", "Kilo", "KGS"),
("2", "Gramo", "GR"),
("3", "Metro Lineal", "MT"),
("4", "Metro Cuadrado", "M2"),
("5", "Metro Cubico", "M3"),
("6", "Pieza", "PZA"),
("7", "Cabeza", "PZA"),
("8", "Litro", "LT"),
("9", "Par", "PAR "),
("10", "Kilowatt", ""),
("11", "Millar", "MILLR"),
("12", "Juego", "JGO"),
("13", "Kilowatt/Hora", ""),
("14", "Tonelada", "TON"),
("15", "Barril", "BARR"),
("16", "Gramo Neto", ""),
("17", "Decenas", "DEC"),
("18", "Cientos", "CIEN"),
("19", "Decenas", "DOCE"),
("20", "Caja", "CAJA"),
("21", "Botella", "PZA"),
("22", "Carat", "CARAT"),
]

View File

@@ -7,14 +7,16 @@ from core.exceptions import ErrorCollector
def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector):
if invoice.status == InvoiceStatus.PROCESSED:
errors.add(
errors.add_error(
"status",
"La factura ya fue procesada y no puede ser exportada",
solution=["Verifique el estatus de la factura antes de intentar exportarla"],
"La factura ya fue procesada y no puede volver a actualizarse. Desactualícela primero.",
solution=["Use el botón 'Desactualizar' antes de volver a procesar la factura."],
code="ALREADY_PROCESSED",
value=invoice.status,
)
errors.raise_if_errors()
return
if not invoice.invoice_date:
errors.add_required_error("invoice_date")
@@ -79,7 +81,7 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_
LineItem.company_id == company_id,
).all()
fractions = {line.fraction for line in lines if line.fraction}
fractions = {line.customs.fraction for line in lines if line.customs.fraction}
if fractions:
warned_fractions = {
row.fraction
@@ -88,7 +90,7 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_
.all()
}
for line in lines:
if line.fraction in warned_fractions:
if line.customs.fraction in warned_fractions:
errors.add_warning(
field="fraction",
message="Advertencia: Esta mercancía, sólo podrá entrar al territorio nacional por las aduanas del país, de lunes a sábado de 8:00 a 13:00 hrs. Ley 10, 18, LIGIE 1, Capítulo 87, RGCE 4.5.31., Anexo 4.",

View File

@@ -1,30 +1,65 @@
from typing import List
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import HistoricalTariffFraction
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import (
HistoricalTariffFraction,
)
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
def _fraction_exists_in_catalog(db: Session, fraction_code: str) -> bool:
"""Returns True if the fraction exists in TariffFraction (SFracciones) or
HistoricalTariffFraction (GFraccionesHistorico).
def _fraction_exists_via_sitar(fraction_code: str) -> bool:
"""Returns True if the fraction exists in SITAR (fracciones o fracciones-anteriores).
Fraction format: first 8 chars = base fraction, chars 9-10 = NICO/country (optional).
Falls back to False if SITAR is not configured or request fails.
"""
if not fraction_code:
return True
base_frac = fraction_code[:8].strip()
nico = fraction_code[8:10].strip() if len(fraction_code) > 8 else ""
try:
from api.v1.modules.sitar.fracciones.service import FraccionesService
from api.v1.modules.sitar.fracciones_anteriores.service import (
FraccionesAnterioresService,
)
# 1. Buscar en fracciones arancelarias (SITAR)
results = FraccionesService.search_sync(
fraccion=base_frac,
nico=nico if nico else None,
limit=1,
)
if results:
return True
# 2. Buscar en fracciones anteriores / histórico (SITAR)
hist_results = FraccionesAnterioresService.search_sync(
fraccion_anterior=base_frac,
limit=1,
)
return len(hist_results) > 0
except (ValueError, Exception):
# SITAR no configurado o error de red: se usa fallback a BD local
return False
def _fraction_exists_in_local_db(db: Session, fraction_code: str) -> bool:
"""Fallback: valida contra TariffFraction e HistoricalTariffFraction locales."""
if not fraction_code:
return True
base_frac = fraction_code[:8]
nico = fraction_code[8:10] if len(fraction_code) > 8 else ""
# Check SFracciones (TariffFraction)
tariff_q = db.query(TariffFraction).filter(
func.left(TariffFraction.code, 8) == base_frac
)
@@ -37,7 +72,6 @@ def _fraction_exists_in_catalog(db: Session, fraction_code: str) -> bool:
if tariff_q.first() is not None:
return True
# Check GFraccionesHistorico (HistoricalTariffFraction)
hist_q = db.query(HistoricalTariffFraction).filter(
HistoricalTariffFraction.historical_fraction == base_frac
)
@@ -53,6 +87,17 @@ def _fraction_exists_in_catalog(db: Session, fraction_code: str) -> bool:
return hist_q.first() is not None
def _fraction_exists_in_catalog(db: Session, fraction_code: str) -> bool:
"""Returns True if the fraction exists in SITAR (fracciones) or fallback a BD local.
Usa las funciones de SITAR de fracciones como fuente principal.
Si SITAR no está configurado o falla, valida contra TariffFraction e HistoricalTariffFraction.
"""
if _fraction_exists_via_sitar(fraction_code):
return True
return _fraction_exists_in_local_db(db, fraction_code)
def _validate_line_fraction(
db: Session, line: LineItem, errors: ErrorCollector
) -> None:

View File

@@ -0,0 +1,234 @@
from decimal import Decimal
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a24.discharges.models import DischargeDetail, DischargeHeader, DischargeStatus
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
from .sub_process.review_rule_octave import borra_saldos_regla_octava
# ─────────────────────────────────────────────────────────────────────────────
# Validación de cantidades retornadas con detalle de exportaciones activas
# ─────────────────────────────────────────────────────────────────────────────
def _validate_returned_quantities(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
errors: ErrorCollector,
) -> None:
"""
Verifica que ninguna partida tenga saldos pendientes por exportaciones
activas que la afecten.
Paridad: bloque 'REVISA CANTIDADES RETORNADAS' (Clarion SCAII).
El Clarion recorre las partidas con (CantRetornadaTemp + CantRetornada +
CantExistencia) <> 0 y luego busca en QEqeMaq (exportaciones definitivas),
QEqeMaqRep (exportaciones de reparación) y QEqiMaqRep (importaciones de
reparación) para identificar qué factura de exportación activa (Estatus='AC')
tiene esa partida descargada.
En Python los mismos vínculos viven en:
DischargeDetail.import_item_line_id → la partida de importación consumida
DischargeDetail.header → DischargeHeader
DischargeHeader.source_invoice_id → InvoiceHeader (la factura de exportación)
InvoiceHeader.status → InvoiceStatus.PROCESSED (≡ Estatus='AC')
Para cada partida con saldo se buscan DischargeDetail con status APPLIED en
una factura de exportación procesada y se reporta qué factura debe
desactualizarse primero.
"""
lines_with_balance = [
line for line in lines
if line.quantity is not None and (
(line.quantity.quantity_returned_temp or Decimal(0))
+ (line.quantity.quantity_returned or Decimal(0))
+ (line.quantity.quantity_existence or Decimal(0))
) != Decimal(0)
]
if not lines_with_balance:
return
for line in lines_with_balance:
qty_ret_temp = line.quantity.quantity_returned_temp or Decimal(0)
qty_ret = line.quantity.quantity_returned or Decimal(0)
qty_exist = line.quantity.quantity_existence or Decimal(0)
# Buscar DischargeDetail vinculados a esta partida de importación
# cuya factura de exportación esté activa (PROCESSED).
# Paridad: bucle sobre QEqeMaq/QEqeMaqRep donde Descarga=1 y
# encabezado de exportación con Estatus='AC'.
details: List[DischargeDetail] = (
db.query(DischargeDetail)
.join(DischargeDetail.header)
.filter(
DischargeDetail.import_item_line_id == line.id,
DischargeHeader.status == DischargeStatus.APPLIED,
)
.all()
)
active_details = [
d for d in details
if d.header
and d.header.source_invoice is not None
and d.header.source_invoice.status == InvoiceStatus.PROCESSED
]
if active_details:
# Reportar un error por cada factura de exportación activa distinta
# (equivale a QueErr en el Clarion).
seen_export_invoices: set = set()
for detail in active_details:
src_invoice = detail.header.source_invoice
src_number = src_invoice.invoice_number or str(src_invoice.id)
if src_number in seen_export_invoices:
continue
seen_export_invoices.add(src_number)
export_line_number = (
detail.export_line.line_number if detail.export_line else "?"
)
errors.add_error(
field=f"line[{line.line_number}].discharge",
message=(
f"La Línea: {line.line_number} se ha descargado "
f"{detail.quantity_discharged} en la factura: {src_number} "
f"de exportación con Línea: {export_line_number}."
),
solution=[
f"Desactualizar la factura: {src_number} "
"para regresar saldos a la partida."
],
code="LINE_HAS_ACTIVE_DISCHARGE",
)
else:
# La partida tiene saldo pero no hay descarga activa rastreable —
# reportar el saldo directamente para que el usuario lo investigue.
errors.add_error(
field=f"line[{line.line_number}].quantities",
message=(
f"La Línea: {line.line_number} tiene saldos pendientes "
f"(retornada: {qty_ret}, retornada temp: {qty_ret_temp}, "
f"existencia: {qty_exist}) y no se puede desactualizar."
),
solution=[
"Verifique las exportaciones que afectan a esta partida "
"y desactualícelas primero."
],
code="LINE_HAS_BALANCE",
)
# ─────────────────────────────────────────────────────────────────────────────
# Reset de la factura e inventario
# ─────────────────────────────────────────────────────────────────────────────
def _reset_invoice_financials(invoice: InvoiceHeader) -> None:
"""
Reinicia los totales financieros del encabezado de la factura a cero y
cambia el estatus a PENDING (equivalente a Estatus='NA' en Clarion).
Paridad: UPDATE QFacImp SET CantImpo=0, PesoNeto=0, PesoBruto=0,
Cantbultos=0, ValorImpoMN=0, ValorImpoME=0, ValorImpoMC=0,
ValorAduanasMN=0, ValorAduanasME=0, Estatus='NA',
ComofueProcesada='', ValorIVAMN=0, ValorIVAME=0 (Clarion SCAII).
"""
fin = invoice.financials
if fin is None:
return
fin.total_quantity = 0.0
fin.net_weight = 0.0
fin.gross_weight = 0.0
fin.total_packages = 0
fin.value_mn = 0.0
fin.value_me = 0.0
fin.value_mc = 0.0
fin.customs_value_mn = 0.0
fin.customs_value_me = 0.0
fin.iva_mn = 0.0
fin.iva_me = 0.0
invoice.status = InvoiceStatus.PENDING
invoice.process_method = None
def _reset_line_quantities(lines: List[LineItem]) -> None:
"""
Reinicia los contadores de inventario de cada partida a cero.
Paridad: UPDATE QEqiMaq SET CantRetornada=0, CantRetornadaTemp=0,
ValorRetornadoMN=0, ValorRetornadoME=0, CantExistencia=0,
ValorIVAMNUsado=0, ValorIVAMEUsado=0 (Clarion SCAII).
"""
for line in lines:
if line.quantity is not None:
line.quantity.quantity_returned = Decimal(0)
line.quantity.quantity_returned_temp = Decimal(0)
line.quantity.quantity_existence = Decimal(0)
if line.financial is not None:
line.financial.value_returned_mxn = Decimal(0)
line.financial.value_returned_usd = Decimal(0)
line.financial.vat_used_mxn = Decimal(0)
line.financial.vat_used_usd = Decimal(0)
# ─────────────────────────────────────────────────────────────────────────────
# Proceso principal de reversión
# ─────────────────────────────────────────────────────────────────────────────
def revert_process(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> list:
"""
Proceso principal de des-actualización de una factura de importación
temporal.
Paridad: rutina principal 'DESACTUALIZAR FACTURA IMPO TEMP' (Clarion SCAII).
Flujo:
1. Verifica que ninguna partida tenga saldos activos por exportaciones
procesadas (QueueErrorAct en Clarion). Si los hay → ValidationException.
2. Si no hay errores de validación:
a. Do BORRASALDOS_REGLA_OCTAVA — revierte cupos de Regla Octava.
b. UPDATE QFacImp — reinicia totales del encabezado (status → PENDING).
c. UPDATE QEqiMaq — reinicia contadores de inventario por partida.
3. Retorna sql_errors (errores no-bloqueantes de BD, equivalente a
QueueErrorSQL en Clarion).
Raises:
ValidationException: si hay partidas con descargas activas
(equivale a Records(QueueErrorAct) <> 0).
"""
# ── Paso 1: REVISA CANTIDADES RETORNADAS ──────────────────────────────────
_validate_returned_quantities(db, invoice, lines, errors)
errors.raise_if_errors()
# ── Paso 2a: Do BORRASALDOS_REGLA_OCTAVA ─────────────────────────────────
sql_errors: list = []
borra_saldos_regla_octava(
db=db,
invoice_import=invoice.invoice_number or "",
tenant_id=tenant_id,
company_id=company_id,
sql_errors=sql_errors,
)
# ── Paso 2b: UPDATE QFacImp ───────────────────────────────────────────────
_reset_invoice_financials(invoice)
# ── Paso 2c: UPDATE QEqiMaq ───────────────────────────────────────────────
_reset_line_quantities(lines)
return sql_errors

View File

@@ -0,0 +1,42 @@
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
def pre_validators(
db: Session,
invoice: InvoiceHeader,
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> List[LineItem]:
"""
Validaciones previas a la reversión de una factura de importación temporal.
- Verifica que la factura esté en estatus PROCESSED.
- Carga y retorna las partidas asociadas a la factura.
"""
if invoice.status != InvoiceStatus.PROCESSED:
errors.add_error(
"status",
"La factura no fue procesada y no puede ser revertida",
solution=["Verifique el estatus de la factura antes de intentar deshacer el proceso"],
code="NOT_PROCESSED",
value=invoice.status,
)
lines: List[LineItem] = (
db.query(LineItem)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.all()
)
return lines

View File

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

View File

@@ -0,0 +1,94 @@
from decimal import Decimal
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.rule_octave.balances.models import OctaveBalance
from api.v1.modules.a76.rule_octave.fractions.models import FractionRuleOctave
def borra_saldos_regla_octava(
db: Session,
invoice_import: str,
tenant_id: str,
company_id: str,
sql_errors: list,
) -> None:
"""
Revierte los saldos de Regla Octava registrados al procesar una factura de
importación temporal.
Paridad: BORRASALDOS_REGLA_OCTAVA (Clarion SCAII).
Por cada registro en SSaldosReglaOctava (OctaveBalance) con origin='TEM' y
system='SCAF' que corresponda a la factura:
1. Resta de vuelta la cantidad y el valor en GFracROctava (FractionRuleOctave).
2. Elimina el registro de OctaveBalance.
Los errores de actualización se acumulan en sql_errors como dicts con las
claves 'consecutive' y 'error'.
"""
consecutive_ref = [0]
balances: List[OctaveBalance] = (
db.query(OctaveBalance)
.filter(
OctaveBalance.tenant_id == tenant_id,
OctaveBalance.company_id == company_id,
OctaveBalance.invoice_import == invoice_import,
OctaveBalance.origin == "TEM",
OctaveBalance.system == "SCAF",
)
.all()
)
for balance in balances:
fra_oct = (
db.query(FractionRuleOctave)
.filter(
FractionRuleOctave.tenant_id == tenant_id,
FractionRuleOctave.company_id == company_id,
FractionRuleOctave.permission == balance.octave_permit,
FractionRuleOctave.line == balance.line,
)
.first()
)
# REGRESAR EL SALDO AL PERMISO DE REGLA OCTAVA
# Si el permiso no existe (TryFetch falla en Clarion), se omite el PUT
# pero el DELETE del saldo se ejecuta de todas formas — paridad Clarion.
if fra_oct is not None:
qty_back = balance.quantity_stock or Decimal(0)
val_back = balance.value_me or Decimal(0)
fra_oct.quantity_used = max(
Decimal(0),
(fra_oct.quantity_used or Decimal(0)) - qty_back,
)
fra_oct.value_used = max(
Decimal(0),
(fra_oct.value_used or Decimal(0)) - val_back,
)
try:
db.flush([fra_oct])
except Exception as exc:
consecutive_ref[0] += 1
sql_errors.append({
"consecutive": consecutive_ref[0],
"error": (
f"Error al regresar el Cupo en (Permiso de Regla Octava) {exc}"
),
})
# DELETE(SSaldosReglaOctava) — siempre se intenta, igual que en Clarion
try:
db.delete(balance)
db.flush([balance])
except Exception as exc:
consecutive_ref[0] += 1
sql_errors.append({
"consecutive": consecutive_ref[0],
"error": (
f"Error al Eliminar en (SSaldosReglaOctava) {exc}"
),
})

View File

@@ -0,0 +1,82 @@
from celery import Task
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.exceptions import ErrorCollector, ValidationException
from api.v1.modules.a76.invoices.models import InvoiceHeader
from .pre_validators import pre_validators
from .main_process import revert_process
def _progress(task: Task, current: int, status: str) -> None:
task.update_state(state="PROGRESS", meta={"current": current, "status": status})
@celery_app.task(bind=True, name="revert_invoice_task")
def revert_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str) -> dict:
"""
Des-actualiza una factura de importación temporal ejecutando todas las
validaciones y reversiones del proceso principal (revert/main_process) con
reporte de progreso.
"""
db = CoreSessionLocal()
try:
# ── Paso 1: Cargar factura ────────────────────────────────────────────
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
}
errors = ErrorCollector()
# ── Paso 2: Pre-validaciones ──────────────────────────────────────────
_progress(self, 10, "Validando estatus de la factura...")
lines = pre_validators(db, invoice, tenant_id, company_id, errors)
if not lines:
errors.add_error(
field="line_items",
message="La factura no contiene partidas para revertir",
solution=["Verifique que la factura tenga partidas antes de intentar revertirla"],
code="NO_LINE_ITEMS",
)
errors.raise_if_errors()
# ── Paso 3: Validar cantidades y ejecutar reversión ───────────────────
_progress(self, 40, "Verificando saldos de partidas...")
sql_errors = revert_process(
db=db,
invoice=invoice,
lines=lines,
tenant_id=tenant_id,
company_id=company_id,
errors=errors,
)
# ── Paso 4: Confirmar transacción ─────────────────────────────────────
_progress(self, 95, "Confirmando cambios...")
db.flush()
db.commit()
return {
"status": "success",
"invoice_id": invoice_id,
"sql_errors": sql_errors,
}
except ValidationException as exc:
db.rollback()
return {
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
except Exception as exc:
db.rollback()
raise exc
finally:
db.close()

View File

@@ -4,6 +4,7 @@ from decimal import Decimal
from pydantic import BaseModel, Field, field_validator
from .models import (
DestinationOriginCove,
InvoiceStatus,
OperationType,
Currency,
TransportType,
@@ -56,7 +57,21 @@ class InvoiceHeaderBase(BaseModel):
)
invoice_date: date = Field(..., description="Invoice date")
emission_date: Optional[date] = Field(None, description="Emission date")
status: bool = Field(False, description="Status")
status: Optional[InvoiceStatus] = Field(None, description="Status: pending, processed, reversed")
@field_validator("status", mode="before")
@classmethod
def normalize_status(cls, v):
"""Coerce legacy boolean strings ('false'/'true') to InvoiceStatus."""
if v is None:
return v
if isinstance(v, str):
v_lower = v.lower()
if v_lower == "false":
return InvoiceStatus.PENDING.value
if v_lower == "true":
return InvoiceStatus.PROCESSED.value
return v
processed_date: Optional[datetime] = Field(None, description="Update date")
who_processed: Optional[str] = Field(None, max_length=20, description="Who processed")
capture_user: Optional[str] = Field(None, max_length=20, description="Capture user")

View File

@@ -26,6 +26,7 @@ from api.v1.modules.public.reference_data.currency_types.models import CurrencyT
from api.v1.modules.a24.fa.fa_parts.models import FaPart
from api.v1.modules.a24.inv.inv_parts.models import InvPart
from api.v1.modules.a24.inv.bom.models import BillOfMaterial
from api.v1.modules.a24.inv.part_countries.models import PartCountry
if TYPE_CHECKING:
@@ -33,7 +34,6 @@ if TYPE_CHECKING:
from api.v1.modules.a76.general_catalogs.units_of_measure.models import (
UnitOfMeasure,
)
from api.v1.modules.a24.inv.part_countries.models import PartCountry
class Part(Base, TenantScopedMixin, TimestampMixin):

View File

@@ -11,6 +11,7 @@ from .customs_brokers.routes import router as customs_broker_router
from .general_catalogs.router import router as general_catalogs_router
from .invoices.routes import router as invoices_router
from .invoices.imports.process.routes import router as invoice_process_router
from .invoices.imports.revert.routes import router as invoice_revert_router
from .items.routes import router as items_router
from .classes.routes import router as classes_router
@@ -59,6 +60,7 @@ router = APIRouter()
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(invoice_process_router, prefix="/a76", tags=["a76 / invoices"])
router.include_router(invoice_revert_router, prefix="/a76", tags=["a76 / invoices"])
router.include_router(items_router, prefix="/a76", tags=["a76 / items"])
router.include_router(imports_router, prefix="/a76/imports", tags=["a76 / imports"])
router.include_router(exportacion_imports_router, prefix="/a76/imports/exportacion", tags=["a76 / imports / exportacion"])

View File

@@ -1,5 +1,6 @@
"""Fracciones Service"""
import asyncio
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import FraccionesResponse
@@ -44,3 +45,26 @@ class FraccionesService(SitarAPIBaseService):
"""Get single Fraccion record by SYSID"""
data = await self._make_request("GET", f"/api/v1/fracciones/{sysid}")
return FraccionesResponse(**data)
@classmethod
def search_sync(
cls,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
description: Optional[str] = None,
nivel: Optional[int] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesResponse]:
"""Search Mexican tariff fractions (sync wrapper for use in Celery/sync context)."""
service = cls.get_instance()
return asyncio.run(
service.search(
fraccion=fraccion,
nico=nico,
description=description,
nivel=nivel,
skip=skip,
limit=limit,
)
)

View File

@@ -1,5 +1,6 @@
"""FraccionesAnteriores Service"""
import asyncio
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import FraccionesAnterioresResponse
@@ -34,3 +35,22 @@ class FraccionesAnterioresService(SitarAPIBaseService):
async def get_by_id(self, sysid: int) -> FraccionesAnterioresResponse:
data = await self._make_request("GET", f"/api/v1/fracciones-anteriores/{sysid}")
return FraccionesAnterioresResponse(**data)
@classmethod
def search_sync(
cls,
fraccion_actual: Optional[str] = None,
fraccion_anterior: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesAnterioresResponse]:
"""Search historical fractions (sync wrapper for use in Celery/sync context)."""
service = cls.get_instance()
return asyncio.run(
service.search(
fraccion_actual=fraccion_actual,
fraccion_anterior=fraccion_anterior,
skip=skip,
limit=limit,
)
)