Spaces:
Runtime error
Runtime error
| """ | |
| msproject.py — Lectura/escritura de cronogramas MS Project (MSPDI XML). | |
| Cambio estructural respecto al original: auditar_xml() ya NO escribe en | |
| st.session_state. Ahora devuelve un AuditResult (dataclass) con TODO lo que las | |
| capas superiores necesitan (df de tareas, bytes crudos, prefijo de namespace, | |
| horas/día, nombre del proyecto, calendario detectado). Esto vuelve el parseo | |
| reutilizable desde cualquier interfaz o test. La lógica de parseo/reescritura es | |
| idéntica a app_tesis_final_V3.py (incluido [AUD-11], el modo Manual). | |
| """ | |
| import re | |
| from dataclasses import dataclass, field | |
| from datetime import datetime, timedelta, time as dtime | |
| import xml.etree.ElementTree as ET | |
| import pandas as pd | |
| class AuditResult: | |
| """Resultado del parseo de un XML MSPDI, autocontenido (sin session_state).""" | |
| df: pd.DataFrame | |
| raw_bytes: bytes | |
| prefix: str | |
| hours_per_day: float | |
| project_name: str | |
| cal_dias: list | None = None | |
| cal_feriados: dict = field(default_factory=dict) | |
| def errores(self) -> pd.DataFrame: | |
| return self.df[self.df['Errores'] != 'OK'] | |
| def _to_bytes(file_or_bytes) -> bytes: | |
| """Acepta bytes o un objeto file-like y devuelve bytes.""" | |
| if isinstance(file_or_bytes, (bytes, bytearray)): | |
| return bytes(file_or_bytes) | |
| file_or_bytes.seek(0) | |
| return file_or_bytes.read() | |
| def extraer_calendario_xml(root, prefix): | |
| """Lee el calendario base del proyecto MSPDI: días laborables (weekday Python) y feriados.""" | |
| map_daytype = {1: 6, 2: 0, 3: 1, 4: 2, 5: 3, 6: 4, 7: 5} # DayType MSPDI (1=Dom..7=Sab) -> weekday() Py | |
| cal_uid_node = root.find(prefix + "CalendarUID") | |
| cal_default = cal_uid_node.text if cal_uid_node is not None else None | |
| dias_idx = set(); feriados = {} | |
| cals = root.find(prefix + "Calendars") | |
| if cals is None: | |
| return [0, 1, 2, 3, 4], {}, None | |
| elegido = None | |
| for cal in cals.findall(prefix + "Calendar"): | |
| uid = cal.findtext(prefix + "UID") | |
| if cal_default and uid == cal_default: | |
| elegido = cal; break | |
| if elegido is None and cal.findtext(prefix + "IsBaseCalendar") == "1": | |
| elegido = cal | |
| if elegido is None: | |
| elegido = cals.find(prefix + "Calendar") | |
| if elegido is None: | |
| return [0, 1, 2, 3, 4], {}, None | |
| wds = elegido.find(prefix + "WeekDays") | |
| if wds is not None: | |
| for wd in wds.findall(prefix + "WeekDay"): | |
| dt_node = wd.findtext(prefix + "DayType"); working = wd.findtext(prefix + "DayWorking") | |
| if dt_node: | |
| if working == "1": | |
| py = map_daytype.get(int(dt_node)) | |
| if py is not None: | |
| dias_idx.add(py) | |
| else: # excepción embebida (forma antigua) | |
| tp = wd.find(prefix + "TimePeriod") | |
| if tp is not None and working == "0": | |
| fd = tp.findtext(prefix + "FromDate"); td = tp.findtext(prefix + "ToDate") | |
| if fd: | |
| d0 = datetime.fromisoformat(fd).date(); d1 = datetime.fromisoformat(td).date() if td else d0 | |
| cur = d0 | |
| while cur <= d1: | |
| feriados[cur.strftime('%Y-%m-%d')] = True; cur += timedelta(days=1) | |
| exc = elegido.find(prefix + "Exceptions") | |
| if exc is not None: # excepciones forma nueva | |
| for e in exc.findall(prefix + "Exception"): | |
| if e.findtext(prefix + "DayWorking") == "0": | |
| tp = e.find(prefix + "TimePeriod") | |
| if tp is not None: | |
| fd = tp.findtext(prefix + "FromDate"); td = tp.findtext(prefix + "ToDate") | |
| if fd: | |
| d0 = datetime.fromisoformat(fd).date(); d1 = datetime.fromisoformat(td).date() if td else d0 | |
| cur = d0 | |
| while cur <= d1: | |
| feriados[cur.strftime('%Y-%m-%d')] = True; cur += timedelta(days=1) | |
| if not dias_idx: | |
| dias_idx = {0, 1, 2, 3, 4} | |
| return sorted(dias_idx), feriados, cal_default | |
| def auditar_xml(file_or_bytes) -> AuditResult: | |
| """Parsea el XML MSPDI y devuelve un AuditResult autocontenido.""" | |
| raw = _to_bytes(file_or_bytes) | |
| root = ET.fromstring(raw) | |
| prefix = root.tag.split("}")[0] + "}" if "}" in root.tag else "" | |
| title = root.find(prefix + "Title") | |
| project_name = title.text if (title is not None and title.text) else "Proyecto_Exportado" | |
| hours_per_day = 8.0 | |
| h_pd_node = root.find(prefix + "MinutesPerDay") | |
| if h_pd_node is not None and h_pd_node.text: | |
| try: | |
| hours_per_day = float(h_pd_node.text) / 60.0 | |
| except Exception: | |
| pass | |
| def parse_duration_days(dur_str): | |
| if not dur_str: | |
| return 0.0 | |
| match = re.search(r'PT(\d+)H', dur_str) | |
| if match: | |
| return float(match.group(1)) / hours_per_day | |
| return 0.0 | |
| def find_val(el, tag): | |
| x = el.find(prefix + tag) | |
| return x.text if x is not None else None | |
| tareas, uid_to_id, valid_ids = [], {}, [] | |
| for task in root.iter(prefix + 'Task'): | |
| uid = find_val(task, 'UID') | |
| row_id = find_val(task, 'ID') | |
| active = find_val(task, 'Active') | |
| summary = find_val(task, 'Summary') | |
| if uid and row_id: | |
| uid_to_id[uid] = row_id | |
| if active != '0' and summary == '0' and row_id: | |
| try: | |
| valid_ids.append(int(row_id)) | |
| except Exception: | |
| pass | |
| valid_ids.sort() | |
| for task in root.iter(prefix + 'Task'): | |
| active = find_val(task, 'Active') | |
| if active != '0': | |
| tid = int(find_val(task, 'ID') or 0) | |
| is_summary = (find_val(task, 'Summary') == '1') | |
| is_milestone = (find_val(task, 'Milestone') == '1') | |
| preds = [] | |
| pred_links = [] | |
| type_map = {'0': 'FF', '1': 'FS', '2': 'SF', '3': 'SS'} | |
| for link in task.findall(prefix + 'PredecessorLink'): | |
| p_uid = find_val(link, 'PredecessorUID') | |
| if p_uid: | |
| pid = uid_to_id.get(p_uid, p_uid) | |
| preds.append(pid) | |
| ltype = type_map.get(find_val(link, 'Type') or '1', 'FS') | |
| try: | |
| pred_links.append((int(pid), ltype)) | |
| except Exception: | |
| pass | |
| orig_preds = ", ".join(preds) | |
| errores = [] | |
| if not is_summary and not is_milestone: | |
| constraint = int(find_val(task, 'ConstraintType') or '0') | |
| if not preds and tid > 1 and constraint <= 1: | |
| prev = [x for x in valid_ids if x < tid] | |
| sug = prev[-1] if prev else "N/A" | |
| errores.append(f"Falta Predecesora (Sugerido ID {sug})") | |
| tareas.append({ | |
| 'ID': tid, 'Name': find_val(task, 'Name'), 'WBS': find_val(task, 'WBS'), | |
| 'Start_XML': find_val(task, 'Start'), 'Finish_XML': find_val(task, 'Finish'), | |
| 'Duration_Days': parse_duration_days(find_val(task, 'Duration')), | |
| 'IsSummary': is_summary, 'IsMilestone': is_milestone, | |
| 'OrigPreds': orig_preds, 'PredLinks': pred_links, | |
| 'Errores': " | ".join(errores) if errores else "OK" | |
| }) | |
| cal_dias, cal_feriados = None, {} | |
| try: | |
| cd, cf, _ = extraer_calendario_xml(root, prefix) | |
| cal_dias = cd | |
| cal_feriados = cf | |
| except Exception: | |
| cal_dias = None; cal_feriados = {} | |
| df = pd.DataFrame(tareas).sort_values('ID') | |
| return AuditResult( | |
| df=df, raw_bytes=raw, prefix=prefix, hours_per_day=hours_per_day, | |
| project_name=project_name, cal_dias=cal_dias, cal_feriados=cal_feriados, | |
| ) | |
| def generar_xml_ajustado(raw_bytes, prefix, df_final, hours_per_day): | |
| """Reescribe cada tarea del XML MSPDI con las fechas de CHRONOFLUX, preservando | |
| calendarios, dependencias y todo lo demás. [AUD-11] Modo Manual: las tareas hoja | |
| se marcan <Manual>1</Manual> para que Project respete las fechas sin recálculo. | |
| """ | |
| NS = "http://schemas.microsoft.com/project" | |
| root = ET.fromstring(raw_bytes) | |
| by_id = {int(r['ID']): r for _, r in df_final.iterrows()} | |
| def fmt(d, hh, mm=0): | |
| return datetime.combine(d, dtime(hh, mm)).strftime('%Y-%m-%dT%H:%M:%S') | |
| def as_date(v): | |
| if v is None or isinstance(v, float): | |
| return None | |
| return v if hasattr(v, 'year') else datetime.fromisoformat(str(v)).date() | |
| for task in root.iter(prefix + 'Task'): | |
| idtxt = task.findtext(prefix + 'ID') | |
| if idtxt is None: | |
| continue | |
| try: | |
| tid = int(idtxt) | |
| except (TypeError, ValueError): | |
| continue | |
| if tid not in by_id: | |
| continue | |
| r = by_id[tid] | |
| is_sum = (task.findtext(prefix + 'Summary') == '1') | |
| is_mile = (task.findtext(prefix + 'Milestone') == '1') | |
| def setext(tag, val): | |
| el = task.find(prefix + tag) | |
| if el is None: | |
| el = ET.SubElement(task, prefix + tag) | |
| el.text = val | |
| d_ini = as_date(r.get('Inicio Nuevo')) | |
| d_fin = as_date(r.get('Fin Nuevo')) | |
| if is_sum: | |
| continue | |
| setext('Manual', '1') | |
| start_iso = fmt(d_ini, 8, 0) if d_ini else None | |
| finish_iso = fmt(d_fin, 17, 0) if d_fin else None | |
| if is_mile: | |
| ref = finish_iso or start_iso | |
| if ref: | |
| setext('Start', ref); setext('Finish', ref) | |
| setext('ManualStart', ref); setext('ManualFinish', ref) | |
| setext('EarlyStart', ref); setext('EarlyFinish', ref) | |
| setext('LateStart', ref); setext('LateFinish', ref) | |
| setext('Duration', 'PT0H0M0S') | |
| setext('ManualDuration', 'PT0H0M0S') | |
| continue | |
| if start_iso: | |
| setext('Start', start_iso) | |
| setext('ManualStart', start_iso) | |
| setext('EarlyStart', start_iso) | |
| setext('LateStart', start_iso) | |
| if finish_iso: | |
| setext('Finish', finish_iso) | |
| setext('ManualFinish', finish_iso) | |
| setext('EarlyFinish', finish_iso) | |
| setext('LateFinish', finish_iso) | |
| try: | |
| dnv = float(r.get('Duración Nueva')) | |
| horas = int(round(dnv * hours_per_day)) | |
| dur_iso = f"PT{horas}H0M0S" | |
| setext('Duration', dur_iso) | |
| setext('ManualDuration', dur_iso) | |
| except (TypeError, ValueError): | |
| pass | |
| ET.register_namespace('', NS) | |
| try: | |
| fins = pd.to_datetime(df_final['Fin Nuevo'], errors='coerce').dropna() | |
| if len(fins): | |
| fd = root.find(prefix + 'FinishDate') | |
| if fd is not None: | |
| fd.text = pd.Timestamp(fins.max()).strftime('%Y-%m-%dT17:00:00') | |
| except Exception: | |
| pass | |
| return ET.tostring(root, encoding='UTF-8', xml_declaration=True) | |