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") # устарело: график теперь у сотрудника (User.schedule_id); значение перенесено сотрудникам при обновлении и больше не используется schedule_id: Mapped[int | None] = mapped_column(ForeignKey("schedules.id"), nullable=True) 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") # график работы сотрудника: должность выполняет тот, кто её занимает и сейчас на смене. Пусто — всегда на смене schedule_id: Mapped[int | None] = mapped_column(ForeignKey("schedules.id"), nullable=True) schedule: Mapped[ShiftSchedule | 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) # папка документации в Nextcloud (путь от корня документации в настройках сервера); пусто — не привязана docs_path: Mapped[str] = mapped_column(String(500), default="") 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 photo_keep: Mapped[bool] = mapped_column(Boolean, default=False) # фото хранить более года (каталог keep/, очистка не трогает) 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") photo_keep: Mapped[bool] = mapped_column(Boolean, default=False) 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 DocIndex(Base): """Индекс документации для AI-помощника: имя файла и фрагмент текста (в нижнем регистре). Строится заново из Nextcloud.""" __tablename__ = "doc_index" id: Mapped[int] = mapped_column(primary_key=True) equipment_id: Mapped[int] = mapped_column(ForeignKey("equipment.id", ondelete="CASCADE"), index=True) path: Mapped[str] = mapped_column(String(1000)) # путь файла от папки машины name: Mapped[str] = mapped_column(String(300)) etag: Mapped[str] = mapped_column(String(200), default="") size: Mapped[int] = mapped_column(Integer, default=0) text: Mapped[str] = mapped_column(Text, default="") # извлечённый текст, нижний регистр, обрезан note: Mapped[str] = mapped_column(String(200), default="") # почему текста нет (большой файл, ошибка чтения) indexed_at: Mapped[datetime] = mapped_column(DateTime, default=config.now) 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) # трудоёмкость (норма), ч — для отчёта по сотрудникам photo_keep: Mapped[bool] = mapped_column(Boolean, default=False) # фото к заданию хранить более года 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))