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 @@ + + + + + + Consolidado Importacion Mexicana - {{ factura.numero }} + + + + +
+
+
+

HTS Code / Fracción Americana (temporal)

+
+

+
+
+

+


+
+
+
+
+ {% if logo_b64 %} +
+ +
+ {% endif %} +
+

{{ cliente_proveedor.header }}

+

{{ 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.header }}

+

{{ 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.header }}

+

{{ 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 %} +

+
+
+


+
+ + + + + + + + + + + + + + + + + {% for partida in partidas %} + + + + + + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + + + + + + + + +
+

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.

+
+ + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py new file mode 100644 index 00000000..9d4455b8 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py @@ -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 \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py new file mode 100644 index 00000000..200a3942 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -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" \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py new file mode 100644 index 00000000..f7c0d72d --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/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 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"} \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/task.py b/backend/api/v1/modules/a76/reports/importacion/facturas/task.py new file mode 100644 index 00000000..6cdf1342 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/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 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() \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html new file mode 100644 index 00000000..ade499e4 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html @@ -0,0 +1,646 @@ + + + + + + Factura Importacion Mexicana - {{ factura.numero }} + + + + +
+
+
+

Factura de Importacion

+
+

+
+
+

+


+
+
+
+
+ {% if logo_b64 %} +
+ +
+ {% endif %} +
+

{{ cliente_proveedor.header }}

+

{{ 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.header }}

+

{{ 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.header }}

+

{{ 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 %} +

+
+
+


+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for partida in partidas %} + + + + + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + + + + + + + +
+

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.

