Se termino el modulo de reportes

This commit is contained in:
2026-02-05 08:22:59 -06:00
parent afba0332b1
commit dbf4b63675
11 changed files with 992 additions and 765 deletions

View File

@@ -8,6 +8,7 @@ from fastapi import APIRouter
from .fa.fa_classes.routes import router as fa_classes_router
from .fa.fa_item_lines.routes import router as fa_item_lines_router
# Router principal de A24
router = APIRouter()

View File

@@ -38,9 +38,9 @@ class LineQuantity(Base):
gross_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESOBRUTO
# Packaging
package_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVEBULTOS
package_id: Mapped[Optional[int]] = mapped_column(Integer) # CLAVEBULTOS (Originally package_key)
package_quantity: Mapped[Optional[int]] = mapped_column(Integer) # CANTBULTOS
package_description: Mapped[Optional[str]] = mapped_column(String(40)) # DESCBULTOS
# package_description removed as not in DB
container_quantity: Mapped[Optional[int]] = mapped_column(SmallInteger) # CANTBULCONT
container_description: Mapped[Optional[str]] = mapped_column(String(40)) # DESCCONTENEDOR
box_count: Mapped[Optional[str]] = mapped_column(String(30)) # NOCAJAS

View File

@@ -52,6 +52,8 @@ class Item(Base, TenantScopedMixin, TimestampMixin):
# Relationships (one-to-many)
lines: Mapped[List["LineItem"]] = relationship(
"LineItem", back_populates="item", cascade="all, delete-orphan")
invoice: Mapped["InvoiceHeader"] = relationship("InvoiceHeader")
# ============================================================================
# SUPPORTING TABLES

View File

