Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit c298f5a830
247 changed files with 23200 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
"""Бизнес-логика: смены, расписание запуска, создание и ход выполнения нарядов."""
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.position.schedule, dt)
# ---------- расписание запуска нарядов ----------
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))
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))
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