- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели) - автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов во внутренней сети Docker; значения из интерфейса важнее - локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API - .env.example, docs/DEPLOY.md (раздел 7а), README
251 lines
11 KiB
Python
251 lines
11 KiB
Python
"""Бизнес-логика: смены, расписание запуска, создание и ход выполнения нарядов."""
|
|
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
|