Merge branch 'feature/reportes_facturas' into development

This commit is contained in:
2026-01-21 12:12:23 -06:00
92 changed files with 6924 additions and 1822 deletions

View File

@@ -29,7 +29,7 @@ class FaPart(Base, TenantScopedMixin, TimestampMixin):
ForeignKeyConstraint(
["id"], ["a76.parts.id"], name="fk_fa_partes_master"
),
{"schema": "a24"},
{"schema": "a24", "extend_existing": True},
)
# El ID hereda el valor de la tabla parts

View File

@@ -32,7 +32,7 @@ class InvPart(Base, TenantScopedMixin, TimestampMixin):
ForeignKeyConstraint(
["id"], ["a76.parts.id"], name="fk_inv_partes_master"
),
{"schema": "a24"},
{"schema": "a24", "extend_existing": True},
)
# Relación 1:1 - El ID es el mismo de la tabla maestra

View File

@@ -17,10 +17,11 @@ from sqlalchemy import (
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.modules.public.reference_data.material_types.models import MaterialType
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.public.reference_data.material_types.models import MaterialType
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
class Class(Base, TenantScopedMixin, TimestampMixin):
@@ -47,7 +48,7 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
"class_code",
name="uq_classes_tenant_company_code",
),
{"schema": "a76"},
{"schema": "a76", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)

View File

