diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py index a85c0c82..9f96a81e 100644 --- a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py @@ -8,12 +8,16 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape from fastapi import HTTPException from sqlalchemy.orm import Session from pydantic import BaseModel +from io import BytesIO +import pdf417gen # --- MODELOS --- from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials, InvoiceLogistics from api.v1.modules.a76.general_catalogs.company.models import Company from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.pedmientos.models.pedimento_validation import PedimentoValidation from api.v1.modules.a76.customs_brokers.models import CustomsBroker, CustomsBrokerPersonnel +from api.v1.modules.a76.items.models import Item from api.v1.modules.a76.clients_and_providers.models import ClientProvider, ClientProviderAddress, ClientProviderPrograms # --- SCHEMAS FOR TEMPLATE CONTEXT --- @@ -71,6 +75,7 @@ class AvisoConsolidadoExportacionService: def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> AvisoConsolidadoContext: try: + with open("/tmp/barcode_debug.log", "a") as f: f.write(f"ENTER obtener_datos ID={invoice_id}\n") if progress_callback: progress_callback(10, "Buscando factura...") # Fetch minimal real data if possible, or use placeholders as requested @@ -104,47 +109,35 @@ class AvisoConsolidadoExportacionService: pedimento_txt = f"{year} {office} {lic} {num}" clave_ped = pedimento.pedimento_code or "" - # 2. Company Address - direccion_empresa = "DOMICILIO NO REGISTRADO" - if company and company.addresses: - # Try to find fiscal address or first available - addr = company.addresses[0] # Default - # TODO: Check if there's a specific flag for fiscal address in submodel - - parts = [] - if addr.street: parts.append(addr.street) - if addr.exterior_number: parts.append(f"No. {addr.exterior_number}") - if addr.interior_number: parts.append(f"Int. {addr.interior_number}") - if addr.neighborhood: parts.append(f"Col. {addr.neighborhood}") - if addr.postal_code: parts.append(f"CP {addr.postal_code}") - if addr.city: parts.append(addr.city) - if addr.state: parts.append(addr.state) - if addr.country: parts.append(addr.country) - - if parts: - direccion_empresa = ", ".join(parts).upper() - - # Determine Mexican Entity based on Operation Type - # IMP -> Client (Sold To/Consignee) - # EXP -> Company (Tenant) + # 2. Importer/Exporter Data (Clarion 100% Match) + # Logic: + # IF EqiFex:EsCambioRegimen = 'S' THEN + # CliPro:Cliente = EqiFex:VendidoA + # ELSE + # CliPro:Cliente = EqiFex:Proveedor + # END target_entity_data = { - "rfc": getattr(company, 'rfc', "") or "", - "razon_social": getattr(company, 'name', "") or "", - "direccion_completa": direccion_empresa + "rfc": "", + "razon_social": "", + "direccion_completa": "DOMICILIO NO REGISTRADO" } - op_type = header.operation_type.upper() if header.operation_type else "EXP" + target_client_id = None - if op_type == "IMP" and compliance and compliance.sold_to_id: - # Fetch Client Data - client_id = compliance.sold_to_id - client_obj = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() + if compliance: + if compliance.is_regime_change: + target_client_id = compliance.sold_to_id + else: + target_client_id = compliance.provider_id + + if target_client_id: + client_obj = db.query(ClientProvider).filter(ClientProvider.id == target_client_id).first() if client_obj: # Fetch Address - c_addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() + c_addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == target_client_id).first() # Fetch Fiscal Data (RFC) - c_prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + c_prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == target_client_id).first() c_rfc = "" if c_prog and c_prog.tax_id: c_rfc = c_prog.tax_id @@ -155,10 +148,13 @@ class AvisoConsolidadoExportacionService: parts_c = [] if c_addr.streets: parts_c.append(c_addr.streets) if c_addr.exterior_number: parts_c.append(f"No. {c_addr.exterior_number}") + if c_addr.interior_number: parts_c.append(f"Int. {c_addr.interior_number}") if c_addr.neighborhood: parts_c.append(f"Col. {c_addr.neighborhood}") + if c_addr.postal_code: parts_c.append(f"CP {c_addr.postal_code}") if c_addr.city: parts_c.append(c_addr.city) if c_addr.state: parts_c.append(c_addr.state) - if c_addr.postal_code: parts_c.append(f"CP {c_addr.postal_code}") + if c_addr.country: parts_c.append(c_addr.country) + if parts_c: c_dir_str = ", ".join(parts_c).upper() @@ -173,6 +169,15 @@ class AvisoConsolidadoExportacionService: razon_social=target_entity_data["razon_social"], direccion_completa=target_entity_data["direccion_completa"] ) + + # Destino/Origen (Clarion: Loc:DestinoOrigen = 'Destino/Origen: '&EqiFex:DestinoOrigenCOVE) + destino_origen_str = "" + if compliance and compliance.origin_destination_cove: + # Assuming enum value or string is what we want. + # If it's an Enum object, accessing .value is safer. + val = compliance.origin_destination_cove + if hasattr(val, 'value'): val = val.value + destino_origen_str = f"Destino/Origen: {val}" @@ -180,10 +185,28 @@ class AvisoConsolidadoExportacionService: financials = header.financials logistics = header.logistics + # Fetch Items associated with this invoice (MOVED UP FOR WEIGHT CALCULATION) + items = db.query(Item).filter(Item.invoice_id == invoice_id).all() + # Peso Bruto peso_bruto_val = "0.0" - if financials and financials.gross_weight: + calculated_gross_weight = 0.0 + + # Calculate sum from items first + if items: + for item in items: + if item.lines: + for line in item.lines: + if line.quantity and line.quantity.gross_weight: + try: + calculated_gross_weight += float(line.quantity.gross_weight) + except (ValueError, TypeError): + pass + + if financials and financials.gross_weight and float(financials.gross_weight) > 0: peso_bruto_val = f"{financials.gross_weight:,.2f}" + elif calculated_gross_weight > 0: + peso_bruto_val = f"{calculated_gross_weight:,.2f}" elif pedimento and pedimento.gross_weight: peso_bruto_val = f"{pedimento.gross_weight:,.2f}" @@ -193,33 +216,363 @@ class AvisoConsolidadoExportacionService: # Split by comma or space if multiple candados_list = [s.strip() for s in logistics.seal_number.replace(',', ' ').split() if s.strip()] - # Vehiculo - placas_val = "" - tipo_veh_val = "" - if logistics: - placas_val = logistics.license_plate or logistics.vehicle_num or logistics.trailer_num or "" - tipo_veh_val = logistics.transport_type or "" + # Vehiculo / Contenedor Logic (Replicating Clarion) + # Clarion Logic: + # 1. Check for explicit `DatosVehiculo`. + # 2. Check for `EsFerrocarril`. + # 3. Build string from Trailer + Transport. + # Since we don't have a direct "DatosVehiculo" text field in Logistics (usually), we construct it. + # However, we'll check if `license_plate` is being used as a catch-all or if we should build it. + + vehiculo_str = "" + tipo_display = "" + + # Basic Logistics Data + l_trailer = logistics.trailer_num.strip() if (logistics and logistics.trailer_num) else "" + l_placa = logistics.license_plate.strip() if (logistics and logistics.license_plate) else "" + l_trans_type = logistics.transport_type.strip() if (logistics and logistics.transport_type) else "" + l_vehicle_num = logistics.vehicle_num.strip() if (logistics and logistics.vehicle_num) else "" + l_container_types = logistics.container_types.strip() if (logistics and logistics.container_types) else "" + + # Check for Ferrocarril explicitly + is_rail = False + if "FERRO" in l_trans_type.upper() or "RAIL" in l_trans_type.upper(): + is_rail = True + + # --- LOGIC NUMERO / TIPO --- + final_numero = "" + final_tipo = "" + + # 1. Container Logic (Clarion: ContenedoresTipo parsing) + # Format expected: "CONTENEDOR|TIPO,CONTENEDOR2|TIPO2..." + if l_container_types: + # Take first container + first_cont_group = l_container_types.split(',')[0] # Split by comma + if '|' in first_cont_group: + parts = first_cont_group.split('|') + final_numero = parts[0].strip() + final_tipo = parts[1].strip() + else: + # Fallback if no pipe + final_numero = first_cont_group.strip() + final_tipo = "CONT" # Default? + + # 2. Transport = Container Logic + elif l_trans_type.upper() == "CONTENEDOR": + # Use trailer num as container num + if l_trailer: + final_numero = l_trailer + # Try to find Type? In Clarion it does a DB lookup into GTrailers.ClaveContenedor + # We assume 'CONTENEDOR' or a default if not found in simplified logic + final_tipo = "CONT" + + # 3. Trailer/General Logic (Fallback) + if not final_numero: + # Construct valid string + parts_veh = [] + if l_trailer: + parts_veh.append(f"TRAILER: {l_trailer}") + if not tipo_display: tipo_display = "TRAILER" + + if l_trans_type and l_trans_type.upper() != "NINGUNO": + if is_rail: + if l_vehicle_num: + parts_veh.append(f"CONTENEDOR: {l_vehicle_num}") + tipo_display = "FERROCARRIL" + else: + segment = l_trans_type + if l_vehicle_num: segment += f": {l_vehicle_num}" + parts_veh.append(segment) + if not tipo_display: tipo_display = l_trans_type + + if not parts_veh and l_placa: + parts_veh.append(f"PLACAS: {l_placa}") + + final_numero = ", ".join(parts_veh).upper() + final_tipo = tipo_display.upper() + + # Codigo de Aceptacion aka Acuse de Validacion + codigo_aceptacion_val = "" + if pedimento and pedimento.pedimento_validation: + # Assuming relationship "pedimento_validation" exists on Pedimentos model (lazy loaded) + # Or we can query it if relationship is scalar 'uselist=False' + if pedimento.pedimento_validation.validation_ack: + codigo_aceptacion_val = pedimento.pedimento_validation.validation_ack + aviso = AvisoSchema( pedimento_completo=pedimento_txt, tipo_operacion=header.operation_type.upper() if header.operation_type else "EXP", clave_pedimento=clave_ped, - acus_valor=compliance.edocument if (compliance and compliance.edocument) else "", + acus_valor=compliance.edocument.upper() if (compliance and compliance.edocument) else "", aduana_seccion=compliance.aduana if (compliance and compliance.aduana) else "", numero_remesa=str(compliance.remesa) if (compliance and compliance.remesa) else "", peso_bruto=peso_bruto_val, - codigo_aceptacion="", # TODO: Clarify source. Using empty for now or Edocument? + codigo_aceptacion=codigo_aceptacion_val, codigo_barras_b64=None, clave_seccion=compliance.aduana if (compliance and compliance.aduana) else "", # Using Aduana as Section Key marcas_numeros_bultos=f"{financials.bundle_count} BULTOS" if (financials and financials.bundle_count) else "1 BULTOS", candados=candados_list, - vehiculo_placas=placas_val, - vehiculo_tipo=tipo_veh_val, - observaciones=header.observation_es or "", + vehiculo_placas=final_numero, + vehiculo_tipo=final_tipo, + observaciones=(header.observation_es or "") + ("\n" + destino_origen_str if destino_origen_str else ""), numero_certificado=compliance.certificate_number if (compliance and compliance.certificate_number) else "", tipo_documento=header.document_type or "FACTURA", # Default firma_electronica=compliance.electronic_signature if (compliance and compliance.electronic_signature) else "" ) + + # --- BARCODE GENERATION (PDF417) --- + # Replicating Clarion "LLENADOCODIGODEBARRAS" logic + try: + # Debug Log + debug_log = [] + debug_log.append(f"Processing Invoice {invoice_id}") + + # 1. Patente (4 Digits) - From Pedimento or Compliance + patente_txt = pedimento.license if pedimento and pedimento.license else "" + if not patente_txt and pedimento_txt: + # Fallback parsing "YY OFF LIC NUMBER" -> LIC is index 2 (0, 1, 2) + try: + parts = pedimento_txt.split() + if len(parts) >= 3: patente_txt = parts[2] + except: pass + + # 2. Pedimento Number (7 Digits) + pedimento_num = pedimento.pedimento_number if pedimento and pedimento.pedimento_number else "" + if not pedimento_num and pedimento_txt: + try: + parts = pedimento_txt.split() + if len(parts) >= 4: pedimento_num = parts[3] + except: pass + + # 3. Recinto (3 chars) - Default to 000 if invalid/missing as per Clarion 'ELSE LINEPRINT('000'...' + # Clarion: Loc:Recinto = EqiFex:Recinto + recinto_txt = "000" + if compliance and compliance.enclosure: + recinto_txt = compliance.enclosure[:3] + if not recinto_txt: recinto_txt = "000" + + # 4. E-Document + edoc_txt = aviso.acus_valor # Already uppercased + + # 5. Num Contenedor (Rail) or 000... + # Clarion: IF Loc:EsFerrocarril = 'SI' ... LINEPRINT(CLIP(Loc:NumContenedor)) ELSE LINEPRINT('0000000000000') + # We reused logic for 'vehiculo_placas' and 'vehiculo_tipo' earlier. + # Let's re-evaluate "EsFerrocarril" logic safely + is_rail_bar = False + if "FERRO" in final_tipo.upper() or "RAIL" in final_tipo.upper(): + is_rail_bar = True + + field_5 = "0000000000000" + if is_rail_bar: + # We extracted container into 'parts_veh' earlier but let's grab from raw if possible or from aviso? + # In our logic above: "CONTENEDOR: {l_vehicle_num}" was added to textual description. + # Let's use compliance.container_ids or logistics.vehicle_num + c_num = logistics.vehicle_num if logistics and logistics.vehicle_num else "" + if c_num: field_5 = c_num + + # 6. Firma Electronica + firma_txt = aviso.firma_electronica + + # 7. Cantidad Comercial (Format @n015.3 -> 15 chars total, 3 decimals?) + # We need to sum quantities. + cant_total = 0.0 + + if items: + for item in items: + if item.lines: + for line in item.lines: + # Priority: Quantity (UMA or Standard) + q = 0.0 + if line.quantity: + try: + if line.quantity.quantity_uma is not None: + q = float(line.quantity.quantity_uma) + elif line.quantity.quantity is not None: + q = float(line.quantity.quantity) + except (ValueError, TypeError): + q = 0.0 + cant_total += q + + # Format: 15 chars, 3 decimals? Actually Clarion LINEPRINT usually just prints the text. + # Clarion 'CLIP(FORMAT(Loc:CantTotal,@n015.3))' removes spaces. + cant_total_str = f"{cant_total:.3f}" + + + + # 8. Valor Total Dlls + # Clarion: LINEPRINT(FORMAT(Loc:ValorTotalDlls,@n012)) -> Integer? Or just standard? + # Clarion @n012 usually means right justified or just specific length? + # Code says: Loc:ValorTotalDlls = GSQLFile3.SQL3:C1 + (rounding logic). + val_usd = 0.0 + if financials: + try: + # Use value_me (Foreign Currency) as primary source for USD amount + if financials.value_me is not None: + val_usd = float(financials.value_me) + elif financials.value_mn is not None: + # Fallback to MN if ME is missing (though technically incorrect for USD field, avoids crash) + val_usd = float(financials.value_mn) + except (ValueError, TypeError): + val_usd = 0.0 + + # Clarion logic: + # IF GSQLFile3.SQL3:C1 > 0 AND GSQLFile3.SQL3:C1 < 1 THEN + # Loc:ValorTotalDlls = GSQLFile3.SQL3:C1 + (1 - GSQLFile3.SQL3:C1) (Result is 1.0) + # ELSE ... ROUND(...,1) or Raw. + + final_val_usd = val_usd + if 0.0 < val_usd < 1.0: + final_val_usd = 1.0 + elif (val_usd - int(val_usd)) > 0 and (val_usd - int(val_usd)) < 0.5: + # Clarion: IF Loc:Decimal > 0 AND Loc:Decimal < 0.5 THEN Loc:ValorTotalDlls = ROUND(GSQLFile3.SQL3:C1,1) + # Round to 1 decimal place? Or standard round? Python round matches generally. + final_val_usd = round(val_usd, 1) + + val_usd_str = f"{final_val_usd:.2f}" + + # 9. Cant Embarques (Rail) + field_9 = "000000000000" + if is_rail_bar: + # Logic for Cant Embarques? + # Clarion: EqiFex:CantGuiasEmbarque + # usage unknown in current DB. Defaulting to 0. + pass + + # 10. NIU / DTA (Rail) + field_10 = "0000000000000" + if is_rail_bar: + # Clarion: EqiFex:NumeroNIU + if compliance and compliance.niu: + field_10 = compliance.niu + + # 11. Remesa (4 chars) + remesa_txt = str(compliance.remesa) if (compliance and compliance.remesa) else "0" + + # 12. Filler + field_12 = "00000000.000" + + # Construct Line Prints (Text content for barcode) + # Clarion LINEPRINT separates by NewLine? Or is it one long string? + # "Glo:GeneraTXT" is a file. LINEPRINT appends a line. + # So the Barcode Content is a multi-line string or specific format. + # PDF417 normally encodes the full text block. + + # 1. Patente (4 Digits) + # Formatted: @P####P -> 4 digits. + # Assuming simple string slice or pad. + patente_formatted = f"{patente_txt}".strip()[:4] + + # 2. Pedimento (7 Digits) + pedimento_formatted = f"{pedimento_num}".strip()[:7] + + # 3. Recinto (3 Digits Zero Padded @n03) + # Ensure it's numeric-like for zero padding or just string pad? + # Clarion FORMAT(Loc:Recinto,@n03) implies numeric. + try: + recinto_val = int(recinto_txt) + recinto_formatted = f"{recinto_val:03d}" + except: + recinto_formatted = "000" + + # 4. E-Document (Left aligned, clipped) + edoc_formatted = edoc_txt.strip() + + # 5. Container (13 chars?) or Rail Logic + # Clarion: IF Rail -> CLIP(Loc:NumContenedor) ELSE '0000000000000' + if is_rail_bar and field_5 and len(field_5) > 0: + field_5_formatted = field_5.strip() + else: + field_5_formatted = "0000000000000" + + # 6. Firma (Clipped) + firma_formatted = firma_txt.strip() + + # 7. Cantidad (FORMAT(Loc:CantTotal,@n015.3)) -> 15 chars, 3 decimals, Zero Padded? + # Python f"{val:015.3f}" produces 15 chars total (including dot) with zero padding. + cant_total_formatted = f"{cant_total:015.3f}" + + # 8. Valor USD (FORMAT(Loc:ValorTotalDlls,@n012)) -> 12 chars, Integer?, Zero Padded? + # If Clarion @n012 means Integer: + # But previously we calculated rounding. If it is integer, we cast to int. + # Clarion default doubles formatted with @n012 usually rounds to integer. + # Let's assume Integer Zero Padded for now based on @n012 (no decimal part). + val_usd_formatted = f"{int(final_val_usd):012d}" + + # 9. Cant Embarques (Rail) (FORMAT(...,@n012)) + field_9_formatted = "000000000000" + if is_rail_bar: + # If we had a value... assuming 0 generally. + pass + + # 10. NIU (Rail) (@s13 -> String 13 chars?) or DTA + # Clarion: LINEPRINT(CLIP(FORMAT(Loc:NumeroNIU,@s13)),Glo:GeneraTXT) + # CLIP removes spaces, FORMAT @s13 makes it string 13? + # Actually CLIP(FORMAT(...,@s13)) might just mean "The string value". + # The ELSE is '0000000000000' (13 chars). + field_10_formatted = "0000000000000" + if is_rail_bar and field_10 != "0000000000000": + field_10_formatted = field_10.strip() + + # 11. Remesa (FORMAT(...,@n04) -> 4 digits zero padded) + try: + remesa_val = int(remesa_txt) + remesa_formatted = f"{remesa_val:04d}" + except: + remesa_formatted = "0000" + + # 12. Filler / Appendix 17 + # Clarion Logic: + # IF TipoFactura = 'IMPOTEMP'/'EXPO'/'IMPODEF' ... IF Apendice17=1 -> '000000000003' ELSE '00000000.000' + # Default '00000000.000' + field_12_formatted = "00000000.000" + if compliance and compliance.appendix_17 == 1: + # Check Doc Type? Assuming broadly for now based on flag. + field_12_formatted = "000000000003" + + barcode_lines = [ + patente_formatted, + pedimento_formatted, + recinto_formatted, + edoc_formatted, + field_5_formatted, + firma_formatted, + cant_total_formatted, + val_usd_formatted, + field_9_formatted, + field_10_formatted, + remesa_formatted, + field_12_formatted + ] + + # Join with appropriate separator. Clarion LINEPRINT adds CR/LF (Windows). + barcode_content = "\r\n".join(barcode_lines) + + # Generate Image + codes = pdf417gen.encode(barcode_content, columns=14) + image = pdf417gen.render_image(codes, scale=5, padding=5) + + # Convert to B64 + buffered = BytesIO() + image.save(buffered, format="PNG") + img_str = base64.b64encode(buffered.getvalue()).decode("utf-8") + + aviso.codigo_barras_b64 = f"data:image/png;base64,{img_str}" + debug_log.append("SUCCESS: Barcode generated.") + + except Exception as e: + import traceback + error_msg = f"Error generando codigo de barras InvID={invoice_id}: {str(e)}\n{traceback.format_exc()}" + print(error_msg) + debug_log.append(f"ERROR: {error_msg}") + aviso.codigo_barras_b64 = None + + # Write Debug Log + try: + with open("/tmp/barcode_debug.log", "a") as f: + f.write("\n".join(debug_log) + "\n--------------------------------\n") + except Exception as e_log: + print(f"FAILED TO WRITE LOG: {e_log}") # 4. Agente Aduanal nombre_agente = "" diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html index 64a41575..d24ba325 100644 --- a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html @@ -218,7 +218,7 @@

CODIGO DE BARRAS

{% if aviso.codigo_barras_b64 %} - + {% else %}


{% endif %} diff --git a/backend/requirements.txt b/backend/requirements.txt index ff7a7cfa..31dc9561 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -46,4 +46,7 @@ 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 +flower==2.0.1 + +# Barcode +pdf417gen==0.8.1 \ No newline at end of file