315 lines
14 KiB
Python
315 lines
14 KiB
Python
#!/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)
|