Merge branch 'development' into fix/modulo-pedimento

This commit is contained in:
2026-03-05 11:53:08 -06:00
34 changed files with 1778 additions and 278 deletions

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ wheels/
backend/.env
frontend/.env
backend/SCRIPTS/
# IDEs
.vscode/
.idea/

View File

@@ -73,9 +73,6 @@ from api.v1.modules.a76.general_catalogs.units_of_measure.seed_adua import (
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.seed import (
seed as tariff_fractions_seed,
)
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.seed import (
seed as historical_tariff_fractions_seed,
)
from api.v1.modules.public.reference_data.trailer_types.seed import (
seed as trailer_types_seed,
)
@@ -429,25 +426,7 @@ def upgrade() -> None:
f"INSERT INTO a76.unit_of_measure_oma (code, description) VALUES {val_add_oma} ON CONFLICT ON CONSTRAINT uq_uom_oma_code DO NOTHING;"
)
# TABLA MAESTRA UOM
# TODO: Generar tenant_id y company_id correctos
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)}, 1, 1)"
for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed
]
)
op.execute("ALTER TABLE a76.units_of_measure DISABLE TRIGGER ALL;")
op.execute(
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;
"""
)
op.execute("ALTER TABLE a76.units_of_measure ENABLE TRIGGER ALL;")
# TABLA MAESTRA UOM se genera ahora al crear una empresa
# --- SEEDS CORE (Permissions) ---
@@ -501,45 +480,7 @@ def upgrade() -> None:
"""
)
def format_bool(val):
"""Convert boolean string to SQL boolean."""
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):
"""Format timestamp for PostgreSQL."""
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"
# El valor ya viene en formato 'YYYY-MM-DD HH:MM:SS'
return f"'{str(val)}'"
values_historical_fractions = ", ".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"1, 1)" # tenant_id=1, company_id=1
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_fractions:
op.execute("SET session_replication_role = replica;")
op.execute(
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_fractions}
ON CONFLICT DO NOTHING;
"""
)
op.execute("SET session_replication_role = DEFAULT;")
# Historical Fractions se generan ahora al crear una empresa
def downgrade() -> None:

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"])

View File

@@ -45,6 +45,9 @@ celery_app.conf.update(
"api.v1.modules.a76.reports.exportacion.transmission.MAINX30.task",
"api.v1.modules.a76.reports.importacion.transmission.temporal.MAINX30.task",
"api.v1.modules.a76.reports.importacion.transmission.definitive.MAINX30.task",
"api.v1.modules.a76.reports.importacion.winsaai.invoices.task",
"api.v1.modules.a76.reports.importacion.winsaai.pedimentos.task",
"api.v1.modules.core.help_center.tasks",
"api.v1.modules.core.help_center.tasks"
] # Ruta al módulo donde están las tareas
)

View File

@@ -266,7 +266,6 @@ services:
max-size: "10m"
max-file: "3"
# celery
celery_worker:
build: ./backend

View File

@@ -62,7 +62,7 @@
"electronic_notices": "Avisos electrónicos",
"back_flush": "Back Flush",
"crossing_notice": "Aviso de cruce"
},
},
"fractions": {
"title": "Fracciones",
"sitar": "Fracciones Sitar",

View File

@@ -0,0 +1,86 @@
const BASE_URL = import.meta.env.VITE_API_URL || '';
export interface WinsaaiGenerationRequest {
invoice_ids: number[];
is_temporal: boolean;
}
export interface WinsaaiResponse {
task_id: string;
status: string;
message: string;
}
export const reportsWinsaaiApi = {
invoices: {
triggerGeneration: async (payload: WinsaaiGenerationRequest): Promise<WinsaaiResponse> => {
const token = localStorage.getItem('access_token');
const response = await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/invoices/generate`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('Error al iniciar la generación WINSAAI de facturas');
return await response.json();
},
getTaskStatus: async (taskId: string): Promise<any> => {
const token = localStorage.getItem('access_token');
const response = await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/invoices/status/${taskId}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Error al consultar estado WINSAAI de facturas');
return await response.json();
},
downloadFile: async (taskId: string): Promise<Response> => {
const token = localStorage.getItem('access_token');
return await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/invoices/download/${taskId}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
}
},
pedimentos: {
triggerGeneration: async (pedimentoIds: number[], isTemporal: boolean = true, isByClass: boolean = false): Promise<WinsaaiResponse> => {
const token = localStorage.getItem('access_token');
const response = await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/pedimentos/generate`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
pedimento_ids: pedimentoIds,
is_temporal: isTemporal,
is_by_class: isByClass
})
});
if (!response.ok) throw new Error('Error al iniciar la generación WINSAAI de pedimentos');
return await response.json();
},
getTaskStatus: async (taskId: string): Promise<any> => {
const token = localStorage.getItem('access_token');
const response = await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/pedimentos/status/${taskId}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Error al consultar estado WINSAAI de pedimentos');
return await response.json();
},
downloadFile: async (taskId: string): Promise<Response> => {
const token = localStorage.getItem('access_token');
return await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/pedimentos/download/${taskId}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
}
}
};

View File

@@ -39,11 +39,15 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="relative h-8 w-8 p-0">
<Button
variant="ghost"
size="icon"
class="relative h-8 w-8 p-0"
>
<span class="sr-only">Abrir menú</span>
<EllipsisIcon class="h-4 w-4" />
</Button>
</DropdownMenu.Trigger>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.Label>Acciones</DropdownMenu.Label>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { authStore, currentUser } from '$lib/auth';
import * as Sheet from '$lib/components/ui/sheet';
import { Button } from '$lib/components/ui/button';
@@ -60,7 +59,8 @@
title: '',
content: '',
updated_at: '',
last_editor: ''
last_editor: '',
content_type: 'markdown'
}; // Mock for UI
}
@@ -100,8 +100,10 @@
}
}
onMount(() => {
loadArticles();
$effect(() => {
if (helpStore.isOpen && articles.length === 0 && !isLoading) {
loadArticles();
}
});
// Simple markdown renderer fallback if marked is not available

View File

@@ -54,6 +54,7 @@
class="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 text-gray-900 shadow-2xl dark:bg-gray-900 dark:text-gray-100"
role="document"
bind:this={modalRef}
role="presentation"
onkeydown={handleFocusTrap}
>
<div
@@ -95,7 +96,9 @@
role="region"
aria-label="Global Navigation shortcuts"
bind:this={globalList}
tabindex="0"
tabindex="-1"
role="region"
aria-label="Global Navigation Shortcuts"
onkeydown={(event) => handleArrowScroll(event, globalList)}
>
{#each Object.entries(GLOBAL_CONF) as [key, route]}
@@ -135,7 +138,9 @@
role="region"
aria-label="Active Actions shortcuts"
bind:this={localList}
tabindex="0"
tabindex="-1"
role="region"
aria-label="Local Action Shortcuts"
onkeydown={(event) => handleArrowScroll(event, localList)}
>
{#each localShortcuts as shortcut}

View File

@@ -4,9 +4,15 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
let {
items
items
}: {
items: {
title: string;
@@ -37,6 +43,7 @@
}
function handleTriggerEnter(title: string) {
if (sidebar.state !== 'collapsed') return;
if (sidebar.state !== 'collapsed') return;
activeTitle = title;
}
@@ -44,13 +51,17 @@
function handleTriggerLeave(event: PointerEvent, title: string) {
if (sidebar.state !== 'collapsed') return;
if (sidebar.state !== 'collapsed') return;
// Si nos movemos al contenido (o nos quedamos en el trigger), no cerramos
if (shouldKeepOpen(event, title)) return;
activeTitle = null;
}
function handleContentEnter(title: string) {
if (sidebar.state !== 'collapsed') return;
if (sidebar.state !== 'collapsed') return;
activeTitle = title;
}
@@ -58,9 +69,12 @@
function handleContentLeave(event: PointerEvent, title: string) {
if (sidebar.state !== 'collapsed') return;
if (sidebar.state !== 'collapsed') return;
// Si nos movemos de vuelta al trigger (o dentro del contenido), no cerramos
if (shouldKeepOpen(event, title)) return;
activeTitle = null;
}
@@ -81,6 +95,7 @@
<Sidebar.Menu>
{#each items as item (item.title)}
{#if item.items && item.items.length > 0}
{#if sidebar.state === 'collapsed'}
{#if sidebar.state === 'collapsed'}
<!-- Sidebar Colapsado: Dropdown controlado por eventos estrictos -->
<Sidebar.MenuItem>
@@ -88,14 +103,21 @@
open={activeTitle === item.title}
onOpenChange={(v) => onOpenChange(v, item.title)}
>
<DropdownMenu.Trigger>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<div
id={`trigger-${item.title}`}
class="relative z-30 flex w-full justify-center"
class="relative z-30 flex w-full justify-center"
onpointerenter={() => handleTriggerEnter(item.title)}
onpointerleave={(e) => handleTriggerLeave(e, item.title)}
>
<Sidebar.MenuButton
{...props}
tooltipContent={undefined}
class="justify-center"
>
<Sidebar.MenuButton
{...props}
tooltipContent={undefined}
@@ -117,6 +139,7 @@
align="start"
sideOffset={0}
class="z-50 w-64 overflow-visible rounded-lg p-0 shadow-lg"
class="z-50 w-64 overflow-visible rounded-lg p-0 shadow-lg"
id={`content-${item.title}`}
onpointerenter={() => handleContentEnter(item.title)}
onpointerleave={(e) => handleContentLeave(e, item.title)}
@@ -126,6 +149,9 @@
Posicionado con right-full para estar exactamente donde el trigger termina (offset 0).
Usamos w-8 h-8 para coincidir con un botón de tamaño estándar de sidebar.
-->
<div
class="absolute top-0 right-full z-50 flex h-8 w-8 items-center justify-center rounded-l-lg border border-r-0 border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground shadow-none"
>
<div
class="absolute top-0 right-full z-50 flex h-8 w-8 items-center justify-center rounded-l-lg border border-r-0 border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground shadow-none"
>
@@ -139,16 +165,23 @@
<!--
Panel Principal
-->
<div
class="pointer-events-auto ml-[0px] h-full w-full rounded-lg rounded-tl-none border border-sidebar-border bg-popover p-1"
>
<div
class="pointer-events-auto ml-[0px] h-full w-full rounded-lg rounded-tl-none border border-sidebar-border bg-popover p-1"
>
<!-- Título en el panel principal -->
<div
class="truncate border-b px-2 py-2 text-sm font-medium text-sidebar-foreground"
>
<div
class="truncate border-b px-2 py-2 text-sm font-medium text-sidebar-foreground"
>
{item.title}
</div>
<DropdownMenu.DropdownMenuGroup class="mt-1 max-h-80 overflow-y-auto">
{#each item.items as subItem (subItem.title)}
<DropdownMenu.DropdownMenuItem>

View File

@@ -1,30 +1,30 @@
<script lang="ts">
import * as Avatar from "$lib/components/ui/avatar/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
import BadgeCheckIcon from "@lucide/svelte/icons/badge-check";
import BellIcon from "@lucide/svelte/icons/bell";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import CreditCardIcon from "@lucide/svelte/icons/credit-card";
import LogOutIcon from "@lucide/svelte/icons/log-out";
import LanguagesIcon from "@lucide/svelte/icons/languages";
import MoonIcon from "@lucide/svelte/icons/moon";
import SunIcon from "@lucide/svelte/icons/sun";
import { logout } from "$lib/auth";
import { cookieName } from "$lib/paraglide/runtime";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { browser } from "$app/environment";
import { getBackendAssetUrl } from "$lib/utils";
import AppVersion from "$lib/components/app-version.svelte";
import * as Avatar from '$lib/components/ui/avatar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
import BellIcon from '@lucide/svelte/icons/bell';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import CreditCardIcon from '@lucide/svelte/icons/credit-card';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import LanguagesIcon from '@lucide/svelte/icons/languages';
import MoonIcon from '@lucide/svelte/icons/moon';
import SunIcon from '@lucide/svelte/icons/sun';
import { logout } from '$lib/auth';
import { cookieName } from '$lib/paraglide/runtime';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { getBackendAssetUrl } from '$lib/utils';
import AppVersion from '$lib/components/app-version.svelte';
let { user }: { user: { name: string; email: string; avatar: string } } = $props();
const sidebar = useSidebar();
// URL completa del avatar
let avatarUrl = $derived(getBackendAssetUrl(user.avatar) || '/avatars/default.jpg');
// Iniciales del usuario (2 primeras letras)
let initials = $derived(user.name.slice(0, 2).toUpperCase());
@@ -65,34 +65,34 @@
function toggleLanguage() {
if (!browser) return;
// Leer la cookie actual para obtener el idioma real
const cookies = document.cookie.split(';').map(c => c.trim());
const localeCookie = cookies.find(c => c.startsWith(`${cookieName}=`));
const cookies = document.cookie.split(';').map((c) => c.trim());
const localeCookie = cookies.find((c) => c.startsWith(`${cookieName}=`));
const current = localeCookie ? localeCookie.split('=')[1] : 'en';
// Alternar el idioma
const newLocale = current === 'en' ? 'es' : 'en';
// Establecer la cookie del idioma
document.cookie = `${cookieName}=${newLocale}; path=/; max-age=34560000; SameSite=Lax`;
// Recargar la página para que el servidor procese el nuevo idioma
window.location.reload();
}
function toggleTheme() {
if (!browser) return;
const html = document.documentElement;
const newTheme = html.classList.contains('dark') ? 'light' : 'dark';
if (newTheme === 'dark') {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
// Guardar la preferencia en localStorage
localStorage.setItem('theme', newTheme);
isDarkMode = newTheme === 'dark';
@@ -123,7 +123,7 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
side={sidebar.isMobile ? "bottom" : "right"}
side={sidebar.isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import BuildingIcon from "@lucide/svelte/icons/building";
import CheckIcon from "@lucide/svelte/icons/check";
import { companyStore } from "$lib/stores/company.svelte";
import { getBackendAssetUrl } from "$lib/utils";
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import BuildingIcon from '@lucide/svelte/icons/building';
import CheckIcon from '@lucide/svelte/icons/check';
import { companyStore } from '$lib/stores/company.svelte';
import { getBackendAssetUrl } from '$lib/utils';
const sidebar = useSidebar();
@@ -14,95 +14,93 @@
let activeCompanyLogoUrl = $derived(
companyStore.activeCompany?.logo
? getBackendAssetUrl(
`v1/a76/company/${companyStore.activeCompany.id}/logo/image?t=${Date.now()}`
)
`v1/a76/company/${companyStore.activeCompany.id}/logo/image?t=${Date.now()}`
)
: null
);
// Iniciales de la compañía activa (2 primeras letras)
let activeCompanyInitials = $derived(
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || "CO"
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO'
);
// Fallback en degradé cuando no hay logo cargado
const fallbackBg =
"radial-gradient(circle at 30% 30%, rgba(0,0,0,0.08), rgba(0,0,0,0.12)), linear-gradient(135deg, rgba(99,102,241,0.12), rgba(14,165,233,0.18))";
'radial-gradient(circle at 30% 30%, rgba(0,0,0,0.08), rgba(0,0,0,0.12)), linear-gradient(135deg, rgba(99,102,241,0.12), rgba(14,165,233,0.18))';
const logoBg = $derived(
activeCompanyLogoUrl
? `url(${activeCompanyLogoUrl})`
: fallbackBg
);
const logoBg = $derived(activeCompanyLogoUrl ? `url(${activeCompanyLogoUrl})` : fallbackBg);
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
size="lg"
class="relative overflow-hidden data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-has-data-[state=collapsed]/sidebar-wrapper:aspect-square group-has-data-[state=collapsed]/sidebar-wrapper:h-10 group-has-data-[state=collapsed]/sidebar-wrapper:w-10 group-has-data-[state=collapsed]/sidebar-wrapper:rounded-lg group-has-data-[state=collapsed]/sidebar-wrapper:justify-center group-has-data-[state=collapsed]/sidebar-wrapper:gap-0"
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
size="lg"
class="relative overflow-hidden group-has-data-[state=collapsed]/sidebar-wrapper:aspect-square group-has-data-[state=collapsed]/sidebar-wrapper:h-10 group-has-data-[state=collapsed]/sidebar-wrapper:w-10 group-has-data-[state=collapsed]/sidebar-wrapper:justify-center group-has-data-[state=collapsed]/sidebar-wrapper:gap-0 group-has-data-[state=collapsed]/sidebar-wrapper:rounded-lg data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div
class="flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg bg-sidebar-primary text-sidebar-primary-foreground group-has-data-[state=collapsed]/sidebar-wrapper:hidden"
>
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg overflow-hidden group-has-data-[state=collapsed]/sidebar-wrapper:hidden"
>
{#if activeCompanyLogoUrl}
<img
src={activeCompanyLogoUrl}
alt={companyStore.activeCompany?.name || "Company"}
class="size-full object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
{:else}
<span class="text-sm font-semibold text-white">{activeCompanyInitials}</span>
{/if}
</div>
<div class="grid flex-1 text-left text-sm leading-tight min-w-0 group-has-data-[state=collapsed]/sidebar-wrapper:hidden">
<span class="truncate font-medium">
{companyStore.activeCompany?.name || "Seleccionar compañía"}
{#if activeCompanyLogoUrl}
<img
src={activeCompanyLogoUrl}
alt={companyStore.activeCompany?.name || 'Company'}
class="size-full object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
<span class="text-sm font-semibold text-white">{activeCompanyInitials}</span>
{/if}
</div>
<div
class="grid min-w-0 flex-1 text-left text-sm leading-tight group-has-data-[state=collapsed]/sidebar-wrapper:hidden"
>
<span class="truncate font-medium">
{companyStore.activeCompany?.name || 'Seleccionar compañía'}
</span>
{#if companyStore.activeCompany?.rfc}
<span class="truncate text-xs text-muted-foreground">
{companyStore.activeCompany.rfc}
</span>
{#if companyStore.activeCompany?.rfc}
<span class="truncate text-xs text-muted-foreground">
{companyStore.activeCompany.rfc}
</span>
{/if}
</div>
<ChevronsUpDownIcon class="ml-auto size-4 group-has-data-[state=collapsed]/sidebar-wrapper:hidden" />
{/if}
</div>
<ChevronsUpDownIcon
class="ml-auto size-4 group-has-data-[state=collapsed]/sidebar-wrapper:hidden"
/>
<!-- Isotipo compacto visible solo en modo colapsado -->
<div
class="relative z-10 hidden size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-foreground text-sm font-semibold shadow-sm ring-1 ring-sidebar-border/40 group-has-data-[state=collapsed]/sidebar-wrapper:flex overflow-hidden"
>
{#if activeCompanyLogoUrl}
<img
src={activeCompanyLogoUrl}
alt={companyStore.activeCompany?.name || "Company"}
class="size-full object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
{:else}
{activeCompanyInitials}
{/if}
</div>
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<!-- Isotipo compacto visible solo en modo colapsado -->
<div
class="relative z-10 hidden size-8 items-center justify-center overflow-hidden rounded-md bg-sidebar-primary text-sm font-semibold text-sidebar-foreground shadow-sm ring-1 ring-sidebar-border/40 group-has-data-[state=collapsed]/sidebar-wrapper:flex"
>
{#if activeCompanyLogoUrl}
<img
src={activeCompanyLogoUrl}
alt={companyStore.activeCompany?.name || 'Company'}
class="size-full object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
{activeCompanyInitials}
{/if}
</div>
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
align="start"
side={sidebar.isMobile ? "bottom" : "right"}
side={sidebar.isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenu.Label class="text-muted-foreground text-xs">
Mis Compañías
</DropdownMenu.Label>
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis Compañías</DropdownMenu.Label>
{#if companyStore.loading}
<DropdownMenu.Item disabled class="gap-2 p-2">
<span class="text-muted-foreground">Cargando...</span>
@@ -113,25 +111,28 @@
</DropdownMenu.Item>
{:else}
{#each companyStore.companies as company, index (company.id)}
<DropdownMenu.Item
onSelect={() => companyStore.setActiveCompany(company)}
class="gap-2 p-2 cursor-pointer"
<DropdownMenu.Item
onSelect={() => companyStore.setActiveCompany(company)}
class="cursor-pointer gap-2 p-2"
>
<div class="flex size-6 items-center justify-center rounded-md border overflow-hidden">
<div
class="flex size-6 items-center justify-center overflow-hidden rounded-md border"
>
{#if company.logo}
<img
src={getBackendAssetUrl(`v1/a76/company/${company.id}/logo/image`)}
<img
src={getBackendAssetUrl(`v1/a76/company/${company.id}/logo/image`)}
alt={company.name}
class="size-full rounded object-cover"
/>
{:else}
<span class="text-xs font-semibold">{company.name.slice(0, 2).toUpperCase()}</span>
<span class="text-xs font-semibold">{company.name.slice(0, 2).toUpperCase()}</span
>
{/if}
</div>
<div class="flex flex-1 flex-col min-w-0">
<span class="font-medium truncate">{company.name}</span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate font-medium">{company.name}</span>
{#if company.rfc}
<span class="text-xs text-muted-foreground truncate">{company.rfc}</span>
<span class="truncate text-xs text-muted-foreground">{company.rfc}</span>
{/if}
</div>
{#if companyStore.activeCompany?.id === company.id}

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import Overlay from './dialog-overlay.svelte';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -19,13 +19,13 @@
} = $props();
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Portal {...portalProps}>
<Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...restProps}
@@ -33,11 +33,11 @@
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
class="absolute end-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</Dialog.Portal>
</DialogPrimitive.Portal>

View File

@@ -1,4 +1,4 @@
import { Dialog } from "bits-ui";
import { Dialog as DialogPrimitive } from "bits-ui";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";

View File

@@ -1,5 +1,5 @@
import { Dialog } from "bits-ui";
const SheetPrimitive = Dialog;
import { Dialog as DialogPrimitive } from "bits-ui";
const SheetPrimitive = DialogPrimitive;
import Trigger from "./sheet-trigger.svelte";
import Close from "./sheet-close.svelte";
import Overlay from "./sheet-overlay.svelte";

View File

@@ -1,47 +1,45 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
import { tv, type VariantProps } from 'tailwind-variants';
export const sidebarMenuButtonVariants = tv({
base: "peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
base: 'peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
"bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
'bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]'
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "group-data-[collapsible=icon]:p-0! h-12 text-sm",
},
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'group-data-[collapsible=icon]:p-0! h-12 text-sm'
}
},
defaultVariants: {
variant: "default",
size: "default",
},
variant: 'default',
size: 'default'
}
});
export type SidebarMenuButtonVariant = VariantProps<
typeof sidebarMenuButtonVariants
>["variant"];
export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>["size"];
export type SidebarMenuButtonVariant = VariantProps<typeof sidebarMenuButtonVariants>['variant'];
export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>['size'];
</script>
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { cn, type WithElementRef, type WithoutChildrenOrChild } from "$lib/utils.js";
import { mergeProps } from "bits-ui";
import type { ComponentProps, Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import { useSidebar } from "./context.svelte.js";
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { cn, type WithElementRef, type WithoutChildrenOrChild } from '$lib/utils.js';
import { mergeProps } from 'bits-ui';
import type { ComponentProps, Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { useSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
child,
variant = "default",
size = "default",
variant = 'default',
size = 'default',
isActive = false,
tooltipContent,
tooltipContentProps,
@@ -59,11 +57,11 @@
const buttonProps = $derived({
class: cn(sidebarMenuButtonVariants({ variant, size }), className),
"data-slot": "sidebar-menu-button",
"data-sidebar": "menu-button",
"data-size": size,
"data-active": isActive,
...restProps,
'data-slot': 'sidebar-menu-button',
'data-sidebar': 'menu-button',
'data-size': size,
'data-active': isActive,
...restProps
});
</script>
@@ -90,10 +88,10 @@
<Tooltip.Content
side="right"
align="center"
hidden={sidebar.state !== "collapsed" || sidebar.isMobile}
hidden={sidebar.state !== 'collapsed' || sidebar.isMobile}
{...tooltipContentProps}
>
{#if typeof tooltipContent === "string"}
{#if typeof tooltipContent === 'string'}
{tooltipContent}
{:else if tooltipContent}
{@render tooltipContent()}

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
side = "top",
side = 'top',
children,
arrowClasses,
...restProps
@@ -22,7 +22,7 @@
{sideOffset}
{side}
class={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs",
'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--bits-tooltip-content-transform-origin) rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground',
className
)}
{...restProps}
@@ -32,11 +32,11 @@
{#snippet child({ props })}
<div
class={cn(
"bg-primary z-50 size-2.5 rotate-45 rounded-[2px]",
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]",
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]",
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2",
"data-[side=left]:-translate-y-[calc(50%_-_3px)]",
'z-50 size-2.5 rotate-45 rounded-[2px] bg-primary',
'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
arrowClasses
)}
{...props}

View File

@@ -9,9 +9,12 @@
import { consolidatedReportsApi } from '$lib/api/dashboard/a76/reports/reports-consolidated';
import { dischargeReportsApi } from '$lib/api/dashboard/a76/reports/reports-descargo';
import { avisoConsolidadoReportsApi } from '$lib/api/dashboard/a76/reports/reports-aviso-consolidado';
import { reportsTransmissionApi } from '$lib/api/dashboard/a76/reports/reports-transmission';
import { reportsWinsaaiApi } from '$lib/api/dashboard/a76/reports/reports-winsaai';
import DataTable from '$lib/components/dashboard/invoices/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/invoices/columns.js';
import * as Card from '$lib/components/ui/card';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
@@ -364,8 +367,10 @@
// Estado para el diálogo de progreso
let showProgressDialog = $state(false);
let isWinsaiiConfirmOpen = $state(false);
let currentTaskId = $state<string | null>(null);
let currentStatusFunction = $state<((taskId: string) => Promise<any>) | null>(null);
let progressDialogTitle = $state('Generando documento');
// Utilidad para convertir Base64 a Blob
function base64ToBlob(base64: string, type: string) {
@@ -394,6 +399,7 @@
// 2. Abrir diálogo de progreso
currentTaskId = task_id;
currentStatusFunction = invoicesReportsApi.getTaskStatus;
progressDialogTitle = 'Generando PDF de Factura';
showProgressDialog = true;
} catch (error) {
console.error(error);
@@ -417,6 +423,7 @@
// 2. Abrir diálogo de progreso
currentTaskId = task_id;
currentStatusFunction = consolidatedReportsApi.getTaskStatus;
progressDialogTitle = 'Generando Consolidado';
showProgressDialog = true;
} catch (error) {
console.error(error);
@@ -450,6 +457,7 @@
// 2. Abrir diálogo de progreso
currentTaskId = task_id;
currentStatusFunction = dischargeReportsApi.getTaskStatus;
progressDialogTitle = 'Generando Reporte PEPS';
showProgressDialog = true;
} catch (error) {
console.error(error);
@@ -473,6 +481,7 @@
// 2. Abrir diálogo de progreso
currentTaskId = task_id;
currentStatusFunction = avisoConsolidadoReportsApi.getTaskStatus;
progressDialogTitle = 'Generando Aviso Consolidado';
showProgressDialog = true;
} catch (error) {
console.error(error);
@@ -497,6 +506,7 @@
currentTaskId = task_id;
// Use the specific status function for Packing List
currentStatusFunction = invoicesReportsApi.getPackingListTaskStatus;
progressDialogTitle = 'Generando Packing List';
showProgressDialog = true;
} catch (error) {
console.error(error);
@@ -504,6 +514,44 @@
}
}
async function handleInterfaceAgenteAduanal(invoice: any) {
if (!companyStore.activeCompany) {
toast.error('No hay empresa seleccionada');
return;
}
if (invoice.operation_type !== 'imp') {
toast.info('La interfaz rápida solo está disponible para facturas de Importación');
return;
}
// Abrir confirmación
isWinsaiiConfirmOpen = true;
}
async function confirmWinsaiiGeneration() {
if (!selectedInvoice) return;
isWinsaiiConfirmOpen = false;
try {
const res = await reportsWinsaaiApi.invoices.triggerGeneration({
invoice_ids: [selectedInvoice.id],
is_temporal: selectedInvoice.invoice_type === 'TEM'
});
if (res.task_id) {
currentTaskId = res.task_id;
// Use the specific status and download functions for WINSAAI
currentStatusFunction = reportsWinsaaiApi.invoices.getTaskStatus;
progressDialogTitle = 'Generando Reporte WINSAAI';
showProgressDialog = true;
}
} catch (error) {
console.error(error);
toast.error('No se pudo iniciar la generación de Interface Agente Aduanal');
}
}
function onPdfComplete(result: any) {
// Esta función se llama cuando el diálogo reporta SUCCESS
try {
@@ -806,8 +854,26 @@
getStatus={currentStatusFunction}
onComplete={onPdfComplete}
onClose={closeProgressDialog}
title={progressDialogTitle}
/>
<AlertDialog.Root bind:open={isWinsaiiConfirmOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Sistema de Control de Aduanas e Inventarios</AlertDialog.Title>
<AlertDialog.Description>
A la Factura <strong>{selectedInvoice?.invoice_number}</strong> de tipo
<strong>{selectedInvoice?.document_type}</strong> se le ha asignado el proceso Generación del
Archivo WINSAAI. ¿Desea Continuar o Cancelar?
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmWinsaiiGeneration}>Continuar</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Footer fijo con botones de acción -->
<div
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
@@ -873,6 +939,16 @@
Packing List
</Button>
<Button
variant="outline"
size="sm"
onclick={() => selectedInvoice && handleInterfaceAgenteAduanal(selectedInvoice)}
disabled={!selectedInvoice}
>
<Send class="mr-2 h-4 w-4" />
Interface Agente Aduanal
</Button>
<Button
variant="outline"
size="sm"

View File

@@ -11,11 +11,15 @@
import type { PageData } from './$types';
import { browser } from '$app/environment';
import { companyStore } from '$lib/stores/company.svelte';
import { Plus, Filter, Trash2, RefreshCw } from 'lucide-svelte';
import { Edit } from 'lucide-svelte';
import { Edit, Send, Plus, Filter, Trash2, RefreshCw } from 'lucide-svelte';
import { obtenerAtajosListaPedimento } from '$lib/config/shortcuts/dashboard/pedimentos/list';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { goto } from '$app/navigation';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { toast } from 'svelte-sonner';
import { reportsWinsaaiApi } from '$lib/api/dashboard/a76/reports/reports-winsaai';
import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
@@ -82,6 +86,16 @@
let selectedIds = $state<number[]>([]);
let hasSelection = $derived(selectedIds.length > 0);
let showDeleteDialog = $state(false);
let isWinsaiiConfirmOpen = $state(false);
let isWinsaiiByClass = $state(false);
// Estado para diálogos de progreso
let showProgressDialog = $state(false);
let progressDialogTitle = $state('Procesando...');
let currentTaskId = $state<string | null>(null);
let currentStatusFunction = $state<any>(null);
let selectedPedimento = $derived(allItems.find((p) => p.id === selectedId) || null);
function handleRowClick(pedimento: Pedimento) {
// Toggle: si ya está seleccionado, deseleccionar; si no, seleccionar
@@ -138,6 +152,81 @@
}
}
function handleInterfaceAgenteAduanal() {
if (!companyStore.activeCompany) {
toast.error('No hay empresa seleccionada');
return;
}
if (!selectedPedimento) {
toast.error('Por favor selecciona un pedimento');
return;
}
// Abrir confirmación
isWinsaiiConfirmOpen = true;
}
async function confirmWinsaiiGeneration() {
if (!selectedPedimento) return;
isWinsaiiConfirmOpen = false;
try {
const res = await reportsWinsaaiApi.pedimentos.triggerGeneration(
[selectedPedimento.id],
true, // isTemporal
isWinsaiiByClass
);
if (res.task_id) {
currentTaskId = res.task_id;
currentStatusFunction = reportsWinsaaiApi.pedimentos.getTaskStatus;
progressDialogTitle = 'Generando Reporte WINSAAI Pedimentos';
showProgressDialog = true;
}
} catch (error) {
console.error(error);
toast.error('No se pudo iniciar la generación de Interface Agente Aduanal');
}
}
function onPdfComplete(result: any) {
try {
if (result.status === 'success') {
const blob = base64ToBlob(result.content, result.media_type);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = result.file_name;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Archivo descargado exitosamente');
} else {
toast.error('Hubo un error en la generación: ' + (result.message || 'Desconocido'));
}
} catch (e) {
console.error('Error al procesar descarga:', e);
toast.error('Error al procesar el archivo descargado');
} finally {
setTimeout(() => {
showProgressDialog = false;
currentTaskId = null;
}, 1000);
}
}
function base64ToBlob(base64: string, type: string) {
const binStr = atob(base64);
const len = binStr.length;
const arr = new Uint8Array(len);
for (let i = 0; i < len; i++) {
arr[i] = binStr.charCodeAt(i);
}
return new Blob([arr], { type: type });
}
// Keyboard Shortcuts
useShortcuts(
'Pedimentos',
@@ -467,6 +556,15 @@
({selectedIds.length})
{/if}
</Button>
<Button
variant="outline"
size="sm"
onclick={handleInterfaceAgenteAduanal}
disabled={!hasSelection}
>
<Send class="mr-2 h-4 w-4" />
Interface Agente Aduanal
</Button>
</div>
</div>
</div>
@@ -497,3 +595,38 @@
</div>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={isWinsaiiConfirmOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>¿Generar Interface Agente Aduanal?</AlertDialog.Title>
<AlertDialog.Description>
Se generará el reporte WINSAAI para el pedimento seleccionado.
<div class="mt-4 flex items-center space-x-2">
<Checkbox id="byClass" bind:checked={isWinsaiiByClass} />
<Label
for="byClass"
class="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Agrupar por clase (SCAF)
</Label>
</div>
</AlertDialog.Description>
</AlertDialog.Header>
<div class="flex justify-end gap-2">
<Button variant="outline" onclick={() => (isWinsaiiConfirmOpen = false)}>Cancelar</Button>
<Button onclick={confirmWinsaiiGeneration}>Generar</Button>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
{#if showProgressDialog && currentTaskId}
<PdfProgressDialog
bind:open={showProgressDialog}
taskId={currentTaskId}
title={progressDialogTitle}
getStatus={currentStatusFunction}
onClose={() => (showProgressDialog = false)}
onComplete={onPdfComplete}
/>
{/if}

View File

@@ -137,6 +137,7 @@ POSTGRES_PORT="${POSTGRES_PORT:-5432}"
POSTGRES_DB="${POSTGRES_DB:-anexo76_core}"
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-postgres}"
BACKEND_URL="${BACKEND_URL:-http://localhost:8000}"
DEMO_USERNAME="demo"
DEMO_PASSWORD="demo123"
@@ -601,27 +602,6 @@ fi
echo -e "${GREEN}✓ Tenant ID: ${TENANT_ID}${NC}"
# Insertar company si no existe
COMPANY_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM a76.company WHERE tenant_id = ${TENANT_ID};")
COMPANY_EXISTS=$(echo "$COMPANY_EXISTS" | xargs)
if [ "$COMPANY_EXISTS" = "0" ]; then
exec_pg_sql "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null
exec_pg_sql_client "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null
echo -e "${GREEN}✓ Company creada (Hub y Cliente)${NC}"
else
echo -e "${YELLOW}⚠ Company ya existe para este tenant${NC}"
fi
# Obtener ID de la company (necesario para user_tenants)
COMPANY_ID=$(exec_pg_sql "SELECT id FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;" | xargs)
if [ -z "$COMPANY_ID" ]; then
echo -e "${RED}✗ Error: No se pudo obtener el ID de la company${NC}"
exit 1
fi
COMPANY_INFO=$(exec_pg_sql "SELECT id, name FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;")
echo -e "${GREEN}✓ Company ID: ${COMPANY_ID} | ${COMPANY_INFO}${NC}"
# Agregar tenant_id al usuario demo en Keycloak
echo -e "\n${YELLOW}Asignando tenant_id al usuario demo...${NC}"
@@ -636,14 +616,71 @@ curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users/${USER_ID}"
echo -e "${GREEN}✓ Atributo tenant_id asignado al usuario${NC}"
# Agregar relación usuario-tenant en la base de datos (usar company_id real)
# Obtener token de demo user para usar la API con su tenant
echo "Obteniendo token de usuario demo..."
DEMO_TOKEN_RESPONSE=$(curl -s -X POST "${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=${DEMO_USERNAME}" \
-d "password=${DEMO_PASSWORD}" \
-d "grant_type=password" \
-d "client_id=anexo76-backend" \
-d "client_secret=${BACKEND_SECRET}")
DEMO_ACCESS_TOKEN=$(echo "$DEMO_TOKEN_RESPONSE" | jq -r '.access_token // empty')
if [ -z "$DEMO_ACCESS_TOKEN" ]; then
echo -e "${RED}✗ Error: No se pudo obtener el token de usuario demo${NC}"
exit 1
fi
# Insertar company si no existe
COMPANY_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM a76.company WHERE tenant_id = ${TENANT_ID};")
COMPANY_EXISTS=$(echo "$COMPANY_EXISTS" | xargs)
if [ "$COMPANY_EXISTS" = "0" ]; then
echo " → Creando company mediante API..."
CREATE_COMPANY_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${BACKEND_URL}/api/v1/a76/company" \
-H "Authorization: Bearer ${DEMO_ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"${COMPANY_NAME}\",
\"rfc\": \"${COMPANY_RFC}\",
\"is_service_company\": false
}")
HTTP_CODE=$(echo "$CREATE_COMPANY_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$CREATE_COMPANY_RESPONSE" | head -n -1)
if [ "$HTTP_CODE" = "201" ]; then
COMPANY_ID=$(echo "$RESPONSE_BODY" | jq -r '.id // empty')
echo -e "${GREEN}✓ Company creada exitosamente via API (Hub, Cliente replicado por DB trigger o servicio asíncrono)${NC}"
else
echo -e "${RED}✗ Error al crear company via API (HTTP ${HTTP_CODE})${NC}"
echo "Respuesta: $RESPONSE_BODY"
exit 1
fi
else
echo -e "${YELLOW}⚠ Company ya existe para este tenant${NC}"
# Obtener información de la company existente
COMPANY_ID=$(exec_pg_sql "SELECT id FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;" | xargs)
fi
COMPANY_INFO=$(exec_pg_sql "SELECT name FROM a76.company WHERE id = ${COMPANY_ID} AND tenant_id = ${TENANT_ID} LIMIT 1;")
echo -e "${GREEN}✓ Company: ${COMPANY_INFO}${NC}"
echo -e "${GREEN}✓ Company ID: ${COMPANY_ID}${NC}"
# Agregar relación usuario-tenant en la base de datos
echo -e "\n${YELLOW}Creando relación usuario-tenant en la base de datos...${NC}"
exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null
exec_pg_sql_client "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null
exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, ${COMPANY_ID}, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null
exec_pg_sql_client "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, ${COMPANY_ID}, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null
echo -e "${GREEN}✓ Relación usuario-tenant creada en la base de datos (Hub y Cliente)${NC}"
###############################################################################
# 9. Crear licencia Enterprise para el tenant
###############################################################################