"""Бизнес-логика: смены, расписание запуска, создание и ход выполнения нарядов.""" import calendar import logging import math from datetime import datetime, date, time, timedelta from sqlalchemy import select, or_ from sqlalchemy.orm import Session from . import config from .models import (ShiftSchedule, User, OrderTemplate, Order, OrderItem, Checklist, Equipment) log = logging.getLogger("logic") # ---------- графики / смены ---------- def _span(start: time, end: time) -> timedelta: length = (datetime.combine(date.min, end) - datetime.combine(date.min, start)) % timedelta(days=1) return length or timedelta(days=1) # start == end -> целые сутки def shift_starting(s: ShiftSchedule, d: date): """Смена, НАЧИНАЮЩАЯСЯ в день d: (начало, конец, подпись) либо None.""" if s.kind == "pattern": steps = s.pattern or [] total = sum(max(int(x.get("days", 1)), 1) for x in steps) if not total: return None n = (d - (s.cycle_start or date(2020, 1, 1))).days % total for x in steps: n -= max(int(x.get("days", 1)), 1) if n < 0: if x.get("type") != "work": return None st, en = _t(x["start_time"]), _t(x["end_time"]) begin = datetime.combine(d, st) return begin, begin + _span(st, en), x.get("label", "") return None if s.kind == "cycle": cycle = max(s.work_days + s.off_days, 1) ok = (d - (s.cycle_start or date(2020, 1, 1))).days % cycle < s.work_days else: ok = d.weekday() in (s.weekdays or []) if not ok: return None begin = datetime.combine(d, s.start_time) return begin, begin + _span(s.start_time, s.end_time), "" def _t(v) -> time: return v if isinstance(v, time) else time.fromisoformat(v) def works_on(s: ShiftSchedule, d: date) -> bool: return shift_starting(s, d) is not None def shift_active(s: ShiftSchedule | None, dt: datetime) -> bool: if s is None: return True # нет графика — всегда на смене for d in (dt.date(), dt.date() - timedelta(days=1)): # вчерашняя смена могла перейти через полночь sh = shift_starting(s, d) if sh and sh[0] <= dt < sh[1]: return True return False def user_on_shift(u: User, dt: datetime | None = None) -> bool: """На смене ли сотрудник: занимает должность и сейчас по СВОЕМУ графику работает (без графика — всегда).""" dt = dt or config.now() return bool(u.position) and shift_active(u.schedule, dt) def schedules_to_users(db) -> int: """Однократный перенос: раньше график задавался должности — теперь он у каждого сотрудника. Сотрудникам без своего графика ставится график их должности; повторно не выполняется (отметка в настройках).""" from . import settings_store as ss from .models import Position if ss.get(db, "schedule_per_user") == "1": return 0 n = 0 for u, sid in db.execute(select(User, Position.schedule_id).join(Position, Position.id == User.position_id) .where(User.schedule_id.is_(None), Position.schedule_id.is_not(None))): u.schedule_id = sid n += 1 ss.set_(db, "schedule_per_user", "1") db.commit() return n # ---------- расписание запуска нарядов ---------- def _matches(t: OrderTemplate, d: date) -> bool: if d < t.start_date: return False if t.recurrence == "daily": return True if t.recurrence == "weekly": return d.weekday() in (t.weekdays or []) if t.recurrence == "monthly": last = calendar.monthrange(d.year, d.month)[1] return d.day == min(t.month_day, last) if t.recurrence == "interval": return (d - t.start_date).days % max(t.interval_days, 1) == 0 return False def next_run_after(t: OrderTemplate, after: datetime) -> datetime | None: for i in range(0, 800): d = after.date() + timedelta(days=i) if _matches(t, d): cand = datetime.combine(d, t.run_time) if cand > after: return cand return None def recalc_next_run(t: OrderTemplate) -> None: t.next_run = next_run_after(t, config.now() - timedelta(seconds=1)) if t.is_active else None # ---------- наряды ---------- def create_order(db: Session, *, title, checklist_id, equipment_id=None, location_id=None, assignee_user_id=None, assignee_position_id=None, reviewer_user_id=None, reviewer_position_id=None, due_at=None, template_id=None) -> Order: cl = db.get(Checklist, checklist_id) if not cl: raise ValueError("Чек-лист не найден") o = Order(title=title, template_id=template_id, checklist_id=checklist_id, equipment_id=equipment_id, location_id=location_id, assignee_user_id=assignee_user_id, assignee_position_id=assignee_position_id, reviewer_user_id=reviewer_user_id, reviewer_position_id=reviewer_position_id, due_at=due_at, created_at=config.now()) if cl.kind == "regular": _regular_items(db, o, cl) else: for it in cl.items: o.items.append(OrderItem(position=it.position, text=it.text, answer_type=it.answer_type, options=it.options, unit=it.unit, min_value=it.min_value, max_value=it.max_value, required=it.required, photo=it.photo, photo_keep=it.photo_keep)) db.add(o) return o def _regular_items(db: Session, o: Order, cl: Checklist) -> None: """Регламентное обслуживание: наработка машины + свои пункты чек-листа + по пункту на каждый обслуживаемый узел.""" from . import service eq = db.get(Equipment, o.equipment_id) if o.equipment_id else None if not eq: raise ValueError("Для регламентного обслуживания выберите оборудование") units = service.order_units(db, eq.id, cl.only_due) if not units and not cl.items: raise ValueError("У выбранной машины нет узлов, требующих обслуживания") pos = 0 o.items.append(OrderItem(position=pos, text="Наработка машины (показание счётчика)", answer_type="number", unit="м/ч", min_value=eq.meter_hours or 0, required=True, is_meter=True)) for it in cl.items: pos += 1 o.items.append(OrderItem(position=pos, text=it.text, answer_type=it.answer_type, options=it.options, unit=it.unit, min_value=it.min_value, max_value=it.max_value, required=it.required, photo=it.photo, photo_keep=it.photo_keep)) for u, ev in units: pos += 1 o.items.append(OrderItem(position=pos, text=service.describe(u, ev) + " — обслужено?", answer_type="status", required=False, photo="optional", service_unit_id=u.id)) def run_template(db: Session, t: OrderTemplate, when: datetime | None = None) -> Order: when = when or config.now() o = create_order(db, title=t.title, checklist_id=t.checklist_id, equipment_id=t.equipment_id, location_id=t.location_id, assignee_user_id=t.assignee_user_id, assignee_position_id=t.assignee_position_id, reviewer_user_id=t.reviewer_user_id, reviewer_position_id=t.reviewer_position_id, due_at=when + timedelta(hours=t.due_hours), template_id=t.id) o.created_at = when t.last_run = when return o def run_due_templates(db: Session) -> list[Order]: """Создаёт наряды по всем шаблонам, у которых наступило время. Возвращает количество.""" now = config.now() due = db.scalars(select(OrderTemplate).where( OrderTemplate.is_active.is_(True), OrderTemplate.next_run.is_not(None), OrderTemplate.next_run <= now)).all() created = [] for t in due: try: created.append(run_template(db, t, t.next_run)) except ValueError as e: # например, у машины нет узлов для регламентного обслуживания — пропускаем запуск log.warning("Шаблон «%s» (id=%s): наряд не создан: %s", t.title, t.id, e) t.next_run = next_run_after(t, now) # пропущенные запуски не копим db.commit() return created def validate_answer(item: OrderItem, value: str | None) -> tuple[str | None, bool]: """Возвращает (нормализованное значение, вне_диапазона). Бросает ValueError при неверном вводе.""" if value is None or str(value).strip() == "": return None, False value = str(value).strip() if item.answer_type == "status": if value not in ("in_progress", "done"): raise ValueError("Статус: in_progress или done") return value, False if item.answer_type == "choice": if value not in (item.options or []): raise ValueError("Значение не из списка") return value, False if item.answer_type == "number": try: n = float(value.replace(",", ".")) except ValueError: raise ValueError("Нужно число") if not math.isfinite(n): # inf / nan / 1e999999 не принимаем (иначе 500 при форматировании) raise ValueError("Нужно число") oor = (item.min_value is not None and n < item.min_value) or \ (item.max_value is not None and n > item.max_value) return (str(int(n)) if n == int(n) else str(n)), oor return value, False def item_done(it: OrderItem) -> bool: if it.answer_type == "status": return it.value == "done" return it.value not in (None, "") def visible_orders_filter(u: User): """SQL-условие: какие наряды видит пользователь.""" if u.is_admin or u.is_moderator or (u.position and u.position.kind == "engineer"): return None # инженер, модератор и админ видят всё conds = [Order.executor_id == u.id, Order.assignee_user_id == u.id] if u.position_id and user_on_shift(u): conds.append((Order.assignee_position_id == u.position_id) & Order.executor_id.is_(None) & Order.assignee_user_id.is_(None)) return or_(*conds) def can_review(u: User, o: Order) -> bool: if u.is_admin or u.is_moderator: return True if o.reviewer_user_id == u.id: return True if o.reviewer_user_id is None and o.reviewer_position_id and u.position_id == o.reviewer_position_id: return user_on_shift(u) if o.reviewer_user_id is None and o.reviewer_position_id is None: return bool(u.position and u.position.kind == "engineer") return False