Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для 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
+510
View File
@@ -0,0 +1,510 @@
from datetime import datetime, date, time
from sqlalchemy import String, Text, Integer, Boolean, ForeignKey, JSON, DateTime, Date, Time, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
from . import config
class ShiftSchedule(Base):
"""График работы. weekly — по дням недели; cycle — N рабочих / M выходных (2/2, 1/3...)."""
__tablename__ = "schedules"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True)
kind: Mapped[str] = mapped_column(String(10), default="weekly") # weekly | cycle
weekdays: Mapped[list] = mapped_column(JSON, default=lambda: [0, 1, 2, 3, 4]) # 0=Пн
work_days: Mapped[int] = mapped_column(Integer, default=2)
off_days: Mapped[int] = mapped_column(Integer, default=2)
cycle_start: Mapped[date | None] = mapped_column(Date, nullable=True)
# kind=pattern: список шагов [{type: work|off, days, start_time, end_time, label}], повторяется по кругу
pattern: Mapped[list | None] = mapped_column(JSON, nullable=True)
start_time: Mapped[time] = mapped_column(Time, default=time(8, 0))
end_time: Mapped[time] = mapped_column(Time, default=time(20, 0))
class Position(Base):
"""Должность. kind: technician — выполняет наряды, engineer — проверяет/планирует."""
__tablename__ = "positions"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True)
kind: Mapped[str] = mapped_column(String(12), default="technician")
schedule_id: Mapped[int | None] = mapped_column(ForeignKey("schedules.id"), nullable=True)
schedule: Mapped[ShiftSchedule | None] = relationship(lazy="joined")
class Department(Base):
"""Отдел (служба, участок). Может входить в другой отдел; руководитель — необязательно."""
__tablename__ = "departments"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(150), unique=True)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True)
head_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", use_alter=True), nullable=True)
notes: Mapped[str] = mapped_column(Text, default="")
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True)
full_name: Mapped[str] = mapped_column(String(150), default="")
password_hash: Mapped[str] = mapped_column(String(100))
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
is_moderator: Mapped[bool] = mapped_column(Boolean, default=False)
is_storekeeper: Mapped[bool] = mapped_column(Boolean, default=False) # администратор склада инструмента
# администратор портала: настройки сервера и обратная связь (admin — всегда; остальным выдаёт admin)
portal_admin: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
position_id: Mapped[int | None] = mapped_column(ForeignKey("positions.id"), nullable=True)
position: Mapped[Position | None] = relationship(lazy="joined")
department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True)
department: Mapped[Department | None] = relationship(lazy="joined", foreign_keys=[department_id])
# карточка сотрудника
photo: Mapped[str] = mapped_column(String(300), default="") # файл в каталоге медиа
skills: Mapped[str] = mapped_column(Text, default="") # «что умею»
phone: Mapped[str] = mapped_column(String(40), default="")
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(150))
parent_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), nullable=True)
class Equipment(Base):
__tablename__ = "equipment"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(150))
inventory_no: Mapped[str] = mapped_column(String(64), default="")
location_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), nullable=True)
notes: Mapped[str] = mapped_column(Text, default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
meter_hours: Mapped[float] = mapped_column(Float, default=0.0) # текущая наработка, моточасы
meter_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# иконка: "builtin:<имя>" (набор в static/icons) или "file:<путь>" (загруженное фото); пусто — без иконки
icon: Mapped[str] = mapped_column(String(300), default="")
# положение на карте завода, % от ширины/высоты картинки; None — не размещено
map_x: Mapped[float | None] = mapped_column(Float, nullable=True)
map_y: Mapped[float | None] = mapped_column(Float, nullable=True)
# закрепление за отделом: оборудование видят сотрудники этого отдела (и вложенных); пусто — видят все
department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True)
# кран (грузоподъёмное оборудование): при включённой карте кранов показывается на ней, а не на карте завода
is_crane: Mapped[bool] = mapped_column(Boolean, default=False)
class ServiceUnit(Base):
"""Обслуживаемый узел машины (масло, фильтр, ремень...) и правило его обслуживания."""
__tablename__ = "service_units"
id: Mapped[int] = mapped_column(primary_key=True)
equipment_id: Mapped[int] = mapped_column(ForeignKey("equipment.id"), index=True)
name: Mapped[str] = mapped_column(String(150))
notes: Mapped[str] = mapped_column(Text, default="")
mode: Mapped[str] = mapped_column(String(10), default="hours") # hours | calendar | both (что наступит раньше)
interval_hours: Mapped[float | None] = mapped_column(Float, nullable=True) # каждые N моточасов
interval_value: Mapped[int | None] = mapped_column(Integer, nullable=True) # каждые N дней/недель/месяцев
interval_unit: Mapped[str | None] = mapped_column(String(6), nullable=True) # day | week | month
warn_percent: Mapped[int] = mapped_column(Integer, default=10) # «скоро» — когда осталось ≤ N% интервала
last_service_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_service_hours: Mapped[float] = mapped_column(Float, default=0.0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
class ServiceEvent(Base):
"""История обслуживания узла: начальное состояние, выполнение по наряду, ручной сброс."""
__tablename__ = "service_events"
id: Mapped[int] = mapped_column(primary_key=True)
unit_id: Mapped[int] = mapped_column(ForeignKey("service_units.id"), index=True)
at: Mapped[datetime] = mapped_column(DateTime)
hours: Mapped[float] = mapped_column(Float, default=0.0) # наработка машины на момент обслуживания
kind: Mapped[str] = mapped_column(String(10), default="reset") # initial | order | reset | incident
order_id: Mapped[int | None] = mapped_column(ForeignKey("orders.id"), nullable=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
note: Mapped[str] = mapped_column(Text, default="")
class MeterReading(Base):
"""Показания счётчика наработки (моточасов) — по ним считается остаток и прогноз."""
__tablename__ = "meter_readings"
id: Mapped[int] = mapped_column(primary_key=True)
equipment_id: Mapped[int] = mapped_column(ForeignKey("equipment.id"), index=True)
hours: Mapped[float] = mapped_column(Float)
at: Mapped[datetime] = mapped_column(DateTime)
source: Mapped[str] = mapped_column(String(10), default="manual") # manual | order | correction
order_id: Mapped[int | None] = mapped_column(ForeignKey("orders.id"), nullable=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
note: Mapped[str] = mapped_column(Text, default="")
class Checklist(Base):
__tablename__ = "checklists"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(150))
description: Mapped[str] = mapped_column(Text, default="")
# inspection — обычный; regular — «регламентное обслуживание»: в наряд добавляются узлы выбранной машины
kind: Mapped[str] = mapped_column(String(12), default="inspection")
only_due: Mapped[bool] = mapped_column(Boolean, default=False) # regular: брать только узлы, которым пора/скоро
items: Mapped[list["ChecklistItem"]] = relationship(
cascade="all, delete-orphan", order_by="ChecklistItem.position", lazy="selectin")
class ChecklistItem(Base):
__tablename__ = "checklist_items"
id: Mapped[int] = mapped_column(primary_key=True)
checklist_id: Mapped[int] = mapped_column(ForeignKey("checklists.id"))
position: Mapped[int] = mapped_column(Integer, default=0)
text: Mapped[str] = mapped_column(Text)
answer_type: Mapped[str] = mapped_column(String(10), default="status") # status|choice|number|text
options: Mapped[list | None] = mapped_column(JSON, nullable=True) # для choice
unit: Mapped[str] = mapped_column(String(20), default="")
min_value: Mapped[float | None] = mapped_column(Float, nullable=True)
max_value: Mapped[float | None] = mapped_column(Float, nullable=True)
required: Mapped[bool] = mapped_column(Boolean, default=True)
photo: Mapped[str] = mapped_column(String(10), default="none") # none|optional|required
class OrderTemplate(Base):
"""Настроенный один раз наряд, который запускается по расписанию."""
__tablename__ = "order_templates"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
checklist_id: Mapped[int] = mapped_column(ForeignKey("checklists.id"))
equipment_id: Mapped[int | None] = mapped_column(ForeignKey("equipment.id"), nullable=True)
location_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), nullable=True)
assignee_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
assignee_position_id: Mapped[int | None] = mapped_column(ForeignKey("positions.id"), nullable=True)
reviewer_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
reviewer_position_id: Mapped[int | None] = mapped_column(ForeignKey("positions.id"), nullable=True)
due_hours: Mapped[int] = mapped_column(Integer, default=24)
# расписание запуска
recurrence: Mapped[str] = mapped_column(String(10), default="daily") # daily|weekly|monthly|interval
weekdays: Mapped[list] = mapped_column(JSON, default=list)
month_day: Mapped[int] = mapped_column(Integer, default=1)
interval_days: Mapped[int] = mapped_column(Integer, default=7)
run_time: Mapped[time] = mapped_column(Time, default=time(8, 0))
start_date: Mapped[date] = mapped_column(Date, default=lambda: config.now().date())
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
next_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class Order(Base):
"""Наряд. Чек-лист копируется (снимок) в OrderItem при создании."""
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
template_id: Mapped[int | None] = mapped_column(ForeignKey("order_templates.id"), nullable=True)
checklist_id: Mapped[int | None] = mapped_column(ForeignKey("checklists.id"), nullable=True)
equipment_id: Mapped[int | None] = mapped_column(ForeignKey("equipment.id"), nullable=True)
location_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), nullable=True)
assignee_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
assignee_position_id: Mapped[int | None] = mapped_column(ForeignKey("positions.id"), nullable=True)
reviewer_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
reviewer_position_id: Mapped[int | None] = mapped_column(ForeignKey("positions.id"), nullable=True)
executor_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
executor_position_id: Mapped[int | None] = mapped_column(ForeignKey("positions.id"), nullable=True) # снимок на момент начала
status: Mapped[str] = mapped_column(String(12), default="new") # new|in_progress|review|completed|cancelled
review_comment: Mapped[str] = mapped_column(Text, default="")
reviewed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
returns: Mapped[int] = mapped_column(Integer, default=0) # сколько раз возвращён с проверки на доработку
items: Mapped[list["OrderItem"]] = relationship(
cascade="all, delete-orphan", order_by="OrderItem.position", lazy="selectin")
class OrderItem(Base):
__tablename__ = "order_items"
id: Mapped[int] = mapped_column(primary_key=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"))
position: Mapped[int] = mapped_column(Integer, default=0)
text: Mapped[str] = mapped_column(Text)
answer_type: Mapped[str] = mapped_column(String(10))
options: Mapped[list | None] = mapped_column(JSON, nullable=True)
unit: Mapped[str] = mapped_column(String(20), default="")
min_value: Mapped[float | None] = mapped_column(Float, nullable=True)
max_value: Mapped[float | None] = mapped_column(Float, nullable=True)
required: Mapped[bool] = mapped_column(Boolean, default=True)
value: Mapped[str | None] = mapped_column(Text, nullable=True) # ответ; для status: in_progress|done
answered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
out_of_range: Mapped[bool] = mapped_column(Boolean, default=False)
photo: Mapped[str] = mapped_column(String(10), default="none")
service_unit_id: Mapped[int | None] = mapped_column(ForeignKey("service_units.id"), nullable=True) # «узел обслужен»
is_meter: Mapped[bool] = mapped_column(Boolean, default=False) # пункт «наработка, м/ч» — обновляет счётчик машины
photos: Mapped[list["Photo"]] = relationship(
cascade="all, delete-orphan", order_by="Photo.id", lazy="selectin")
class Photo(Base):
__tablename__ = "photos"
id: Mapped[int] = mapped_column(primary_key=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"))
item_id: Mapped[int] = mapped_column(ForeignKey("order_items.id"))
path: Mapped[str] = mapped_column(String(300)) # относительно каталога фото
content_type: Mapped[str] = mapped_column(String(40))
size: Mapped[int] = mapped_column(Integer, default=0)
client_key: Mapped[str | None] = mapped_column(String(64), nullable=True) # Idempotency-Key клиента
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
class Comment(Base):
__tablename__ = "comments"
id: Mapped[int] = mapped_column(primary_key=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
text: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
class Feedback(Base):
"""Обратная связь по порталу: идея, ошибка или неудобство. Видят только администраторы портала."""
__tablename__ = "feedback"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
kind: Mapped[str] = mapped_column(String(10), default="idea") # idea | bug | other
text: Mapped[str] = mapped_column(Text)
page: Mapped[str] = mapped_column(String(200), default="") # где был пользователь (раздел портала)
client: Mapped[str] = mapped_column(String(10), default="web") # web | m | android
status: Mapped[str] = mapped_column(String(10), default="new") # new | taken | done | rejected
answer: Mapped[str] = mapped_column(Text, default="") # заметка администратора
answered_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
answered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(Text, default="")
class PushSubscription(Base):
__tablename__ = "push_subscriptions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
endpoint: Mapped[str] = mapped_column(String(600), unique=True)
p256dh: Mapped[str] = mapped_column(String(200))
auth: Mapped[str] = mapped_column(String(100))
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
device: Mapped[str] = mapped_column(String(100), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
expires_at: Mapped[datetime] = mapped_column(DateTime)
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
class DeviceToken(Base):
"""FCM-токены Android-устройств."""
__tablename__ = "device_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
token: Mapped[str] = mapped_column(String(300), unique=True)
platform: Mapped[str] = mapped_column(String(20), default="android")
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
# ================= Портал: инвентарь, склады, заявки =================
class Warehouse(Base):
"""Склад инструмента. zone — прямоугольник на карте завода в % ({x, y, w, h})."""
__tablename__ = "warehouses"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(150), unique=True)
notes: Mapped[str] = mapped_column(Text, default="")
zone: Mapped[dict | None] = mapped_column(JSON, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class InventoryItem(Base):
"""Позиция каталога инструмента и инвентаря. Штучный инструмент — с инвентарным номером и количеством 1."""
__tablename__ = "inventory_items"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200))
category: Mapped[str] = mapped_column(String(100), default="")
unit: Mapped[str] = mapped_column(String(20), default="шт")
inventory_no: Mapped[str] = mapped_column(String(64), default="")
notes: Mapped[str] = mapped_column(Text, default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
class Stock(Base):
"""Сколько позиции лежит на складе либо числится за сотрудником (ровно одно из warehouse_id / user_id)."""
__tablename__ = "stock"
id: Mapped[int] = mapped_column(primary_key=True)
item_id: Mapped[int] = mapped_column(ForeignKey("inventory_items.id"), index=True)
warehouse_id: Mapped[int | None] = mapped_column(ForeignKey("warehouses.id"), nullable=True, index=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
qty: Mapped[float] = mapped_column(Float, default=0.0)
since: Mapped[datetime] = mapped_column(DateTime, default=config.now) # когда последний раз менялось
class InventoryMove(Base):
"""Журнал движения: поступление (нет «откуда»), выдача/возврат/перемещение, списание (нет «куда»)."""
__tablename__ = "inventory_moves"
id: Mapped[int] = mapped_column(primary_key=True)
item_id: Mapped[int] = mapped_column(ForeignKey("inventory_items.id"), index=True)
qty: Mapped[float] = mapped_column(Float)
kind: Mapped[str] = mapped_column(String(10)) # receive | issue | return | transfer | writeoff
from_warehouse_id: Mapped[int | None] = mapped_column(ForeignKey("warehouses.id"), nullable=True)
from_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
to_warehouse_id: Mapped[int | None] = mapped_column(ForeignKey("warehouses.id"), nullable=True)
to_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
at: Mapped[datetime] = mapped_column(DateTime, default=config.now, index=True)
note: Mapped[str] = mapped_column(Text, default="")
class ToolRequest(Base):
"""Заявка сотрудника на покупку инструмента/инвентаря."""
__tablename__ = "tool_requests"
id: Mapped[int] = mapped_column(primary_key=True)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
what: Mapped[str] = mapped_column(String(300)) # что купить
why: Mapped[str] = mapped_column(Text) # зачем
link: Mapped[str] = mapped_column(String(1000), default="") # ссылка на товар
qty: Mapped[float] = mapped_column(Float, default=1.0)
status: Mapped[str] = mapped_column(String(10), default="new") # new | approved | ordered | done | rejected
answer: Mapped[str] = mapped_column(Text, default="") # ответ ответственного
handled_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
# ================= Портал: инциденты =================
class ApiKey(Base):
"""Ключ доступа внешней системы к API инцидентов. Хранится только хэш; сам ключ показывается один раз."""
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(150))
key_hash: Mapped[str] = mapped_column(String(64), unique=True)
prefix: Mapped[str] = mapped_column(String(12)) # начало ключа — чтобы узнать его в списке
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
class Incident(Base):
"""Авария или иная нештатная ситуация на оборудовании. Открытый инцидент — машина в состоянии «Авария».
kind=fault — «Сообщение о неисправности»: неисправность замечена, но оборудование работает. Машина в состоянии
«Неисправность» (не «Авария»); если ситуация ухудшилась, сообщение переводится в инцидент."""
__tablename__ = "incidents"
id: Mapped[int] = mapped_column(primary_key=True)
kind: Mapped[str] = mapped_column(String(8), default="incident") # incident | fault
equipment_id: Mapped[int] = mapped_column(ForeignKey("equipment.id"), index=True)
author_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
occurred_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
title: Mapped[str] = mapped_column(String(200))
description: Mapped[str] = mapped_column(Text, default="")
replaced: Mapped[bool] = mapped_column(Boolean, default=False) # была ли замена
replacement_note: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(8), default="open") # open | closed
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
closed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
close_note: Mapped[str] = mapped_column(Text, default="")
# откуда пришёл: staff — сотрудник в портале, public — публичная форма, api — внешняя система
source: Mapped[str] = mapped_column(String(8), default="staff")
reporter_name: Mapped[str] = mapped_column(String(150), default="")
reporter_contact: Mapped[str] = mapped_column(String(200), default="")
external_id: Mapped[str] = mapped_column(String(100), default="") # номер заявки во внешней системе
api_key_id: Mapped[int | None] = mapped_column(ForeignKey("api_keys.id"), nullable=True)
# --- данные для анализа надёжности (заполняются при открытии и в форме закрытия) ---
hours_at_open: Mapped[float | None] = mapped_column(Float, nullable=True) # наработка машины при регистрации
escalated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # неисправность переведена в отказ
repair_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
restored_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # ввод в эксплуатацию
parts_wait_hours: Mapped[float | None] = mapped_column(Float, nullable=True) # ожидание запчастей, ч
hours_at_failure: Mapped[float | None] = mapped_column(Float, nullable=True) # наработка на момент отказа
meter_at_restore: Mapped[float | None] = mapped_column(Float, nullable=True)
cause_unit_id: Mapped[int | None] = mapped_column(ForeignKey("service_units.id"), nullable=True) # узел-причина
cause_unit_other: Mapped[str] = mapped_column(String(200), default="")
action: Mapped[str] = mapped_column(String(10), default="") # repair | replace | adjust | restart
nature: Mapped[str] = mapped_column(String(10), default="") # sudden | gradual
cause: Mapped[str] = mapped_column(String(10), default="") # wear | operation | external | power | defect | secondary | unknown
precursor: Mapped[str] = mapped_column(String(12), default="") # inspection | fault_report | none
units: Mapped[list["IncidentUnit"]] = relationship(cascade="all, delete-orphan", lazy="selectin")
class IncidentUnit(Base):
"""Затронутый узел; replaced — узел заменён (сбрасывает его ресурс, как ручной сброс)."""
__tablename__ = "incident_units"
id: Mapped[int] = mapped_column(primary_key=True)
incident_id: Mapped[int] = mapped_column(ForeignKey("incidents.id"), index=True)
unit_id: Mapped[int | None] = mapped_column(ForeignKey("service_units.id"), nullable=True)
unit_name: Mapped[str] = mapped_column(String(150), default="") # снимок названия — узел могут удалить
replaced: Mapped[bool] = mapped_column(Boolean, default=False)
# ================= Портал: суточные задания =================
class DailyTask(Base):
"""Простое задание на день (не наряд): статусы назначен / в работе / приостановлен / выполнен / отменён."""
__tablename__ = "daily_tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
description: Mapped[str] = mapped_column(Text, default="")
day: Mapped[date] = mapped_column(Date, index=True)
assignee_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
equipment_id: Mapped[int | None] = mapped_column(ForeignKey("equipment.id"), nullable=True)
unit_id: Mapped[int | None] = mapped_column(ForeignKey("service_units.id"), nullable=True)
status: Mapped[str] = mapped_column(String(12), default="assigned") # assigned|in_progress|paused|done|cancelled
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
labor_hours: Mapped[float | None] = mapped_column(Float, nullable=True) # трудоёмкость (норма), ч — для отчёта по сотрудникам
class TaskEvent(Base):
"""История статусов задания с комментариями."""
__tablename__ = "task_events"
id: Mapped[int] = mapped_column(primary_key=True)
task_id: Mapped[int] = mapped_column(ForeignKey("daily_tasks.id"), index=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
from_status: Mapped[str] = mapped_column(String(12), default="")
to_status: Mapped[str] = mapped_column(String(12))
kind: Mapped[str] = mapped_column(String(10), default="status") # status | transfer
from_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) # transfer: кто был исполнителем
to_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) # transfer: кому передано
comment: Mapped[str] = mapped_column(Text, default="")
class TaskPhoto(Base):
__tablename__ = "task_photos"
id: Mapped[int] = mapped_column(primary_key=True)
task_id: Mapped[int] = mapped_column(ForeignKey("daily_tasks.id"), index=True)
path: Mapped[str] = mapped_column(String(300))
content_type: Mapped[str] = mapped_column(String(40))
size: Mapped[int] = mapped_column(Integer, default=0)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
# --- updated_at: любое изменение наряда, его пунктов, фото или комментариев двигает курсор синхронизации ---
from sqlalchemy import event, update # noqa: E402
from sqlalchemy.orm import Session as _Session # noqa: E402
@event.listens_for(_Session, "before_flush")
def _touch_orders(session, ctx, instances):
t = config.now()
ids = set()
for obj in list(session.new) + list(session.dirty) + list(session.deleted):
if isinstance(obj, Order):
obj.updated_at = t
elif isinstance(obj, (OrderItem, Photo, Comment)) and obj.order_id:
ids.add(obj.order_id)
if ids:
session.execute(update(Order).where(Order.id.in_(ids)).values(updated_at=t))