|
|
|
|
@@ -14,14 +14,16 @@ 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
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# --- MODELO DE FRACCIONES ---
|
|
|
|
|
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
|
|
|
|
|
|
|
|
|
|
# --- SCHEMAS ---
|
|
|
|
|
from .schemas import (
|
|
|
|
|
@@ -50,14 +52,16 @@ class FacturaImportacionMexService:
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
@@ -79,61 +83,32 @@ class FacturaImportacionMexService:
|
|
|
|
|
|
|
|
|
|
def obtener_datos(self, db: Session, invoice_id: int, company_id: int) -> FacturaImportacionCompleta:
|
|
|
|
|
try:
|
|
|
|
|
# 1. Cabecera
|
|
|
|
|
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")
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
# 2. Relaciones Críticas (Compliance y Logistics)
|
|
|
|
|
# Nota: Usamos la relación ORM 'compliance_mx' que definiste en el modelo
|
|
|
|
|
compliance = header.compliance_mx
|
|
|
|
|
|
|
|
|
|
# Si logistics es una lista, tomamos el primero, si no, None
|
|
|
|
|
logistics = header.logistics[0] if header.logistics else None
|
|
|
|
|
|
|
|
|
|
# Pedimento: Prioridad al de Compliance, si no, al de Header
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# 3. Mapeo Actores (¡AQUÍ ESTABA EL DETALLE!)
|
|
|
|
|
|
|
|
|
|
# A) Proveedor: Sacado de compliance.provider_id
|
|
|
|
|
proveedor_id = compliance.provider_id if compliance else None
|
|
|
|
|
if proveedor_id:
|
|
|
|
|
cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / Supplier")
|
|
|
|
|
else:
|
|
|
|
|
cliente_proveedor = ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="",
|
|
|
|
|
codigo_postal="", ciudad="", estado="", pais="")
|
|
|
|
|
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="")
|
|
|
|
|
|
|
|
|
|
# B) Agente Aduanal: Sacado de compliance.customs_broker_id
|
|
|
|
|
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 # Asumiendo que CustomsBroker tiene 'name'
|
|
|
|
|
if broker: nombre_agente = broker.name
|
|
|
|
|
|
|
|
|
|
# C) Importador (Company)
|
|
|
|
|
company = db.query(Company).filter(Company.id == header.company_id).first()
|
|
|
|
|
cliente_vendido = ClienteSchema(
|
|
|
|
|
header="Importador / Consignatario",
|
|
|
|
|
nombre=getattr(company, 'name', "Empresa Local"),
|
|
|
|
|
direccion="DOMICILIO FISCAL",
|
|
|
|
|
num_exterior="",
|
|
|
|
|
colonia="",
|
|
|
|
|
codigo_postal="",
|
|
|
|
|
ciudad="",
|
|
|
|
|
estado="",
|
|
|
|
|
pais="MEX",
|
|
|
|
|
num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX",
|
|
|
|
|
tax_id=getattr(company, 'rfc', ""),
|
|
|
|
|
programa=getattr(company, 'program', "IMMEX"),
|
|
|
|
|
autorizacion=getattr(company, 'program_number', "")
|
|
|
|
|
programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 4. Mapeo Factura
|
|
|
|
|
# Nota: Muchos datos vienen de 'compliance', no de 'header'
|
|
|
|
|
remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else ""
|
|
|
|
|
acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A"
|
|
|
|
|
|
|
|
|
|
@@ -144,30 +119,22 @@ class FacturaImportacionMexService:
|
|
|
|
|
moneda=getattr(header, 'currency', "USD") or "USD",
|
|
|
|
|
incoterm=logistics.incoterm if logistics else "",
|
|
|
|
|
observaciones=header.observation_es or header.observation_en or "",
|
|
|
|
|
|
|
|
|
|
# Pedimento y Agente
|
|
|
|
|
pedimento=pedimento.pedimento_number if pedimento else "",
|
|
|
|
|
clave_pedimento=pedimento.pedimento_code if pedimento else "",
|
|
|
|
|
regimen=pedimento.regime if pedimento else "",
|
|
|
|
|
patente=pedimento.license if pedimento else "",
|
|
|
|
|
agente_aduanal=nombre_agente, # Agregamos el nombre real
|
|
|
|
|
|
|
|
|
|
# Transporte
|
|
|
|
|
agente_aduanal=nombre_agente,
|
|
|
|
|
transporte=str(logistics.transport_type.value) if (logistics and logistics.transport_type) else "",
|
|
|
|
|
num_transporte=logistics.trailer_num if logistics else "",
|
|
|
|
|
placas=logistics.license_plate if logistics else "",
|
|
|
|
|
transportista=logistics.carrier_id if logistics else "", # Si carrier_id es ID, aquí habría que buscar nombre
|
|
|
|
|
|
|
|
|
|
# Otros
|
|
|
|
|
transportista=logistics.carrier_id if logistics else "",
|
|
|
|
|
aduana=pedimento.customs_office if pedimento else "",
|
|
|
|
|
precinto=logistics.seal_number if logistics else "",
|
|
|
|
|
destino=logistics.destination_goods if logistics else "",
|
|
|
|
|
remesa=remesa_valor,
|
|
|
|
|
acuse_electronico=acuse_valor
|
|
|
|
|
remesa=remesa_valor, acuse_electronico=acuse_valor
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 5. Mapeo Partidas
|
|
|
|
|
lines = db.query(LineItem).filter(LineItem.item_id == header.id).all()
|
|
|
|
|
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all()
|
|
|
|
|
partidas_list = []
|
|
|
|
|
|
|
|
|
|
for line in lines:
|
|
|
|
|
@@ -175,13 +142,54 @@ class FacturaImportacionMexService:
|
|
|
|
|
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 ""
|
|
|
|
|
|
|
|
|
|
# --- LÓGICA DE FRACCIÓN DESDE BASE DE DATOS ---
|
|
|
|
|
# Limpiar la fracción de la BD (quitar puntos y asegurar 8 dígitos)
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
# Reglas de negocio solicitadas:
|
|
|
|
|
# 1. Preferencia default: "General"
|
|
|
|
|
# 2. AdValorem default: "0%" si no existe o es vacío
|
|
|
|
|
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%" # O podrías poner "EXENTO" si prefieres
|
|
|
|
|
|
|
|
|
|
fraccion_imprimir = fraccion_db.fraction or fraccion_raw
|
|
|
|
|
else:
|
|
|
|
|
# Si no existe en la tabla, aplicamos el fallback visual de puntos
|
|
|
|
|
fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia)
|
|
|
|
|
|
|
|
|
|
partidas_list.append(PartidaSchema(
|
|
|
|
|
numero_parte=part_master.part_number if part_master else str(line.part_number or "S/N"),
|
|
|
|
|
descripcion=part_master.description_spanish if part_master else "S/D",
|
|
|
|
|
fraccion=part_master.fraction if part_master else "",
|
|
|
|
|
origen="MEX",
|
|
|
|
|
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 "KG",
|
|
|
|
|
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 if qty else "",
|
|
|
|
|
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
|
|
|
|
|
@@ -190,21 +198,17 @@ class FacturaImportacionMexService:
|
|
|
|
|
valor_total=self.formatear_numero(fin.total_commercial_value if fin else 0)
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# 6. Totales
|
|
|
|
|
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
|
|
|
|
|
|
|
|
|
|
return FacturaImportacionCompleta(
|
|
|
|
|
cliente_proveedor=cliente_proveedor,
|
|
|
|
|
cliente_vendido=cliente_vendido,
|
|
|
|
|
cliente_enviado=cliente_vendido,
|
|
|
|
|
factura=factura_schema,
|
|
|
|
|
partidas=partidas_list,
|
|
|
|
|
totales=totales
|
|
|
|
|
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
|
|
|
|
|
cliente_enviado=cliente_vendido, 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 procesando datos: {str(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)
|
|
|
|
|
@@ -214,40 +218,24 @@ class FacturaImportacionMexService:
|
|
|
|
|
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)
|
|
|
|
|
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") -> Tuple[bytes, str, str]:
|
|
|
|
|
datos = self.obtener_datos(db, invoice_id, company_id)
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
'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()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
|
|
|
|
|
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())
|
|
|
|
|
return pdf, nombre, "application/pdf"
|