diff --git a/.gitignore b/.gitignore
index 9beb6fa8..1da35013 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,6 +51,7 @@ logs/
.pytest_cache/
.coverage
htmlcov/
+backend/app_data/
# Node (para frontend)
node_modules/
diff --git a/backend/Dockerfile b/backend/Dockerfile
index cff9b109..89d5822d 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -9,6 +9,31 @@ RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
+# Instalar dependencias para wkhtmltopdf y reportes PDF
+RUN apt-get update \
+ && apt-get install -y \
+ xvfb \
+ fontconfig \
+ fonts-dejavu-core \
+ libfontconfig1 \
+ libxrender1 \
+ libxtst6 \
+ libxi6 \
+ libxrandr2 \
+ ca-certificates \
+ libjpeg62-turbo \
+ libpng16-16 \
+ && rm -rf /var/lib/apt/lists/*
+
+# Instalar wkhtmltopdf binario oficial con soporte para footers/headers
+RUN curl -k -L -o /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb \
+ && apt-get update \
+ && apt-get install -y /tmp/wkhtmltox.deb \
+ && rm /tmp/wkhtmltox.deb \
+ && rm -rf /var/lib/apt/lists/* \
+ && wkhtmltopdf --version
+
+
# Copiar requirements
COPY requirements.txt .
diff --git a/backend/api/v1/modules/a24/fa/fa_parts/models.py b/backend/api/v1/modules/a24/fa/fa_parts/models.py
index ba5bbe75..ad813d8e 100644
--- a/backend/api/v1/modules/a24/fa/fa_parts/models.py
+++ b/backend/api/v1/modules/a24/fa/fa_parts/models.py
@@ -29,7 +29,7 @@ class FaPart(Base, TenantScopedMixin, TimestampMixin):
ForeignKeyConstraint(
["id"], ["a76.parts.id"], name="fk_fa_partes_master"
),
- {"schema": "a24"},
+ {"schema": "a24", "extend_existing": True},
)
# El ID hereda el valor de la tabla parts
diff --git a/backend/api/v1/modules/a24/inv/inv_parts/models.py b/backend/api/v1/modules/a24/inv/inv_parts/models.py
index 467bbc2c..5e8c76e2 100644
--- a/backend/api/v1/modules/a24/inv/inv_parts/models.py
+++ b/backend/api/v1/modules/a24/inv/inv_parts/models.py
@@ -32,7 +32,7 @@ class InvPart(Base, TenantScopedMixin, TimestampMixin):
ForeignKeyConstraint(
["id"], ["a76.parts.id"], name="fk_inv_partes_master"
),
- {"schema": "a24"},
+ {"schema": "a24", "extend_existing": True},
)
# Relación 1:1 - El ID es el mismo de la tabla maestra
diff --git a/backend/api/v1/modules/a76/classes/models.py b/backend/api/v1/modules/a76/classes/models.py
index 58459b11..a5288980 100644
--- a/backend/api/v1/modules/a76/classes/models.py
+++ b/backend/api/v1/modules/a76/classes/models.py
@@ -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)
diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py
index d62fc99a..cc507462 100644
--- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py
+++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py
@@ -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 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),
diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py
index 4564c8d6..eada4a3e 100644
--- a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py
+++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py
@@ -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"
diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py
index 19304472..f032f76e 100644
--- a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py
+++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py
@@ -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)
diff --git a/backend/api/v1/modules/a76/items/line_items/models.py b/backend/api/v1/modules/a76/items/line_items/models.py
index 4ed22e07..c3641ee6 100644
--- a/backend/api/v1/modules/a76/items/line_items/models.py
+++ b/backend/api/v1/modules/a76/items/line_items/models.py
@@ -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(
diff --git a/backend/api/v1/modules/a76/parts/models.py b/backend/api/v1/modules/a76/parts/models.py
index b920065a..df231753 100644
--- a/backend/api/v1/modules/a76/parts/models.py
+++ b/backend/api/v1/modules/a76/parts/models.py
@@ -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):
diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/schemas.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/schemas.py
new file mode 100644
index 00000000..d543a5a2
--- /dev/null
+++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/schemas.py
@@ -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
diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py
new file mode 100644
index 00000000..5cff09b4
--- /dev/null
+++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py
@@ -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"
diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/routes.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/routes.py
new file mode 100644
index 00000000..5254cfe4
--- /dev/null
+++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/routes.py
@@ -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"}
diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/task.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/task.py
new file mode 100644
index 00000000..23e680c1
--- /dev/null
+++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/task.py
@@ -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()
diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/templates/cons_mex_ver.html b/backend/api/v1/modules/a76/reports/importacion/consolidados/templates/cons_mex_ver.html
new file mode 100644
index 00000000..2f0d026b
--- /dev/null
+++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/templates/cons_mex_ver.html
@@ -0,0 +1,540 @@
+
+
+
+
HTS Code / Fracción Americana (temporal)
+ + ++
{{ cliente_proveedor.nombre }}
+{{ 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 %} +
+{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} CP: {{ + cliente_proveedor.codigo_postal }}{% endif %}
+{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}
+TAX ID: {{ cliente_proveedor.tax_id }} + {% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %} + {{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }} + {% endif %} +
+|
+ Invoice No.: + |
+
+ {{ factura.numero }} + |
+ ||
|
+ Date: + |
+
+ {{ factura.fecha }} + |
+
+ Rate: + |
+
+ {{ factura.tipo_cambio }} + |
+
Coveyance Co. / Cia. Transportista:
+{{ factura.transportista_info }}
+{{ cliente_vendido.nombre }}
+{{ 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 %} +
+{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} CP: {{ + cliente_vendido.codigo_postal }}{% endif %}
+{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }} +
+RFC: {{ cliente_vendido.tax_id }} + {% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %} + {{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }} + {% endif %} +
++ {% 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 %} +
+{{ cliente_enviado.nombre }}
+{{ 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 %} +
+{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} CP: {{ + cliente_enviado.codigo_postal }}{% endif %}
+{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }} +
+RFC: {{ cliente_enviado.tax_id }} + {% if cliente_enviado.programa and cliente_enviado.programa != 'Ninguno' %} + {{ cliente_enviado.programa }}: {{ cliente_enviado.autorizacion }} + {% endif %} +
++ {% 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 %} +
+|
+ HTS Code / Fracción Americana (temporal) + + |
+
+ Country + |
+
+ Comm. Qty + |
+
+ Unit + |
+
+ Weight (KGS) +
+
+
+
+ Net +
+
+ Gross + |
+
+ Dutiable Values +
+
+
+
+ Unit +
+
+ Totals + |
+
+ Rate + |
+
+ Est. Duties + |
+ ||
|
+ {{ partida.fraccion }} + |
+
+ {{ partida.origen or 'MEX' }} + |
+
+ {{ partida.cantidad_importacion }} + |
+
+ {{ partida.unidad_medida }} + |
+
+ {{ partida.peso_neto }} + |
+
+ {{ partida.peso_bruto }} + |
+
+ ${{ partida.valor_costo_unitario }} + |
+
+ ${{ partida.valor_total }} + |
+
+ {{ partida.advalorem }} + |
+
+ ${{ partida.valor_estimado }} + |
+
|
+ + TOTALS + + |
+
+ {{ totales.cantidad_total }} + |
+
+ + {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} + {{ totales.clave_bultos or '' }} + + |
+
+ {{ totales.peso_neto_total }} + |
+
+ {{ totales.peso_bruto_total }} + |
+ + + | +
+ ${{ totales.valor_total_total }} + |
+ + + | +
+ ${{ totales.valor_estimado_total }} + |
+ |
|
+ {{ factura.observaciones }} + |
+
+
+ {{ factura.representante_legal }} +{{ factura.nombre_empresa }} + |
+ ||||||||
|
+ Normal Por Parte +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. + |
+ |||||||||
Factura de Importacion
+ + ++
{{ cliente_proveedor.nombre }}
+{{ 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 %} +
+{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} CP: {{ + cliente_proveedor.codigo_postal }}{% endif %}
+{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}
+TAX ID: {{ cliente_proveedor.tax_id }} + {% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %} + {{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }} + {% endif %} +
+|
+ FACTURA: + |
+
+ {{ factura.numero }} + |
+ ||
|
+ Fecha: + |
+
+ {{ factura.fecha }} + |
+
+ T. Cambio: + |
+
+ {{ factura.tipo_cambio }} + |
+
|
+ Pedimento: + |
+
+ {{ factura.pedimento }} + |
+
+ Clave: + |
+
+ {{ factura.clave_pedimento }} + |
+
|
+ Remesa: + |
+
+ {{ factura.remesa }} + |
+
+ Acuse: + |
+
+ {{ factura.acuse_electronico or 'N/A' }} + |
+
|
+ Agente Aduanal: +{{ factura.agente_aduanal or '' }} + |
+ |||
|
+ Patente: {{ factura.patente or '' }} + |
+ + Regimen:{{ + factura.regimen or '' }} + | +
+ INCOTERM: +{{ factura.incoterm or '' }} + |
+ |
|
+ {% if factura.precinto %}
+ Precinto: {{ factura.precinto }} + {% endif %} + |
+
+ Aduana: {{ factura.aduana }} + |
+
+ {% if factura.destino %}
+ Destino: {{ factura.destino }} + {% endif %} + |
+ |
{{ cliente_vendido.nombre }}
+{{ 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 %} +
+{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} CP: {{ + cliente_vendido.codigo_postal }}{% endif %}
+{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }} +
+RFC: {{ cliente_vendido.tax_id }} + {% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %} + {{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }} + {% endif %} +
++ {% 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 %} +
+{{ cliente_enviado.nombre }}
+{{ 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 %} +
+{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} CP: {{ + cliente_enviado.codigo_postal }}{% endif %}
+{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }} +
+RFC: {{ cliente_enviado.tax_id }} + {% if cliente_enviado.programa and cliente_enviado.programa != 'Ninguno' %} + {{ cliente_enviado.programa }}: {{ cliente_enviado.autorizacion }} + {% endif %} +
++ {% 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 %} +
+|
+ Transportista: + |
+
+ {{ factura.transportista }} + |
+
+ SCAC: {{ factura.scac }} + |
+
+ INCOTERM: + |
+
+ {{ factura.incoterm }} + |
+
+ Aduana: {{ factura.aduana }} / Ped: {{ factura.pedimento }} + |
+
+ |
+ |||
|
+ Transporte: + |
+
+ {{ factura.transporte }}: {{ factura.num_transporte }} + |
+
+ CAAT: {{ factura.caat }} + |
+
+ Placas: {{ factura.placas or '' }} / Rem: {{ factura.placas_remolque or + '' }} + |
+ ||||||
|
+ Chofer/Licencia: + |
+
+ {{ factura.licencia_conductor or 'N/A' }} + |
+ ||||||||
|
+ Línea + |
+
+ Número de Parte +Descripción + |
+
+ Comercial + |
+
+ Empaque + |
+
+ Peso (KGS) + |
+
+ Valores + |
+ ||||
|
+ Cantidad + |
+
+ U.M. + |
+
+ Tipo + |
+
+ Neto + |
+
+ Bruto + |
+
+ Unitario + |
+
+ Total + |
+ |||
|
+ {{ loop.index }} + |
+
+ {{ partida.numero_parte }} +{{ partida.descripcion }} +Frac: {{ partida.fraccion }} / Orig: {{ partida.origen or 'MEX' }} ++ {% if partida.advalorem %}ADV: {{ partida.advalorem }}{% endif %} + {% if partida.preferencia %} / PREF: {{ partida.preferencia }}{% endif %} + + |
+
+ {{ partida.cantidad_importacion }} + |
+
+ {{ partida.unidad_medida }} + |
+
+ + {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} + {{ partida.clave_bultos }} + + |
+
+ {{ partida.peso_neto }} + |
+
+ {{ partida.peso_bruto }} + |
+
+ ${{ partida.valor_costo_unitario }} + |
+
+ ${{ partida.valor_total }} + |
+ |
|
+ + Observaciones: + TOTALES + + |
+
+ {{ totales.cantidad_total }} + |
+ + |
+ + {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} + {{ totales.clave_bultos or '' }} + + |
+
+ {{ totales.peso_neto_total }} + |
+
+ {{ totales.peso_bruto_total }} + |
+ + |
+ ${{ totales.valor_total_total }} + |
+ ||
|
+ {{ factura.observaciones }} + |
+
+
+ {{ cliente_proveedor.nombre }} +Los valores expresados en esta factura + son en: {{ factura.moneda }} + |
+ ||||||||
|
+ Normal Por Parte +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. + |
+ |||||||||
| Clave | Descripción | @@ -876,7 +876,7 @@
|---|
| Código | Descripción | @@ -924,7 +924,7 @@
|---|
| Fracción | NICO | @@ -982,7 +982,7 @@
|---|
| Código | Prefijo | @@ -1042,7 +1042,7 @@
|---|
| Fracción | Descripción | @@ -1098,7 +1098,7 @@
|---|
| Clave FDA | Descripción | @@ -1152,7 +1152,7 @@
|---|
| Código | Descripción | diff --git a/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte index fadd2e9a..2b7f0e75 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte @@ -24,9 +24,10 @@ // Filtro reactivo local let filteredClients = $derived( clients.filter(c => - c.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (c.client_or_provider === 'client' || c.client_or_provider === 'both') && + (c.name.toLowerCase().includes(searchTerm.toLowerCase()) || c.rfc.toLowerCase().includes(searchTerm.toLowerCase()) || - c.id.toString().includes(searchTerm) + c.id.toString().includes(searchTerm)) ) ); @@ -42,10 +43,8 @@ loading = true; try { - // Petición a la API - const res = await clientsProvidersApi.list(companyStore.activeCompany.id, 1, 100, { - type: 'client' - }); + // Petición a la API - Traer todos para filtrar localmente + const res = await clientsProvidersApi.list(companyStore.activeCompany.id, 1, 1000); // Normalización de respuesta const responseData = (res as any).data || res; @@ -103,18 +102,20 @@ {:else}
|---|
| ID | RFC | Razón Social | Estado | -Acción | |
|---|---|---|---|---|---|
| {client.id} | {client.rfc} | @@ -138,17 +139,6 @@ {/if} | -- - |
| Clave | Descripción | diff --git a/frontend/src/lib/components/dashboard/goods/modales/unit-measure-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/unit-measure-dialog.svelte index 0deb485c..2520f8b6 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/unit-measure-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/unit-measure-dialog.svelte @@ -116,7 +116,6 @@Clave | Descripción | UM | -Acción |
|---|---|---|---|---|---|
| {item.class_code} @@ -137,18 +139,6 @@ {item.unit_of_measure || '-'} | - -- - |