+
+ + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 95f5791a..24220d6d 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -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"] +) \ No newline at end of file diff --git a/backend/api/v1/modules/core/tenants/models.py b/backend/api/v1/modules/core/tenants/models.py index 91eb9aff..fbc6af78 100644 --- a/backend/api/v1/modules/core/tenants/models.py +++ b/backend/api/v1/modules/core/tenants/models.py @@ -12,8 +12,7 @@ from sqlalchemy import Enum as SQLEnum from sqlalchemy import Integer, String, Text from sqlalchemy.orm import Mapped, relationship -if TYPE_CHECKING: - from api.v1.modules.core.user_tenant.models import UserTenant +from api.v1.modules.core.user_tenant.models import UserTenant class TenantType(enum.Enum): diff --git a/backend/api/v1/modules/public/reference_data/containers/models.py b/backend/api/v1/modules/public/reference_data/containers/models.py index b8d3c630..c6081c34 100644 --- a/backend/api/v1/modules/public/reference_data/containers/models.py +++ b/backend/api/v1/modules/public/reference_data/containers/models.py @@ -7,11 +7,11 @@ class Container(Base): __tablename__ = "containers" # GContenedores __table_args__ = ( PrimaryKeyConstraint("key", name="containers_pkey"), - {"schema": "public", "extend_existing": True}, # opcional + {"extend_existing": True}, # opcional ) key: Mapped[str] = mapped_column( - String(3), nullable=False + String(3), primary_key=True, nullable=False ) # mantiene ceros iniciales description: Mapped[str] = mapped_column( String(500), nullable=False diff --git a/backend/api/v1/modules/public/reference_data/material_types/models.py b/backend/api/v1/modules/public/reference_data/material_types/models.py index 04fa9478..5862649a 100644 --- a/backend/api/v1/modules/public/reference_data/material_types/models.py +++ b/backend/api/v1/modules/public/reference_data/material_types/models.py @@ -11,7 +11,7 @@ class MaterialType(Base): ) key: Mapped[str] = mapped_column( - String(10), nullable=False) # clave del material + String(10), primary_key=True, nullable=False) # clave del material type: Mapped[str] = mapped_column(String(15), nullable=False) # tipo description: Mapped[str] = mapped_column( String(256), nullable=False diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py index 1a4a8c47..d9b4d517 100644 --- a/backend/api/v1/router.py +++ b/backend/api/v1/router.py @@ -12,6 +12,7 @@ from .modules.a24.router import router as a24_router from .modules.public.router import router as public_router from .modules.a24.router import router as a24_router + # Router principal router = APIRouter() @@ -29,3 +30,5 @@ router.include_router(a24_router) def status(): """Health check de la API""" return {"status": "ok", "version": "1.0.0", "api": "v1"} + + diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py new file mode 100644 index 00000000..c118db31 --- /dev/null +++ b/backend/core/celery_app.py @@ -0,0 +1,28 @@ +import os +from celery import Celery + + +valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0") + +celery_app = Celery( + "anexo76_tasks", + broker=valkey_url, + backend=valkey_url, + include=[ + "api.v1.modules.a76.reports.importacion.facturas.task", + "api.v1.modules.a76.reports.importacion.consolidados.task" + ] # Ruta al módulo donde están las tareas +) + +# Configuraciones adicionales +celery_app.conf.update( + task_track_started=True, + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="America/Mexico_City", + enable_utc=True, +) + +if __name__ == "__main__": + celery_app.start() \ No newline at end of file diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 0ed55a15..d7da611b 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -1,29 +1,15 @@ -""" -Middleware personalizado para Anexo76 -- Validación de licencias -- Gestión de multi-tenancy -- Logging de requests -""" - import logging import time from typing import Callable - -from fastapi import HTTPException, Request +from fastapi import HTTPException, Request, Response from starlette.middleware.base import BaseHTTPMiddleware - from .config import settings from .database import CoreSessionLocal from .security import get_tenant_from_token, verify_token logger = logging.getLogger(__name__) - class TenantMiddleware(BaseHTTPMiddleware): - """ - Middleware para identificar y validar el tenant en cada request - """ - async def dispatch(self, request: Request, call_next: Callable): # Rutas públicas que no requieren tenant # Permitir acceso sin autenticación a rutas de documentación y salud @@ -37,53 +23,35 @@ class TenantMiddleware(BaseHTTPMiddleware): ] path = request.url.path - # Permitir cualquier subruta de docs/redoc/openapi.json (por ejemplo, /api/docs, /api/docs/, /api/docs/oauth2-redirect) - if any( - path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes - ): + + # 3. Bypass para rutas públicas y docs + if any(path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes): return await call_next(request) - # Permitir rutas públicas exactas o con prefijo - if any( - path == prefix or (prefix != "/" and path.startswith(prefix)) - for prefix in public_prefixes - ): + + if any(path == prefix or (prefix != "/" and path.startswith(prefix)) for prefix in public_prefixes): return await call_next(request) - # Extraer token y obtener tenant + # 4. Validación estricta de Token (solo para lo que no es público ni OPTIONS) auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): raise HTTPException( - status_code=401, detail="Missing or invalid authorization header" + status_code=401, + detail="Missing or invalid authorization header" ) token = auth_header.split(" ")[1] - try: user_info = verify_token(token) tenant_id = get_tenant_from_token(user_info) - - # ⚠️ NOTA: tenant_id puede ser None para usuarios SSO que aún no tienen tenant asignado - # En ese caso, el endpoint específico deberá manejarlo - if not tenant_id: - logger.warning( - f"⚠️ Token sin tenant_id para usuario: {user_info.get('sub', 'unknown')}" - ) - # No lanzamos error aquí, dejamos que el endpoint decida qué hacer - - # Agregar tenant_id al state del request (puede ser None) + request.state.tenant_id = tenant_id request.state.user_info = user_info - - except HTTPException: - # Re-lanzar HTTPException directamente - raise except Exception as e: logger.error(f"❌ Tenant validation error: {str(e)}") raise HTTPException(status_code=401, detail="Invalid authentication") - response = await call_next(request) - return response + # 5. Continuar con la petición real + return await call_next(request) class LicenseValidationMiddleware(BaseHTTPMiddleware): diff --git a/backend/requirements.txt b/backend/requirements.txt index 1e600ac7..ff7a7cfa 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -38,3 +38,12 @@ black==25.9.0 flake8==7.3.0 mypy==1.18.2 pylint==4.0.2 + +# reportes +Jinja2==3.1.6 +pdfkit==1.0.0 + +# Desarrollo en seguno plano +celery==5.3.6 +redis==5.0.1 +flower==2.0.1 \ No newline at end of file diff --git a/debug_values.py b/debug_values.py new file mode 100644 index 00000000..337140ee --- /dev/null +++ b/debug_values.py @@ -0,0 +1,76 @@ +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from api.v1.modules.a76.items.line_items.models import LineItem +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.items.line_financials.models import LineFinancial +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.pedmientos.models import Pedimentos + +# Setup DB (Adjust connection string if needed, checking environment assumption) +# Assuming local connection string or deriving from environment/config +# For this environment, I'll attempt a standard connection or reuse existing if possible. +# Since I cannot easily import 'db' from main app without setup, I will rely on standard raw SQL or simple ORM setup if I can import 'Session'. +# Using the imports available in the user's file. + +import sys +sys.path.append('/home/josmar/dev/anexo76/backend') + +from core.database import CoreSessionLocal + +# Manual Session creation +db = CoreSessionLocal() + +try: + # 1. Find the lines matching the description (Qty 1500 + HTS) + # The user said HTS: 2710190650 + # Qty: 1500 + + print("--- SEARCHING FOR LINES ---") + + # We look for lines with quantity 1500 first + candidates = db.query(LineItem).join(LineQuantity).filter( + LineQuantity.quantity == 1500 + ).all() + + found = False + for line in candidates: + part = db.query(Part).filter(Part.id == line.part_number).first() + fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first() + inv = db.query(InvoiceHeader).filter(InvoiceHeader.id == line.item.invoice_id).first() if line.item else None + + us_frac = part.us_fraction if part else "N/A" + + # Check fraction match (loose match) + if "2710190650" in us_frac.replace(".","").replace(" ",""): + found = True + print(f"\nMATCH FOUND: Line ID {line.id} - Invoice {inv.invoice_number if inv else 'N/A'}") + print(f"HTS: {us_frac}") + print(f"Qty: {1500}") + print(f"Inv Currency: {inv.financials.currency} / {inv.financials.currency_type} Rate: {inv.financials.exchange_rate}") + + print("\nFINANCIALS:") + if fin: + print(f" value_usd: {fin.value_usd}") + print(f" value_mxn: {fin.value_mxn}") + print(f" value_commercial_usd: {fin.value_commercial_usd}") + print(f" value_commercial_mxn: {fin.value_commercial_mxn}") + print(f" unit_cost_usd: {fin.unit_cost_usd}") + print(f" unit_cost_commercial_usd: {fin.unit_cost_commercial_usd}") + print(f" unit_cost_mxn: {fin.unit_cost_mxn}") + + # Check calculation hypothesis + val_usd = float(fin.value_usd or 0) + val_mxn = float(fin.value_mxn or 0) + rate = float(inv.financials.exchange_rate or 1) + + print(f"\n If using ValueMXN/Rate: {val_mxn} / {rate} = {val_mxn/rate}") + print(f" If using Max(MXN, USD): {max(val_mxn, val_usd)}") + else: + print(" No Financials found.") + + if not found: + print("No matching line (1500 qty, HTS 2710190650) found.") + +finally: + db.close() diff --git a/docker-compose.yml b/docker-compose.yml index 655340d1..c26575a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: - backend-net restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d anexo76_core || exit 1"] + test: [ "CMD-SHELL", "pg_isready -U postgres -d anexo76_core || exit 1" ] interval: 5s timeout: 3s retries: 10 @@ -54,7 +54,7 @@ services: - backend-net restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d keycloak || exit 1"] + test: [ "CMD-SHELL", "pg_isready -U postgres -d keycloak || exit 1" ] interval: 5s timeout: 3s retries: 10 @@ -97,7 +97,7 @@ services: KC_HOSTNAME_PATH: /kcauth KC_LOG_LEVEL: INFO JAVA_OPTS_APPEND: "-Xms256m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true" - command: + command: - start-dev - --http-relative-path=/kcauth - --db=postgres @@ -122,7 +122,19 @@ services: - backend-net restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r\nhost: 127.0.0.1\r\nConnection: close\r\n\r\n' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"] + test: + [ + "CMD-SHELL", + "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r + + host: 127.0.0.1\r + + Connection: close\r + + \r + + ' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1" + ] interval: 10s timeout: 5s retries: 30 @@ -178,10 +190,10 @@ services: - backend-net - frontend-net restart: unless-stopped - entrypoint: ["/entrypoint.sh"] - command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--log-level", "info"] + entrypoint: [ "/entrypoint.sh" ] + command: [ "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--log-level", "info" ] healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"] + test: [ "CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1" ] interval: 15s timeout: 5s retries: 5 @@ -223,7 +235,7 @@ services: depends_on: backend: condition: service_healthy - entrypoint: ["/frontend-entrypoint.sh"] + entrypoint: [ "/frontend-entrypoint.sh" ] volumes: - ./frontend:/app - frontend_node_modules:/app/node_modules @@ -232,9 +244,9 @@ services: - frontend-net - auth-net restart: unless-stopped - command: ["pnpm", "run", "dev", "--", "--host", "0.0.0.0"] + command: [ "pnpm", "run", "dev", "--", "--host", "0.0.0.0" ] healthcheck: - test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5173/ || exit 1"] + test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5173/ || exit 1" ] interval: 15s timeout: 5s retries: 5 @@ -250,6 +262,27 @@ services: memory: 1G reservations: memory: 512M + # celery + celery_worker: + build: ./backend + container_name: a76_worker + command: celery -A core.celery_app worker --loglevel=info + environment: + - VALKEY_URL=redis://valkey:6379/0 + depends_on: + - backend + - valkey + networks: + - backend-net + + valkey: + image: valkey/valkey:7.2 + container_name: a76_valkey + restart: always + ports: + - "6379:6379" + networks: + - backend-net volumes: postgres_app_data: @@ -278,4 +311,4 @@ networks: driver: bridge ipam: config: - - subnet: 172.22.0.0/16 \ No newline at end of file + - subnet: 172.22.0.0/16 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0f0aeaa4..f17d6fd5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -46,7 +46,7 @@ async function refreshToken(): Promise { if (!browser) return null; let refreshTokenValue = localStorage.getItem('refresh_token'); - + // Si no está en localStorage, intentar obtenerlo de las cookies if (!refreshTokenValue) { const getCookie = (name: string): string | null => { @@ -55,18 +55,18 @@ async function refreshToken(): Promise { if (parts.length === 2) return parts.pop()?.split(';').shift() || null; return null; }; - + refreshTokenValue = getCookie('refresh_token'); - if (refreshTokenValue) { + if (refreshTokenValue) { localStorage.setItem('refresh_token', refreshTokenValue); } } - + if (!refreshTokenValue) { console.error('❌ [API] No hay refresh token disponible'); return null; - } - + } + try { const response = await fetch(`${API_BASE_URL}/v1/auth/refresh`, { method: 'POST', @@ -94,25 +94,25 @@ async function refreshToken(): Promise { return null; } - const data = await response.json(); + const data = await response.json(); // Guardar los nuevos tokens if (data.access_token) { localStorage.setItem('access_token', data.access_token); - + if (data.refresh_token) { localStorage.setItem('refresh_token', data.refresh_token); } - + // Actualizar también las cookies const isSecure = window.location.protocol === 'https:'; const secureFlag = isSecure ? '; Secure' : ''; - + document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secureFlag}`; if (data.refresh_token) { document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax${secureFlag}`; } - + // Actualizar el authStore si está disponible try { const { authStore } = await import('./auth'); @@ -121,7 +121,7 @@ async function refreshToken(): Promise { // Si no se puede importar authStore, no es crítico console.warn('⚠️ [API] No se pudo actualizar authStore:', e); } - + return data.access_token; } @@ -141,7 +141,7 @@ async function fetchApi( retryCount = 0 ): Promise> { // Si ya estamos refrescando el token, esperar - if (isRefreshing && retryCount === 0) { + if (isRefreshing && retryCount === 0) { return new Promise((resolve) => { subscribeTokenRefresh((newToken) => { resolve(fetchApi(endpoint, options, 1)); @@ -150,16 +150,20 @@ async function fetchApi( } const token = getToken(); - + if (!token && !endpoint.includes('/auth/login')) { console.warn('⚠️ [API] No hay token disponible para', endpoint); } const headers: Record = { - 'Content-Type': 'application/json', ...((options.headers as Record) || {}) }; + // Only set Content-Type to application/json if not already set and body is not FormData + if (!headers['Content-Type'] && !(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + if (token) { headers['Authorization'] = `Bearer ${token}`; } @@ -244,7 +248,7 @@ async function fetchApi( // Errores de validación de FastAPI (con detail) else if (data.detail) { let errorMessage = 'Error de validación: '; - + // FastAPI devuelve errores de validación en data.detail como array if (Array.isArray(data.detail)) { const errors = data.detail.map((err: any) => { @@ -257,14 +261,14 @@ async function fetchApi( } else { errorMessage += JSON.stringify(data.detail); } - + return { error: errorMessage, status: response.status }; } } - + return { error: data.message || data.detail || 'Error en la petición', status: response.status @@ -299,7 +303,7 @@ export const api = { method: 'PUT', body: JSON.stringify(body) }), - + patch: (endpoint: string, body: any) => fetchApi(endpoint, { method: 'PATCH', @@ -332,5 +336,8 @@ export const api = { myLicense: () => api.get('/v1/licenses/my-license'), usage: (tenantId: number) => api.get(`/v1/licenses/usage/${tenantId}`), validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}`) - } + }, + + // Generic request for custom needs (like file uploads) + request: (endpoint: string, options: RequestInit = {}) => fetchApi(endpoint, options) }; diff --git a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts index f91acdcb..49b336e1 100644 --- a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts +++ b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts @@ -87,13 +87,13 @@ export interface CreateCustomsBrokerData { */ export const customsBrokersApi = { list: (companyId: string) => { - return api.get(`/v1/a76/customs-brokers/?company_id=${companyId}`); + return api.get(`/v1/a76/customs-brokers?company_id=${companyId}`); }, get: (brokerKey: string, companyId: string) => { return api.get(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`); }, - + create: (data: CreateCustomsBrokerData, companyId: string) => { return api.post(`/v1/a76/customs-brokers/?company_id=${companyId}`, data); }, diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index fff914d5..0c7fab48 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -26,6 +26,13 @@ export interface Company { order_format_type?: string | null; ctpat_svi?: string | null; trusted_exporter_number?: string | null; + logo?: string | null; + previous_code?: number | null; + client_name?: string | null; + subassembly_mode?: string | null; + inter_db_name?: string | null; + prevalidator_key?: string | null; + seventh_amendment?: boolean; created_at: string | null; updated_at: string | null; } diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/multi-currency-types.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/multi-currency-types.ts index a624e361..fb415397 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/multi-currency-types.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/multi-currency-types.ts @@ -1,5 +1,11 @@ import { api } from '$lib/api'; -import type { PaginatedResponse } from '$lib/types'; + +export interface PaginatedResponse { + page: number; + page_size: number; + total: number; + total_pages: number; +} export interface MultiCurrencyType { id: number; @@ -29,11 +35,13 @@ export interface MultiCurrencyTypeListResponse extends PaginatedResponse { items: MultiCurrencyType[]; } +import type { ApiResponse } from '$lib/api'; + export async function getMultiCurrencyTypes( companyId: number, page?: number, pageSize?: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); if (page) params.append('page', page.toString()); if (pageSize) params.append('page_size', pageSize.toString()); @@ -44,7 +52,7 @@ export async function getMultiCurrencyTypes( export async function getMultiCurrencyType( multiCurrencyTypeId: number, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.get(`/v1/a76/multi-currency-types/${multiCurrencyTypeId}?${params.toString()}`); } @@ -52,7 +60,7 @@ export async function getMultiCurrencyType( export async function createMultiCurrencyType( data: MultiCurrencyTypeCreate, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.post(`/v1/a76/multi-currency-types/?${params.toString()}`, data); } @@ -61,7 +69,7 @@ export async function updateMultiCurrencyType( multiCurrencyTypeId: number, data: MultiCurrencyTypeUpdate, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.put( `/v1/a76/multi-currency-types/${multiCurrencyTypeId}?${params.toString()}`, @@ -72,7 +80,7 @@ export async function updateMultiCurrencyType( export async function deleteMultiCurrencyType( multiCurrencyTypeId: number, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.delete(`/v1/a76/multi-currency-types/${multiCurrencyTypeId}?${params.toString()}`); } \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/parts.ts b/frontend/src/lib/api/dashboard/a76/parts.ts index 88538db8..492c93ea 100644 --- a/frontend/src/lib/api/dashboard/a76/parts.ts +++ b/frontend/src/lib/api/dashboard/a76/parts.ts @@ -55,24 +55,24 @@ export interface Part { tenant_id: number; company_id: number; client_id: number; - + // Identificación part_number: string; commercial_part_number: string | null; - + // Descripciones y Clase description_spanish: string | null; description_english: string | null; part_class: string | null; - unit_of_measure: string | null; - + unit_of_measure: string | null; + // Costos y Pesos unit_cost: number | null; currency_key: string | null; currency_type: string | null; unit_weight: number | null; weight_type: string | null; - + // Regulatorio fraction: string | null; us_fraction: string | null; @@ -82,14 +82,14 @@ export interface Part { eccn: string | null; export_code: string | null; exclusion_symbol: string | null; - + // Estado y Media is_active: boolean; part_photo: string | null; created_at: string; updated_at: string; - + fa_data?: FaData | null; inv_data?: InvData | null; } @@ -99,7 +99,7 @@ export interface PartCreate extends Omit {} +export interface PartUpdate extends Partial { } export interface PartListResponse { @@ -112,20 +112,20 @@ export interface PartListResponse { export const partsApi = { - list: (params: { - company_id: number; - page?: number; - page_size?: number; - q?: string + list: (params: { + company_id: number; + page?: number; + page_size?: number; + q?: string }) => { const { company_id, page = 1, page_size = 50, q = '' } = params; const skip = (page - 1) * page_size; - + const query = new URLSearchParams({ company_id: company_id.toString(), skip: skip.toString(), limit: page_size.toString(), - description: q + description: q }); return api.get(`/v1/a76/parts/?${query.toString()}`); @@ -140,11 +140,11 @@ export const partsApi = { }, update: (id: number, data: PartUpdate, company_id: number) => { - return api.put(`/v1/a76/parts/${id}?company_id=${company_id}`, data); + return api.put(`/v1/a76/parts/${id}/?company_id=${company_id}`, data); }, delete: (id: number, company_id: number) => { - return api.delete(`/v1/a76/parts/${id}?company_id=${company_id}`); + return api.delete(`/v1/a76/parts/${id}/?company_id=${company_id}`); } }; \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-consolidated.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-consolidated.ts new file mode 100644 index 00000000..9e6c171a --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-consolidated.ts @@ -0,0 +1,35 @@ + +const BASE_URL = import.meta.env.VITE_API_URL || ''; + +export const consolidatedReportsApi = { + + triggerPdfGeneration: async (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/consolidados/${invoiceId}/download-async?${params.toString()}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) throw new Error('Error al iniciar la generación del consolidado'); + return await response.json(); + }, + + getTaskStatus: async (taskId: string) => { + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/consolidados/tasks/${taskId}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) throw new Error('Error al consultar estado del consolidado'); + return await response.json(); + } +}; diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts new file mode 100644 index 00000000..33c619f7 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts @@ -0,0 +1,35 @@ + +const BASE_URL = import.meta.env.VITE_API_URL || ''; + +export const invoicesReportsApi = { + + triggerPdfGeneration: async (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/${invoiceId}/download-async?${params.toString()}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) throw new Error('Error al iniciar la generación'); + return await response.json(); + }, + + getTaskStatus: async (taskId: string) => { + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) throw new Error('Error al consultar estado'); + return await response.json(); + } +}; \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/clients_and_providers/data-table.svelte b/frontend/src/lib/components/dashboard/clients_and_providers/data-table.svelte index ef98de23..4c448e3d 100644 --- a/frontend/src/lib/components/dashboard/clients_and_providers/data-table.svelte +++ b/frontend/src/lib/components/dashboard/clients_and_providers/data-table.svelte @@ -62,7 +62,7 @@
- + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} {#each headerGroup.headers as header (header.id)} diff --git a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte index e4b4fb5e..06bcd2d0 100644 --- a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte @@ -832,7 +832,7 @@
- + @@ -876,7 +876,7 @@
Clave Descripción
- + @@ -924,7 +924,7 @@
Código Descripción
- + @@ -982,7 +982,7 @@
Fracción NICO
- + @@ -1042,7 +1042,7 @@
Código Prefijo
- + @@ -1098,7 +1098,7 @@
Fracción Descripción
- + @@ -1152,7 +1152,7 @@
Clave FDA 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}
Código Descripción
- + - {#each filteredClients as client} - + handleSelect(client)} + > - {/each} diff --git a/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte new file mode 100644 index 00000000..27943e6b --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte @@ -0,0 +1,185 @@ + + + + + + Seleccionar País + + Seleccione el país de origen del catálogo. + + + +
+ + +
+ +
+ {#if loading} +
+ +

Cargando catálogo...

+
+ {:else if filteredItems.length === 0} +
+

No se encontraron países.

+
+ {:else} + + + + M3 + MEX + AME + Descripción ES + Description EN + + + + {#each filteredItems as item} + handleSelect(item)} + > + +
+ + + {item.m3_key} + +
+
+ + {item.mex_key} + + + {item.ame_key} + + + {item.description_es} + + + {item.description_en} + +
+ {/each} +
+
+ {/if} +
+ + +
+ {filteredItems.length} registros encontrados +
+ +
+
+
diff --git a/frontend/src/lib/components/dashboard/goods/modales/currency-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/currency-selector-dialog.svelte new file mode 100644 index 00000000..4add0b4a --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/modales/currency-selector-dialog.svelte @@ -0,0 +1,149 @@ + + + + + + Seleccionar Moneda + + Seleccione el tipo de moneda del catálogo público. + + + +
+ + +
+ +
+ {#if loading} +
+ +

Cargando catálogo...

+
+ {:else if filteredItems.length === 0} +
+

No se encontraron monedas.

+
+ {:else} + + + + Código + Moneda + País + + + + {#each filteredItems as item} + handleSelect(item)} + > + +
+ + + {item.code} + +
+
+ + {item.currency_name} + + + {item.country_description || '-'} + +
+ {/each} +
+
+ {/if} +
+ + +
+ {filteredItems.length} registros encontrados +
+ +
+
+
diff --git a/frontend/src/lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte new file mode 100644 index 00000000..ea9819ed --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte @@ -0,0 +1,156 @@ + + + + + + Seleccionar Fracción Arancelaria + + Seleccione la fracción arancelaria del catálogo. + + + +
+ + +
+ +
+ {#if loading} +
+ +

Cargando catálogo...

+
+ {:else if filteredItems.length === 0} +
+

No se encontraron fracciones.

+
+ {:else} + + + + Código + Fracción + NICO + Descripción + + + + {#each filteredItems as item} + handleSelect(item)} + > + + {item.code} + + +
+ + + {item.fraction} + +
+
+ + {item.nico || '-'} + + + {item.description || '-'} + +
+ {/each} +
+
+ {/if} +
+ + +
+ {filteredItems.length} registros encontrados +
+ +
+
+
diff --git a/frontend/src/lib/components/dashboard/goods/modales/material-type-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/material-type-selector-dialog.svelte index 5fa0974f..3e03b975 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/material-type-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/material-type-selector-dialog.svelte @@ -93,7 +93,7 @@ {:else}
ID RFC Razón Social EstadoAcción
{client.id} {client.rfc} @@ -138,17 +139,6 @@ {/if} - -
- + 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 @@ Código Descripción - @@ -129,7 +128,7 @@ {:else} {#each items as item} handleSelect(item)} > {item.code} @@ -141,11 +140,6 @@ {/if} - - - {/each} {/if} diff --git a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte new file mode 100644 index 00000000..1f514190 --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte @@ -0,0 +1,146 @@ + + + + + + Seleccionar Fracción Americana + + Seleccione la fracción arancelaria (HTS) del catálogo. + + + +
+ + +
+ +
+ {#if loading} +
+ +

Cargando catálogo...

+
+ {:else if filteredItems.length === 0} +
+

No se encontraron fracciones.

+
+ {:else} + + + + Código (HTS) + Descripción + + + + {#each filteredItems as item} + handleSelect(item)} + > + +
+ + + {item.code} + +
+
+ + {item.description || '-'} + +
+ {/each} +
+
+ {/if} +
+ + +
+ {filteredItems.length} registros encontrados +
+ +
+
+
diff --git a/frontend/src/lib/components/dashboard/goods/parts/class-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/parts/class-selector-dialog.svelte index 5d03286a..04a8ce34 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/class-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/class-selector-dialog.svelte @@ -110,12 +110,14 @@
- {#each filteredItems as item} - + handleSelect(item)} + > - - {/each} diff --git a/frontend/src/lib/components/dashboard/goods/parts/data-table.svelte b/frontend/src/lib/components/dashboard/goods/parts/data-table.svelte index ef98de23..4c448e3d 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/data-table.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/data-table.svelte @@ -62,7 +62,7 @@
- + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} {#each headerGroup.headers as header (header.id)} diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index db5d7d67..5991f15e 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -9,14 +9,17 @@ import * as Tabs from '$lib/components/ui/tabs'; import * as Card from '$lib/components/ui/card'; import * as Select from "$lib/components/ui/select"; - import { Switch } from "$lib/components/ui/switch"; + import { Switch } from "$lib/components/ui/switch"; + import { Separator } from '$lib/components/ui/separator'; + import { Badge } from '$lib/components/ui/badge'; // Iconos import { ArrowLeft, LoaderCircle, Save, Package, DollarSign, FileText, Settings, Image as ImageIcon, FolderSearch, - UserCheck, CheckCircle2, XCircle, Tag, Layers, Scale, Info, Briefcase, ShieldCheck + UserCheck, CheckCircle2, XCircle, Tag, Layers, Scale, Info, Briefcase, ShieldCheck, Globe } from 'lucide-svelte'; + import { toast } from "svelte-sonner"; // Stores & APIs import { companyStore } from '$lib/stores/company.svelte'; @@ -30,6 +33,10 @@ import ClassSelectorDialog from '$lib/components/dashboard/goods/parts/class-selector-dialog.svelte'; import MaterialTypeSelectorDialog from '$lib/components/dashboard/goods/modales/material-type-selector-dialog.svelte'; import UnitMeasureSelectorDialog from '$lib/components/dashboard/goods/modales/unit-measure-dialog.svelte'; + import CurrencySelectorDialog from '$lib/components/dashboard/goods/modales/currency-selector-dialog.svelte'; + import CountrySelectorDialog from '$lib/components/dashboard/goods/modales/country-selector-dialog.svelte'; + import FractionSelectorDialog from '$lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte'; + import USFractionSelectorDialog from '$lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte'; // --- PROPS --- let { partId = null, formType = 'inv' }: { partId?: number | null, formType?: 'inv' | 'fa' } = $props(); @@ -40,6 +47,7 @@ let loading = $state(false); let error = $state(null); + let activeTab = $state('general'); // Estado Modales let showClientModal = $state(false); @@ -47,11 +55,17 @@ let showMaterialModal = $state(false); let showUOMModal = $state(false); let showAltUOMModal = $state(false); + let showCurrencyModal = $state(false); + let showCountryModal = $state(false); + let showFractionModal = $state(false); + let showUSFractionModal = $state(false); // Descripciones Visuales let selectedClientName = $state(""); let selectedClientStatus = $state(true); let selectedClassDesc = $state(""); + let selectedCurrencyName = $state(""); + let selectedCountryName = $state(""); let selectedMaterialDesc = $state(""); // Estado Formulario @@ -60,13 +74,14 @@ part_number: '', description_spanish: '', description_english: '', - part_class: '', + part_class: '', material_type: '', unit_of_measure: 'PZ', unit_weight: 0, weight_type: 'KG', unit_cost: 0, - currency_key: 'USD', + currency_type: '', + currency_key: null as string | null, added_value: 0, value_added_type: 'USD', us_fraction: '', @@ -129,10 +144,14 @@ alternate_unit_measure: d.inv_data?.alternate_uom || '', part_photo: d.part_photo || '', is_active: d.is_active ?? true, - // Cargar datos FA si existen sector: d.fa_data?.sector || '', - fraction_type: d.fa_data?.fraction_type || '' + fraction_type: d.fa_data?.fraction_type || '', + currency_type: '' // Initialize to match type }; + // Ensure currency_type is mapped correctly if coming from DB (optional, depending on DB values) + if (d.currency_key === 'MXN') formData.currency_type = 'NA'; + else if (d.currency_key === 'USD') formData.currency_type = 'EX'; + if (d.client_id) await fetchClientName(d.client_id, companyId); if (d.part_class) await fetchClassDesc(d.part_class, companyId); if (d.inv_data?.material_type) await fetchMaterialName(d.inv_data.material_type); @@ -140,6 +159,18 @@ } catch (e) { console.error(e); } finally { loading = false; } } + // --- EFECTOS REACTIVOS --- + $effect(() => { + // Auto-set currency based on type selection + if (formData.currency_type === 'NA') { + formData.currency_key = 'MXN'; + selectedCurrencyName = 'MXN'; + } else if (formData.currency_type === 'EX') { + formData.currency_key = 'USD'; + selectedCurrencyName = 'USD'; + } + }); + // --- HELPERS VISUALES --- async function fetchClientName(clientId: number, companyId: number) { try { @@ -180,6 +211,20 @@ function handleMaterialSelect(item: any) { formData.material_type = item.key; selectedMaterialDesc = item.description; } function handleUOMSelect(item: any) { formData.unit_of_measure = item.code; } function handleAltUOMSelect(item: any) { formData.alternate_unit_measure = item.code; } + function handleCurrencySelect(currency: any) { + formData.currency_type = ''; // Reset type legacy field + // FIX: Usar 'code' de la API pública currency_types + const code = currency.code || currency.currency_type_code; + formData.currency_key = code; + selectedCurrencyName = code; + } + function handleCountrySelect(country: any) { + // FIX: Asegurar que se asigna la clave correcta + formData.origin_country = country.m3_key || country.country_key; + selectedCountryName = country.description_es; + } + function handleFractionSelect(item: any) { formData.fraction = item.fraction; } + function handleUSFractionSelect(item: any) { formData.us_fraction = item.code; } // --- SUBMIT --- async function handleSubmit() { @@ -191,134 +236,185 @@ loading = true; try { - // Construimos el payload. - // Si es FA, inyectamos el objeto fa_data anidado. let commonData: any = { ...formData }; - + if (!commonData.currency_key || commonData.currency_key === 'USD') { + commonData.currency_key = null; + } if (formType === 'fa') { commonData.fa_data = { sector: formData.sector, fraction_type: formData.fraction_type, origin_country: formData.origin_country }; + delete commonData.sector; + delete commonData.fraction_type; + delete commonData.origin_country; } + console.log("Submitting Part Data:", { + isEdit, + partId, + commonData + }); + if (isEdit && partId) { - await partsApi.update(partId, commonData, activeCompanyId); + // TODO: Verify partId is number/string as expected + const res = await partsApi.update(Number(partId), commonData, activeCompanyId); + console.log("Update Response:", res); + if (res.error) throw new Error(res.error); } else { - await partsApi.create({ ...commonData, company_id: activeCompanyId }, activeCompanyId); + const result = await partsApi.create({ ...commonData, company_id: activeCompanyId }, activeCompanyId); + console.log("Create Response:", result); + if (result.error) { error = result.error; return; } } + toast.success(isEdit ? "Parte actualizada" : "Parte creada"); goto('/dashboard/goods/parts'); - } catch (e: any) { error = e.message || 'Error al guardar'; } finally { loading = false; } + } catch (e: any) { + console.error("Submit Error:", e); + error = e.message || 'Error al guardar'; + toast.error(error); + } finally { loading = false; } } -
-
- -
-

{title}

-

- {formType === 'fa' ? 'Gestión de Activos Fijos (Q-Partes)' : 'Gestión detallada de números de parte (S-Partes).'} +

+
+
+
+ +

{title}

+ + {isEdit ? "Editar" : "Nueva"} + +
+

+ {formType === 'fa' ? 'Gestión de Activo Fijo' : 'Gestión de Inventario'}

+ + {#if error} -
+
⚠️ {error}
{/if} +
+ {#if formType === 'fa'}
{ e.preventDefault(); handleSubmit(); }} class="space-y-6"> - + - +
- - + -
-
+
+
- -
-
- -
- showClientModal = true} class="cursor-pointer font-mono" placeholder="Seleccione..."/> - +
+ + +
+
+
+ +
+
+ + showClientModal = true} class="pl-9 cursor-pointer hover:bg-muted/50 transition-colors" placeholder="Seleccione un cliente..."/> +
+
- {#if selectedClientName}
{selectedClientName}
{/if}
-
-
- -
Clave Descripción Clave Descripción UMAcción
{item.class_code} @@ -137,18 +139,6 @@ {item.unit_of_measure || '-'} - -