Merge branch 'development' into feature/clarion-validations-csv-pedimentos

This commit is contained in:
hreyes
2026-03-05 11:36:39 -07:00
48 changed files with 16107 additions and 1082 deletions

View File

@@ -3,6 +3,7 @@ Capa de servicio para lógica de negocio de empresa
"""
import logging
from datetime import datetime
from typing import List, Optional, Tuple, Dict, Any
from fastapi import HTTPException
@@ -12,7 +13,10 @@ from sqlalchemy.orm import Session
from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO
from .models import Company
from ...audit_log.services.service import AuditService
from ..units_of_measure.seed import seed as units_of_measure_seed
from ..fractions.historical_tariff_fractions.seed import seed as historical_tariff_fractions_seed
from core.context import get_user_context
from sqlalchemy import text
logger = logging.getLogger(__name__)
@@ -34,7 +38,7 @@ class CompanyService:
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Company], int]:
"""Get all companies for a tenant with pagination"""
query = db.query(Company).filter(Company.tenant_id == tenant_id)
query = db.query(Company).filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
# Apply filters if provided
if filters:
@@ -62,6 +66,7 @@ class CompanyService:
.filter(
Company.id == company_id,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None)
)
.first()
)
@@ -381,7 +386,10 @@ class CompanyService:
if addr_ind2:
self.db.add(CompanyAddress(**addr_ind2, address_type='industrial2', company_id=db_company.id))
# 7. Commit
# 7. Seed company data (tenant/company dependent)
self._seed_company_data(self.db, tenant_id, db_company.id)
# 8. Commit
self.db.commit()
self.db.refresh(db_company)
@@ -584,17 +592,9 @@ class CompanyService:
# ----------------------
try:
# Cascading deletes are handled by relationship settings, but manual is safer here
if company.certification: db.delete(company.certification)
if company.prevalidator: db.delete(company.prevalidator)
if company.electronic_agent: db.delete(company.electronic_agent)
if company.ventanilla_unica: db.delete(company.ventanilla_unica)
if company.cfdi: db.delete(company.cfdi)
for cert in company.digital_certificates: db.delete(cert)
for addr in company.addresses: db.delete(addr)
company.deleted_at = datetime.utcnow()
db.flush()
db.delete(company)
db.commit()
# --- Audit Log ---
@@ -698,11 +698,68 @@ class CompanyService:
# Custom methods
def _seed_company_data(self, db: Session, tenant_id: int, company_id: int):
"""Seeds tenant/company dependent data for a new company"""
def format_value(val):
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"
return f"'{str(val).replace(chr(39), chr(39)*2)}'"
# 1. Units of Measure
val_uom = ", ".join(
[
f"({format_value(code)}, {format_value(desc)}, {format_value(desc_en)}, "
f"{format_value(customs)}, {format_value(american)}, {format_value(ace)}, {format_value(oma)}, {tenant_id}, {company_id})"
for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed
]
)
db.execute(text("ALTER TABLE a76.units_of_measure DISABLE TRIGGER ALL;"))
db.execute(text(f"INSERT INTO a76.units_of_measure (code, description, description_en, customs_code, american_code, ace_code, oma_code, tenant_id, company_id) VALUES {val_uom} ON CONFLICT (code, tenant_id, company_id) DO NOTHING;"))
db.execute(text("ALTER TABLE a76.units_of_measure ENABLE TRIGGER ALL;"))
# 2. Historical Tariff Fractions
def format_bool(val):
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"
return "TRUE" if str(val).upper() == "TRUE" else "FALSE"
def format_timestamp(val):
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"
return f"'{str(val)}'"
values_historical = ", ".join(
[
f"({format_value(historical_fraction)}, {format_value(nico)}, {format_value(unit_measure)}, {format_value(country)}, "
f"{format_value(fraction_type)}, {format_value(sector)}, {format_value(import_tax)}, "
f"{format_value(export_tax)}, {format_timestamp(pub_date)}, {format_bool(is_immex)}, "
f"{format_bool(normal_temp)}, {format_bool(services_temp)}, {format_bool(certified_temp)}, "
f"{format_bool(by_log)}, {format_timestamp(end_date)}, "
f"{tenant_id}, {company_id})"
for (historical_fraction, nico, unit_measure, country, fraction_type, sector, import_tax,
export_tax, pub_date, is_immex, normal_temp, services_temp, certified_temp, by_log, end_date) in historical_tariff_fractions_seed
]
)
if values_historical:
db.execute(text("SET session_replication_role = replica;"))
db.execute(text(f"""
INSERT INTO a76.historical_tariff_fractions
(historical_fraction, nico, unit_of_measure_code, country, fraction_type, sector,
import_tax_rate, export_tax_rate, publication_date, is_immex,
normal_temporality, services_temporality, certified_temporality, by_log, end_date,
tenant_id, company_id)
VALUES {values_historical}
ON CONFLICT DO NOTHING;
"""))
db.execute(text("SET session_replication_role = DEFAULT;"))
def get_companies_by_tenant(self, tenant_id: int) -> List[Company]:
"""Get all companies for a tenant"""
return (
self.db.query(Company)
.filter(Company.tenant_id == tenant_id)
.filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
.order_by(Company.name)
.all()
)
@@ -711,7 +768,7 @@ class CompanyService:
"""Check if a company exists for a tenant"""
return (
self.db.query(Company)
.filter(Company.tenant_id == tenant_id)
.filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
.first()
is not None
)

View File

@@ -0,0 +1,78 @@
from fastapi import APIRouter, Depends, BackgroundTasks
from sqlalchemy.orm import Session
from core.database import get_core_db
from .schemas import WinsaaiGenerationRequest, WinsaaiResponse
from .task import generate_winsaai_task
from core.celery_app import celery_app
from celery.result import AsyncResult
from typing import Dict, Any
import logging
logger = logging.getLogger(__name__)
router = APIRouter(tags=["WINSAAI"])
@router.post("/generate", response_model=WinsaaiResponse)
async def trigger_winsaai_generation(
request: WinsaaiGenerationRequest,
db: Session = Depends(get_core_db)
):
task = generate_winsaai_task.delay(request.invoice_ids, request.is_temporal)
return WinsaaiResponse(
task_id=task.id,
status="PENDING",
message="WINSAAI generation task started"
)
@router.get("/status/{task_id}")
async def get_task_status(task_id: str):
logger.info(f"Polling status for WINSAAI task {task_id}")
task_result = AsyncResult(task_id, app=celery_app)
try:
state = task_result.state
info = task_result.info
logger.debug(f"Task {task_id} state: {state}, info: {info}")
except Exception as e:
logger.error(f"Error polling task {task_id}: {str(e)}")
return {"task_id": task_id, "state": "FAILURE", "result": str(e), "info": None}
response = {
"task_id": task_id,
"state": state,
"result": None,
"info": None
}
if state == 'FAILURE':
# Safely try to get result, but handle cases where it can't be deserialized
try:
# result = task_result.result # This might trigger the ValueError
# Better: if it's FAILURE, just report it as failed and maybe look into info/meta
response["result"] = "Task failed. Check worker logs for details."
if isinstance(info, dict) and 'error' in info:
response["result"] = info['error']
except Exception:
response["result"] = "Task failed (error reading exception details)"
elif state == 'SUCCESS':
response["result"] = task_result.result
elif state in ['PROCESSING', 'PROGRESS']:
response["info"] = info
return response
@router.get("/download/{task_id}")
async def download_winsaai_file(task_id: str):
res = AsyncResult(task_id)
if not res.ready():
return {"error": "Task not ready"}
result = res.result
if isinstance(result, dict) and "filepath" in result:
from fastapi.responses import FileResponse
return FileResponse(
path=result["filepath"],
filename=result["filename"],
media_type="text/plain"
)
return {"error": "File not found"}

View File

@@ -0,0 +1,20 @@
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
class WinsaaiGenerationRequest(BaseModel):
invoice_ids: List[int] = Field(..., description="List of invoice IDs to process")
is_temporal: bool = Field(True, description="Whether to generate as temporal (SCAII) or definitive")
class ErrorValidacion(BaseModel):
partida: Optional[int] = None
linea: int
descripcion: str
soluciones: Optional[str] = None
identificador: Optional[str] = None
class WinsaaiResponse(BaseModel):
task_id: str
status: str = "PENDING"
message: str = "WINSAAI generation task started"
validation_errors: List[ErrorValidacion] = []

View File

@@ -0,0 +1,340 @@
"""
Invoice WINSAAI Service Legacy Format
Generates the legacy WINSAAI .txt file that matches the output of the
original Clarion system, used with the WINSAAI utility.
Format reference (from real system example):
501|1|REGIME|PED_NUM|ADUANA|IGI|IVA|DTA|PREVAL|OTROS|WEIGHT|BULTOS|7|7|7||...
503||GUIA||
505|FACTURA|YYYYMMDD|INCOTERM|MONEDA|VALOR_ME|VALOR_MN|...|CANT_LINES|SOLD_TO_SHORT|SOLD_TO_NAME|
551|FRACCION|DESC|VAL_MN|VAL_ME|CANT|UM|CANT_TAR|0|0|FPAGO|||P_ORIG|P_IMP|||||0||PESO|||MONEDA|FACTURA||0|PESO|PESO|MET_VALOR|LINEA|||...
558||
999|
"""
import re
from typing import List, Optional
from sqlalchemy.orm import Session, joinedload
from api.v1.modules.a76.invoices.models import (
InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials, InvoiceLogistics,
)
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_customs.models import LineCustom
from api.v1.modules.a76.items.line_descriptions.models import LineDescription
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.pedmientos.models.pedimento_contributions import PedimentoContributions
# ─────────────────────────────────────────────────────────────────────────────
# HELPERS
# ─────────────────────────────────────────────────────────────────────────────
def _s(val) -> str:
"""Safe string conversion return '' for None."""
if val is None:
return ""
return str(val).strip()
def _n(val, decimals: int = 0) -> str:
"""Format numeric value without leading zeros, with optional decimal places."""
if val is None:
return "0"
try:
f = float(val)
if decimals:
return f"{f:.{decimals}f}"
else:
# Integer representation, no decimals
return str(int(f))
except (TypeError, ValueError):
return "0"
def _fmt_date_ymd(dt) -> str:
"""Format as YYYYMMDD (legacy WINSAAI style)."""
if not dt:
return ""
return dt.strftime("%Y%m%d")
def _clean(text: str) -> str:
"""Remove pipes and line breaks that would corrupt the pipe-delimited format."""
if not text:
return ""
text = re.sub(r'[\r\n]+', ' ', text)
text = text.replace('|', '/')
return text.strip()
def _sanitize_filename(name: str) -> str:
return re.sub(r'[/\\:*?"<>|]', '', name)
# ─────────────────────────────────────────────────────────────────────────────
# SERVICE
# ─────────────────────────────────────────────────────────────────────────────
class WinsaaiService:
def __init__(self, db: Session, company_id: int, tenant_id: int):
self.db = db
self.company_id = company_id
self.tenant_id = tenant_id
# ── ENTRY POINT ──────────────────────────────────────────────────────────
def generate_winsaai_data(self, invoice_ids: List[int], is_temporal: bool) -> List[str]:
all_lines: List[str] = []
for inv_id in invoice_ids:
invoice = (
self.db.query(InvoiceHeader)
.options(
joinedload(InvoiceHeader.compliance_mx),
joinedload(InvoiceHeader.financials),
joinedload(InvoiceHeader.logistics),
joinedload(InvoiceHeader.details),
)
.filter(InvoiceHeader.id == inv_id)
.first()
)
if not invoice:
continue
all_lines.extend(self._gen_legacy(invoice))
# Terminator
all_lines.append("999|")
return all_lines
# ── MAIN GENERATOR ───────────────────────────────────────────────────────
def _gen_legacy(self, inv: InvoiceHeader) -> List[str]:
lines: List[str] = []
comp: Optional[InvoiceComplianceMx] = inv.compliance_mx
fin: Optional[InvoiceFinancials] = inv.financials
log: Optional[InvoiceLogistics] = inv.logistics
# ── Pedimento ────────────────────────────────────────────────────────
ped: Optional[Pedimentos] = None
if comp and comp.pedimento_id:
ped = self.db.query(Pedimentos).filter(Pedimentos.id == comp.pedimento_id).first()
ped_full = (ped.pedimento_number or "") if ped else ""
# Clarion: SUB(Pedimento,4,4) → chars 3-7 = license/patente
# SUB(Pedimento,9,7) → after hyphen = 7-digit number
# Legacy example uses just the number portion (the integer part)
# We'll parse: aduana(3) + año(2) + patente(4) + num(7) or use raw sub-strings
ped_aduana = _s(ped.customs_office) if ped else ""
ped_regime = _s(ped.regime) if ped else ""
# Try to extract 7-digit number from pedimento_number
# Formats: "010 IN 3130441" or "01023130441"
ped_num_raw = "".join(filter(str.isdigit, ped_full))
if len(ped_num_raw) >= 7:
ped_num_short = ped_num_raw[-7:] # last 7 digits (the folio)
else:
ped_num_short = ped_num_raw
# ── Partners ─────────────────────────────────────────────────────────
sold_to = self._get_partner(comp.sold_to_id) if comp and comp.sold_to_id else None
provider = self._get_partner(comp.provider_id) if comp and comp.provider_id else None
# ── Financial totals ─────────────────────────────────────────────────
tc = float(fin.exchange_rate or 1) if fin else 1.0
currency = _s(fin.currency_type) if fin else "USD"
if currency.upper() in ("MXP", "MXN"):
currency = "MXP"
else:
currency = "USD"
valor_me_hdr = float(fin.value_me or 0) if fin else 0.0
freight = float(fin.freight or 0) if fin else 0.0
insurance = float(fin.insurance or 0) if fin else 0.0
gross_wt_hdr = float(fin.gross_weight or 0) if fin else 0.0
bundles = int(fin.bundle_count or 0) if fin else 0
incoterm = _s(log.incoterm) if log else ""
# ── Items (needed early for summing) ─────────────────────────────────
items = (
self.db.query(LineItem)
.filter(LineItem.invoice_id == inv.id)
.order_by(LineItem.line_number)
.all()
)
# Sum values from line items when header totals are missing/zero
sum_val_me = 0.0
sum_val_mn = 0.0
sum_gross = 0.0 # goes into 501 (peso bruto total)
sum_bultos = 0
for _item in items:
_fin = _item.financial
_qty = _item.quantity
if _fin:
sum_val_me += float(_fin.value_usd or 0)
sum_val_mn += float(_fin.value_mxn or 0)
if _qty:
sum_gross += float(_qty.gross_weight or _qty.net_weight or 0)
sum_bultos += int(_qty.package_quantity or 0)
# Use header value if present, otherwise sum from items
valor_me = valor_me_hdr if valor_me_hdr > 0 else sum_val_me
valor_mn = (valor_me * tc) if sum_val_mn == 0 else sum_val_mn
gross_wt = gross_wt_hdr if gross_wt_hdr > 0 else sum_gross
if bundles == 0 and sum_bultos > 0:
bundles = sum_bultos
# ── Contributions (DTA, Prevalidación, IGI, IVA) ─────────────────────
igi_total = 0.0
iva_total = 0.0
dta_total = 0.0
preval_total = 0.0
if ped:
contribs = (
self.db.query(PedimentoContributions)
.filter(PedimentoContributions.pedimento_id == ped.id)
.all()
)
for c in contribs:
code = (c.contribucion or "").upper()
abrev = (c.abreviacion or "").upper()
amount = float(c.importe or 0)
if "IGI" in code or abrev == "IGI":
igi_total += amount
elif "IVA" in code or abrev == "IVA":
iva_total += amount
elif "DTA" in code or abrev == "DTA":
dta_total += amount
elif "PREVAL" in code or "15" in code:
preval_total += amount
# ── REGISTRO 501 ──────────────────────────────────────────────────────
# Legacy: 501|1|REGIME|PED_NUM|ADUANA|IGI|IVA|DTA|PREVAL|0|GROSS_WT|BUNDLES|7|7|7||...
# Example: 501|1|IN|3130441|ENTRADA|0|0|0|0|0|4156.960|0|7|7|7||||160|1764|...
lines.append(
f"501|1|{ped_regime}|{ped_num_short}|{ped_aduana}|"
f"{_n(igi_total, 3)}|{_n(iva_total, 3)}|{_n(dta_total, 3)}|{_n(preval_total, 3)}|0|"
f"{_n(gross_wt, 3)}|{bundles}|7|7|7||||"
f"{_n(freight, 3)}|{_n(insurance, 3)}|1|||||||||||||||"
)
# ── REGISTRO 503 ──────────────────────────────────────────────────────
niu = _s(comp.niu_number) if comp else ""
guide = _s(comp.guide_type_to_identify) if comp else ""
lines.append(f"503|{niu}|{guide}||")
# ── REGISTRO 505 ──────────────────────────────────────────────────────
# Legacy: 505|FACTURA|YYYYMMDD|INCOTERM|MONEDA|VALOR_ME|VALOR_MN||||||||||CANT_LINES|SOLD_SHORT|SOLD_NAME|
# Example: 505|1111|20230525|DAP|USD|69760.00|69760.00||||||||||3|ENTRADA|ENTRADA GROUP...|
sold_short = _s(sold_to.short_name) if sold_to else ""
sold_name = _clean(_s(sold_to.name)) if sold_to else ""
invoicenum = _s(inv.invoice_number)
fecha_fac = _fmt_date_ymd(inv.invoice_date)
lines.append(
f"505|{invoicenum}|{fecha_fac}|{incoterm}|{currency}|"
f"{_n(valor_me, 2)}|{_n(valor_mn, 2)}||||||||||"
f"{len(items)}|{sold_short}|{sold_name}|"
)
# ── REGISTROS 551 + 558 por partida ──────────────────────────────────
# Legacy 551 format (from example):
# 551|FRACCION|DESC|VAL_MN(int)|VAL_ME(dec)|CANT(3dec)|UM_ADUANA|CANT_TAR(3dec)|0|0|FPAGO|||P_ORIG|P_IMP|||||0||PESO|||MONEDA|FACTURA||0|PESO|PESO|MET_VALOR|LINEA|||||||FPAGO||||||||99|
linea_num = 0
for item in items:
linea_num += 1
item_qty: Optional[LineQuantity] = item.quantity
item_fin: Optional[LineFinancial] = item.financial
item_cust: Optional[LineCustom] = item.customs
item_desc: Optional[LineDescription] = item.description
# Fraccion
fraccion = ""
if item_cust and item_cust.fraction:
fraccion = item_cust.fraction[:8]
# Descripción
part = None
if item.part_number_id:
part = self.db.query(Part).filter(Part.id == item.part_number_id).first()
desc = ""
if part:
desc = _clean(part.description_spanish or part.description or "")
if not desc and item_desc:
desc = _clean(item_desc.description_spanish or item_desc.description1 or "")
# Valores
val_me = float(item_fin.value_usd or 0) if item_fin else 0.0
val_mn = float(item_fin.value_mxn or val_me * tc) if item_fin else val_me * tc
# Cantidades
cant = float(item_qty.quantity or 0) if item_qty else 0.0
# UM aduanas (Clave_AMex)
um_aduana = ""
if item.unit_of_measure:
uom = self.db.query(UnitOfMeasure).filter(UnitOfMeasure.id == item.unit_of_measure).first()
if uom:
um_aduana = _s(uom.customs_code)
# Cantidad tarifa
cant_tar = float(item_qty.quantity_uma or cant) if item_qty else cant
# Forma de pago (del item)
forma_pago = _s(item.payment_method)
# País origen y país importador
pais_orig = _s(item_cust.origin_country) if item_cust else ""
pais_imp = ""
if sold_to and sold_to.address:
pais_imp = _s(sold_to.address.country)
# Peso neto
peso = float(item_qty.net_weight or 0) if item_qty else 0.0
# MetValor
met_valor = _s(item.valuation_method) or _s(comp.value_method if comp else "")
# Legacy 551 field layout (counting from example):
# 551|frac|desc|val_mn|val_me|cant|um|cant_tar|0|0|fpago|||p_orig|p_imp|||||0||peso|||moneda|factura||0|peso|peso|met_valor|linea|||||||fpago||||||||99|
lines.append(
f"551|{fraccion}|{desc}|{_n(val_mn, 0)}|{_n(val_me, 2)}|"
f"{_n(cant, 3)}|{um_aduana}|{_n(cant_tar, 3)}|0|0|{forma_pago}|||"
f"{pais_orig}|{pais_imp}|||||0||{_n(peso, 3)}|||"
f"{currency}|{invoicenum}||0|{_n(peso, 3)}|{_n(peso, 3)}|"
f"{met_valor}|{linea_num}|||||||{forma_pago}||||||||99|"
)
# ── REGISTRO 558 ──────────────────────────────────────────────────
# Legacy: always emit an empty 558 after each 551
# If there is additional info, emit it, otherwise just empty: 558||
info = ""
if item_desc and hasattr(item_desc, 'extra_description'):
info = _clean(item_desc.extra_description or "")
lines.append(f"558|{info}|")
return lines
# ── HELPERS ──────────────────────────────────────────────────────────────
def _get_partner(self, partner_id: int) -> Optional[ClientProvider]:
if not partner_id:
return None
return (
self.db.query(ClientProvider)
.options(joinedload(ClientProvider.address))
.filter(ClientProvider.id == partner_id)
.first()
)
def get_filename(self, invoice: InvoiceHeader) -> str:
inv_num = _sanitize_filename(invoice.invoice_number or "")
return f"{inv_num}.txt"

View File

@@ -0,0 +1,62 @@
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from .service import WinsaaiService
import os
import tempfile
import logging
import sys
logger = logging.getLogger(__name__)
@celery_app.task(bind=True)
def generate_winsaai_task(self, invoice_ids: list, is_temporal: bool):
logger.info(f"Starting WINSAAI task {self.request.id} for {invoice_ids}")
db = CoreSessionLocal()
try:
self.update_state(state='PROCESSING', meta={'current': 10, 'status': 'Cargando servicio...'})
from api.v1.modules.a76.invoices.models import InvoiceHeader as _InvH
_first = db.query(_InvH).filter(_InvH.id == invoice_ids[0]).first() if invoice_ids else None
_company_id = _first.company_id if _first else 0
_tenant_id = _first.tenant_id if _first else 0
service = WinsaaiService(db, _company_id, _tenant_id)
self.update_state(state='PROCESSING', meta={'current': 30, 'status': 'Generando datos...'})
lines = service.generate_winsaai_data(invoice_ids, is_temporal)
self.update_state(state='PROCESSING', meta={'current': 70, 'status': 'Guardando archivo...'})
# Determine filename
from api.v1.modules.a76.invoices.models import InvoiceHeader
first_invoice = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_ids[0]).first() if invoice_ids else None
filename = f"{first_invoice.invoice_number}.txt" if first_invoice else f"winsaai_{self.request.id}.txt"
temp_dir = tempfile.gettempdir()
filepath = os.path.join(temp_dir, filename)
with open(filepath, "w", encoding="latin-1") as f:
for line in lines:
f.write(line + "\n")
self.update_state(state='PROCESSING', meta={'current': 90, 'status': 'Finalizando...'})
import base64
with open(filepath, "rb") as f:
encoded_content = base64.b64encode(f.read()).decode('utf-8')
logger.info(f"WINSAAI task {self.request.id} completed successfully")
return {
"status": "success",
"content": encoded_content,
"filepath": filepath,
"filename": filename,
"file_name": filename,
"media_type": "text/plain",
"validation_errors": []
}
except Exception as e:
logger.error(f"Error in generate_winsaai_task: {str(e)}", exc_info=True)
# No actualizar estado a FAILURE manualmente, dejar que Celery lo haga al re-lanzar
raise e
finally:
db.close()

View File

@@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from core.database import get_core_db
from .schemas import PedimentosWinsaaiGenerationRequest, WinsaaiResponse
from .task import generate_pedimentos_winsaai_task
from core.celery_app import celery_app
from celery.result import AsyncResult
from typing import Dict, Any
import logging
logger = logging.getLogger(__name__)
router = APIRouter(tags=["WINSAAI-Pedimentos"])
@router.post("/generate", response_model=WinsaaiResponse)
async def trigger_pedimentos_generation(
request: PedimentosWinsaaiGenerationRequest,
db: Session = Depends(get_core_db)
):
task = generate_pedimentos_winsaai_task.delay(
request.pedimento_ids,
request.is_temporal,
request.is_by_class
)
return WinsaaiResponse(
task_id=task.id,
status="PENDING",
message="WINSAAI pedimentos generation task started"
)
@router.get("/status/{task_id}")
async def get_task_status(task_id: str):
logger.info(f"Polling status for WINSAAI pedimento task {task_id}")
task_result = AsyncResult(task_id, app=celery_app)
try:
state = task_result.state
info = task_result.info
except Exception as e:
logger.error(f"Error polling task {task_id}: {str(e)}")
return {"task_id": task_id, "state": "FAILURE", "result": str(e), "info": None}
response = {
"task_id": task_id,
"state": state,
"result": None,
"info": None
}
if state == 'FAILURE':
response["result"] = "Task failed. Check worker logs for details."
if isinstance(info, dict) and 'error' in info:
response["result"] = info['error']
elif state == 'SUCCESS':
response["result"] = task_result.result
elif state in ['PROCESSING', 'PROGRESS']:
response["info"] = info
return response
@router.get("/download/{task_id}")
async def download_file(task_id: str):
res = AsyncResult(task_id)
if not res.ready():
raise HTTPException(status_code=400, detail="Task not ready")
result = res.result
if isinstance(result, dict) and "filepath" in result:
from fastapi.responses import FileResponse
return FileResponse(
path=result["filepath"],
filename=result["filename"],
media_type="text/plain"
)
raise HTTPException(status_code=404, detail="File not found")

View File

@@ -0,0 +1,20 @@
from pydantic import BaseModel, Field
from typing import List, Optional
class PedimentosWinsaaiGenerationRequest(BaseModel):
pedimento_ids: List[int] = Field(..., description="List of Pedimento IDs to process")
is_temporal: bool = Field(True, description="Whether to generate as temporal (SCAII) or definitive")
is_by_class: bool = Field(False, description="Whether to group items by Class (SCAF)")
class ErrorValidacion(BaseModel):
partida: Optional[int] = None
linea: int
descripcion: str
soluciones: Optional[str] = None
identificador: Optional[str] = None
class WinsaaiResponse(BaseModel):
task_id: str
status: str = "PENDING"
message: str = "WINSAAI generation task started"
validation_errors: List[ErrorValidacion] = []

View File

@@ -0,0 +1,460 @@
"""
Pedimentos WINSAAI Service Legacy Format
Generates the legacy WINSAAI .txt file for a pedimento, consolidating
all linked invoices and their line items into a single file.
Format:
501|<consolidated header>
503|<guides per invoice>
505|<invoice header per invoice>
551|<item per invoice> (repeated)
558|<observations per item>
999|
"""
import re
from typing import List, Optional
from decimal import Decimal
from sqlalchemy.orm import Session, joinedload
from fastapi import HTTPException
from api.v1.modules.a76.invoices.models import (
InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials, InvoiceLogistics
)
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_customs.models import LineCustom
from api.v1.modules.a76.items.line_descriptions.models import LineDescription
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.classes.models import Class
from .schemas import ErrorValidacion
# ─────────────────────────────────────────────────────────────────────────────
# HELPERS
# ─────────────────────────────────────────────────────────────────────────────
def _s(val) -> str:
if val is None:
return ""
return str(val).strip()
def _n(val, decimals: int = 0) -> str:
if val is None:
return "0"
try:
f = float(val)
if decimals:
return f"{f:.{decimals}f}"
return str(int(f))
except (TypeError, ValueError):
return "0"
def _fmt_date_ymd(dt) -> str:
if not dt:
return ""
return dt.strftime("%Y%m%d")
def _clean(text: str) -> str:
if not text:
return ""
text = re.sub(r'[\r\n]+', ' ', text)
text = text.replace('|', '/')
return text.strip()
# ─────────────────────────────────────────────────────────────────────────────
# SERVICE
# ─────────────────────────────────────────────────────────────────────────────
class PedimentosWinsaaiService:
def __init__(self, db: Session):
self.db = db
self.validation_errors: List[ErrorValidacion] = []
self.line_count = 0
# ── ENTRY POINT ──────────────────────────────────────────────────────────
def generate_pedimentos_data(
self,
pedimento_ids: List[int],
is_temporal: bool = True,
is_by_class: bool = False,
) -> List[str]:
lines: List[str] = []
self.line_count = 0
self.validation_errors = []
company = self.db.query(Company).first()
if not company:
raise HTTPException(status_code=404, detail="Company information not found")
for p_id in pedimento_ids:
pedimento = self.db.query(Pedimentos).filter(Pedimentos.id == p_id).first()
if not pedimento:
continue
# All invoices linked to this pedimento
invoices = (
self.db.query(InvoiceHeader)
.join(InvoiceHeader.compliance_mx)
.filter(InvoiceComplianceMx.pedimento_id == p_id)
.options(
joinedload(InvoiceHeader.compliance_mx),
joinedload(InvoiceHeader.financials),
joinedload(InvoiceHeader.logistics),
)
.all()
)
if not invoices:
self.validation_errors.append(ErrorValidacion(
linea=self.line_count,
descripcion=f"Pedimento {pedimento.pedimento_number} no tiene facturas vinculadas",
identificador=pedimento.pedimento_number,
))
continue
# ── Consolidate totals across all invoices for 501 ────────────────
total_peso = 0.0
total_bultos = 0
total_flete = 0.0
total_seguro = 0.0
total_embalaje = 0.0
total_otros = 0.0
for inv in invoices:
fin = inv.financials
if fin:
total_flete += float(fin.freight or 0)
total_seguro += float(fin.insurance or 0)
total_embalaje += float(getattr(fin, 'packaging', None) or 0)
total_otros += float(getattr(fin, 'other_increments', None) or 0)
h_peso = float(fin.gross_weight or 0)
h_bultos = int(fin.bundle_count or 0)
if h_peso > 0:
total_peso += h_peso
else:
rows = (
self.db.query(LineQuantity.gross_weight)
.join(LineItem)
.filter(LineItem.invoice_id == inv.id)
.all()
)
total_peso += sum(float(r[0] or 0) for r in rows)
if h_bultos > 0:
total_bultos += h_bultos
else:
rows = (
self.db.query(LineQuantity.package_quantity)
.join(LineItem)
.filter(LineItem.invoice_id == inv.id)
.all()
)
total_bultos += sum(int(r[0] or 0) for r in rows)
# ── REGISTRO 501 (once per pedimento) ────────────────────────────
lines.append(self._record_501(
pedimento, invoices[0],
total_peso, total_bultos,
total_flete, total_seguro, total_embalaje, total_otros,
))
# ── One 503 + 505 + 551s per invoice ─────────────────────────────
for inv_idx, inv in enumerate(invoices, 1):
# 503
lines.append(self._record_503(inv))
# Sum financial totals for 505
items = (
self.db.query(LineItem)
.options(
joinedload(LineItem.financial),
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.description),
joinedload(LineItem.part_info),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.class_info),
joinedload(LineItem.fa_data),
)
.filter(LineItem.invoice_id == inv.id)
.all()
)
sum_me = sum(float(it.financial.value_usd or 0) for it in items if it.financial)
sum_mn = sum(float(it.financial.value_mxn or 0) for it in items if it.financial)
# Fallback to header
if inv.financials:
hdr_me = float(inv.financials.value_me or 0)
if hdr_me > 0:
sum_me = hdr_me
# 505
lines.append(self._record_505(inv, sum_mn, sum_me, inv_idx))
# Items — optionally grouped by class
items_to_process = self._group_items(items, is_by_class)
for it_idx, (item, source_items) in enumerate(items_to_process, 1):
tc = float(inv.financials.exchange_rate or 1) if inv.financials else 1.0
lines.append(self._record_551(item, inv, tc, it_idx))
# 554 identifiers
cust = item.customs
if cust and cust.fraction_type:
ftype = cust.fraction_type
if ftype == "TLCS":
lines.append(f"554|TL|{_s(cust.origin_country)}|")
elif ftype == "PROSEC":
lines.append(f"554|PS|{_s(cust.sector)}|")
elif ftype == "ALADI":
lines.append(f"554|AL|{_s(cust.sector)}|")
# 558 observations
parts: List[str] = []
if item.description:
if item.description.brand:
parts.append(f"MARCA: {item.description.brand}")
if item.description.model:
parts.append(f"MODELO: {item.description.model}")
if item.description.extra_description:
parts.append(item.description.extra_description)
for src in source_items:
if src.fa_data and src.fa_data.equipment_message:
if src.fa_data.equipment_message not in parts:
parts.append(src.fa_data.equipment_message)
full_obs = _clean(". ".join(parts))
if full_obs:
for i in range(0, len(full_obs), 120):
lines.append(f"558|{full_obs[i:i+120]}|")
else:
lines.append("558||")
self.line_count += len(items_to_process)
lines.append("999|")
return lines
# ── RECORD BUILDERS ──────────────────────────────────────────────────────
def _record_501(
self,
ped: Pedimentos,
rep_inv: InvoiceHeader,
peso: float, bultos: int,
flete: float, seguro: float, embalaje: float, otros: float,
) -> str:
"""
Legacy: 501|1|REGIME|NUM7|ADUANA|IGI|IVA|DTA|PREVAL|0|PESO|BULTOS|7|7|7||||FLETE|SEGURO|...
Example: 501|1|IN|3130441|ENTRADA|0|0|0|0|0|4156.960|0|7|7|7||||160|1764|...
"""
regime = _s(ped.regime)
aduana = _s(ped.customs_office)
num_raw = "".join(filter(str.isdigit, _s(ped.pedimento_number)))
num7 = num_raw[-7:] if len(num_raw) >= 7 else num_raw
return (
f"501|1|{regime}|{num7}|{aduana}|0|0|0|0|0|"
f"{_n(peso, 3)}|{bultos}|7|7|7||||"
f"{_n(flete, 3)}|{_n(seguro, 3)}|1|||||||||||||||||"
)
def _record_503(self, inv: InvoiceHeader) -> str:
comp = inv.compliance_mx
niu = _s(comp.niu_number) if comp else ""
guide = _s(comp.guide_type_to_identify) if comp else ""
return f"503|{niu}|{guide}||"
def _record_505(
self,
inv: InvoiceHeader,
val_mn: float,
val_me: float,
seq: int,
) -> str:
"""
Legacy: 505|FACTURA|YYYYMMDD|INCOTERM|MONEDA|VAL_ME|VAL_MN|...|COUNT|SHORT|NAME|
"""
comp = inv.compliance_mx
fin = inv.financials
log = inv.logistics
invoicenum = _s(inv.invoice_number)
fecha = _fmt_date_ymd(inv.invoice_date)
incoterm = _s(log.incoterm) if log else ""
currency = _s(fin.currency_type) if fin else "USD"
currency = "MXP" if currency.upper() in ("MXP", "MXN") else "USD"
tc = float(fin.exchange_rate or 1) if fin else 1.0
# val_mn fallback
if val_mn == 0 and val_me > 0:
val_mn = val_me * tc
sold_to_id = comp.sold_to_id if comp else None
sold_to = self._get_partner(sold_to_id)
sold_short = _s(sold_to.short_name) if sold_to else ""
sold_name = _clean(_s(sold_to.name)) if sold_to else ""
# Count items for this invoice
item_count = (
self.db.query(LineItem)
.filter(LineItem.invoice_id == inv.id)
.count()
)
return (
f"505|{invoicenum}|{fecha}|{incoterm}|{currency}|"
f"{_n(val_me, 2)}|{_n(val_mn, 2)}||||||||||"
f"{item_count}|{sold_short}|{sold_name}|"
)
def _record_551(
self,
item: LineItem,
inv: InvoiceHeader,
tc: float,
line_num: int,
) -> str:
"""
Legacy 551:
551|FRACCION|DESC|VAL_MN|VAL_ME|CANT|UM|CANT_TAR|0|0|FPAGO|||P_ORIG|P_IMP|||||0||PESO|||MONEDA|FACTURA||0|PESO|PESO|MET_VALOR|LINEA|||...99|
"""
fin = item.financial
qty = item.quantity
cust = item.customs
desc = item.description
# Fracción
fraccion = ""
if cust and cust.fraction:
fraccion = cust.fraction[:8]
# Descripción
part = None
if item.part_number_id:
part = self.db.query(Part).filter(Part.id == item.part_number_id).first()
description = ""
if part:
description = _clean(part.description_spanish or part.description or "")
if not description and desc:
description = _clean(getattr(desc, 'description_spanish', None) or getattr(desc, 'description1', None) or "")
if not description and item.class_info:
description = _clean(getattr(item.class_info, 'description_es', None) or "")
# Valores
val_me = float(fin.value_usd or 0) if fin else 0.0
val_mn = float(fin.value_mxn or val_me * tc) if fin else val_me * tc
# Cantidades
cant = float(qty.quantity or 0) if qty else 0.0
# UM aduanas
um_aduana = ""
if item.unit_of_measure:
uom = self.db.query(UnitOfMeasure).filter(UnitOfMeasure.id == item.unit_of_measure).first()
if uom:
um_aduana = _s(uom.customs_code)
cant_tar = float(qty.quantity_uma or cant) if qty else cant
# FormaPago y MetValor
forma_pago = _s(item.payment_method)
met_valor = _s(item.valuation_method)
if not met_valor and inv.compliance_mx:
met_valor = _s(inv.compliance_mx.value_method)
# País origen e importador
pais_orig = _s(cust.origin_country) if cust else ""
pais_imp = ""
comp = inv.compliance_mx
if comp and comp.sold_to_id:
sold_to = self._get_partner(comp.sold_to_id)
if sold_to and sold_to.address:
pais_imp = _s(sold_to.address.country)
# Peso neto
peso = float(qty.net_weight or 0) if qty else 0.0
# Currency and invoice number
fin_obj = inv.financials
currency = _s(fin_obj.currency_type) if fin_obj else "USD"
currency = "MXP" if currency.upper() in ("MXP", "MXN") else "USD"
invoicenum = _s(inv.invoice_number)
return (
f"551|{fraccion}|{description}|{_n(val_mn, 0)}|{_n(val_me, 2)}|"
f"{_n(cant, 3)}|{um_aduana}|{_n(cant_tar, 3)}|0|0|{forma_pago}|||"
f"{pais_orig}|{pais_imp}|||||0||{_n(peso, 3)}|||"
f"{currency}|{invoicenum}||0|{_n(peso, 3)}|{_n(peso, 3)}|"
f"{met_valor}|{line_num}|||||||{forma_pago}||||||||99|"
)
# ── HELPERS ──────────────────────────────────────────────────────────────
def _group_items(self, items, is_by_class: bool):
"""Returns list of (item, source_items). Groups by class if is_by_class=True."""
if not is_by_class:
return [(it, [it]) for it in items]
groups = {}
for it in items:
fraction = (it.class_info.fraction if it.class_info else getattr(it.customs, 'fraction', '')) or ''
country = getattr(it.customs, 'origin_country', '') or ''
key = (it.class_id, fraction, country, it.valuation_method)
if key not in groups:
groups[key] = {
'rep': it,
'val_me': 0.0, 'val_mn': 0.0,
'qty': 0.0, 'qty_uma': 0.0,
'gross': 0.0, 'net': 0.0, 'bultos': 0,
'sources': [],
}
g = groups[key]
g['val_me'] += float(it.financial.value_usd or 0) if it.financial else 0
g['val_mn'] += float(it.financial.value_mxn or 0) if it.financial else 0
g['qty'] += float(it.quantity.quantity or 0) if it.quantity else 0
g['qty_uma'] += float(it.quantity.quantity_uma or 0) if it.quantity else 0
g['gross'] += float(it.quantity.gross_weight or 0) if it.quantity else 0
g['net'] += float(it.quantity.net_weight or 0) if it.quantity else 0
g['bultos'] += int(it.quantity.package_quantity or 0) if it.quantity else 0
g['sources'].append(it)
result = []
for g in groups.values():
rep = g['rep']
if rep.financial:
rep.financial.value_usd = g['val_me']
rep.financial.value_mxn = g['val_mn']
if rep.quantity:
rep.quantity.quantity = g['qty']
rep.quantity.quantity_uma = g['qty_uma']
rep.quantity.gross_weight = g['gross']
rep.quantity.net_weight = g['net']
rep.quantity.package_quantity = g['bultos']
if rep.class_info and rep.customs:
rep.customs.fraction = getattr(rep.class_info, 'fraction', rep.customs.fraction)
result.append((rep, g['sources']))
return result
def _get_partner(self, partner_id: Optional[int]) -> Optional[ClientProvider]:
if not partner_id:
return None
return (
self.db.query(ClientProvider)
.options(joinedload(ClientProvider.address))
.filter(ClientProvider.id == partner_id)
.first()
)

View File

@@ -0,0 +1,53 @@
import logging
from typing import List
import base64
import os
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from .service import PedimentosWinsaaiService
logger = logging.getLogger(__name__)
@celery_app.task(bind=True)
def generate_pedimentos_winsaai_task(self, pedimento_ids: List[int], is_temporal: bool, is_by_class: bool = False):
logger.info(f"Starting Pedimentos WINSAAI task {self.request.id} for pedimentos {pedimento_ids} (by_class={is_by_class})")
db = CoreSessionLocal()
try:
self.update_state(state='PROCESSING', meta={'current': 10, 'status': 'Cargando servicio...'})
service = PedimentosWinsaaiService(db)
self.update_state(state='PROCESSING', meta={'current': 30, 'status': 'Generando datos...'})
lines = service.generate_pedimentos_data(pedimento_ids, is_temporal, is_by_class)
self.update_state(state='PROCESSING', meta={'current': 70, 'status': 'Guardando archivo...'})
# Use first pedimento number for filename
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
ped = db.query(Pedimentos).filter(Pedimentos.id == pedimento_ids[0]).first() if pedimento_ids else None
if ped:
filename = f"{ped.year}-{ped.license}-{ped.pedimento_number}.txt"
else:
filename = "pedimentos.txt"
filepath = f"/tmp/{filename}"
# Clarion uses ANSI/UTF-8 usually, but let's stick to UTF-8 for now unless specific requirement
with open(filepath, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
self.update_state(state='PROCESSING', meta={'current': 90, 'status': 'Finalizando...'})
with open(filepath, "rb") as f:
content_b64 = base64.b64encode(f.read()).decode("utf-8")
return {
"status": "success",
"content": content_b64,
"filepath": filepath,
"filename": filename,
"file_name": filename,
"media_type": "text/plain",
"validation_errors": [e.model_dump() for e in service.validation_errors]
}
except Exception as e:
logger.error(f"Error in generate_pedimentos_winsaai_task: {str(e)}", exc_info=True)
raise e
finally:
db.close()

View File

@@ -0,0 +1,9 @@
from fastapi import APIRouter
from .invoices.routes import router as invoices_router
router = APIRouter()
router.include_router(invoices_router, prefix="/invoices", tags=["WINSAAI - Invoices"])
from .pedimentos.routes import router as pedimentos_router
router.include_router(pedimentos_router, prefix="/pedimentos", tags=["WINSAAI - Pedimentos"])

View File

@@ -45,6 +45,7 @@ from .manifests.manifiesto_anexo.routes import router as manifest_anexos_router
from .reports.exportacion.transmission.MAINX30.routes import router as transmission_router
from .reports.importacion.transmission.temporal.MAINX30.routes import router as transmission_temporal_router
from .reports.importacion.transmission.definitive.MAINX30.routes import router as transmission_definitive_router
from .reports.importacion.winsaai.router import router as winsaai_router
# Router principal
@@ -154,6 +155,12 @@ router.include_router(
tags=["a76 / reports"]
)
router.include_router(
winsaai_router,
prefix="/a76/reports/importacion/winsaai",
tags=["a76 / reports"]
)
# Registrar router de bitácora
from .audit_log.router import router as audit_log_router
router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"])