Files
tech-service-portal/app/service.py
T

198 lines
10 KiB
Python
Raw 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.
"""Регламентное обслуживание: расчёт состояния узлов (по наработке и/или по календарю), сброс, прогноз."""
import calendar
from datetime import date, datetime, timedelta
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from . import config
from .models import DailyTask, Equipment, IncidentUnit, MeterReading, Order, OrderItem, ServiceEvent, ServiceUnit
UNIT_RU = {"day": ("день", "дня", "дней"), "week": ("неделю", "недели", "недель"), "month": ("месяц", "месяца", "месяцев")}
STATE_RU = {"ok": "В норме", "soon": "Скоро", "due": "Требуется", "nodata": "Нет данных"}
RATE_WINDOW_DAYS = 30 # по какому окну считаем среднюю наработку в сутки для прогноза
def plural(n: int, forms: tuple) -> str:
n10, n100 = n % 10, n % 100
if n10 == 1 and n100 != 11:
return forms[0]
if 2 <= n10 <= 4 and not 12 <= n100 <= 14:
return forms[1]
return forms[2]
def num(x: float) -> str:
return str(int(x)) if float(x) == int(x) else f"{x:.1f}".rstrip("0").rstrip(".")
def add_months(d: date, months: int) -> date:
m = d.month - 1 + months
y, m = d.year + m // 12, m % 12 + 1
return date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
def add_interval(d: date, value: int, unit: str) -> date:
if unit == "month":
return add_months(d, value)
return d + timedelta(days=value * (7 if unit == "week" else 1))
def uses_hours(u: ServiceUnit) -> bool:
return u.mode in ("hours", "both") and bool(u.interval_hours)
def uses_calendar(u: ServiceUnit) -> bool:
return u.mode in ("calendar", "both") and bool(u.interval_value) and u.interval_unit in UNIT_RU
def rule_text(u: ServiceUnit) -> str:
parts = []
if uses_hours(u):
parts.append(f"каждые {num(u.interval_hours)} м/ч")
if uses_calendar(u):
v = u.interval_value
parts.append("раз в " + (UNIT_RU[u.interval_unit][0] if v == 1 else f"{v} {plural(v, UNIT_RU[u.interval_unit])}"))
return " или ".join(parts) or "правило не задано"
def daily_rate(db: Session, equipment_id: int, now: datetime | None = None) -> float | None:
"""Средняя наработка в сутки за последние RATE_WINDOW_DAYS по показаниям счётчика (None — данных мало)."""
now = now or config.now()
rows = db.execute(select(MeterReading.at, MeterReading.hours).where(
MeterReading.equipment_id == equipment_id, MeterReading.at >= now - timedelta(days=RATE_WINDOW_DAYS),
MeterReading.source != "correction").order_by(MeterReading.at)).all()
if len(rows) < 2:
return None
(t0, h0), (t1, h1) = rows[0], rows[-1]
days = (t1 - t0).total_seconds() / 86400
if days < 1 or h1 <= h0:
return None
return (h1 - h0) / days
def evaluate(u: ServiceUnit, meter_hours: float, rate: float | None = None, now: datetime | None = None) -> dict:
"""Состояние узла: остаток по наработке/календарю, процент выработки ресурса, срок и прогноз."""
now = now or config.now()
today = now.date()
out = {"state": "ok", "percent": 0, "used_hours": None, "remaining_hours": None, "due_date": None,
"remaining_days": None, "forecast_date": None, "rule": rule_text(u)}
warn = max(0, min(90, u.warn_percent or 0)) / 100
pcts, soon, due = [], False, False
if not (uses_hours(u) or uses_calendar(u)): # правило не задано — считать нечего
out["state"] = "nodata"
return out
if uses_hours(u):
used = max(0.0, meter_hours - (u.last_service_hours or 0))
rem = u.interval_hours - used
out.update(used_hours=round(used, 1), remaining_hours=round(rem, 1))
pcts.append(used / u.interval_hours * 100)
due |= rem <= 0
soon |= rem <= u.interval_hours * warn
if uses_calendar(u):
base = (u.last_service_at or u.created_at or now).date()
due_date = add_interval(base, u.interval_value, u.interval_unit)
total = max(1, (due_date - base).days)
rem_d = (due_date - today).days
out.update(due_date=due_date.isoformat(), remaining_days=rem_d)
pcts.append((today - base).days / total * 100)
due |= rem_d <= 0
soon |= rem_d <= total * warn
out["percent"] = round(max(pcts)) if pcts else 0
out["state"] = "due" if due else "soon" if soon else "ok"
# прогноз: ближайшая из дат (календарная и по средней наработке)
cands = []
if out["due_date"]:
cands.append(date.fromisoformat(out["due_date"]))
if out["remaining_hours"] is not None and rate:
cands.append(today + timedelta(days=max(0, int(out["remaining_hours"] / rate))))
if cands:
out["forecast_date"] = min(cands).isoformat()
return out
def state_rank(state: str) -> int:
return {"due": 0, "soon": 1, "nodata": 2, "ok": 3}[state]
# ---------- действия ----------
def record_meter(db: Session, eq: Equipment, hours: float, *, at: datetime | None = None, source: str = "manual",
user_id: int | None = None, order_id: int | None = None, note: str = "", correction: bool = False) -> MeterReading:
"""Новое показание счётчика. Меньше текущего допускается только как коррекция (замена/сброс счётчика)."""
at = at or config.now()
if hours < (eq.meter_hours or 0) and not correction:
raise ValueError("Наработка не может быть меньше текущей (для замены счётчика отметьте «Коррекция»)")
r = MeterReading(equipment_id=eq.id, hours=hours, at=at, source="correction" if correction else source,
user_id=user_id, order_id=order_id, note=note)
db.add(r)
if eq.meter_updated_at is None or at >= eq.meter_updated_at or correction: # более раннее показание не «откатывает» счётчик
eq.meter_hours, eq.meter_updated_at = hours, at
return r
def reset_unit(db: Session, u: ServiceUnit, *, at: datetime | None = None, hours: float | None = None,
kind: str = "reset", user_id: int | None = None, order_id: int | None = None, note: str = "") -> ServiceEvent:
"""Сброс узла: обслуживание выполнено (по наряду) или проведено отдельно / при внештатной ситуации."""
eq = db.get(Equipment, u.equipment_id)
at = at or config.now()
hours = (eq.meter_hours or 0) if hours is None else hours
u.last_service_at, u.last_service_hours = at, hours
ev = ServiceEvent(unit_id=u.id, at=at, hours=hours, kind=kind, order_id=order_id, user_id=user_id, note=note)
db.add(ev)
return ev
def describe(u: ServiceUnit, ev: dict) -> str:
"""Короткий текст о состоянии для пункта наряда."""
bits = [u.name, rule_text(u)]
if ev["remaining_hours"] is not None:
r = ev["remaining_hours"]
bits.append(f"осталось {num(r)} м/ч" if r > 0 else f"просрочено на {num(-r)} м/ч")
if ev["remaining_days"] is not None:
r = ev["remaining_days"]
bits.append(f"осталось {r} дн." if r > 0 else f"просрочено на {-r} дн." if r < 0 else "срок сегодня")
return " · ".join(bits)
def order_units(db: Session, equipment_id: int, only_due: bool) -> list[tuple[ServiceUnit, dict]]:
eq = db.get(Equipment, equipment_id)
rate = daily_rate(db, equipment_id)
units = db.scalars(select(ServiceUnit).where(ServiceUnit.equipment_id == equipment_id,
ServiceUnit.is_active.is_(True)).order_by(ServiceUnit.id)).all()
pairs = [(u, evaluate(u, eq.meter_hours or 0, rate)) for u in units]
if only_due:
pairs = [p for p in pairs if p[1]["state"] in ("due", "soon")]
return pairs
def apply_completed_order(db: Session, o: Order) -> None:
"""Наряд завершён: фиксируем наработку и сбрасываем узлы, отмеченные «обслужено». Повторный вызов безопасен."""
at = o.completed_at or config.now()
eq = db.get(Equipment, o.equipment_id) if o.equipment_id else None
for it in o.items:
if it.is_meter and eq and it.value not in (None, ""):
try:
hours = float(it.value.replace(",", "."))
except ValueError:
continue
seen = db.scalar(select(MeterReading.id).where(MeterReading.order_id == o.id))
if not seen and hours >= (eq.meter_hours or 0):
record_meter(db, eq, hours, at=at, source="order", user_id=o.executor_id, order_id=o.id)
for it in o.items:
if not it.service_unit_id or it.value != "done":
continue
u = db.get(ServiceUnit, it.service_unit_id)
if not u:
continue
if db.scalar(select(ServiceEvent.id).where(ServiceEvent.unit_id == u.id, ServiceEvent.order_id == o.id)):
continue
reset_unit(db, u, at=at, kind="order", user_id=o.executor_id, order_id=o.id, note=f"Наряд №{o.id}")
def unlink_unit(db: Session, unit_id: int) -> None:
"""Перед удалением узла: пункты нарядов остаются, но больше не ссылаются на него."""
db.execute(update(OrderItem).where(OrderItem.service_unit_id == unit_id).values(service_unit_id=None))
db.execute(update(IncidentUnit).where(IncidentUnit.unit_id == unit_id).values(unit_id=None))
db.execute(update(DailyTask).where(DailyTask.unit_id == unit_id).values(unit_id=None))
db.execute(ServiceEvent.__table__.delete().where(ServiceEvent.unit_id == unit_id))