import pdfplumber import os import re import unicodedata def _sin_tildes(texto: str) -> str: """Quita tildes/diacríticos para comparaciones tolerantes a variaciones de formato.""" return "".join(c for c in unicodedata.normalize("NFKD", texto) if not unicodedata.combining(c)) TIPO_MAP = { "FACTURA": "01", "BOLETA": "03", "GUIA DE REMISION": "09", "GUIA DE REMISION TRANSPORTISTA": "31", } # ============================================================================ # HELPERS INTERNOS (desde leer_pdf.py) # ============================================================================ def _extract_name_ruc(text_input: str) -> tuple[str | None, str | None]: if not text_input: return None, None full_s = " ".join(text_input.split()).strip() name, ruc = full_s, None if " - " in full_s: name = full_s.split(" - ")[0].strip() r_match = re.search(r"(\d{11})", full_s) if r_match: ruc = r_match.group(1) return name, ruc # ============================================================================ def _extract_base_data(text: str, pdf_path: str) -> dict: basename = os.path.basename(pdf_path) data = { "archivo": basename, "ruc_emisor": None, "tipo_documento": None, "serie_numero": None, "fecha_emision": None, "ruc_receptor": None, "nombre_receptor": None, "monto_total": None, } ruc_match = re.search(r"R\.U\.C\.?\s*(\d{11})", text, re.IGNORECASE) if ruc_match: data["ruc_emisor"] = ruc_match.group(1) else: if "-" in basename: parts = basename.split('-') if len(parts[0]) == 11 and parts[0].isdigit(): data["ruc_emisor"] = parts[0] if not data["ruc_emisor"]: standalone_ruc = re.search(r"(\d{11})", text) if standalone_ruc: data["ruc_emisor"] = standalone_ruc.group(1) text_norm = _sin_tildes(text.upper()) if "FACTURA ELECTRONICA" in text_norm: data["tipo_documento"] = "FACTURA" elif "BOLETA DE VENTA" in text_norm: data["tipo_documento"] = "BOLETA" elif "TRANSPORTISTA" in text_norm and "GUIA DE REMISION" in text_norm: data["tipo_documento"] = "GUIA DE REMISION TRANSPORTISTA" elif "GUIA DE REMISION" in text_norm or "GRRE:" in text_norm: data["tipo_documento"] = "GUIA DE REMISION" serie_numero_match = re.search(r"(?:N°\s*)?([A-Z0-9]{4})\s*-\s*(\d+)", text) if serie_numero_match: data["serie_numero"] = f"{serie_numero_match.group(1)}-{serie_numero_match.group(2)}" elif "GRRE:" in text: grre_match = re.search(r"GRRE:\s*([A-Z0-9-]{4,})", text) if grre_match: data["serie_numero"] = grre_match.group(1) date_match = re.search(r"(\d{2})\s*[/-]\s*(\d{2})\s*[/-]\s*(\d{4})", text) if date_match: data["fecha_emision"] = f"{date_match.group(1)}/{date_match.group(2)}/{date_match.group(3)}" ruc_label_match = re.search(r"(?:Ruc|REGISTRO ÚNICO DE CONTRIBUYENTES N°)\s*:?\s*(\d{11})", text, re.IGNORECASE) if ruc_label_match and ruc_label_match.group(1) != data["ruc_emisor"]: data["ruc_receptor"] = ruc_label_match.group(1) else: all_rucs = re.findall(r"(? 2: data["nombre_receptor"] = name break return data # ============================================================================ def _extract_factura_details(text: str, data: dict) -> None: amount_regex = r"((?:\d{1,3}(?:,\d{3})*|\d+)\.\d{2})" total_patterns = [ rf"TOTAL\s*(?:S/|S/\.)?[:\s]*{amount_regex}", rf"IMPORTE TOTAL\s*(?:S/|S/\.)?[:\s]*{amount_regex}", rf"NETO A PAGAR\s*(?:S/|S/\.)?[:\s]*{amount_regex}", ] total_found = False for pattern in total_patterns: match = re.search(pattern, text, re.IGNORECASE) if match: data["monto_total"] = match.group(1).replace(",", "") total_found = True break if not total_found: amounts = re.findall(amount_regex, text) if amounts: data["monto_total"] = amounts[-1].replace(",", "") data.update({ "items": [], "detraccion": {"tiene": False, "porcentaje": None, "monto": None}, }) lines = text.split('\n') table_start_index = -1 for i, line in enumerate(lines): line_up = line.upper() headers_found = sum(1 for h in ["CANT", "DESCRIP", "PRECIO", "TOTAL", "UNIT", "VALOR", "CÓDIGO", "ITEM"] if h in line_up) if headers_found >= 3: table_start_index = i break if table_start_index != -1: items_lines = lines[table_start_index + 1:] current_item = None pre_item_buffer = [] for line in items_lines: line = line.strip() if not line: continue if re.search(r"^(?:SUB|TOTAL|IGV|SON:|OP\.|RES|CUO|DET|BANCO|Representación|Podrá|Autorizado|FORMA|CUOTA|GRAVADO|I\.G\.V\.|DESEMBOLSO|OBSERVACION)", line, re.IGNORECASE): break amounts = re.findall(r"(?= 2: if current_item: data["items"].append(current_item) qty = amounts[0] p_unit = amounts[-2].replace(",", "") total = amounts[-1].replace(",", "") desc_line = line seen = set() for amt in amounts: if amt not in seen: desc_line = desc_line.replace(amt, "", 1) seen.add(amt) desc_parts = [p.strip() for p in desc_line.split() if p.strip()] cod = None for j in range(min(3, len(desc_parts))): if re.search(r"[A-Z].*\d|\d.*[A-Z]|[A-Z]{2,}-\d+", desc_parts[j]): cod = desc_parts[j] desc_parts.pop(j) break if desc_parts and desc_parts[0].isdigit() and len(desc_parts[0]) <= 2: desc_parts.pop(0) descripcion = " ".join(desc_parts) current_item = { "codigo": cod, "cantidad": qty, "descripcion": descripcion, "precio_unitario": p_unit, "total": total, "guias_remision": [], "guia_transportista": None, "bolsas": None, "peso": None, "unidad_peso": None, } if pre_item_buffer: current_item["descripcion"] = " ".join(pre_item_buffer) + " " + descripcion pre_item_buffer = [] elif current_item and len(line) > 5: current_item["descripcion"] += " " + line elif not current_item and len(line) > 5: pre_item_buffer.append(line) if current_item: data["items"].append(current_item) # Extraer guías de cada ítem usando los mismos patrones que sunat_schema GUIAS_PDF_PATTERNS = [ (r"(?:GRE\s+REMITENTE|GRR|G\.R\.R)\s*[:.]?\s*([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", "09"), (r"\b(?:GRT|G\.R\.T)\s*[:.]?\s*([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", "31"), ] for item in data["items"]: desc = item["descripcion"] for patron, tipo in GUIAS_PDF_PATTERNS: for m in re.finditer(patron, desc, re.IGNORECASE): numero = m.group(2).lstrip("0") or "0" if tipo == "09": item["guias_remision"].append(f"{m.group(1).upper()}-{numero}") else: item["guia_transportista"] = f"{m.group(1).upper()}-{numero}" bolsas_match = re.search(r"(\d+)\s*(?:BOLSAS|BLS|BOLS|SACOS)\b", desc, re.IGNORECASE) if bolsas_match: item["bolsas"] = bolsas_match.group(1) peso_match = re.search(r"(?:PESO(?: TOTAL)?|CARGA)\s*(?:(TN|KG|KILOS?))?\.?\s*[:\s]\s*([\d,.]+)\s*(TN|KG|KILOS?)?", desc, re.IGNORECASE) if peso_match: item["peso"] = peso_match.group(2).strip() item["unidad_peso"] = (peso_match.group(1) or peso_match.group(3) or "").upper() det = data["detraccion"] if re.search(r"\bDETRACC(?:I[OÓ]N|\.)", text, re.IGNORECASE): det["tiene"] = True pct_match = re.search(r"PORCENTAJE\s*[:/]\s*([\d.]+)\s*%", text, re.IGNORECASE) if not pct_match: pct_match = re.search(r"DETRACC[^\n]*?([\d]+(?:\.\d+)?)\s*%", text, re.IGNORECASE) if pct_match: det["porcentaje"] = pct_match.group(1) monto_match = re.search( r"(?:MONTO\s*(?:DETRACC\.?|DE\s*DETRACCI[OÓ]N)|Monto\s*Detracc\.?)\s*[:/]?\s*S/\.?\s*([\d,.]+)", text, re.IGNORECASE, ) if not monto_match: monto_match = re.search(r"MONTO\s*DETRACC\w*[:\s]+S?/?\.?\s*([\d,.]+)", text, re.IGNORECASE) if not monto_match: monto_match = re.search(r"DETRACCION\s+\d+%\s+([\d.]+)", text, re.IGNORECASE) if monto_match: raw = monto_match.group(1).replace(",", "") try: det["monto"] = f"{float(raw):.2f}" except ValueError: det["monto"] = raw # ============================================================================ def _extract_guia_remision_details(text: str, data: dict) -> None: data.update({ "peso_total": None, "punto_partida": None, "punto_llegada": None, "placa": None, "marca": None, "chofer": None, "brevete": None, "configuracion_vehicular": None, "items": [], }) peso_match = re.search(r"Peso Total (?:TN|KG)?:\s*([\d,.]+)", text, re.IGNORECASE) if not peso_match: peso_match = re.search(r"Peso Total:\s*([\d,.]+)", text, re.IGNORECASE) if peso_match: data["peso_total"] = peso_match.group(1) conf = re.search(r"(?:Configuración vehicular|C\. Vehc\.?):\s*([A-Z0-9/]+)", text, re.IGNORECASE) if conf: data["configuracion_vehicular"] = conf.group(1).strip() for field, pattern in { "placa": r"Placa:\s*([A-Z0-9\s/]+?)(?=\s*(?:C\.|Marca|Peso|$))", "marca": r"Marca:\s*([A-Z0-9\s-]+?)(?=\s*(?:Placa|Chofer|Peso|$))", "chofer": r"Chofer:\s*([A-Z\s.-]+?)(?=\s*(?:Brevete|Placa|Peso|$))", "brevete": r"Brevete:\s*([A-Z0-9]+)", }.items(): match = re.search(pattern, text, re.IGNORECASE) if match: data[field] = match.group(1).strip() p_match = re.search(r"Punto de Partida:\s*(.*?)\s*Punto de Llegada:", text, re.DOTALL | re.IGNORECASE) if p_match: data["punto_partida"] = " ".join(p_match.group(1).split()).strip() l_match = re.search(r"Punto de Llegada:\s*(.*?)\s*(?:Fecha de inicio|T[0-9]{3}-|$)", text, re.DOTALL | re.IGNORECASE) if l_match: data["punto_llegada"] = " ".join(l_match.group(1).split()).strip() header = re.search(r"(?:CANT|CANTIDAD)\s+(?:UN|UNIDAD)\s+(?:DESCRIPCIÓN|DESCRIPCION|DETALLE)", text, re.IGNORECASE) if header: items_text = text[header.end():].split("Peso Aprox")[0] current_item = None for line in items_text.split('\n'): line = line.strip() if not line or re.search(r"^(?:Peso Aprox|Punto de|Fecha de|Representación|Placa|Chofer|C\. Vehc)", line, re.IGNORECASE): continue match = re.match(r"^(\d+(?:\.\d+)?)\s+([A-Z]{2,3})\s+(.+)", line) if match: if current_item: data["items"].append(current_item) current_item = {"cantidad": match.group(1), "unidad": match.group(2), "descripcion": match.group(3).strip()} elif current_item: current_item["descripcion"] += " " + line if current_item: data["items"].append(current_item) # ============================================================================ def _extract_guia_transportista_details(text: str, data: dict) -> None: data.update({ "punto_partida": None, "punto_llegada": None, "nombre_remitente": None, "ruc_remitente": None, "nombre_destinatario": None, "ruc_destinatario": None, "documentos_relacionados": [], "unidad_medida_peso_bruto": None, "peso_bruto_total_carga": None, "indicador_pagador_flete": None, "nombre_pagador": None, "ruc_pagador": None, "datos_vehiculos": [], "datos_conductores": [], "observaciones": None, }) p_match = re.search(r"Punto de Partida:\s*(.*?)\s*Punto de llegada:", text, re.DOTALL | re.IGNORECASE) if p_match: data["punto_partida"] = " ".join(p_match.group(1).split()).strip() l_match = re.search(r"Punto de llegada:\s*(.*?)\s*Datos del remitente:", text, re.DOTALL | re.IGNORECASE) if l_match: data["punto_llegada"] = " ".join(l_match.group(1).split()).strip() rem_match = re.search(r"Datos del remitente:\s*(.*?)\s*Datos del destinatario:", text, re.DOTALL | re.IGNORECASE) if rem_match: data["nombre_remitente"], data["ruc_remitente"] = _extract_name_ruc(rem_match.group(1)) dest_match = re.search(r"Datos del destinatario:\s*(.*?)\s*Documentos Relacionados:", text, re.DOTALL | re.IGNORECASE) if dest_match: data["nombre_destinatario"], data["ruc_destinatario"] = _extract_name_ruc(dest_match.group(1)) pag_match = re.search(r"Datos de pagador de flete:\s*(.*?)\s*(?:Observaciones|Esta es|$)", text, re.DOTALL | re.IGNORECASE) if pag_match: data["nombre_pagador"], data["ruc_pagador"] = _extract_name_ruc(pag_match.group(1)) rel_match = re.search(r"Documentos Relacionados:\s*(.*?)\s*(?:Bienes por Transportar|Datos de|$)", text, re.DOTALL | re.IGNORECASE) if rel_match: for line in rel_match.group(1).split('\n'): line = line.strip() if not line: continue doc = re.search(r"(?:N°\s*)([A-Z0-9\s-]+)- RUC N°\s*(\d{11})", line, re.IGNORECASE) data["documentos_relacionados"].append({"documento": doc.group(1).strip(), "ruc_relacionado": doc.group(2)} if doc else line) u_match = re.search(r"Unidad de Medida del Peso Bruto:\s*(\w+)", text, re.IGNORECASE) if u_match: data["unidad_medida_peso_bruto"] = u_match.group(1) p_match = re.search(r"Peso Bruto total de la carga:\s*([\d,.]+)", text, re.IGNORECASE) if p_match: data["peso_bruto_total_carga"] = p_match.group(1) ind_match = re.search(r"Indicador del pagador del flete:\s*(\w+)", text, re.IGNORECASE) if ind_match: data["indicador_pagador_flete"] = ind_match.group(1) v_match = re.search(r"Datos de los vehículos:\s*(.*?)\s*Datos de los conductores:", text, re.DOTALL | re.IGNORECASE) if v_match: data["datos_vehiculos"] = [{"placa": p} for p in re.findall(r"Número de placa:\s*([A-Z0-9]+)", v_match.group(1), re.IGNORECASE)] c_match = re.search(r"Datos de los conductores:\s*(.*?)\s*(?:Datos de pagador|Observaciones|$)", text, re.DOTALL | re.IGNORECASE) if c_match: c_text = c_match.group(1) data["datos_conductores"] = [{"nombre": n.strip()} for n in re.findall(r"Principal:\s*([A-Z\s-]+)(?:\s*-|$)", c_text, re.IGNORECASE)] for i, b in enumerate(re.findall(r"Número de lincencia de conducir:\s*([A-Z0-9]+)", c_text, re.IGNORECASE)): if i < len(data["datos_conductores"]): data["datos_conductores"][i]["brevete"] = b obs = re.search(r"Observaciones\s*:\s*(.*?)(?=\s*(?:Esta es|$))", text, re.IGNORECASE | re.DOTALL) if obs: data["observaciones"] = obs.group(1).strip() # ============================================================================ def _extract_detraccion_spatial(pdf, data: dict) -> None: det = data.get("detraccion", {}) if not det.get("tiene"): return if det.get("porcentaje") and det.get("monto"): return for page in pdf.pages: words = page.extract_words() if not words: continue det_words = [w for w in words if re.search(r"Detrac", w["text"], re.IGNORECASE)] if not det_words: continue region_text = None for rect in (page.rects or []): for dw in det_words: if (rect["x0"] <= dw["x0"] <= rect["x1"] and rect["top"] <= dw["top"] <= rect["bottom"]): cropped = page.crop((rect["x0"], rect["top"], rect["x1"], rect["bottom"])) region_text = cropped.extract_text() or "" break if region_text: break if not region_text and det_words: dw = det_words[0] cropped = page.crop((0, max(0, dw["top"] - 30), page.width, min(page.height, dw["bottom"] + 80))) region_text = cropped.extract_text() or "" if not region_text: continue if not det.get("porcentaje"): pct = re.search(r"(\d+(?:\.\d+)?)\s*%\s*Detrac", region_text, re.IGNORECASE) if not pct: pct = re.search(r"PORCENTAJE\s*[:/]\s*(\d+(?:\.\d+)?)\s*%", region_text, re.IGNORECASE) if pct: try: det["porcentaje"] = f"{float(pct.group(1)):.2f}" except ValueError: det["porcentaje"] = pct.group(1) if not det.get("monto"): monto = re.search(r"Detrac[^:]*:\s*([\d,.]+)", region_text, re.IGNORECASE) if not monto: monto = re.search(r"MONTO[^:]*:\s*S?/?\.?\s*([\d,.]+)", region_text, re.IGNORECASE) if monto: raw = monto.group(1).replace(",", "") try: det["monto"] = f"{float(raw):.2f}" except ValueError: det["monto"] = raw break # ============================================================================ # FUNCIONES PÚBLICAS # ============================================================================ def extraer_datos_pdf(pdf_path: str) -> dict: try: with pdfplumber.open(pdf_path) as pdf: text = "".join(page.extract_text() or "" for page in pdf.pages) data = _extract_base_data(text, pdf_path) if data["tipo_documento"] == "FACTURA": _extract_factura_details(text, data) _extract_detraccion_spatial(pdf, data) elif data["tipo_documento"] == "GUIA DE REMISION": _extract_guia_remision_details(text, data) elif data["tipo_documento"] == "GUIA DE REMISION TRANSPORTISTA": _extract_guia_transportista_details(text, data) return data except Exception as e: return {"archivo": os.path.basename(pdf_path), "error": str(e)} # ============================================================================ def generar_nombre_pdf(data: dict) -> str | None: ruc = data.get("ruc_emisor") tipo_doc = data.get("tipo_documento") serie_numero = data.get("serie_numero") if not ruc or not tipo_doc or not serie_numero: return None abrev = TIPO_MAP.get(tipo_doc.upper()) if not abrev: return None if "-" in serie_numero: serie, numero = serie_numero.split("-", 1) numero = numero.lstrip("0") or "0" else: serie = serie_numero numero = "" return f"{ruc}-{abrev}-{serie}-{numero}.pdf" # ============================================================================ def renombrar_pdf(pdf_path: str, data: dict) -> str: dirname = os.path.dirname(pdf_path) nuevo_nombre = generar_nombre_pdf(data) if not nuevo_nombre: return pdf_path nuevo_path = os.path.join(dirname, nuevo_nombre) if pdf_path == nuevo_path: return pdf_path try: os.rename(pdf_path, nuevo_path) return nuevo_path except OSError: return pdf_path # ============================================================================ def _extraer_serie_numero(texto: str) -> tuple[str, str] | None: m = re.search(r'\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b', texto) if m: return m.group(1).upper(), m.group(2) return None def extraer_referencias(items: list) -> list: referencias = [] vistos = set() for item in items: for gr_str in item.get("guias_remision", []): parsed = _extraer_serie_numero(gr_str) if not parsed: partes = gr_str.split("-", 1) if len(partes) == 2: parsed = (partes[0].strip(), partes[1].strip()) else: continue serie, numero = parsed clave = f"09|{serie}|{numero}" if clave not in vistos: vistos.add(clave) referencias.append({"tipo": "09", "serie": serie, "numero": numero}) gt_str = item.get("guia_transportista") if gt_str: parsed = _extraer_serie_numero(gt_str) if not parsed: partes = gt_str.split("-", 1) if len(partes) == 2: parsed = (partes[0].strip(), partes[1].strip()) else: continue serie, numero = parsed clave = f"31|{serie}|{numero}" if clave not in vistos: vistos.add(clave) referencias.append({"tipo": "31", "serie": serie, "numero": numero}) return referencias