@@ -6,11 +6,36 @@ from typing import Dict, Any
from core.database import get_core_db as get_db
from core.security import get_current_user
from .task import generate_descarga_pdf_task
from .service import FIFOAssignmentService
from .task import generate_descarga_pdf_task # FORCE RELOAD
from celery.result import AsyncResult
router = APIRouter()
@router.post("/fifo-assign/{invoice_id}")
def run_fifo_assignment(
invoice_id: int,
db: Session = Depends(get_db),
current_user: Any = Depends(get_current_user),
):
"""
Executes FIFO (PEPS) calculation for an Export Invoice.
Returns the calculated discharges in JSON format.
"""
service = FIFOAssignmentService()
try:
discharges = service.calculate_fifo(db, invoice_id)
return {
"invoice_id": invoice_id,
"total_discharges": len(discharges),
"discharges": discharges
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error calculating FIFO: {str(e)}"
)
@router.post("/{invoice_id}/download-async")
async def trigger_descarga_generation(
invoice_id: int,

View File

@@ -9,17 +9,159 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from decimal import Decimal
from datetime import datetime
# --- MODELOS (Imported from system for Header info) ---
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from sqlalchemy.orm import joinedload, load_only
# --- FIFO SERVICE (Internalized) ---
from api.v1.modules.a76.items.series.models import Serie
class FIFOAssignmentService:
"""
Service for calculating FIFO (PEPS) assignments in real-time.
Does not persist to database, returns calculated discharge objects.
"""
def calculate_fifo(self, db: Session, invoice_id: int) -> List[Dict[str, Any]]:
"""
Calculates the FIFO trail for all lines in an export invoice.
Returns a list of calculated discharges.
"""
# 1. Get Export Lines
export_lines = db.query(LineItem).join(Item).filter(
Item.invoice_id == invoice_id
).options(
joinedload(LineItem.quantity),
joinedload(LineItem.description),
joinedload(LineItem.customs),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.part_info)
).all()
results = []
self._log(f"Starting FIFO for Invoice {invoice_id}. Export Lines: {len(export_lines)}")
for exp_line in export_lines:
qty_needed = float(exp_line.quantity.quantity) if exp_line.quantity and exp_line.quantity.quantity is not None else 0.0
if qty_needed <= 0:
continue
part_number = exp_line.part_number
if not part_number:
self._log(f"Skipping line {exp_line.id}, no part number")
continue
self._log(f"Processing Exp Line {exp_line.id}, Part: {part_number}, Qty Needed: {qty_needed}")
# --- SERIES EXPO LOOKUP ---
series_desc = ""
series_count = db.query(Serie).filter(Serie.line_item_id == exp_line.id).count()
if series_count > 0:
series_list = db.query(Serie).filter(Serie.line_item_id == exp_line.id).all()
parts_str = []
for idx, s in enumerate(series_list, 1):
line_parts = [f"{idx}) Serie: {s.serial_numbers or ''}", f"Modelo: {s.model or ''}", f"Parte: {exp_line.part_info.part_number if exp_line.part_info else ''}", f"Num ID Expo: {s.number_id or ''}", f"SubModelo: {s.sub_model or ''}"]
parts_str.append(", ".join([p for p in line_parts if p]))
if parts_str:
series_desc = "\nSeries:\n" + "\n".join(parts_str)
# 2. Find Import Candidates (FIFO order by payment date)
# Use outerjoin for pedimento dates to avoid filtering out candidates with missing dates
candidates = db.query(LineItem).join(Item).join(InvoiceHeader)\
.join(InvoiceComplianceMx).join(InvoiceComplianceMx.pedimento).outerjoin(Pedimentos.pedimento_dates)\
.filter(
LineItem.part_number == part_number,
InvoiceHeader.operation_type == 'imp', # Assuming 'imp' is the value for Import based on Enum
).order_by(
PedimentoDates.payment_date.asc()
).options(
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.financial),
joinedload(LineItem.item).joinedload(Item.invoice).joinedload(InvoiceHeader.compliance_mx).joinedload(InvoiceComplianceMx.pedimento).joinedload(Pedimentos.pedimento_dates)
).all()
self._log(f"Found {len(candidates)} candidates for {part_number}")
for imp_line in candidates:
if qty_needed <= 0:
break
imp_qty_total = float(imp_line.quantity.quantity) if imp_line.quantity and imp_line.quantity.quantity is not None else 0.0
if imp_qty_total <= 0:
continue
take = min(qty_needed, imp_qty_total)
ratio = take / imp_qty_total if imp_qty_total > 0 else 0
# Calculate Proportions
imp_weight = float(imp_line.quantity.net_weight) if imp_line.quantity and imp_line.quantity.net_weight is not None else 0.0
# imp_val_mn = float(imp_line.customs.customs_value) if imp_line.customs and imp_line.customs.customs_value is not None else 0.0
imp_val_me = float(imp_line.financial.customs_value_usd) if imp_line.financial and imp_line.financial.customs_value_usd is not None else 0.0
imp_igi = float(imp_line.customs.igi_amount) if imp_line.customs and imp_line.customs.igi_amount is not None else 0.0
imp_inv = imp_line.item.invoice
ped = imp_inv.compliance_mx.pedimento if imp_inv and imp_inv.compliance_mx else None
# --- EXCHANGE RATE VALIDATION ---
payment_date = ped.pedimento_dates.payment_date if ped and ped.pedimento_dates else None
exchange_rate_val = 1.0
validation_error = None
if payment_date:
er_obj = db.query(ExchangeRate).filter(ExchangeRate.date == payment_date).first()
if er_obj:
exchange_rate_val = float(er_obj.value)
else:
# Try previous day if strict match fails (mimicking SisGen:UtilizarFechaPagoPedDeUnDiaAnterior logic broadly or just flagging)
# For now, flag it.
validation_error = f"Tipo de Cambio no encontrado para fecha {payment_date}"
# Calculate Valor MN based on Clarion logic: (CantDesc * ValorImpoME / CantImpo) * TC
# which simplifies to: Ratio * ValorImpoME * TC
val_mn_calc = (imp_val_me * ratio) * exchange_rate_val
discharge = {
"export_line_id": exp_line.id,
"import_line_id": imp_line.id,
"quantity": take,
"net_weight": imp_weight * ratio,
"value_mxn": val_mn_calc,
"value_usd": imp_val_me * ratio,
"igi_amount": imp_igi * ratio,
"import_invoice": imp_inv.invoice_number if imp_inv else "N/A",
"pedimento": ped.pedimento_number if ped else "N/A",
"pedimento_clave": ped.pedimento_code if ped else "",
"pedimento_date": payment_date.isoformat() if payment_date else None,
"series_desc": series_desc,
"validation_error": validation_error
}
self._log(f"MATCH: Taking {take} from Imp Line {imp_line.id}")
results.append(discharge)
qty_needed -= take
return results
def _log(self, msg):
try:
with open("/tmp/fifo_debug.log", "a") as f:
f.write(f"{datetime.now()}: {msg}\n")
except: pass
# --- SCHEMAS FOR TEMPLATE CONTEXT ---
class DischargeItemSchema(BaseModel):
@@ -57,8 +199,10 @@ class DischargeItemSchema(BaseModel):
# Let's assume Se Pago is a boolean/string.
# Last col: "Linea Expo".
se_pago_val: str
linea_expo: str
# Errors
error_msg: Optional[str] = None
# Helper for Jinja (if methods not allowed in pydantic models in template)
def __init__(self, **data):
@@ -104,115 +248,80 @@ class DescargaReportService:
try:
if progress_callback: progress_callback(10, "Buscando factura...")
# Fetch Header for basic info
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
# --- 1. Obtener Cabeceras (Igual que antes) ---
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id).first()
if not header:
raise HTTPException(status_code=404, detail="Factura no encontrada")
company = db.query(Company).filter(Company.id == company_id).first()
tenant = db.query(Tenant).filter(Tenant.id == header.tenant_id).first()
company = db.query(Company).filter(Company.id == header.company_id).first()
# --- 2. Obtener Líneas de Exportación (Lo que necesitamos cubrir) ---
if progress_callback: progress_callback(20, "Obteniendo items a exportar...")
if progress_callback: progress_callback(30, "Procesando descargas...")
# --- REAL IMPLEMENTATION ---
# 1. Fetch Export Lines with FA Data
export_lines = db.query(LineItem).filter(
LineItem.item_id == Item.id,
Item.invoice_id == invoice_id
).options(
joinedload(LineItem.fa_data),
joinedload(LineItem.quantity).load_only(LineQuantity.quantity, LineQuantity.net_weight),
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.description),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.part_info), # Fetch Part Relation
# joinedload(LineItem.item).joinedload(Item.invoice) # Removed due to missing relationship
joinedload(LineItem.part_info)
).join(Item).all()
# 2. Collect unique Import Invoices to bulk fetch
# fa_data.search_invoice stores the "FacturaImpo" number
import_inv_nums = set()
for line in export_lines:
if line.fa_data and line.fa_data.search_invoice:
import_inv_nums.add(line.fa_data.search_invoice)
# Map InvoiceNumber -> (InvoiceHeader, Pedimento)
import_map = {}
if import_inv_nums:
# We need to find the invoices. Warning: search_invoice is just a string number.
# potentially non-unique across companies, but we filter by current Company.
imp_invoices = db.query(InvoiceHeader).filter(
InvoiceHeader.invoice_number.in_(import_inv_nums),
InvoiceHeader.company_id == company_id,
InvoiceHeader.invoice_type == 'Ingreso' # Assuming Imports are Ingreso/Import
).options(
joinedload(InvoiceHeader.compliance_mx)
).all()
# Fetch Pedimentos for these invoices
ped_ids = {inv.compliance_mx.pedimento_id for inv in imp_invoices if inv.compliance_mx and inv.compliance_mx.pedimento_id}
peds = db.query(Pedimentos).filter(Pedimentos.id.in_(ped_ids)).all()
ped_map = {p.id: p for p in peds}
for inv in imp_invoices:
ped = None
if inv.compliance_mx and inv.compliance_mx.pedimento_id:
ped = ped_map.get(inv.compliance_mx.pedimento_id)
import_map[inv.invoice_number] = (inv, ped)
items_reporte = []
items = []
# --- 3. EL ALGORITMO PEPS EN VIVO ---
if progress_callback: progress_callback(40, "Calculando PEPS en tiempo real...")
fifo_service = FIFOAssignmentService()
discharges = fifo_service.calculate_fifo(db, invoice_id)
print(f"DEBUG: Calculated {len(discharges)} discharges.") # FORCE PRINT
for line in export_lines:
# Defaults
ped_str = ""
ped_clave = ""
ped_fecha = ""
fac_impo = ""
se_pago = ""
valor_igi = 0.0
# Create map for faster/safer lookup
exp_map = {l.id: l for l in export_lines}
for d in discharges:
exp_id = d["export_line_id"]
exp_line = exp_map.get(exp_id)
# Linkage
if line.fa_data and line.fa_data.search_invoice:
fac_impo = line.fa_data.search_invoice
if fac_impo in import_map:
inv_imp, ped_imp = import_map[fac_impo]
if ped_imp:
ped_str = f"{ped_imp.pedimento_number}"
ped_clave = f"{ped_imp.pedimento_code}"
# Format date if exists
# Simple date fallback from header if needed or Pedimento Date logic (revisit model if needed)
pass
if not exp_line:
print(f"DEBUG: Skipping discharge, Exp Line {exp_id} not found in map keys: {list(exp_map.keys())}")
continue
# Calculation logic (Prorate)
qty = float(line.quantity.quantity) if line.quantity else 0.0
print(f"DEBUG: Adding item to report: Imp {d['import_line_id']} -> Exp {exp_id}")
valor_me = 0.0
valor_mn = 0.0
# Create Schema
items.append(DischargeItemSchema(
pedimento_numero=ped_str,
pedimento_clave=ped_clave,
pedimento_fecha_pago=ped_fecha,
factura_impo=fac_impo,
numero_parte=line.part_info.part_number if hasattr(line, 'part_info') and line.part_info else (str(line.part_number) if line.part_number else "S/N"),
descripcion=line.description.description_spanish if line.description else "S/D",
fraccion=line.customs.fraction if line.customs else "",
origen_pref_sector=f"{line.customs.origin_country or ''} - {line.customs.sector or ''}" if line.customs else "",
cantidad=self.formatear_numero(qty, 3),
unidad_medida=line.unit_of_measure_info.code if line.unit_of_measure_info else "PZA",
peso_neto=self.formatear_numero(float(line.quantity.net_weight) if line.quantity else 0.0, 3),
valor_mn=self.formatear_numero(valor_mn),
valor_me=self.formatear_numero(valor_me),
valor_igi=self.formatear_numero(valor_igi),
se_pago=se_pago or "NO",
se_pago_val=se_pago,
linea_expo=str(line.line_number)
desc_final = exp_line.description.description_spanish if exp_line.description else "S/D"
items_reporte.append(DischargeItemSchema(
pedimento_numero=d["pedimento"],
pedimento_clave=d["pedimento_clave"],
pedimento_fecha_pago=d["pedimento_date"].split("T")[0] if d["pedimento_date"] else "",
factura_impo=d["import_invoice"],
numero_parte=exp_line.part_info.part_number if exp_line.part_info else "",
descripcion=desc_final,
fraccion=exp_line.customs.fraction if exp_line.customs else "",
origen_pref_sector=f"{exp_line.customs.origin_country or ''} - {exp_line.customs.sector or ''}" if exp_line.customs else "",
cantidad=self.formatear_numero(d["quantity"], 3),
unidad_medida=exp_line.unit_of_measure_info.code if exp_line.unit_of_measure_info else "PZA",
peso_neto=self.formatear_numero(d["net_weight"], 3),
valor_mn=self.formatear_numero(d["value_mxn"]),
valor_me=self.formatear_numero(d["value_usd"]),
valor_igi=self.formatear_numero(d["igi_amount"]),
se_pago="",
linea_expo=str(exp_line.line_number),
error_msg=d.get("validation_error")
))
# Totals
# Company Address Construction
print(f"DEBUG: Final Report Items Count: {len(items_reporte)}")
# --- 4. Totales y Finalización (Igual que antes) ---
addr_str = "DIRECCION NO REGISTRADA"
immex_val = ""
@@ -237,17 +346,17 @@ class DescargaReportService:
if company.program and "IMMEX" in company.program and company.program_number:
immex_val = company.program_number
# Calculate Totals
t_cant = sum(float(i.cantidad.replace(",","")) for i in items if i.cantidad)
t_peso = sum(float(i.peso_neto.replace(",","")) for i in items if i.peso_neto)
t_mn = sum(float(i.valor_mn.replace(",","")) for i in items if i.valor_mn)
t_me = sum(float(i.valor_me.replace(",","")) for i in items if i.valor_me)
t_igi = sum(float(i.valor_igi.replace(",","")) for i in items if i.valor_igi)
# Recalcular totales basados en la lista generada
t_cant = sum(float(i.cantidad.replace(",","")) for i in items_reporte)
t_peso = sum(float(i.peso_neto.replace(",","")) for i in items_reporte)
t_mn = sum(float(i.valor_mn.replace(",","")) for i in items_reporte)
t_me = sum(float(i.valor_me.replace(",","")) for i in items_reporte)
t_igi = sum(float(i.valor_igi.replace(",","")) for i in items_reporte)
return DischargeContext(
items=items,
items=items_reporte,
invoice_number=header.invoice_number or "SIN FOLIO",
company_name=company.name if company else "EMPRESA DESCONOCIDA",
company_name=company.name if company else (tenant.name if tenant else "EMPRESA DESCONOCIDA"),
company_address=addr_str,
company_rfc=company.rfc if company else "",
company_immex=immex_val,

View File

@@ -2,7 +2,7 @@
from celery import shared_task
from sqlalchemy.orm import Session
from core.database import CoreSessionLocal as SessionLocal
from .service import DescargaReportService
from .service import DescargaReportService # FORCE RELOAD 2
import base64
import traceback

View File

@@ -103,10 +103,10 @@
<table class="main-table">
<thead>
<tr>
<th style="width: 12%;">No. Pedimento<br>Clave Fecha de Pago</th>
<th style="width: 12%;">No. Pedimento<br>Fecha de Pago</th>
<th style="width: 10%;">Import Invoice/<br>Factura de Impo.</th>
<th style="width: 25%;">Part Number/No. de Parte Componente<br>Description/Descripción
(Origen-Prefer.-Sector)</th>
<th style="width: 15%; border-right: none;">Part Number/No. de Parte<br>Description/Descripción</th>
<th style="width: 10%; border-left: none;">Fracción<br>(Origen-Prefer.-Sector)</th>
<th style="width: 8%;">Quantity/<br>Cantidad U.M.</th>
<th style="width: 8%;">Net Weight/<br>Peso Neto (KGS)</th>
<th style="width: 8%;">Value/Valor M.N.<br>MXP/Pesos</th>
@@ -117,23 +117,28 @@
</tr>
</thead>
<tbody>
<!-- Sub Header -->
<tr>
<td colspan="10" class="sub-header">Comp. Temporales:</td>
</tr>
<!-- Loops Items -->
{% for item in items %}
<tr class="{{ 'row-border-bottom' if loop.last else '' }}">
<td>
<b>{{ item.pedimento_numero }}</b><br>
{{ item.pedimento_clave }} &nbsp;&nbsp; {{ item.pedimento_fecha_pago }}
<div style="font-weight: bold;">{{ item.pedimento_numero }}</div>
<table style="width: 100%; border: none; margin-top: 2px; border-collapse: collapse;">
<tr>
<td style="border: none; padding: 0; font-size: 6.5pt; text-align: left; width: 30%;">{{
item.pedimento_clave }}</td>
<td style="border: none; padding: 0; font-size: 6.5pt; text-align: right; width: 70%;">{{
item.pedimento_fecha_pago }}</td>
</tr>
</table>
</td>
<td>{{ item.factura_impo }}</td>
<td>
<td style="border-right: none;">
<b>{{ item.numero_parte }}</b><br>
{{ item.descripcion }}<br>
<span style="font-size: 6pt;">{{ item.fraccion }}<br>{{ item.origen_pref_sector }}</span>
{{ item.descripcion }}
</td>
<td style="font-size: 6pt; border-left: none;">
{{ item.fraccion }}<br>
{{ item.origen_pref_sector }}
</td>
<td class="text-right">
{{ item.cantidad }} {{ item.unidad_medida }}
@@ -147,28 +152,28 @@
</tr>
{% endfor %}
<!-- Totals -->
<!-- Sub Totals -->
<tr class="totals-row">
<td colspan="3" class="text-center">Totales de los Comp. Temporales:</td>
<td>{{ total_cantidad }}</td>
<td>{{ total_peso }}</td>
<td>{{ total_valor_mn }}</td>
<td>{{ total_valor_me }}</td>
<td>{{ total_igi }}</td>
<td></td>
<td></td>
<td colspan="4" class="text-center">Totales de los Comp. Temporales:</td>
<td class="text-right">{{ total_cantidad }}</td>
<td class="text-right">{{ total_peso }}</td>
<td class="text-right">{{ total_valor_mn }}</td>
<td class="text-right">{{ total_valor_me }}</td>
<td class="text-right">{{ total_igi }}</td>
<td class="text-center"></td>
<td class="text-center"></td>
</tr>
<!-- Grand Totals (Same as above for this example) -->
<!-- Grand Totals -->
<tr class="totals-row" style="border-top: 2px solid black;">
<td colspan="3" class="text-center" style="font-size: 9pt;">TOTALES:</td>
<td>{{ total_cantidad }}</td>
<td>{{ total_peso }}</td>
<td>{{ total_valor_mn }}</td>
<td>{{ total_valor_me }}</td>
<td>{{ total_igi }}</td>
<td></td>
<td></td>
<td colspan="4" class="text-center" style="font-size: 9pt;">TOTALES:</td>
<td class="text-right">{{ total_cantidad }}</td>
<td class="text-right">{{ total_peso }}</td>
<td class="text-right">{{ total_valor_mn }}</td>
<td class="text-right">{{ total_valor_me }}</td>
<td class="text-right">{{ total_igi }}</td>
<td class="text-center"></td>
<td class="text-center"></td>
</tr>
</tbody>

View File

@@ -1,6 +1,6 @@
"""
Router principal de API v1
Agrega todos los módulos de la aplicación
Agrega todos los módulos de la aplicación (Reload)
"""
from fastapi import APIRouter
@@ -10,7 +10,6 @@ from .modules.core.router import router as core_router
from .modules.a76.router import router as a76_router
from .modules.a24.router import router as a24_router
from .modules.public.router import router as public_router
from .modules.a24.router import router as a24_router
# Router principal
@@ -21,8 +20,6 @@ router.include_router(core_router)
router.include_router(a76_router)
router.include_router(a24_router)
router.include_router(public_router)
# nuevas rutas de partes de anexo 24
router.include_router(a24_router)
# Health check