Merge branch 'feature/reportes_facturas' into development
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
)
|
||||
Reference in New Issue
Block a user