Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
+232
@@ -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
|
||||
Reference in New Issue
Block a user