#!/usr/bin/env python3 # -*- coding: utf-8 -*- from typing import List, Tuple, Optional from decimal import Decimal, ROUND_HALF_UP, getcontext import sys # Точность для операций Decimal getcontext().prec = 28 # ------------ Ввод/форматирование ------------ def parse_total_minutes(s: str) -> int: """ Поддерживает форматы: - "8" (часы) - "7.5" (десятичные часы) - "7:30" (ЧЧ:ММ) Возвращает целые минуты. """ s = s.strip().replace(",", ".") if ":" in s: parts = s.split(":") if len(parts) != 2: raise ValueError("Неверный формат времени (ожидается ЧЧ:ММ).") h = int(parts[0]); m = int(parts[1]) if h < 0 or m < 0: raise ValueError("Часы и минуты не могут быть отрицательными.") return h * 60 + m hours = float(s) if hours < 0: raise ValueError("Часы не могут быть отрицательными.") return int(round(hours * 60)) def ask_nonempty(prompt: str, default: str = "") -> str: v = input(prompt).strip() return v if v else default def ask_int(prompt: str, min_value: int = None, max_value: int = None) -> int: while True: v = input(prompt).strip() try: x = int(v) if min_value is not None and x < min_value: print(f"Значение должно быть ≥ {min_value}") continue if max_value is not None and x > max_value: print(f"Значение должно быть ≤ {max_value}") continue return x except ValueError: print("Введите целое число.") def ask_float(prompt: str, min_value: float = None, max_value: float = None, default: float = None) -> float: while True: v = input(prompt).strip().replace(",", ".") if not v and default is not None: return default try: x = float(v) if min_value is not None and x < min_value: print(f"Значение должно быть ≥ {min_value}") continue if max_value is not None and x > max_value: print(f"Значение должно быть ≤ {max_value}") continue return x except ValueError: print("Введите число (можно с десятичной точкой).") def format_hhmm(minutes: int) -> str: h = minutes // 60 m = minutes % 60 return f"{h:02d}:{m:02d}" # ------------ Арифметика десятичных часов ------------ def minutes_to_decimal_hours_half_up(minutes: int, step: str) -> Decimal: """ Перевод минут в десятичные часы с округлением HALF_UP к заданному шагу: step = '0.01' -> сотые часа step = '0.1' -> десятые часа (0.1ч = 6 минут) """ d = Decimal(minutes) / Decimal(60) q = Decimal(step) return d.quantize(q, rounding=ROUND_HALF_UP) def balanced_decimal_hours(minutes_by_task: List[int], total_minutes: int, step: str = "0.01") -> List[str]: """ Балансированное округление в десятичные часы так, что сумма в выбранных сотых/десятых совпадает с общим временем. """ factor = 100 if step == "0.01" else 10 exact = [Decimal(m) / Decimal(60) for m in minutes_by_task] scaled = [x * factor for x in exact] # точные сотые/десятые base = [int(x) for x in scaled] # нижняя целая часть target = int(Decimal(total_minutes) / Decimal(60) * factor) remainder = target - sum(base) # Метод наибольших остатков по дробным частям order = sorted(range(len(scaled)), key=lambda i: (scaled[i] - base[i]), reverse=True) for i in range(max(0, remainder)): base[order[i]] += 1 # Форматированный вывод if factor == 100: return [f"{Decimal(v) / Decimal(100):.2f}" for v in base] else: return [f"{Decimal(v) / Decimal(10):.1f}" for v in base] # ------------ Распределение минут ------------ def distribute_by_weights(total_minutes: int, weights: List[float]) -> List[int]: """ Распределение целых минут методом наибольших остатков из точных долей. """ if total_minutes < 0: raise ValueError("total_minutes < 0") if not weights or any(w < 0 for w in weights): raise ValueError("Некорректные веса.") s = sum(weights) n = len(weights) if s == 0: weights = [1.0] * n s = float(n) exact = [total_minutes * (w / s) for w in weights] base = [int(x) for x in exact] allocated = sum(base) remainder = total_minutes - allocated # Раздать остаток по наибольшим дробным частям frac_with_idx = sorted(((i, exact[i] - base[i]) for i in range(n)), key=lambda t: t[1], reverse=True) i = 0 while remainder > 0 and i < n: idx = frac_with_idx[i][0] base[idx] += 1 remainder -= 1 i += 1 return base def distribute_quanta_tenths(total_minutes: int, weights: List[float]) -> List[int]: """ Распределение «квантов» по 0.1 часа (6 минут) методом наибольших остатков. Требует, чтобы total_minutes делилось на 6. """ step_min = 6 # 0.1 часа = 6 минут if total_minutes % step_min != 0: raise ValueError("Для режима десятых часа общее время должно делиться на 6 без остатка.") total_quanta = total_minutes // step_min # всего десятых (квантов) if not weights or any(w < 0 for w in weights): raise ValueError("Некорректные веса.") s = sum(weights) or float(len(weights)) quotas = [total_quanta * (w / s) for w in weights] base = [int(q) for q in quotas] allocated = sum(base) remainder = total_quanta - allocated # Метод наибольших остатков по дробным частям квантов order = sorted(range(len(weights)), key=lambda i: (quotas[i] - base[i]), reverse=True) for i in range(remainder): base[order[i]] += 1 # Переводим кванты обратно в минуты return [q * step_min for q in base] # ------------ Таблица ------------ def render_table(rows: List[Tuple[int, str, float, int, Optional[str]]], show_decimals: bool, dec_step: Optional[str]) -> str: """ rows: [(idx, name, percent, minutes, dec_str)] """ idx_w = max(len("#"), max((len(str(r[0])) for r in rows), default=1)) name_w = max(len("Задача"), max((len(r[1]) for r in rows), default=5)) perc_w = len("%") time_w = len("ЧЧ:ММ") min_w = len("Мин") header = f"{'#'.rjust(idx_w)} {'Задача'.ljust(name_w)} {'%'.rjust(perc_w)} {'ЧЧ:ММ'.rjust(time_w)} {'Мин'.rjust(min_w)}" if show_decimals and dec_step: dec_hdr = "Часы(" + dec_step + ")" dec_w = max(len(dec_hdr), max((len(r[4]) for r in rows if r[4] is not None), default=len(dec_hdr))) header += f" {dec_hdr.rjust(dec_w)}" sep = "-" * len(header) lines = [header, sep] if show_decimals and dec_step: dec_w = len(header) - len(f"{'#'.rjust(idx_w)} {'Задача'.ljust(name_w)} {'%'.rjust(perc_w)} {'ЧЧ:ММ'.rjust(time_w)} {'Мин'.rjust(min_w)} ") for idx, name, percent, minutes, dec_str in rows: lines.append( f"{str(idx).rjust(idx_w)} {name.ljust(name_w)} {percent:>5.1f} {format_hhmm(minutes):>{time_w}} {str(minutes).rjust(min_w)} {dec_str.rjust(dec_w) if dec_str else ''.rjust(dec_w)}" ) else: for idx, name, percent, minutes, _ in rows: lines.append( f"{str(idx).rjust(idx_w)} {name.ljust(name_w)} {percent:>5.1f} {format_hhmm(minutes):>{time_w}} {str(minutes).rjust(min_w)}" ) return "\n".join(lines) # ------------ Главная логика ------------ def main(): print("Распределение времени по задачам (консольная утилита)\n") # 1) Общее время while True: total_str = input("Сколько часов всего? (напр., 8 | 7.5 | 7:30): ").strip() try: total_minutes = parse_total_minutes(total_str) if total_minutes <= 0: print("Время должно быть > 0.") continue break except Exception as e: print(f"Ошибка: {e}") # 2) Количество задач n_tasks = ask_int("Сколько задач? (целое > 0): ", min_value=1) # 3) Режим распределения mode = "" while mode not in ("e", "w"): mode = input("Распределить поровну (e) или по весам/приоритетам (w)? [e/w]: ").strip().lower() or "e" names: List[str] = [] weights: List[float] = [] print() if mode == "e": print("Ввод названий задач (можно пусто — будет Task N).") for i in range(1, n_tasks + 1): name = ask_nonempty(f"Название задачи {i}: ", default=f"Task {i}") names.append(name) weights = [1.0] * n_tasks else: print("Ввод названий и весов (важности) задач; чем больше вес, тем больше времени.") for i in range(1, n_tasks + 1): name = ask_nonempty(f"Название задачи {i}: ", default=f"Task {i}") weight = ask_float(f"Вес/приоритет для «{name}» (по умолчанию 1): ", min_value=0.0, default=1.0) names.append(name) weights.append(weight) # 4) Выбор режима распределения минут quant_tenths = (input("Квантовать по 0.1 часа (6 мин) для точной суммы по десятым? [y/N]: ").strip().lower() or "n") == "y" used_quant_tenths = False if quant_tenths: try: minutes_by_task = distribute_quanta_tenths(total_minutes, weights) used_quant_tenths = True except Exception as e: print(f"Предупреждение: {e} — переключаюсь на распределение по минутам без квантования.") minutes_by_task = distribute_by_weights(total_minutes, weights) else: minutes_by_task = distribute_by_weights(total_minutes, weights) # 5) Настройка вывода десятичных часов show_dec = (input("Показывать десятичные часы? [y/N]: ").strip().lower() or "n") == "y" dec_step: Optional[str] = None if show_dec: choice = (input("Формат десятичных часов: 1) 0.01 (сотые), 2) 0.1 (десятые, 6 мин) [1/2, по умолчанию 1]: ").strip() or "1") dec_step = "0.01" if choice == "1" else "0.1" # 6) Готовим строки таблицы rows: List[Tuple[int, str, float, int, Optional[str]]] = [] for i, (name, mins) in enumerate(zip(names, minutes_by_task), start=1): percent = (mins / total_minutes) * 100 if total_minutes > 0 else 0.0 rows.append((i, name, percent, mins, None)) # 7) Балансированный вывод десятичных часов (если требуется) if show_dec and dec_step is not None: # Для любого режима распределения делаем балансировку отображения, # чтобы сумма десят. часов ровно совпала с общим временем в выбранном формате. dec_list = balanced_decimal_hours([r[3] for r in rows], total_minutes, step=dec_step) rows = [(r[0], r[1], r[2], r[3], dec_list[idx]) for idx, r in enumerate(rows)] print("\nИтоговая таблица:\n") print(render_table(rows, show_dec, dec_step)) # 8) Контрольные итоги sum_minutes = sum(r[3] for r in rows) print("\nИтоги:") print(f"- Всего: {format_hhmm(total_minutes)} ({total_minutes} мин)") print(f"- Сумма по задачам: {format_hhmm(sum_minutes)} ({sum_minutes} мин)") if show_dec and dec_step is not None: # Показать суммарные десятичные часы total_dec = minutes_to_decimal_hours_half_up(total_minutes, dec_step) sum_dec = (sum(Decimal(r[4]) for r in rows if r[4] is not None)) if rows and rows[0][4] is not None else total_dec if dec_step == "0.01": print(f"- Десятичные часы (сумма): {sum_dec:.2f} (из {total_dec:.2f})") else: print(f"- Десятичные часы (сумма): {sum_dec:.1f} (из {total_dec:.1f})") delta = sum_minutes - total_minutes if delta == 0: print("- Баланс минут: OK") else: sign = "+" if delta > 0 else "-" print(f"- Баланс минут: {sign}{abs(delta)} мин (проверьте веса/округление)") if __name__ == "__main__": try: main() except KeyboardInterrupt: print("\nПрервано пользователем.") sys.exit(130)