Files
timesheet/Time.py
smolkik-code 9a39cfa2ef Create Time.py
2025-10-22 16:58:01 +07:00

315 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)