"""E8 · reglas (sin modelos) de cantidad, unidad y producto sobre el OBJETO del contrato, implementación 1. Mercados: carburantes (v3 = v2 de A1 + correcciones de su informe), electricidad (energía con unidad), papel A4 (cajas equivalentes de 2.500 hojas). Python puro (re). La 2.ª implementación (`impl2_e8.py`) reescribe todo esto aparte. Entradas: el objeto tal cual. Todas las funciones normalizan con `norm` (minúsculas, sin acentos).""" import re, sys, unicodedata import os for _d in ('/a1/scripts', '/precios/scripts'): if os.path.isdir(_d) and _d not in sys.path: sys.path.insert(0, _d); break from reglas_v2 import cantidad_principal, cantidades2, excluido_v2 # A1, sin tocar VERSION = 'e8-v1' def norm(t): t = (t or '').lower() t = ''.join(ch for ch in unicodedata.normalize('NFKD', t) if not unicodedata.combining(ch)) return t.replace('_', ' ') # «gasoleo b_centro», «a4_50»: el guion bajo separa palabras def num_es(s): """'1.234,5' → 1234,5; '1 234' → 1234; '3,5' → 3,5; '1,500' → 1500 (inglés, < 10 con coma decimal); '15,000' → 15; '2,000,000' → 2e6.""" s = s.strip() if re.fullmatch(r"\d{1,3}(?:\s\d{3})+", s): return float(s.replace(' ', '')) if re.fullmatch(r"\d{1,3}(?:\.\d{3})+(?:,\d+)?", s): return float(s.replace('.', '').replace(',', '.')) if re.fullmatch(r"\d{1,3}(?:,\d{3}){2,}", s): return float(s.replace(',', '')) m = re.fullmatch(r"(\d{1,3}),(\d{3})", s) if m and float(f"{m.group(1)}.{m.group(2)}") < 10: # coma de millares inglesa (regla de A1): «1,500» → 1.500 return float(m.group(1) + m.group(2)) return float(s.replace(',', '.')) # ============================================================ CARBURANTES (v3) ========================================= _GO = r"(?:gasoleo|gasoil|gas-oil|gasoli\b|gas oil)" _LET = lambda L: re.compile(_GO + r"\s*(?:tipo\s*|clase\s*)?[\"“'«]?" + L + r"\b") _LA, _LB, _LC = _LET('a'), _LET('b'), _LET('c') _C_CTX = re.compile(r"calefacc|calefaccio|caldera|colegio|\bceip\b|pabellon|poliesportiu|\bcalderas\b") _B_CTX = re.compile(r"agricol|bonificad") _A_CTX = re.compile(r"automocion|\bb7\b|vehicul|flota|parque movil|parc mobil") _NO_C = re.compile(r"vehicul|automocion") _GASOLINA = re.compile(r"gasolina|sin plomo|\bs/?plomo|\beuro ?super\b") _G98 = re.compile(r"gasolina\s*(?:sin plomo\s*)?98|sin plomo\s*98|\b98\s*octanos") _GASOLEO = re.compile(_GO + r"|diesel") _ADBLUE = re.compile(r"ad ?blue|urea") _LA_ESTRICTA = re.compile(_GO + r"\s*(?:tipo\s*|clase\s*)?[\"“'«]?a\b(?=\s*(?:$|[,.;:)(\"”»/\-]|y\b|e\b|para\b|de automocion|automocion|b7|\d))") _A_SUELTA = re.compile(_GO + r"\s*(?:tipo\s*)?[\"“'«]?a\b") def producto_carb(objeto): """gasoleo_a | gasoleo_b | gasoleo_c | gasolina95 | gasolina98 | mixto | sin_producto. Cambios sobre producto2 (A1 v2), los que pidió su informe: la letra B o C explícita manda sobre el contexto («gasóleo B de calefacción» → B); dos letras distintas (B, C o una A estricta: «gasóleo A», «gasóleo A y C») → mixto; AdBlue/urea → mixto; gasolina 98 aparte (sin referencia). Sin letra B/C, el orden de A1: contexto C (calefacción…) → C; contexto B → B; A (letra o contexto de automoción) → A; si no, sin_producto. La «a» suelta tras gasóleo puede ser preposición («gasóleo a los colegios»): por eso solo cuenta como letra para el «mixto» la A estricta.""" o = norm(objeto) bc = {k for k, rx in (('b', _LB), ('c', _LC)) if rx.search(o)} a_estricta = bool(_LA_ESTRICTA.search(o)) gl, go = bool(_GASOLINA.search(o)), bool(_GASOLEO.search(o)) if _ADBLUE.search(o) or len(bc) > 1 or (bc and a_estricta) or (gl and go): return 'mixto' if bc: return 'gasoleo_b' if 'b' in bc else 'gasoleo_c' if gl: return 'gasolina98' if _G98.search(o) else 'gasolina95' if _C_CTX.search(o) and not _NO_C.search(o) and not a_estricta: return 'gasoleo_c' if _B_CTX.search(o): return 'gasoleo_b' if a_estricta or _A_SUELTA.search(o) or _A_CTX.search(o): return 'gasoleo_a' return 'sin_producto' def cantidad_carb(objeto): """(litros, literal, n_distintas) o None: primera cantidad en litros que no es capacidad (reglas_v2 de A1).""" return cantidad_principal(objeto, 'litros') # ============================================================ ELECTRICIDAD ============================================= _NUM = r"(\d{1,3}(?:[.\s]\d{3})+(?:,\d+)?|\d{1,3}(?:,\d{3}){1,}(?![\d,])|\d+(?:[.,]\d+)?)" _EN = re.compile(_NUM + r"\s*(gwh|mwh|kwh|kw\s?/\s?h|kw\s?h|kilovatios?\s?hora|megavatios?\s?hora|gigavatios?\s?hora)(?![a-z])") _EN_FACTOR = {'g': 1e6, 'm': 1e3, 'k': 1.0} _UMBRAL_ANTES = re.compile(r"(superior|inferior|mayor|menor|igual|hasta|mas de|menos de|>|<|limite|capacidad|almacenamiento|" r"bateria|maximo|minimo|entre|superiores|inferiores|iguales)") _ANUAL_DESPUES = re.compile(r"^\s*(?:/\s*(?:ano|any|a)\b|al ano|al any|anual|anuales|anuals|ano\b|any\b|por ano|/ano)") _EXCL_ELEC = re.compile(r"certificad|certificats|garanties d.origen|bateria|almacenamiento|autoconsumo|fotovolt|" r"recarga|punto de recarga|generador") def energia_elec(objeto): """dict(kwh, anual: bool, literal, n_distintas, motivo) o dict(motivo=...) sin cantidad. Sin modelos. Cantidad = número + kWh/MWh/GWh que no sea umbral («superior a 1.000.000 kWh») ni capacidad; si el lote es de gas natural, fuera; si hay varias cantidades distintas, fuera («varias_cantidades»).""" o = norm(objeto) lote = o.split(' — ', 1)[1] if ' — ' in o else '' if 'gas natural' in lote or (('gas natural' in o or re.search(r"(? 1: return dict(motivo='varias_cantidades', n_distintas=len(dist)) k, a, lit = cands[0] return dict(motivo='ok', kwh=k, anual=a, literal=lit, n_distintas=1) BANDAS = [(20, 'MWH_LT20'), (500, 'MWH20-499'), (2000, 'MWH500-1999'), (20000, 'MWH2000-19999'), (70000, 'MWH20000-69999'), (150000, 'MWH70000-149999'), (float('inf'), 'MWH_GE150000')] def banda_eurostat(mwh_anual): for lim, b in BANDAS: if mwh_anual < lim: return b # ============================================================ PAPEL A4 ================================================= # Unidad: «caja equivalente» = 2.500 hojas (la caja del PCAP de los AM 20/20xx: 5 paquetes de 500 hojas). _CAJA = r"(?:cajas?|caixes|caixa|capses|capsa)" _PAQ = r"(?:paquetes?|paquets?|paq\.?|pqt\.?|resmas?|packs?)" _HOJA = r"(?:folios?|folis|hojas?|fulls?|follas?)" _UNI = r"(?:unidades|unidad|uds?\.?|u\.)" _N = r"(\d{1,3}(?:\.\d{3})+|\d+(?:,\d+)?)" _RX_CAJA = re.compile(r"(?= 7 def papel(objeto): """dict(motivo, cajas_eq, hojas, fibra, gramaje, formato_explicito, literal). Sin modelos. motivo ∈ ok | excluido_articulo | formato_no_a4 | gramaje_especial | fibra_mixta | otros_articulos | suma | sin_cantidad | unidad_ambigua | varias_cantidades.""" o = norm(objeto) if _EXCL_PAPEL.search(o): return dict(motivo='excluido_articulo') if _FORMATO_OTRO.search(o): return dict(motivo='formato_no_a4') gr = set() for m in _GRAM_RANGO.finditer(o): gr |= {int(m.group(1)), int(m.group(2))} for m in _GRAM.finditer(o): gr.add(int(m.group(1))) if (gr - {75, 80}) or len(gr) > 1: return dict(motivo='gramaje_especial') gramaje = gr.pop() if gr else 80 rec, vir = bool(_RECIC.search(o)), bool(_VIRGEN.search(o)) if (rec and vir) or _MEZCLA_FIBRA.search(o): return dict(motivo='fibra_mixta') fibra = 'reciclado' if rec else ('virgen' if vir else 'sin_fibra') for m in _NUM_PALABRA.finditer(o): # «3 percheros», «12 rotuladores»: otro artículo en el mismo contrato n, sep, w = m.group(1), m.group(2), m.group(3) if re.fullmatch(r"(19|20)\d\d", n): continue # años if not sep: continue # códigos pegados («2300cr20sum»); «80gr», «500h» ya son unidades if re.search(r"(lote|n\.?o?|num|expte?|exp|cm|ref)\.?\s*$", o[max(0, m.start() - 7):m.start()]): continue if re.search(r"(\ba|din|dina)\s*-?\s*(a\s*-?\s*)?$", o[max(0, m.start() - 7):m.start()]): continue # el «4» de «A4», «DIN-4», «din a 4» if not _PALABRA_OK.match(w): return dict(motivo='otros_articulos', palabra=w) if re.search(r"\d\s*\+\s*\d", o): return dict(motivo='suma') hp, hc = _hojas_por_paquete(o), _hojas_por_caja(o) tam = set() for rx in (_RX_CAJA_CONT_PAQ, _RX_CAJA_CONT_HOJ, _RX_PAQ_CONT, _RX_PAQ_POR_CAJA, _RX_CAJA_DE_N_PAQ, _RX_5X500): for m in rx.finditer(o): tam.add(m.start(1)) cand = [] # (hojas, literal) for rx, por in ((_RX_CAJA, hc), (_RX_PAQ, hp)): for m in rx.finditer(o): if m.start(1) in tam or _es_codigo(m.group(1)): continue cand.append((num_es(m.group(1)) * por, m.group(0))) for m in _RX_UDS_DE.finditer(o): if _es_codigo(m.group(1)): continue u = m.group(2) cand.append((num_es(m.group(1)) * (hc if u.startswith(('caj', 'caix')) else hp), m.group(0))) if not cand: hojas = [(num_es(m.group(1)), m.group(0)) for m in _RX_HOJA.finditer(o) if m.start(1) not in tam and not _es_codigo(m.group(1)) and num_es(m.group(1)) >= 1000] if hojas: cand = hojas else: uds = [m for m in _RX_UDS.finditer(o) if m.start(1) not in tam and not _es_codigo(m.group(1))] env = re.search(r"(paquete|paquet|resma|pq|caja|caixa)?\s*(?:de\s*)?\b(500|2\.?500)\s*(?:hojas|folios|fulls|folis|h\b)", o) if len(uds) == 1 and env: per = 2500 if (env.group(1) or '').startswith(('caj', 'caix')) or env.group(2) != '500' else 500 cand = [(num_es(uds[0].group(1)) * per, uds[0].group(0))] elif uds: return dict(motivo='unidad_ambigua') else: return dict(motivo='sin_cantidad') dist = {round(c[0], 3) for c in cand} if len(dist) > 1: return dict(motivo='varias_cantidades', n_distintas=len(dist)) hojas, lit = cand[0] if hojas <= 0: return dict(motivo='sin_cantidad') return dict(motivo='ok', hojas=hojas, cajas_eq=hojas / 2500.0, fibra=fibra, gramaje=gramaje, formato_explicito=bool(_A4.search(o)), literal=lit)