Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into feature/item_por_tipo_factura
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
76
backend/api/v1/modules/a76/invoices/imports/revert/routes.py
Normal file
76
backend/api/v1/modules/a76/invoices/imports/revert/routes.py
Normal 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,
|
||||
}
|
||||
@@ -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}"
|
||||
),
|
||||
})
|
||||
82
backend/api/v1/modules/a76/invoices/imports/revert/task.py
Normal file
82
backend/api/v1/modules/a76/invoices/imports/revert/task.py
Normal 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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user