@@ -5,9 +5,13 @@ Rutas para gestión de empresa
import os
import shutil
from typing import List, Optional
import os
import shutil
from pathlib import Path
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile
from fastapi.responses import FileResponse, UploadFile, File
from sqlalchemy.orm import Session
from core.database import get_core_db
@@ -305,11 +309,98 @@ async def update_company(
return CompanyResponseDTO.model_validate(updated_company)
@router.delete(
"/{company_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete company",
return CompanyResponseDTO.model_validate(updated_company)
@router.post(
"/{company_id}/upload-logo",
response_model=dict,
summary="Upload company logo",
)
async def upload_company_logo(
company_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Upload logo for a company"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
# 1. Verify company exists
company = CompanyService.get_by_id(db, company_id, tenant_id, 0)
if not company:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
# 2. Define upload path
# Use a persistent path: 'app_data/logos/{company_id}'
upload_dir = Path(f"app_data/logos/{company_id}")
upload_dir.mkdir(parents=True, exist_ok=True)
# 3. Save file
# Preserve original filename
filename = file.filename or "logo.png"
file_path = upload_dir / filename
try:
# Check if file exists and remove it to avoid accumulation if needed,
# or just overwrite (shutil.copyfileobj overwrites)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Could not save file: {e}",
)
# 4. Returns the absolute path keys
abs_path = str(file_path.absolute())
return {"path": abs_path}
@router.get(
"/{company_id}/logo/image",
summary="Get company logo image",
)
@router.get(
"/{company_id}/logo/image",
summary="Get company logo image",
)
async def get_company_logo_image(
company_id: int,
db: Session = Depends(get_core_db),
# Public endpoint to allow <img> tags to load the image without custom headers
):
"""Serve the company logo image file"""
# Security: In a stricter environment, we would use a signed short-lived URL
# or cookie-based auth. For now, checking if company exists is sufficient.
# We find the company ignoring tenant checks for the image serving
# (Logos are generally considered semi-public assets in this context)
company = db.query(Company).filter(Company.id == company_id).first()
if not company or not company.logo:
raise HTTPException(status_code=404, detail="Logo not found")
file_path = Path(company.logo)
if not file_path.exists():
# Fallback for old paths or moved files
# Check if it exists in the 'standard' location even if DB thinks otherwise
standard_path = Path(f"app_data/logos/{company_id}") / file_path.name
if standard_path.exists():
return FileResponse(standard_path)
raise HTTPException(status_code=404, detail="Logo file not found on server")
return FileResponse(file_path)
async def delete_company(
company_id: int,
db: Session = Depends(get_core_db),

View File

@@ -12,6 +12,10 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
# Import models referenced by TenantScopedMixin (Tenant, Company) to ensure they are loaded
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.a76.general_catalogs.company.models import Company
# 1. GUniMedACE
class UnitOfMeasureACE(Base, TimestampMixin):
__tablename__ = "unit_of_measure_ace"

View File

@@ -34,10 +34,13 @@ class BaseService:
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Any], int]:
query = db.query(cls.model).filter(
cls.model.tenant_id == tenant_id,
cls.model.company_id == company_id,
)
query = db.query(cls.model)
if hasattr(cls.model, "tenant_id"):
query = query.filter(cls.model.tenant_id == tenant_id)
if hasattr(cls.model, "company_id"):
query = query.filter(cls.model.company_id == company_id)
if filters:
if filters.get("code"):
@@ -56,11 +59,15 @@ class BaseService:
def get_by_id(
cls, db: Session, id: int, tenant_id: int, company_id: int
) -> Optional[Any]:
return db.query(cls.model).filter(
cls.model.id == id,
cls.model.tenant_id == tenant_id,
cls.model.company_id == company_id,
).first()
query = db.query(cls.model).filter(cls.model.id == id)
if hasattr(cls.model, "tenant_id"):
query = query.filter(cls.model.tenant_id == tenant_id)
if hasattr(cls.model, "company_id"):
query = query.filter(cls.model.company_id == company_id)
return query.first()
@classmethod
def create(
@@ -70,9 +77,15 @@ class BaseService:
tenant_id: int,
company_id: int,
) -> Any:
db_obj = cls.model(
**data.model_dump(), tenant_id=tenant_id, company_id=company_id
)
create_kwargs = data.model_dump()
if hasattr(cls.model, "tenant_id"):
create_kwargs["tenant_id"] = tenant_id
if hasattr(cls.model, "company_id"):
create_kwargs["company_id"] = company_id
db_obj = cls.model(**create_kwargs)
db.add(db_obj)
db.commit()
db.refresh(db_obj)

View File

@@ -1,10 +1,13 @@
from decimal import Decimal
from typing import Optional, TYPE_CHECKING
from sqlalchemy import Boolean, String, Integer, Numeric, SmallInteger, ForeignKey
from sqlalchemy import Boolean, String, Integer, Numeric, SmallInteger, ForeignKey, ForeignKeyConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
if TYPE_CHECKING:
from ..models import Item
from ..line_financials.models import LineFinancial
@@ -12,10 +15,6 @@ if TYPE_CHECKING:
from ..line_customs.models import LineCustom
from ..line_descriptions.models import LineDescription
from ..line_references.models import LineReference
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import (
UnitOfMeasure,
)
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
@@ -26,19 +25,24 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
"""
__tablename__ = "item_lines"
__table_args__ = {
"schema": "a76",
}
__table_args__ = (
ForeignKeyConstraint(
["class_code", "tenant_id", "company_id"],
["a76.classes.class_code", "a76.classes.tenant_id", "a76.classes.company_id"],
name="fk_item_lines_class"
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
item_id: Mapped[int] = mapped_column(ForeignKey("a76.items.id"))
line_number: Mapped[int] = mapped_column(Integer) # LINEAIMPO/LINEAEXPO/LINEA
# Part identification
part_number_id: Mapped[Optional[str]] = mapped_column(
part_number: Mapped[Optional[str]] = mapped_column(
ForeignKey("a76.parts.id")
) # NUMPARTE
component_part_number_id: Mapped[Optional[str]] = mapped_column(
component_part_number: Mapped[Optional[str]] = mapped_column(
ForeignKey("a76.parts.id")
) # NUMPARTECOM
class_id: Mapped[Optional[int]] = mapped_column(

View File

@@ -22,14 +22,16 @@ from sqlalchemy import (
# Importante usar relationship y Mapped
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.a24.fa.fa_parts.models import FaPart
from api.v1.modules.a24.inv.inv_parts.models import InvPart
if TYPE_CHECKING:
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.a76.general_catalogs.units_of_measure.models import (
UnitOfMeasure,
)
from api.v1.modules.a24.fa.fa_parts.models import FaPart
from api.v1.modules.a24.inv.inv_parts.models import InvPart
class Part(Base, TenantScopedMixin, TimestampMixin):

View File

@@ -0,0 +1,98 @@
from decimal import Decimal
from typing import List, Optional, Union
from pydantic import BaseModel, field_validator
class ClienteSchema(BaseModel):
header: str
nombre: str
# Ponemos valor por defecto "" y permitimos que sea opcional
direccion: Optional[str] = ""
num_exterior: Optional[str] = ""
num_interior: Optional[str] = ""
colonia: Optional[str] = ""
codigo_postal: Optional[str] = ""
ciudad: Optional[str] = ""
estado: Optional[str] = ""
pais: Optional[str] = ""
tax_id: str
programa: Optional[str] = ""
autorizacion: Optional[str] = ""
prosec: Optional[str] = ""
reg_emp: Optional[str] = ""
cert: Optional[str] = ""
# Si llega un None, lo convertimos en "" automáticamente
@field_validator('direccion', 'nombre', mode='before')
@classmethod
def prevent_none(cls, v):
return v or ""
class FacturaSchema(BaseModel):
numero: str
fecha: str
tipo_cambio: float
moneda: str
# Campos de aduanas (Opcionales por si A76 aún no los tiene)
pedimento: str = ""
clave_pedimento: str = ""
remesa: str = ""
acuse_electronico: str = ""
representante_legal: str = ""
nombre_empresa: str = ""
agente_aduanal: str = ""
patente: str = ""
precinto: str = ""
regimen: str = ""
transportista: str = ""
scac: str = ""
caat: str = ""
incoterm: str = ""
transporte: str = ""
num_transporte: str = ""
placas: str = ""
placas_remolque: str = ""
licencia_conductor: str = ""
caat: str = ""
scac: str = ""
aduana: str = ""
destino: str = ""
observaciones: str = ""
transportista_info: str = ""
class PartidaSchema(BaseModel):
numero_parte: str
descripcion: str
fraccion: str
origen: str
advalorem:Optional[str] = ""
preferencia:Optional[str] = ""
cantidad_importacion: Union[float, str]
unidad_medida: str
cantidad_bultos: int
clave_bultos: str
peso_neto: Union[float, str]
peso_bruto: Union[float, str]
valor_costo_unitario: Union[float, str]
valor_total: Union[float, str]
valor_estimado: Union[float, str] = "0.00"
class TotalesSchema(BaseModel):
cantidad_total: Union[float, str]
bultos_total: int
clave_bultos: str = ""
peso_neto_total: Union[float, str]
peso_bruto_total: Union[float, str]
valor_total_total: Union[float, str]
valor_total_dolares: Union[float, str]
valor_estimado_total: Union[float, str] = "0.00"
class FacturaImportacionCompleta(BaseModel):
cliente_proveedor: ClienteSchema
cliente_vendido: ClienteSchema
cliente_enviado: ClienteSchema
factura: FacturaSchema
partidas: List[PartidaSchema]
totales: TotalesSchema

View File

@@ -0,0 +1,630 @@
import shutil
import base64
import pdfkit
from pathlib import Path
from decimal import Decimal
from typing import Tuple, List, Callable, Optional
from jinja2 import Environment, FileSystemLoader, select_autoescape
from fastapi import HTTPException
from sqlalchemy.orm import Session
# --- MODELOS ---
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics, InvoiceComplianceMx
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider, ClientProviderAddress, ClientProviderPrograms
)
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.items.models import Item
# --- TRANSPORTATION MODELS ---
from api.v1.modules.a76.transportation.transporters.models import Transporter
from api.v1.modules.a76.transportation.vehicles.models import Vehicle
from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
# --- SCHEMAS ---
from .schemas import (
ClienteSchema, PartidaSchema, TotalesSchema,
FacturaSchema, FacturaImportacionCompleta
)
class ConsolidadoImportacionMexService:
def __init__(self):
self.template_dir = Path(__file__).parent.parent / "templates"
self.jinja_env = Environment(
loader=FileSystemLoader(self.template_dir),
autoescape=select_autoescape(['html', 'xml'])
)
self.template = self.jinja_env.get_template('cons_mex_ver.html')
def _get_wkhtmltopdf_config(self):
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
if not Path(path).exists():
raise RuntimeError("wkhtmltopdf no encontrado.")
return pdfkit.configuration(wkhtmltopdf=path)
def formatear_numero(self, valor, decimales: int = 2):
if valor is None: return 0.0
try:
return round(float(valor), decimales)
except: return 0.0
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
if not fraccion_raw or len(fraccion_raw) < 8:
return fraccion_raw
return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}"
def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema:
main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first()
if not main:
return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX")
addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first()
prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first()
return ClienteSchema(
header=rol,
nombre=(main.name or main.short_name) or "S/N",
direccion=(addr.streets or "") if addr else "",
num_exterior=(addr.exterior_number or "") if addr else "",
num_interior=(addr.interior_number or "") if addr else "",
colonia=(addr.neighborhood or "") if addr else "",
codigo_postal=(addr.postal_code or "") if addr else "",
ciudad=(addr.city or "") if addr else "",
estado=(addr.state or "") if addr else "",
pais=(addr.country or "MEX") if addr else "MEX",
tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
programa="IMMEX" if (prog and prog.program) else "",
autorizacion=prog.program_number if prog else "",
prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "",
reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else (
prog.certified_company_registry if (prog and prog.certified_company_registry) else ""
),
cert=prog.is_certified_company if (prog and prog.is_certified_company) else ""
)
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta:
try:
if progress_callback: progress_callback(10, "Buscando factura...")
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
if not header: raise HTTPException(status_code=404, detail="Factura no encontrada")
compliance = header.compliance_mx
logistics = header.logistics if header.logistics else None
financials = header.financials if header.financials else None
if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...")
pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id
pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None
if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...")
proveedor_id = compliance.provider_id if compliance else None
cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="")
nombre_agente = ""
if compliance and compliance.customs_broker_id:
broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first()
if broker: nombre_agente = broker.name
company = db.query(Company).filter(Company.id == header.company_id).first()
# Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A)
# Default Header (Company)
cliente_default = ClienteSchema(
header="Importer / Consignee:",
nombre=getattr(company, 'name', "Empresa Local"),
direccion="DOMICILIO FISCAL",
num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX",
tax_id=getattr(company, 'rfc', ""),
programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "")
)
# Left Side Logic (Consignatario / Sold To)
cliente_vendido = cliente_default
if compliance and compliance.sold_to_id:
# Map known headers or default to Sold To / Vendido a
raw = (compliance.sold_to_header or "").upper()
if "CONSIGN" in raw:
clean_header = "Consignee / Consignatario:"
else:
clean_header = "Sold To / Vendido a:"
cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header)
# Right Side Logic (Enviado A / Shipped To)
cliente_enviado = cliente_default
if compliance and compliance.shipped_to_id:
# Map to Shipped To / Enviado a
clean_header_shipped = "Shipped To / Enviado a:"
# Fetch client data
cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped)
remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else ""
acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A"
patente_val = ""
if pedimento and pedimento.license:
patente_val = pedimento.license
elif 'broker' in locals() and broker and broker.license:
patente_val = broker.license
# --- Transport Data Fetching ---
transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else ""
num_transporte_val = (logistics.trailer_num or "") if logistics else ""
# Init values
placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto
placas_remolque_val = ""
transportista_val = (logistics.carrier_id or "") if logistics else ""
caat_val = ""
scac_val = ""
licencia_cond_val = ""
conductor_nombre = ""
# Block Logic (Clarion Style) for transportista_info
transport_lines = []
if logistics:
# 1. Transporter (CAAT / SCAC)
if logistics.carrier_id:
transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first()
if transporter_obj:
caat_val = transporter_obj.caat_code or ""
scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC
transportista_val = transporter_obj.name or logistics.carrier_id
# Clarion Logic: Name first
# Line 1: Name
transport_lines.append(transporter_obj.name or "")
# Line 2: Streets
if transporter_obj.streets:
transport_lines.append(transporter_obj.streets)
# Line 3: City, State, Country
loc_line = ""
if transporter_obj.city:
loc_line = transporter_obj.city
if transporter_obj.state:
loc_line += f", {transporter_obj.state}, "
else:
loc_line += ", "
else:
if transporter_obj.state:
loc_line = f"{transporter_obj.state},"
country_desc = transporter_obj.country or ""
if loc_line:
loc_line += f" {country_desc}"
elif country_desc:
loc_line = country_desc
if loc_line.strip(", "):
transport_lines.append(loc_line)
# 2. Vehicle (Placas Tracto) - Try transport_id first
if logistics.transport_id:
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first()
if veh_obj:
placas_val = veh_obj.plate_number or placas_val
elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first()
if veh_obj:
placas_val = veh_obj.plate_number or placas_val
# 3. Trailer (Placas Remolque)
if logistics.trailer_num:
trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first()
if trl_obj:
placas_remolque_val = trl_obj.plate_number or ""
# 4. Driver (License)
if logistics.carrier_id and logistics.driver_name:
conductor_nombre = logistics.driver_name
# Attempt to find driver by name + carrier
drv_obj = db.query(Driver).filter(
Driver.transporter_key == logistics.carrier_id,
Driver.driver_name == logistics.driver_name
).first()
if drv_obj:
licencia_cond_val = drv_obj.license_number or ""
# --- Building the rest of the block ---
# Line 4: Driver
if conductor_nombre:
transport_lines.append(f"Driver/Conductor: {conductor_nombre}")
# Line 5: Conveyance / Transporte
t_label = "Conveyance / Transporte"
t_val = placas_val # Default to Truck Plate
if logistics.transport_type:
ttype = str(logistics.transport_type).lower()
if "caja" in ttype or "trailer" in ttype:
t_label = "Trailer / Caja"
t_val = placas_remolque_val or num_transporte_val
elif "placa" in ttype:
t_label = "Plates / Placas"
elif "camion" in ttype or "truck" in ttype:
t_label = "Truck / Camión"
if t_val:
transport_lines.append(f"{t_label}: {t_val}")
# Line 6: SCAC / CAAT
codes_line = ""
if scac_val:
codes_line = f"SCAC Code/Clave: {scac_val}"
if caat_val:
if codes_line:
codes_line += f", CAAT Code/Clave: {caat_val}"
else:
codes_line = f"CAAT Code/Clave: {caat_val}"
if codes_line:
transport_lines.append(codes_line)
# Join with newlines
transport_block_str = "\n".join([l for l in transport_lines if l])
factura_schema = FacturaSchema(
numero=header.invoice_number or "S/N",
fecha=str(header.invoice_date) if header.invoice_date else "",
tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0),
moneda=getattr(header, 'currency', "USD") or "USD",
incoterm=(logistics.incoterm or "") if logistics else "",
observaciones=header.observation_es or header.observation_en or "",
pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "",
clave_pedimento=pedimento.pedimento_code if pedimento else "",
regimen=header.document_type or "",
patente=patente_val,
agente_aduanal=nombre_agente,
transporte=transporte_txt,
num_transporte=num_transporte_val,
placas=placas_val,
placas_remolque=placas_remolque_val,
transportista=transportista_val,
caat=caat_val,
scac=scac_val,
licencia_conductor=licencia_cond_val,
aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""),
precinto=(logistics.seal_number or "") if logistics else "",
destino=(logistics.destination_goods or "") if logistics else "",
remesa=remesa_valor, acuse_electronico=acuse_valor,
representante_legal=getattr(company, 'responsible', "") or "",
nombre_empresa=getattr(company, 'name', "") or "",
transportista_info=transport_block_str
)
if progress_callback: progress_callback(50, "Procesando partidas...")
# --- Fetch Lines from SINGLE Invoice (Requested Scope Change) ---
# User requested to ONLY report items from the specific selected invoice,
# NOT consolidating all invoices from the same Pedimento.
target_invoice_ids = [header.id]
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(
Item.invoice_id.in_(target_invoice_ids)
).all()
partidas_list = []
# --- AGGREGATION LOGIC (Refactoring based on Clarion) ---
from collections import defaultdict
# Key: (us_fraction_code, origin_country)
# Value: Object with accumulated fields
aggregated_data = defaultdict(lambda: {
"qty": 0.0,
"net_weight_kgs": 0.0,
"gross_weight_kgs": 0.0,
"total_value": 0.0,
"est_total_value": 0.0,
"description": "",
"advalorem_txt": "0%",
"unit_measure": "PZA", # Placeholder, takes first one found
"hts_code_print": "",
"part_number_display": "CONSOLIDADO"
})
# Pre-fetch US Tariff Fractions for efficiency if possible, or query inside loop (caching recommended)
# For simplicity in this step, we query inside or rely on Part data.
# Ideally fetch USTariffFraction from DB based on Part.us_fraction
# --- Optimización: Cargar Facturas en Memoria ---
invoices_list = db.query(InvoiceHeader).filter(InvoiceHeader.id.in_(target_invoice_ids)).all()
invoice_map = {inv.id: inv for inv in invoices_list}
from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction
for line in lines:
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
part_master = db.query(Part).filter(Part.id == line.part_number).first()
# --- Resolver Identificadores (MOVED INSIDE MAIN LOOP) ---
us_fraction_raw = ""
origin_final = "MEX"
if part_master:
origin_final = part_master.fa_data.origin_country if (part_master.fa_data and part_master.fa_data.origin_country) else "MEX"
us_fraction_raw = part_master.us_fraction if part_master.us_fraction else ""
# Key for aggregation
us_frac_clean = us_fraction_raw.strip()
agg_key = (us_frac_clean, origin_final)
# --- Weights & Qty ---
q_line = float(qty.quantity) if (qty and qty.quantity) else 0.0
nw_line = float(qty.net_weight) if qty else 0.0
gw_line = float(qty.gross_weight) if qty else 0.0
# --- Multi-Currency Normalization Logic ---
# Determine Line Currency context
# Use manual lookup instead of specific attribute
invoice_id = line.item.invoice_id if line.item else None
line_invoice = invoice_map.get(invoice_id) if invoice_id else None
line_currency_is_mxn = False
line_exchange_rate = 1.0
if line_invoice and line_invoice.financials:
# Check explicit currency string AND code
curr_desc = str(line_invoice.financials.currency or "").upper()
curr_code = str(line_invoice.financials.currency_type or "").upper()
# Logic: It is MXN if description says PESO/MX or code is MXN/MN
is_mx_desc = ("MX" in curr_desc or "PESO" in curr_desc)
is_mx_code = ("MXN" in curr_code or "MN" == curr_code)
# But if code allows clarifying USD, prioritize that
is_usd_code = ("USD" in curr_code)
if is_usd_code:
line_currency_is_mxn = False
elif is_mx_code or is_mx_desc:
line_currency_is_mxn = True
else:
line_currency_is_mxn = False # Default to Foreign/USD if unsure
line_exchange_rate = float(line_invoice.financials.exchange_rate or 1.0)
# Target Report Currency
report_is_mxn = (factura_schema.moneda == 'MXN')
# DEBUG LOGGING
if line_invoice:
print(f"DEBUG: Line {line.id} - Inv {line_invoice.id} - CurrDesc: '{curr_desc}' Code: '{curr_code}' - Rate: {line_exchange_rate}")
print(f"DEBUG: Is MXN Context? {line_currency_is_mxn}. Report is MXN? {report_is_mxn}")
# --- Get Financials for Line (Raw) ---
v_total_raw = 0.0
v_unitario_raw = 0.0
if fin:
# NEW PRIORITY LOGIC (To avoid Inflation from dirty Customs Unit Cost)
# Priority 1: Use 'fin.value_usd' if it exists and > 0.
# Priority 2: Use 'fin.total_commercial_value' if it exists and > 0.
# Priority 3: Calculate using 'fin.unit_cost_commercial_usd' * 'q_line'.
# Priority 4: Only use 'fin.unit_cost_usd' * 'q_line' if commercial data is also missing.
val_usd = float(fin.value_usd or 0.0)
total_comm = float(fin.total_commercial_value or 0.0)
unit_comm_usd = float(fin.unit_cost_commercial_usd or 0.0)
unit_usd = float(fin.unit_cost_usd or 0.0)
# 1. Direct Total: Custom Value (Best case)
if val_usd > 0:
v_total_raw = val_usd
# 2. Direct Total: Commercial Total
elif total_comm > 0:
# Convert if invoice currency is MXN
if line_currency_is_mxn and line_exchange_rate > 0:
v_total_raw = total_comm / line_exchange_rate
else:
v_total_raw = total_comm
# 3. Calc from Commercial Unit Cost (Safe Fallback)
elif unit_comm_usd > 0 and q_line > 0:
v_total_raw = unit_comm_usd * q_line
# 4. Calc from Customs Unit Cost (Unknown Risk - Last Resort)
elif unit_usd > 0 and q_line > 0:
v_total_raw = unit_usd * q_line
else:
v_total_raw = 0.0
# NOTE: v_unitario_raw is left as 0.0 here.
# It will be calculated in the 'Calculation Gap Fill' block below:
# v_unitario_raw = v_total_raw / q_line
# This guarantees consistency and avoids the inflated unit cost record (198.00).
# --- Calculation Gap Fill (Raw) ---
if q_line > 0:
if v_total_raw == 0 and v_unitario_raw > 0:
v_total_raw = v_unitario_raw * q_line
if v_unitario_raw == 0 and v_total_raw > 0:
v_unitario_raw = v_total_raw / q_line
# --- Conversion to Report Currency (DISABLED TEMPORARILY) ---
# User confirms all are USD. Forcing direct sum to avoid logic errors in detection.
v_total_line = v_total_raw
v_unitario_line = v_unitario_raw
# if report_is_mxn and not line_currency_is_mxn:
# # USD -> MXN
# v_total_line = v_total_raw * line_exchange_rate
# v_unitario_line = v_unitario_raw * line_exchange_rate
# elif not report_is_mxn and line_currency_is_mxn:
# # MXN -> USD
# if line_exchange_rate > 0:
# v_total_line = v_total_raw / line_exchange_rate
# v_unitario_line = v_unitario_raw / line_exchange_rate
# else:
# v_total_line = 0.0
# v_unitario_line = 0.0
print(f"DEBUG: ValRaw: {v_total_raw} -> ValFinal: {v_total_line}")
# --- Resolve Fraction Details (Description & Rate) ---
# Only if this is the first time we see this key (or overwrite, doesn't matter much as they should be same for same HTS)
# We check if we already have description set to avoid re-querying if we want optimization,
# but relying on DB query per distinct fraction is safer.
current_agg = aggregated_data[agg_key]
if not current_agg["description"]:
us_frac_db = db.query(USTariffFraction).filter(USTariffFraction.code == us_frac_clean).first()
if us_frac_db:
current_agg["description"] = us_frac_db.description or "Sin Descripción"
# Parse AdValorem from DB if available, else 0 ??
# Creating logical placeholder. The provided Clarion code used `FraAme.Adv`
adv_val = us_frac_db.ad_valorem # Assuming field exists based on viewing file later?
# Wait, in us-tariff-fractions.ts I saw `ad_valorem: number | null`.
current_agg["advalorem_txt"] = f"{adv_val}%" if adv_val is not None else "0%"
else:
current_agg["description"] = part_master.description_spanish if part_master else "S/D"
current_agg["hts_code_print"] = us_frac_clean
current_agg["unit_measure"] = qty.weight_unit if qty else "KGS" # Default to first found
# --- Calculate Estimated Tax for this Line ---
rate = 0.0
try:
clean_adv = current_agg["advalorem_txt"].replace("%", "").strip()
rate = float(clean_adv) / 100.0
except: rate = 0.0
v_est_line = v_total_line * rate
# --- Accumulate ---
current_agg["qty"] += q_line
current_agg["net_weight_kgs"] += nw_line
current_agg["gross_weight_kgs"] += gw_line
current_agg["total_value"] += v_total_line
current_agg["est_total_value"] += v_est_line
# --- Convert Aggregated Data to Schema List ---
partidas_list = []
for (hts, origin), data in aggregated_data.items():
# Calculate Unit Price based on Total Value / Total Qty
unit_price = 0.0
if data["qty"] > 0:
unit_price = data["total_value"] / data["qty"]
partidas_list.append(PartidaSchema(
numero_parte="VARIOS", # Or empty
descripcion=data["description"],
fraccion=data["hts_code_print"],
origen=origin,
advalorem=data["advalorem_txt"],
preferencia="General",
cantidad_importacion=self.formatear_numero(data["qty"]),
unidad_medida=data["unit_measure"],
cantidad_bultos=0, # Summing bultos might be tricky if not homogeneous, check logic later
clave_bultos="",
peso_neto=self.formatear_numero(data["net_weight_kgs"]),
peso_bruto=self.formatear_numero(data["gross_weight_kgs"]),
valor_costo_unitario=self.formatear_numero(unit_price),
valor_total=self.formatear_numero(data["total_value"]),
valor_estimado=self.formatear_numero(data["est_total_value"])
))
# Sort by Fraction (HTS Code)
partidas_list.sort(key=lambda x: x.fraccion)
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
return FacturaImportacionCompleta(
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
cliente_enviado=cliente_enviado, factura=factura_schema,
partidas=partidas_list, totales=totales
)
except Exception as e:
print(f"Error Service A76: {e}")
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema:
cant = sum(p.cantidad_importacion for p in partidas)
valor = sum(p.valor_total for p in partidas)
peso_n = sum(p.peso_neto for p in partidas)
peso_b = sum(p.peso_bruto for p in partidas)
bultos = sum(p.cantidad_bultos for p in partidas)
claves = [p.clave_bultos for p in partidas if p.clave_bultos]
clave_comun = max(set(claves), key=claves.count) if claves else ""
if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S"
v_est = sum(p.valor_estimado for p in partidas if isinstance(p.valor_estimado, (int, float, Decimal)))
tc = float(tipo_cambio) if tipo_cambio else 1.0
return TotalesSchema(
cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun,
peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b),
valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0),
valor_estimado_total=self.formatear_numero(v_est)
)
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]:
if progress_callback: progress_callback(5, "Iniciando servicio de reporte...")
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback)
if progress_callback: progress_callback(80, "Renderizando plantilla...")
# LOGO LOGIC
logo_b64 = None
try:
# Fetch company to get logo path
comp_logo = db.query(Company).filter(Company.id == company_id).first()
if comp_logo and comp_logo.logo:
p = Path(comp_logo.logo)
# Logic robusta de búsqueda (igual que en routes.py)
target_path = p
if not target_path.exists():
# Intentar en la ruta estándar: app_data/logos/{id}/{nombre}
# Esto cubre el caso donde solo se guardó el nombre del archivo o la ruta absoluta cambió
fallback = Path(f"app_data/logos/{company_id}") / p.name
if fallback.exists():
target_path = fallback
if target_path.exists():
with open(target_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
# Detect MIME type loosely
mime = "image/png"
if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg"
logo_b64 = f"data:{mime};base64,{encoded_string}"
except Exception as e:
print(f"Error loading logo: {e}")
context = {
'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(),
'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(),
'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(),
'logo_b64': logo_b64
}
html_content = self.template.render(**context)
nombre = f"Consolidado_{datos.factura.numero}.{formato}"
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
if progress_callback: progress_callback(90, "Generando PDF final...")
options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None}
pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config())
if progress_callback: progress_callback(100, "Completado")
return pdf, nombre, "application/pdf"

View File

@@ -0,0 +1,48 @@
from enum import Enum
from typing import Dict, Any
from fastapi import APIRouter, Depends, Query, Response, HTTPException
from sqlalchemy.orm import Session
from celery.result import AsyncResult
from core.celery_app import celery_app
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .mex.service import ConsolidadoImportacionMexService
from .task import generar_pdf_consolidado_async
router = APIRouter()
servicio_mex = ConsolidadoImportacionMexService()
@router.get("/tasks/{task_id}")
async def get_task_status(
task_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db)
):
task_result = AsyncResult(task_id, app=celery_app)
response = {
"task_id": task_id,
"state": task_result.state,
"result": None,
"info": None
}
if task_result.state == 'FAILURE':
response["result"] = str(task_result.result)
elif task_result.state == 'SUCCESS':
response["result"] = task_result.result
elif task_result.state == 'PROCESSING':
response["info"] = task_result.info
return response
@router.post("/{invoice_id}/download-async")
async def trigger_descarga_consolidado(
invoice_id: int,
company_id: int = Query(..., description="ID de la empresa"),
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db)
):
validate_access_to_resource(db, company_id, current_user)
task = generar_pdf_consolidado_async.delay(invoice_id, company_id)
return {"task_id": task.id, "message": "Generación iniciada"}

View File

@@ -0,0 +1,52 @@
import base64
import logging
from core.celery_app import celery_app
from celery import current_task, states
from core.database import CoreSessionLocal
from .mex.service import ConsolidadoImportacionMexService
logger = logging.getLogger(__name__)
@celery_app.task(name="generar_pdf_consolidado_async", bind=True)
def generar_pdf_consolidado_async(self, invoice_id: int, company_id: int):
# 1. Abrimos conexión a la DB
db = CoreSessionLocal()
try:
logger.info(f"Worker procesando consolidado {invoice_id}...")
# 2. Instanciamos el servicio de reportes
service = ConsolidadoImportacionMexService()
# Update state to PROCESSING
self.update_state(state='PROCESSING', meta={'current': 5, 'total': 100, 'status': 'Iniciando generación...'})
def progress_callback(progress: int, status: str):
self.update_state(state='PROCESSING', meta={'current': progress, 'total': 100, 'status': status})
# 3. Generamos los bytes del PDF
pdf_bytes, nombre, media_type = service.generar_factura_completa(
db=db,
invoice_id=invoice_id,
company_id=company_id,
progress_callback=progress_callback
)
# 4. Codificamos a base64 para que viaje seguro por Valkey
pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8')
return {
"status": "success",
"file_name": nombre,
"content": pdf_base64,
"media_type": media_type
}
except Exception as e:
logger.error(f"Error en Celery Worker: {str(e)}")
return {"status": "error", "message": str(e)}
finally:
# 5. MUY IMPORTANTE: Cerramos la conexión para no saturar Postgres
db.close()

View File

@@ -0,0 +1,540 @@
<!DOCTYPE html>
<html lang="es" xml:lang="es">
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
<title>Consolidado Importacion Mexicana - {{ factura.numero }}</title>
<style type="text/css">
/* ESTILOS EXACTOS DE SCAPII PARA MANTENER EL FORMATO */
* {
margin: 0;
padding: 0;
font-family: Tahoma, sans-serif;
color: black;
}
.titulo {
font-size: 14pt;
padding: 3pt 0 0 6pt;
}
.grande {
font-size: 13pt;
padding-left: 5pt;
}
.medio-bold {
font-size: 9pt;
font-weight: bold;
padding: 3pt 0 0 3pt;
}
.normal {
font-size: 8pt;
}
.small-bold {
font-size: 8pt;
font-weight: bold;
}
.tiny-bold {
font-size: 7pt;
font-weight: bold;
}
.tiny {
font-size: 7pt;
}
.mini {
font-size: 5pt;
}
h1 {
font-weight: bold;
font-size: 8pt;
}
p {
font-size: 7pt;
}
.cliente {
border-bottom: 1pt solid black;
text-decoration: none;
display: block;
width: 100%
}
.center {
text-align: center;
}
.right {
text-align: right;
}
.border {
border: 1pt solid #808080;
}
.p-t-1 {
padding-top: 1pt;
}
.p-t-2 {
padding-top: 2pt;
}
.p-t-3 {
padding-top: 3pt;
}
.p-t-4 {
padding-top: 4pt;
}
.p-t-5 {
padding-top: 5pt;
}
.p-t-8 {
padding-top: 8pt;
}
.p-t-9 {
padding-top: 9pt;
}
.p-l-2 {
padding-left: 2pt;
}
.p-l-3 {
padding-left: 3pt;
}
.p-l-5 {
padding-left: 5pt;
}
.p-l-6 {
padding-left: 6pt;
}
.p-l-7 {
padding-left: 7pt;
}
.p-l-8 {
padding-left: 8pt;
}
.p-l-9 {
padding-left: 9pt;
}
.p-l-10 {
padding-left: 10pt;
}
.p-r-1 {
padding-right: 1pt;
}
.p-r-2 {
padding-right: 2pt;
}
.p-r-4 {
padding-right: 4pt;
}
.p-r-5 {
padding-right: 5pt;
}
.p-r-9 {
padding-right: 9pt;
}
.p-b-1 {
padding-bottom: 1pt;
}
.h-10 {
height: 10pt;
}
.h-11 {
height: 11pt;
}
.h-14 {
height: 14pt;
}
.h-15 {
height: 15pt;
}
.h-18 {
height: 18pt;
}
.h-22 {
height: 22pt;
}
.h-82 {
height: 82pt;
}
.h-384 {
height: 384pt;
}
.line-1 {
line-height: 1pt;
}
.line-7 {
line-height: 7pt;
}
.line-8 {
line-height: 8pt;
}
.line-9 {
line-height: 9pt;
}
.line-10 {
line-height: 10pt;
}
.m-l-3 {
margin-left: 3pt;
}
.m-l-5 {
margin-left: 5.74pt;
}
.flex-container {
width: 100%;
overflow: hidden;
}
.flex-container-end {
width: 100%;
overflow: hidden;
}
.width-48 {
width: 48%;
float: left;
}
.width-48-right {
width: 48%;
float: right;
text-align: right;
}
.position-relative-centered {
position: relative;
width: 48%;
float: left;
}
.full-width-block {
display: block;
width: 100%;
}
.clearfix::after {
content: "";
display: table;
clear: both;
}
table,
tbody {
vertical-align: top;
overflow: visible;
}
</style>
</head>
<body>
<header>
<div class="flex-container clearfix">
<div class="width-48" style="position: relative;">
<p class="titulo">HTS Code / Fracción Americana <span class="mini">(temporal)</span></p>
<div class="cliente" style="height: 1px;"></div>
<p class="cliente p-t-1 p-b-1"></p>
</div>
<div class="width-48-right">
<p class="p-l-5 line-1"><span></span></p>
<p class="p-t-2"><br></p>
</div>
</div>
<div class="flex-container-end clearfix" style="overflow: visible;">
<div class="position-relative-centered">
{% if logo_b64 %}
<div style="position: absolute; top: 10pt; left: 0;">
<img src="{{ logo_b64 }}" style="max-height: 70pt; max-width: 120pt;" />
</div>
{% endif %}
<div style="margin-left: 130pt; padding-top: 25pt; text-align: left;">
<h1 class="cliente">{{ cliente_proveedor.header }}</h1>
<p>{{ cliente_proveedor.nombre }}</p>
<p>{{ cliente_proveedor.direccion }}
{% if cliente_proveedor.num_exterior %} Ext: {{ cliente_proveedor.num_exterior }}{% endif %}
{% if cliente_proveedor.num_interior %} Int: {{ cliente_proveedor.num_interior }}{% endif %}
</p>
<p>{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} CP: {{
cliente_proveedor.codigo_postal }}{% endif %}</p>
<p>{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}</p>
<p>TAX ID: {{ cliente_proveedor.tax_id }}
{% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %}
{{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }}
{% endif %}
</p>
<p><br></p>
</div>
</div>
<div style="position: relative;">
<table cellspacing="0" class="m-l-3" style="float: right; text-align: left; width: 230pt;">
<tbody>
<tr class="h-18">
<td bgcolor="#E4E4E4" class="border" colspan="2" style="width:90pt">
<p class="medio-bold p-l-3">Invoice No.:</p>
</td>
<td class="border" colspan="2" style="width:140pt">
<p class="grande center">{{ factura.numero }}</p>
</td>
</tr>
<tr class="h-18">
<td bgcolor="#E4E4E4" class="border" style="width:35pt">
<p class="medio-bold p-l-2 line-9">Date:</p>
</td>
<td class="border" style="width:65pt">
<p class="normal center line-9">{{ factura.fecha }}</p>
</td>
<td bgcolor="#E4E4E4" class="border" style="width:35pt">
<p class="medio-bold p-l-2 line-9">Rate:</p>
</td>
<td class="border" style="width:65pt">
<p class="normal center line-9">{{ factura.tipo_cambio }}</p>
</td>
</tr>
</tbody>
</table>
<!-- Transporter Info Block (Clarion Adaptation) -->
<div
style="float: right; width: 230pt; margin-top: 5pt; border: 1px solid black; padding: 2pt; margin-left: 10pt;">
<p class="medio-bold">Coveyance Co. / Cia. Transportista:</p>
<p class="normal line-9" style="white-space: pre-line;">{{ factura.transportista_info }}</p>
</div>
</div>
</div>
<div class="flex-container clearfix">
<div class="width-48">
<h1 class="p-t-5 p-l-5 line-10 cliente">{{ cliente_vendido.header }}</h1>
<p class="p-l-5 line-8">{{ cliente_vendido.nombre }}</p>
<p class="p-l-5">{{ cliente_vendido.direccion }}
{% if cliente_vendido.num_exterior %} Ext: {{ cliente_vendido.num_exterior }}{% endif %}
{% if cliente_vendido.num_interior %} Int: {{ cliente_vendido.num_interior }}{% endif %}
</p>
<p class="p-l-5">{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} CP: {{
cliente_vendido.codigo_postal }}{% endif %}</p>
<p class="p-l-5">{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }}
</p>
<p class="p-l-5">RFC: {{ cliente_vendido.tax_id }}
{% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %}
{{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }}
{% endif %}
</p>
<p class="p-l-5">
{% if cliente_vendido.prosec %}PROSEC: {{ cliente_vendido.prosec }} {% endif %}
{% if cliente_vendido.reg_emp %}REG EMP: {{ cliente_vendido.reg_emp }} {% endif %}
{% if cliente_vendido.cert %}CERT: {{ cliente_vendido.cert }}{% endif %}
</p>
</div>
<div class="width-48-right" style="text-align: left;">
<h1 class="p-t-5 p-l-5 line-10 cliente full-width-block">{{ cliente_enviado.header }}</h1>
<p class="p-l-5 line-8">{{ cliente_enviado.nombre }}</p>
<p class="p-l-5">{{ cliente_enviado.direccion }}
{% if cliente_enviado.num_exterior %} Ext: {{ cliente_enviado.num_exterior }}{% endif %}
{% if cliente_enviado.num_interior %} Int: {{ cliente_enviado.num_interior }}{% endif %}
</p>
<p class="p-l-5">{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} CP: {{
cliente_enviado.codigo_postal }}{% endif %}</p>
<p class="p-l-5">{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }}
</p>
<p class="p-l-5">RFC: {{ cliente_enviado.tax_id }}
{% if cliente_enviado.programa and cliente_enviado.programa != 'Ninguno' %}
{{ cliente_enviado.programa }}: {{ cliente_enviado.autorizacion }}
{% endif %}
</p>
<p class="p-l-5">
{% if cliente_enviado.prosec %}PROSEC: {{ cliente_enviado.prosec }} {% endif %}
{% if cliente_enviado.reg_emp %}REG EMP: {{ cliente_enviado.reg_emp }} {% endif %}
{% if cliente_enviado.cert %}CERT: {{ cliente_enviado.cert }}{% endif %}
</p>
</div>
</div>
<p class="p-t-8"><br /></p>
</header>
<table cellspacing="0" class="m-l-5" style="width: 99%; max-width: 580pt;">
<thead>
<tr class="h-10">
<td class="border" style="width:15%">
<p class="tiny-bold center p-t-3">HTS Code / Fracción Americana <span class="mini">(temporal)</span>
</p>
</td>
<td class="border" style="width:10%">
<p class="tiny-bold center p-t-3">Country</p>
</td>
<td class="border" style="width:10%">
<p class="tiny-bold center p-t-3">Comm. Qty</p>
</td>
<td class="border" style="width:5%">
<p class="tiny-bold center p-t-3">Unit</p>
</td>
<td class="border" colspan="2" style="width:15%">
<p class="tiny-bold center line-9">Weight (KGS)</p>
<div class="clearfix">
<div class="width-48" style="width: 50%; text-align: center;">
<p class="mini">Net</p>
</div>
<div class="width-48-right" style="width: 50%; text-align: center;">
<p class="mini">Gross</p>
</div>
</div>
</td>
<td class="border" colspan="2" style="width:25%">
<p class="tiny-bold center line-9">Dutiable Values</p>
<div class="clearfix">
<div class="width-48" style="width: 50%; text-align: center;">
<p class="mini">Unit</p>
</div>
<div class="width-48-right" style="width: 50%; text-align: center;">
<p class="mini">Totals</p>
</div>
</div>
</td>
<td class="border" style="width:5%">
<p class="tiny-bold center p-t-3">Rate</p>
</td>
<td class="border" style="width:15%">
<p class="tiny-bold center p-t-3">Est. Duties</p>
</td>
</tr>
</thead>
<tbody class="h-384" style="width: 100%;">
{% for partida in partidas %}
<tr>
<td class="border">
<p class="mini p-t-2 center">{{ partida.fraccion }}</p>
</td>
<td class="border">
<p class="mini p-t-2 center">{{ partida.origen or 'MEX' }}</p>
</td>
<td class="border">
<p class="mini p-t-2 center">{{ partida.cantidad_importacion }}</p>
</td>
<td class="border">
<p class="mini p-t-2 center">{{ partida.unidad_medida }}</p>
</td>
<td class="border" style="border-right: 0;">
<p class="mini p-t-2 center">{{ partida.peso_neto }}</p>
</td>
<td class="border" style="border-left: 0;">
<p class="mini p-t-2 center">{{ partida.peso_bruto }}</p>
</td>
<td class="border" style="border-right: 0;">
<p class="mini p-t-2 center">${{ partida.valor_costo_unitario }}</p>
</td>
<td class="border" style="border-left: 0;">
<p class="mini p-t-2 center">${{ partida.valor_total }}</p>
</td>
<td class="border">
<p class="mini p-t-2 center">{{ partida.advalorem }}</p>
</td>
<td class="border">
<p class="mini p-t-2 right p-r-2">${{ partida.valor_estimado }}</p>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="h-14">
<td bgcolor="#E4E4E4" class="border" colspan="2" style="width:25%">
<p class="small-bold p-t-2 p-l-3 line-10">
<span>TOTALS</span>
</p>
</td>
<td class="border" style="width:10%">
<p class="tiny p-t-5 center line-8">{{ totales.cantidad_total }}</p>
</td>
<td class="border" style="width:5%">
<p class="tiny p-t-4 center line-8">
{% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %}
<span>{{ totales.clave_bultos or '' }}</span>
</p>
</td>
<td class="border" style="width:7.5%">
<p class="tiny p-t-5 center line-8">{{ totales.peso_neto_total }}</p>
</td>
<td class="border" style="width:7.5%">
<p class="tiny p-t-5 center line-8">{{ totales.peso_bruto_total }}</p>
</td>
<td class="border" style="width:12.5%">
<!-- Unitario Total N/A -->
</td>
<td class="border" style="width:12.5%">
<p class="tiny p-t-5 right line-8 p-r-2">${{ totales.valor_total_total }}</p>
</td>
<td class="border" style="width:5%">
<!-- Tasa N/A -->
</td>
<td class="border" style="width:15%">
<p class="tiny p-t-5 right p-r-2">${{ totales.valor_estimado_total }}</p>
</td>
</tr>
<tr>
<td colspan="2" class="border" style="height: 100pt; text-align:start; vertical-align: top;">
<p class="mini p-l-2 p-t-2">{{ factura.observaciones }}</p>
</td>
<td colspan="8" style="width:80%; vertical-align: bottom; height: 100%;">
<p style="border-bottom: 1pt solid black; width: 80%; margin: 60pt auto 2pt auto;"></p>
<p class="normal center" style="margin-bottom: 0;">{{ factura.representante_legal }}</p>
<p class="normal center" style="margin-bottom: 0;">{{ factura.nombre_empresa }}</p>
<p style="margin-bottom: 0;"><br /></p>
</td>
</tr>
<tr>
<td colspan="10" style="width:100%; vertical-align: top; height: 100%;">
<p class="p-t-8"><br /></p>
<p class="p-l-5 line-8 tiny-bold">Normal Por Parte</p>
<p class="normal p-l-5 line-9">I declare under penalty of perjury that the information contained in
this document is true and I am responsible for proving what is declared here.</p>
</td>
</tr>
</tfoot>
</table>
</body>
</html>

View File

@@ -0,0 +1,93 @@
from decimal import Decimal
from typing import List, Optional, Union
from pydantic import BaseModel, field_validator
class ClienteSchema(BaseModel):
header: str
nombre: str
# Ponemos valor por defecto "" y permitimos que sea opcional
direccion: Optional[str] = ""
num_exterior: Optional[str] = ""
num_interior: Optional[str] = ""
colonia: Optional[str] = ""
codigo_postal: Optional[str] = ""
ciudad: Optional[str] = ""
estado: Optional[str] = ""
pais: Optional[str] = ""
tax_id: str
programa: Optional[str] = ""
autorizacion: Optional[str] = ""
prosec: Optional[str] = ""
reg_emp: Optional[str] = ""
cert: Optional[str] = ""
# Si llega un None, lo convertimos en "" automáticamente
@field_validator('direccion', 'nombre', mode='before')
@classmethod
def prevent_none(cls, v):
return v or ""
class FacturaSchema(BaseModel):
numero: str
fecha: str
tipo_cambio: float
moneda: str
# Campos de aduanas (Opcionales por si A76 aún no los tiene)
pedimento: str = ""
clave_pedimento: str = ""
remesa: str = ""
acuse_electronico: str = ""
agente_aduanal: str = ""
patente: str = ""
precinto: str = ""
regimen: str = ""
transportista: str = ""
scac: str = ""
caat: str = ""
incoterm: str = ""
transporte: str = ""
num_transporte: str = ""
placas: str = ""
placas_remolque: str = ""
licencia_conductor: str = ""
caat: str = ""
scac: str = ""
aduana: str = ""
destino: str = ""
observaciones: str = ""
class PartidaSchema(BaseModel):
numero_parte: str
descripcion: str
fraccion: str
origen: str
advalorem:Optional[str] = ""
preferencia:Optional[str] = ""
cantidad_importacion: Union[float, str]
unidad_medida: str
cantidad_bultos: int
clave_bultos: str
peso_neto: Union[float, str]
peso_bruto: Union[float, str]
valor_costo_unitario: Union[float, str]
valor_total: Union[float, str]
class TotalesSchema(BaseModel):
cantidad_total: Union[float, str]
bultos_total: int
clave_bultos: str = ""
peso_neto_total: Union[float, str]
peso_bruto_total: Union[float, str]
valor_total_total: Union[float, str]
valor_total_dolares: Union[float, str]
class FacturaImportacionCompleta(BaseModel):
cliente_proveedor: ClienteSchema
cliente_vendido: ClienteSchema
cliente_enviado: ClienteSchema
factura: FacturaSchema
partidas: List[PartidaSchema]
totales: TotalesSchema

View File

@@ -0,0 +1,403 @@
import shutil
import base64
import pdfkit
from pathlib import Path
from decimal import Decimal
from typing import Tuple, List, Callable, Optional
from jinja2 import Environment, FileSystemLoader, select_autoescape
from fastapi import HTTPException
from sqlalchemy.orm import Session
# --- MODELOS ---
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider, ClientProviderAddress, ClientProviderPrograms
)
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.items.models import Item
# --- TRANSPORTATION MODELS ---
from api.v1.modules.a76.transportation.transporters.models import Transporter
from api.v1.modules.a76.transportation.vehicles.models import Vehicle
from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
# --- SCHEMAS ---
from .schemas import (
ClienteSchema, PartidaSchema, TotalesSchema,
FacturaSchema, FacturaImportacionCompleta
)
class FacturaImportacionMexService:
def __init__(self):
self.template_dir = Path(__file__).parent.parent / "templates"
self.jinja_env = Environment(
loader=FileSystemLoader(self.template_dir),
autoescape=select_autoescape(['html', 'xml'])
)
self.template = self.jinja_env.get_template('factura_mex_ver.html')
def _get_wkhtmltopdf_config(self):
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
if not Path(path).exists():
raise RuntimeError("wkhtmltopdf no encontrado.")
return pdfkit.configuration(wkhtmltopdf=path)
def formatear_numero(self, valor, decimales: int = 2):
if valor is None: return 0.0
try:
return round(float(valor), decimales)
except: return 0.0
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
if not fraccion_raw or len(fraccion_raw) < 8:
return fraccion_raw
return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}"
def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema:
main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first()
if not main:
return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX")
addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first()
prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first()
return ClienteSchema(
header=rol,
nombre=(main.name or main.short_name) or "S/N",
direccion=(addr.streets or "") if addr else "",
num_exterior=(addr.exterior_number or "") if addr else "",
num_interior=(addr.interior_number or "") if addr else "",
colonia=(addr.neighborhood or "") if addr else "",
codigo_postal=(addr.postal_code or "") if addr else "",
ciudad=(addr.city or "") if addr else "",
estado=(addr.state or "") if addr else "",
pais=(addr.country or "MEX") if addr else "MEX",
tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
programa="IMMEX" if (prog and prog.program) else "",
autorizacion=prog.program_number if prog else "",
prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "",
reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else (
prog.certified_company_registry if (prog and prog.certified_company_registry) else ""
),
cert=prog.is_certified_company if (prog and prog.is_certified_company) else ""
)
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta:
try:
if progress_callback: progress_callback(10, "Buscando factura...")
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
if not header: raise HTTPException(status_code=404, detail="Factura no encontrada")
compliance = header.compliance_mx
logistics = header.logistics if header.logistics else None
financials = header.financials if header.financials else None
if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...")
pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id
pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None
if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...")
proveedor_id = compliance.provider_id if compliance else None
cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="")
nombre_agente = ""
if compliance and compliance.customs_broker_id:
broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first()
if broker: nombre_agente = broker.name
company = db.query(Company).filter(Company.id == header.company_id).first()
# Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A)
cliente_default = ClienteSchema(
header="Importador / consignatario:",
nombre=getattr(company, 'name', "Empresa Local"),
direccion="DOMICILIO FISCAL",
num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX",
tax_id=getattr(company, 'rfc', ""),
programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "")
)
# Left Side Logic (Consignatario / Sold To)
cliente_vendido = cliente_default
if compliance and compliance.sold_to_id:
raw_header = compliance.sold_to_header or "CONSIGNATARIO"
clean_header = raw_header.replace("_", " ").capitalize() + ":"
cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header)
# Right Side Logic (Enviado A / Shipped To)
cliente_enviado = cliente_default
if compliance and compliance.shipped_to_id:
# Clean header: "enviado_a" -> "Enviado a:"
raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO"
clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":"
# Fetch client data
cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped)
remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else ""
acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A"
patente_val = ""
if pedimento and pedimento.license:
patente_val = pedimento.license
elif 'broker' in locals() and broker and broker.license:
patente_val = broker.license
# --- Transport Data Fetching ---
transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else ""
num_transporte_val = (logistics.trailer_num or "") if logistics else ""
# Init values
placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto
placas_remolque_val = ""
transportista_val = (logistics.carrier_id or "") if logistics else ""
caat_val = ""
scac_val = ""
licencia_cond_val = ""
if logistics:
# 1. Transporter (CAAT / SCAC)
if logistics.carrier_id:
transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first()
if transporter_obj:
caat_val = transporter_obj.caat_code or ""
scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC
transportista_val = transporter_obj.name or logistics.carrier_id
# 2. Vehicle (Placas Tracto) - Try transport_id first
if logistics.transport_id:
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first()
if veh_obj:
placas_val = veh_obj.plate_number or placas_val
elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first()
if veh_obj:
placas_val = veh_obj.plate_number or placas_val
# 3. Trailer (Placas Remolque)
if logistics.trailer_num:
trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first()
if trl_obj:
placas_remolque_val = trl_obj.plate_number or ""
# 4. Driver (License)
if logistics.carrier_id and logistics.driver_name:
# Attempt to find driver by name + carrier
drv_obj = db.query(Driver).filter(
Driver.transporter_key == logistics.carrier_id,
Driver.driver_name == logistics.driver_name
).first()
if drv_obj:
licencia_cond_val = drv_obj.license_number or ""
factura_schema = FacturaSchema(
numero=header.invoice_number or "S/N",
fecha=str(header.invoice_date) if header.invoice_date else "",
tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0),
moneda=getattr(header, 'currency', "USD") or "USD",
incoterm=(logistics.incoterm or "") if logistics else "",
observaciones=header.observation_es or header.observation_en or "",
pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "",
clave_pedimento=pedimento.pedimento_code if pedimento else "",
regimen=header.document_type or "",
patente=patente_val,
agente_aduanal=nombre_agente,
transporte=transporte_txt,
num_transporte=num_transporte_val,
placas=placas_val,
placas_remolque=placas_remolque_val,
transportista=transportista_val,
caat=caat_val,
scac=scac_val,
licencia_conductor=licencia_cond_val,
aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""),
precinto=(logistics.seal_number or "") if logistics else "",
destino=(logistics.destination_goods or "") if logistics else "",
remesa=remesa_valor, acuse_electronico=acuse_valor
)
if progress_callback: progress_callback(50, "Procesando partidas...")
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all()
partidas_list = []
for line in lines:
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
part_master = db.query(Part).filter(Part.id == line.part_number).first()
desc_final = "S/D"
num_parte_final = str(line.part_number or "S/N")
fraccion_raw = ""
origen_final = "MEX"
if part_master:
desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc."
num_parte_final = part_master.part_number
fraccion_raw = part_master.fraction if part_master.fraction else ""
# Fetch Origin from Master Catalog (FaPart)
if part_master.fa_data and part_master.fa_data.origin_country:
origen_final = part_master.fa_data.origin_country
fraccion_limpia = fraccion_raw.replace(".", "").strip()
if fraccion_limpia:
fraccion_limpia = fraccion_limpia[:8].zfill(8)
# Consultar tabla tariff_fractions
fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first()
preferencia_txt = "General"
advalorem_txt = "0%"
fraccion_imprimir = fraccion_raw
if fraccion_db:
# Si el valor en BD es None, "0", o vacío, dejarlo como "0%" o "EXENTO"
adv_db = fraccion_db.adv_impo
if adv_db and adv_db.strip() not in ["0", "0.0", "0.00", ""]:
advalorem_txt = adv_db if "%" in adv_db else f"{adv_db}%"
else:
advalorem_txt = "0%"
fraccion_imprimir = fraccion_db.fraction or fraccion_raw
else:
fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia)
# Logic to determine values - Prioritize Specific Currency Columns
v_unitario = 0.0
v_total = 0.0
if fin:
is_mxn = (factura_schema.moneda == 'MXN')
# 1. Try Specific Currency Columns First
if is_mxn:
v_unitario = float(fin.unit_cost_commercial_mxn or 0.0)
v_total = float(fin.value_commercial_mxn or 0.0)
else:
v_unitario = float(fin.unit_cost_commercial_usd or 0.0)
v_total = float(fin.value_commercial_usd or 0.0)
# 2. Fallback to Generic independently if Specific is 0
if not v_unitario:
v_unitario = float(fin.commercial_unit_cost or 0.0)
if not v_total:
v_total = float(fin.total_commercial_value or 0.0)
# 3. Calculate from Quantity if still missing
cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0
if cantidad > 0:
if v_unitario > 0 and v_total == 0:
v_total = v_unitario * cantidad
elif v_total > 0 and v_unitario == 0:
v_unitario = v_total / cantidad
partidas_list.append(PartidaSchema(
numero_parte=num_parte_final,
descripcion=desc_final,
fraccion=fraccion_imprimir,
origen=origen_final,
advalorem=advalorem_txt,
preferencia=preferencia_txt,
cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0),
unidad_medida=qty.weight_unit if qty else "PZA",
cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0,
clave_bultos=(qty.package_key or "") if qty else "",
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0),
valor_costo_unitario=self.formatear_numero(v_unitario),
valor_total=self.formatear_numero(v_total)
))
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
return FacturaImportacionCompleta(
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
cliente_enviado=cliente_enviado, factura=factura_schema,
partidas=partidas_list, totales=totales
)
except Exception as e:
print(f"Error Service A76: {e}")
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema:
cant = sum(p.cantidad_importacion for p in partidas)
valor = sum(p.valor_total for p in partidas)
peso_n = sum(p.peso_neto for p in partidas)
peso_b = sum(p.peso_bruto for p in partidas)
bultos = sum(p.cantidad_bultos for p in partidas)
claves = [p.clave_bultos for p in partidas if p.clave_bultos]
clave_comun = max(set(claves), key=claves.count) if claves else ""
if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S"
tc = float(tipo_cambio) if tipo_cambio else 1.0
return TotalesSchema(
cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun,
peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b),
valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0)
)
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]:
if progress_callback: progress_callback(5, "Iniciando servicio de reporte...")
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback)
if progress_callback: progress_callback(80, "Renderizando plantilla...")
# LOGO LOGIC
logo_b64 = None
try:
# Fetch company to get logo path
comp_logo = db.query(Company).filter(Company.id == company_id).first()
if comp_logo and comp_logo.logo:
p = Path(comp_logo.logo)
# Logic robusta de búsqueda (igual que en routes.py)
target_path = p
if not target_path.exists():
# Intentar en la ruta estándar: app_data/logos/{id}/{nombre}
# Esto cubre el caso donde solo se guardó el nombre del archivo o la ruta absoluta cambió
fallback = Path(f"app_data/logos/{company_id}") / p.name
if fallback.exists():
target_path = fallback
if target_path.exists():
with open(target_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
# Detect MIME type loosely
mime = "image/png"
if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg"
logo_b64 = f"data:{mime};base64,{encoded_string}"
except Exception as e:
print(f"Error loading logo: {e}")
context = {
'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(),
'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(),
'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(),
'logo_b64': logo_b64
}
html_content = self.template.render(**context)
nombre = f"Factura_{datos.factura.numero}.{formato}"
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
if progress_callback: progress_callback(90, "Generando PDF final...")
options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None}
pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config())
if progress_callback: progress_callback(100, "Completado")
return pdf, nombre, "application/pdf"

View File

@@ -0,0 +1,48 @@
from enum import Enum
from typing import Dict, Any
from fastapi import APIRouter, Depends, Query, Response, HTTPException
from sqlalchemy.orm import Session
from celery.result import AsyncResult
from core.celery_app import celery_app
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .mex.service import FacturaImportacionMexService
from .task import generar_pdf_factura_async
router = APIRouter()
servicio_mex = FacturaImportacionMexService()
@router.get("/tasks/{task_id}")
async def get_task_status(
task_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db)
):
task_result = AsyncResult(task_id, app=celery_app)
response = {
"task_id": task_id,
"state": task_result.state,
"result": None,
"info": None
}
if task_result.state == 'FAILURE':
response["result"] = str(task_result.result)
elif task_result.state == 'SUCCESS':
response["result"] = task_result.result
elif task_result.state == 'PROCESSING':
response["info"] = task_result.info
return response
@router.post("/{invoice_id}/download-async")
async def trigger_descarga_factura(
invoice_id: int,
company_id: int = Query(..., description="ID de la empresa"),
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db)
):
validate_access_to_resource(db, company_id, current_user)
task = generar_pdf_factura_async.delay(invoice_id, company_id)
return {"task_id": task.id, "message": "Generación iniciada"}

View File

@@ -0,0 +1,52 @@
import base64
import logging
from core.celery_app import celery_app
from celery import current_task, states
from core.database import CoreSessionLocal
from .mex.service import FacturaImportacionMexService
logger = logging.getLogger(__name__)
@celery_app.task(name="generar_pdf_factura_async", bind=True)
def generar_pdf_factura_async(self, invoice_id: int, company_id: int):
# 1. Abrimos conexión a la DB
db = CoreSessionLocal()
try:
logger.info(f"Worker procesando factura {invoice_id}...")
# 2. Instanciamos el servicio de reportes
service = FacturaImportacionMexService()
# Update state to PROCESSING
self.update_state(state='PROCESSING', meta={'current': 5, 'total': 100, 'status': 'Iniciando generación...'})
def progress_callback(progress: int, status: str):
self.update_state(state='PROCESSING', meta={'current': progress, 'total': 100, 'status': status})
# 3. Generamos los bytes del PDF
pdf_bytes, nombre, media_type = service.generar_factura_completa(
db=db,
invoice_id=invoice_id,
company_id=company_id,
progress_callback=progress_callback
)
# 4. Codificamos a base64 para que viaje seguro por Valkey
pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8')
return {
"status": "success",
"file_name": nombre,
"content": pdf_base64,
"media_type": media_type
}
except Exception as e:
logger.error(f"Error en Celery Worker: {str(e)}")
return {"status": "error", "message": str(e)}
finally:
# 5. MUY IMPORTANTE: Cerramos la conexión para no saturar Postgres
db.close()

View File

@@ -0,0 +1,646 @@
<!DOCTYPE html>
<html lang="es" xml:lang="es">
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
<title>Factura Importacion Mexicana - {{ factura.numero }}</title>
<style type="text/css">
/* ESTILOS EXACTOS DE SCAPII PARA MANTENER EL FORMATO */
* {
margin: 0;
padding: 0;
font-family: Tahoma, sans-serif;
color: black;
}
.titulo {
font-size: 14pt;
padding: 3pt 0 0 6pt;
}
.grande {
font-size: 13pt;
padding-left: 5pt;
}
.medio-bold {
font-size: 9pt;
font-weight: bold;
padding: 3pt 0 0 3pt;
}
.normal {
font-size: 8pt;
}
.small-bold {
font-size: 8pt;
font-weight: bold;
}
.tiny-bold {
font-size: 7pt;
font-weight: bold;
}
.tiny {
font-size: 7pt;
}
.mini {
font-size: 5pt;
}
h1 {
font-weight: bold;
font-size: 8pt;
}
p {
font-size: 7pt;
}
.cliente {
border-bottom: 1pt solid black;
text-decoration: none;
display: block;
width: 100%
}
.center {
text-align: center;
}
.right {
text-align: right;
}
.border {
border: 1pt solid #808080;
}
.p-t-1 {
padding-top: 1pt;
}
.p-t-2 {
padding-top: 2pt;
}
.p-t-3 {
padding-top: 3pt;
}
.p-t-4 {
padding-top: 4pt;
}
.p-t-5 {
padding-top: 5pt;
}
.p-t-8 {
padding-top: 8pt;
}
.p-t-9 {
padding-top: 9pt;
}
.p-l-2 {
padding-left: 2pt;
}
.p-l-3 {
padding-left: 3pt;
}
.p-l-5 {
padding-left: 5pt;
}
.p-l-6 {
padding-left: 6pt;
}
.p-l-7 {
padding-left: 7pt;
}
.p-l-8 {
padding-left: 8pt;
}
.p-l-9 {
padding-left: 9pt;
}
.p-l-10 {
padding-left: 10pt;
}
.p-r-1 {
padding-right: 1pt;
}
.p-r-2 {
padding-right: 2pt;
}
.p-r-4 {
padding-right: 4pt;
}
.p-r-5 {
padding-right: 5pt;
}
.p-r-9 {
padding-right: 9pt;
}
.p-b-1 {
padding-bottom: 1pt;
}
.h-10 {
height: 10pt;
}
.h-11 {
height: 11pt;
}
.h-14 {
height: 14pt;
}
.h-15 {
height: 15pt;
}
.h-18 {
height: 18pt;
}
.h-22 {
height: 22pt;
}
.h-82 {
height: 82pt;
}
.h-384 {
height: 384pt;
}
.line-1 {
line-height: 1pt;
}
.line-7 {
line-height: 7pt;
}
.line-8 {
line-height: 8pt;
}
.line-9 {
line-height: 9pt;
}
.line-10 {
line-height: 10pt;
}
.m-l-3 {
margin-left: 3pt;
}
.m-l-5 {
margin-left: 5.74pt;
}
.flex-container {
width: 100%;
overflow: hidden;
}
.flex-container-end {
width: 100%;
overflow: hidden;
}
.width-48 {
width: 48%;
float: left;
}
.width-48-right {
width: 48%;
float: right;
text-align: right;
}
.position-relative-centered {
position: relative;
width: 48%;
float: left;
}
.full-width-block {
display: block;
width: 100%;
}
.clearfix::after {
content: "";
display: table;
clear: both;
}
table,
tbody {
vertical-align: top;
overflow: visible;
}
</style>
</head>
<body>
<header>
<div class="flex-container clearfix">
<div class="width-48" style="position: relative;">
<p class="titulo">Factura de Importacion</p>
<div class="cliente" style="height: 1px;"></div>
<p class="cliente p-t-1 p-b-1"></p>
</div>
<div class="width-48-right">
<p class="p-l-5 line-1"><span></span></p>
<p class="p-t-2"><br></p>
</div>
</div>
<div class="flex-container-end clearfix" style="overflow: visible;">
<div class="position-relative-centered">
{% if logo_b64 %}
<div style="position: absolute; top: 10pt; left: 0;">
<img src="{{ logo_b64 }}" style="max-height: 70pt; max-width: 120pt;" />
</div>
{% endif %}
<div style="margin-left: 130pt; padding-top: 25pt; text-align: left;">
<h1 class="cliente">{{ cliente_proveedor.header }}</h1>
<p>{{ cliente_proveedor.nombre }}</p>
<p>{{ cliente_proveedor.direccion }}
{% if cliente_proveedor.num_exterior %} Ext: {{ cliente_proveedor.num_exterior }}{% endif %}
{% if cliente_proveedor.num_interior %} Int: {{ cliente_proveedor.num_interior }}{% endif %}
</p>
<p>{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} CP: {{
cliente_proveedor.codigo_postal }}{% endif %}</p>
<p>{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}</p>
<p>TAX ID: {{ cliente_proveedor.tax_id }}
{% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %}
{{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }}
{% endif %}
</p>
<p><br></p>
</div>
</div>
<div style="position: relative;">
<table cellspacing="0" class="m-l-3" style="float: right; text-align: left;">
<tbody>
<tr class="h-18">
<td bgcolor="#E4E4E4" class="border" style="width:52pt">
<p class="medio-bold">FACTURA:</p>
</td>
<td class="border" colspan="3" style="width:176pt">
<p class="grande">{{ factura.numero }}</p>
</td>
</tr>
<tr class="h-11">
<td bgcolor="#E4E4E4" class="border" style="width:52pt">
<p class="normal p-l-3 line-9">Fecha:</p>
</td>
<td class="border" style="width:72pt">
<p class="normal p-l-2 line-9">{{ factura.fecha }}</p>
</td>
<td bgcolor="#E4E4E4" class="border" style="width:52pt">
<p class="normal p-l-2 line-9">T. Cambio:</p>
</td>
<td class="border" style="width:52pt">
<p class="normal center line-9">{{ factura.tipo_cambio }}</p>
</td>
</tr>
<tr class="h-11">
<td bgcolor="#E4E4E4" class="border" style="width:52pt">
<p class="small-bold p-l-3 line-9">Pedimento:</p>
</td>
<td class="border" colspan="1" style="width:109pt">
<p class="normal p-l-3 line-9">{{ factura.pedimento }}</p>
</td>
<td bgcolor="#E4E4E4" class="border">
<p class="normal p-l-3 line-9">Clave:</p>
</td>
<td class="border" style="width:37pt">
<p class="normal p-r-2 line-9 center">{{ factura.clave_pedimento }}</p>
</td>
</tr>
<tr class="h-11">
<td bgcolor="#E4E4E4" class="border" style="width:52pt">
<p class="normal p-l-3 line-9">Remesa:</p>
</td>
<td class="border" style="width:38pt">
<p class="normal p-l-10 line-9">{{ factura.remesa }}</p>
</td>
<td bgcolor="#E4E4E4" class="border" style="width:79pt">
<p class="normal p-l-6 line-9">Acuse:</p>
</td>
<td class="border" style="width:59pt">
<p class="normal p-l-6 line-9">{{ factura.acuse_electronico or 'N/A' }}</p>
</td>
</tr>
<tr class="h-22">
<td class="border" colspan="4" style="width:228pt">
<p class="small-bold p-l-3 line-7">Agente Aduanal:</p>
<p class="normal p-l-3 line-7">{{ factura.agente_aduanal or '' }}</p>
</td>
</tr>
<tr class="h-15">
<td class="border" colspan="2">
<p><span class="small-bold p-l-3 line-7">Patente: </span><span
class="normal p-l-3 line-7">{{ factura.patente or '' }}</span></p>
</td>
<td class="border">
<span class="tiny-bold p-l-3 line-7">Regimen:</span><span class="tiny p-l-3 line-7">{{
factura.regimen or '' }}</span>
</td>
<td class="border">
<p class="tiny-bold p-l-3 line-7">INCOTERM:</p>
<p class="tiny p-l-3 line-7">{{ factura.incoterm or '' }}</p>
</td>
</tr>
<tr>
<td class="border" colspan="2">
{% if factura.precinto %}
<p class="tiny-bold p-l-3 line-7">Precinto: {{ factura.precinto }}</p>
{% endif %}
</td>
<td class="border">
<p class="tiny-bold p-l-3 line-7">Aduana: {{ factura.aduana }}</p>
</td>
<td class="border">
{% if factura.destino %}
<p class="tiny-bold p-l-3 line-7">Destino: {{ factura.destino }}</p>
{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex-container clearfix">
<div class="width-48">
<h1 class="p-t-5 p-l-5 line-10 cliente">{{ cliente_vendido.header }}</h1>
<p class="p-l-5 line-8">{{ cliente_vendido.nombre }}</p>
<p class="p-l-5">{{ cliente_vendido.direccion }}
{% if cliente_vendido.num_exterior %} Ext: {{ cliente_vendido.num_exterior }}{% endif %}
{% if cliente_vendido.num_interior %} Int: {{ cliente_vendido.num_interior }}{% endif %}
</p>
<p class="p-l-5">{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} CP: {{
cliente_vendido.codigo_postal }}{% endif %}</p>
<p class="p-l-5">{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }}
</p>
<p class="p-l-5">RFC: {{ cliente_vendido.tax_id }}
{% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %}
{{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }}
{% endif %}
</p>
<p class="p-l-5">
{% if cliente_vendido.prosec %}PROSEC: {{ cliente_vendido.prosec }} {% endif %}
{% if cliente_vendido.reg_emp %}REG EMP: {{ cliente_vendido.reg_emp }} {% endif %}
{% if cliente_vendido.cert %}CERT: {{ cliente_vendido.cert }}{% endif %}
</p>
</div>
<div class="width-48-right" style="text-align: left;">
<h1 class="p-t-5 p-l-5 line-10 cliente full-width-block">{{ cliente_enviado.header }}</h1>
<p class="p-l-5 line-8">{{ cliente_enviado.nombre }}</p>
<p class="p-l-5">{{ cliente_enviado.direccion }}
{% if cliente_enviado.num_exterior %} Ext: {{ cliente_enviado.num_exterior }}{% endif %}
{% if cliente_enviado.num_interior %} Int: {{ cliente_enviado.num_interior }}{% endif %}
</p>
<p class="p-l-5">{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} CP: {{
cliente_enviado.codigo_postal }}{% endif %}</p>
<p class="p-l-5">{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }}
</p>
<p class="p-l-5">RFC: {{ cliente_enviado.tax_id }}
{% if cliente_enviado.programa and cliente_enviado.programa != 'Ninguno' %}
{{ cliente_enviado.programa }}: {{ cliente_enviado.autorizacion }}
{% endif %}
</p>
<p class="p-l-5">
{% if cliente_enviado.prosec %}PROSEC: {{ cliente_enviado.prosec }} {% endif %}
{% if cliente_enviado.reg_emp %}REG EMP: {{ cliente_enviado.reg_emp }} {% endif %}
{% if cliente_enviado.cert %}CERT: {{ cliente_enviado.cert }}{% endif %}
</p>
</div>
</div>
<p class="p-t-8"><br /></p>
</header>
<table cellspacing="0" class="m-l-5" style="width: 99%; max-width: 580pt;">
<thead>
<tr class="h-10">
<td bgcolor="#E4E4E4" class="border" style="width:60pt">
<p class="tiny line-9">Transportista:</p>
</td>
<td class="border" colspan="4" style="width:120pt">
<p class="tiny">{{ factura.transportista }}</p>
</td>
<td class="border" style="width:40pt">
<p class="tiny">SCAC: {{ factura.scac }}</p>
</td>
<td bgcolor="#E4E4E4" class="border" style="width:40pt">
<p class="tiny">INCOTERM:</p>
</td>
<td class="border" style="width:40pt">
<p class="tiny">{{ factura.incoterm }}</p>
</td>
<td class="border" style="width:50pt">
<p class="tiny line-9">Aduana: <span class="mini">{{ factura.aduana }}</span> / Ped: <span
class="mini">{{ factura.pedimento }}</span></p>
</td>
<td bgcolor="#E4E4E4" class="border" style="width:20pt">
<p><br /></p>
</td>
</tr>
<tr class="h-10">
<td bgcolor="#E4E4E4" class="border" style="width:60pt">
<p class="tiny line-9">Transporte:</p>
</td>
<td class="border" colspan="4" style="width:120pt">
<p class="tiny">{{ factura.transporte }}: {{ factura.num_transporte }}</p>
</td>
<td class="border" colspan="3" style="width:80pt">
<p class="tiny">CAAT: {{ factura.caat }}</p>
</td>
<td class="border" colspan="2" style="width:100pt">
<p class="tiny p-t-1 line-8">Placas: {{ factura.placas or '' }} / Rem: {{ factura.placas_remolque or
'' }}</p>
</td>
</tr>
<tr class="h-10">
<td bgcolor="#E4E4E4" class="border" colspan="2" style="width:80pt">
<p class="tiny p-l-1 line-9">Chofer/Licencia:</p>
</td>
<td class="border" colspan="8">
<p class="tiny p-l-3">{{ factura.licencia_conductor or 'N/A' }}</p>
</td>
</tr>
<tr class="h-10">
<td class="border" rowspan="2">
<p class="tiny-bold p-t-5 center">Línea</p>
</td>
<td class="border" rowspan="2">
<p class="tiny-bold p-l-2 line-10">Número de Parte</p>
<p class="tiny-bold p-l-2 line-10">Descripción</p>
</td>
<td class="border" colspan="3">
<p class="tiny-bold center line-9">Comercial</p>
</td>
<td class="border" style="width:50pt">
<p class="tiny-bold center line-9">Empaque</p>
</td>
<td class="border" colspan="2">
<p class="tiny-bold center line-9">Peso (KGS)</p>
</td>
<td class="border" colspan="2">
<p class="tiny-bold line-9 center">Valores</p>
</td>
</tr>
<tr class="h-11">
<td class="border center" colspan="2">
<p class="mini p-l-2">Cantidad</p>
</td>
<td class="border center">
<p class="mini p-l-1">U.M.</p>
</td>
<td class="border">
<p class="mini center">Tipo</p>
</td>
<td class="border">
<p class="mini center">Neto</p>
</td>
<td class="border">
<p class="mini center">Bruto</p>
</td>
<td class="border">
<p class="mini center">Unitario</p>
</td>
<td class="border">
<p class="mini center">Total</p>
</td>
</tr>
</thead>
<tbody class="h-384" style="width: 100%;">
{% for partida in partidas %}
<tr>
<td class="border">
<p class="mini p-t-3 center">{{ loop.index }}</p>
</td>
<td class="border">
<p class="mini p-t-1" style="font-weight: bold;">{{ partida.numero_parte }}</p>
<p class="mini">{{ partida.descripcion }}</p>
<p class="mini">Frac: {{ partida.fraccion }} / Orig: {{ partida.origen or 'MEX' }}</p>
<p class="mini">
{% if partida.advalorem %}ADV: {{ partida.advalorem }}{% endif %}
{% if partida.preferencia %} / PREF: {{ partida.preferencia }}{% endif %}
</p>
</td>
<td class="border" colspan="2">
<p class="mini p-t-2 center">{{ partida.cantidad_importacion }}</p>
</td>
<td class="border">
<p class="mini p-t-2 center">{{ partida.unidad_medida }}</p>
</td>
<td class="border">
<p class="mini p-t-2 center">
{% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %}
{{ partida.clave_bultos }}
</p>
</td>
<td class="border">
<p class="mini p-t-3 center">{{ partida.peso_neto }}</p>
</td>
<td class="border">
<p class="mini p-t-3 center">{{ partida.peso_bruto }}</p>
</td>
<td class="border">
<p class="mini p-t-2 center">${{ partida.valor_costo_unitario }}</p>
</td>
<td class="border">
<p class="mini p-t-2 right p-r-2">${{ partida.valor_total }}</p>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="h-14">
<td bgcolor="#E4E4E4" class="border" colspan="2" style="width:123pt">
<p class="small-bold p-t-2 p-l-3 line-10">
<span>Observaciones:</span>
<span class="small-bold" style="float: right; margin-right: 2pt;">TOTALES</span>
</p>
</td>
<td class="border" colspan="2" style="width:28pt">
<p class="tiny p-t-5 p-r-1 line-8 center">{{ totales.cantidad_total }}</p>
</td>
<td class="border" style="width:22pt"></td>
<td class="border" style="width:40pt">
<p class="tiny p-t-4 center line-8">
{% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %}
<span>{{ totales.clave_bultos or '' }}</span>
</p>
</td>
<td class="border" style="width:27pt">
<p class="tiny p-t-5 center line-8">{{ totales.peso_neto_total }}</p>
</td>
<td class="border" style="width:28pt">
<p class="tiny p-t-5 center line-8">{{ totales.peso_bruto_total }}</p>
</td>
<td style="width:30pt"></td>
<td class="border" style="width:35pt">
<p class="tiny p-t-5 right line-8 p-r-2">${{ totales.valor_total_total }}</p>
</td>
</tr>
<tr>
<td colspan="2" class="border" style="height: 100pt; text-align:start; vertical-align: top;">
<p class="mini p-l-2 p-t-2">{{ factura.observaciones }}</p>
</td>
<td colspan="8" style="width:336pt; vertical-align: bottom; height: 100%;">
<p style="border-bottom: 1pt solid black; width: 80%; margin: 0 auto 2pt auto;"></p>
<p class="normal center" style="margin-bottom: 0;">{{ cliente_proveedor.nombre }}</p>
<p style="margin-bottom: 0;"><br /></p>
<p class="small-bold center line-7" style="margin-bottom: 0;">Los valores expresados en esta factura
son en: {{ factura.moneda }}</p>
</td>
</tr>
<tr>
<td colspan="10" style="width:580pt; vertical-align: top; height: 100%;">
<p class="p-t-8"><br /></p>
<p class="p-l-5 line-8 tiny-bold">Normal Por Parte</p>
<p class="normal p-l-5 line-9">Declaro bajo protesta de decir verdad que la información contenida en
este documento es verdadera y me hago responsable de comprobar lo aquí declarado.</p>
</td>
</tr>
</tfoot>
</table>
</body>
</html>

View File

@@ -48,6 +48,10 @@ from .transportation.transporters.routes import router as transporters_router
from .transportation.vehicles.routes import router as vehicles_router
from api.v1.modules.public.reference_data.material_types.routes import router as material_types_router
# --- NUEVO IMPORT PARA REPORTES DE FACTURAS ---
from .reports.importacion.facturas.routes import router as invoices_reports_router
from .reports.importacion.consolidados.routes import router as consolidated_reports_router
# Router principal
router = APIRouter()
@@ -114,3 +118,16 @@ router.include_router(
prefix="/public/reference-data",
tags=["Reference Data"]
)
# --- REGISTRO DE RUTAS DE REPORTES ---
router.include_router(
invoices_reports_router,
prefix="/a76/reports/importacion/facturas",
tags=["a76 / reports"]
)
router.include_router(
consolidated_reports_router,
prefix="/a76/reports/importacion/consolidados",
tags=["a76 / reports"]
)

View File

@@ -12,8 +12,7 @@ from sqlalchemy import Enum as SQLEnum
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, relationship
if TYPE_CHECKING:
from api.v1.modules.core.user_tenant.models import UserTenant
from api.v1.modules.core.user_tenant.models import UserTenant
class TenantType(enum.Enum):

View File

@@ -7,11 +7,11 @@ class Container(Base):
__tablename__ = "containers" # GContenedores
__table_args__ = (
PrimaryKeyConstraint("key", name="containers_pkey"),
{"schema": "public", "extend_existing": True}, # opcional
{"extend_existing": True}, # opcional
)
key: Mapped[str] = mapped_column(
String(3), nullable=False
String(3), primary_key=True, nullable=False
) # mantiene ceros iniciales
description: Mapped[str] = mapped_column(
String(500), nullable=False

View File

@@ -11,7 +11,7 @@ class MaterialType(Base):
)
key: Mapped[str] = mapped_column(
String(10), nullable=False) # clave del material
String(10), primary_key=True, nullable=False) # clave del material
type: Mapped[str] = mapped_column(String(15), nullable=False) # tipo
description: Mapped[str] = mapped_column(
String(256), nullable=False