- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели) - автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов во внутренней сети Docker; значения из интерфейса важнее - локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API - .env.example, docs/DEPLOY.md (раздел 7а), README
923 lines
29 KiB
Python
923 lines
29 KiB
Python
import re
|
||
from datetime import datetime as _datetime, date, time
|
||
from typing import Annotated, Literal, Optional
|
||
from pydantic import AfterValidator, BaseModel, BeforeValidator, ConfigDict, Field, PlainSerializer, create_model, field_validator
|
||
|
||
from . import config
|
||
from .netcheck import is_allowed_push_endpoint
|
||
|
||
|
||
def _dt_in(v):
|
||
"""Вход: ISO с часовым поясом приводим к локальному naive; без пояса — считаем локальным."""
|
||
if isinstance(v, str):
|
||
v = _datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||
if isinstance(v, _datetime) and v.tzinfo:
|
||
v = v.astimezone(config.TZ).replace(tzinfo=None)
|
||
return v
|
||
|
||
|
||
def _dt_out(v):
|
||
return v.replace(tzinfo=config.TZ).isoformat() if v.tzinfo is None else v.isoformat()
|
||
|
||
|
||
# Все даты-время в API: на выходе ISO 8601 со смещением (напр. 2026-10-07T11:25:00+03:00)
|
||
datetime = Annotated[_datetime, BeforeValidator(_dt_in), PlainSerializer(_dt_out, return_type=str, when_used="json")]
|
||
|
||
ICON_RE = re.compile(r"builtin:[a-z0-9_-]{1,40}|file:[0-9a-f]{32}\.(jpg|png|webp)")
|
||
|
||
AnswerType = Literal["status", "choice", "number", "text"]
|
||
|
||
|
||
class ORM(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
|
||
def partial(model: type[BaseModel]) -> type[BaseModel]:
|
||
"""Копия схемы, где все поля необязательны (для PATCH)."""
|
||
fields = {n: (Optional[f.annotation], None) for n, f in model.model_fields.items()}
|
||
return create_model(model.__name__ + "Patch", **fields)
|
||
|
||
|
||
# ----- auth / users -----
|
||
class LoginIn(BaseModel):
|
||
username: str
|
||
password: str
|
||
device: str = "" # название устройства, для списка сессий
|
||
|
||
|
||
class TokenOut(BaseModel):
|
||
access_token: str
|
||
refresh_token: str
|
||
token_type: str = "bearer"
|
||
expires_in: int # секунд до истечения access_token
|
||
|
||
|
||
class RefreshIn(BaseModel):
|
||
refresh_token: str
|
||
device: str = ""
|
||
|
||
|
||
PASSWORD_MIN = 10
|
||
|
||
|
||
def _pw_bytes(v: str) -> str:
|
||
if len(v.encode("utf-8")) > 72: # предел bcrypt: лишнее молча отбрасывалось бы
|
||
raise ValueError("Пароль слишком длинный (не более 72 байт)")
|
||
return v
|
||
|
||
|
||
# Пароль: от 10 символов, не длиннее 72 байт
|
||
Password = Annotated[str, Field(min_length=PASSWORD_MIN), AfterValidator(_pw_bytes)]
|
||
|
||
|
||
class PasswordIn(BaseModel):
|
||
old_password: str
|
||
new_password: Password
|
||
|
||
|
||
class UserIn(BaseModel):
|
||
username: str = Field(min_length=2, max_length=64)
|
||
full_name: str = ""
|
||
password: Password
|
||
is_admin: bool = False
|
||
is_moderator: bool = False
|
||
is_storekeeper: bool = False
|
||
portal_admin: bool = False
|
||
is_active: bool = True
|
||
position_id: Optional[int] = None
|
||
schedule_id: Optional[int] = None # график работы сотрудника (пусто — всегда на смене)
|
||
department_id: Optional[int] = None
|
||
skills: str = Field(default="", max_length=4000)
|
||
phone: str = Field(default="", max_length=40)
|
||
|
||
|
||
class UserPatch(BaseModel):
|
||
username: Optional[str] = None
|
||
full_name: Optional[str] = None
|
||
password: Optional[Password] = None
|
||
is_admin: Optional[bool] = None
|
||
is_moderator: Optional[bool] = None
|
||
is_storekeeper: Optional[bool] = None
|
||
portal_admin: Optional[bool] = None
|
||
is_active: Optional[bool] = None
|
||
position_id: Optional[int] = None
|
||
schedule_id: Optional[int] = None
|
||
department_id: Optional[int] = None
|
||
skills: Optional[str] = Field(default=None, max_length=4000)
|
||
phone: Optional[str] = Field(default=None, max_length=40)
|
||
|
||
|
||
class DepartmentBrief(ORM):
|
||
id: int
|
||
name: str
|
||
|
||
|
||
class DepartmentIn(BaseModel):
|
||
name: str = Field(min_length=1, max_length=150)
|
||
parent_id: Optional[int] = None
|
||
head_user_id: Optional[int] = None
|
||
notes: str = ""
|
||
|
||
|
||
class DepartmentOut(DepartmentIn, ORM):
|
||
id: int
|
||
members: int = 0
|
||
|
||
|
||
class PositionBrief(ORM):
|
||
id: int
|
||
name: str
|
||
kind: str
|
||
|
||
|
||
class UserOut(ORM):
|
||
id: int
|
||
username: str
|
||
full_name: str
|
||
is_admin: bool
|
||
is_moderator: bool
|
||
is_storekeeper: bool = False
|
||
is_active: bool
|
||
position_id: Optional[int]
|
||
position: Optional[PositionBrief] = None
|
||
schedule_id: Optional[int] = None
|
||
department_id: Optional[int] = None
|
||
department: Optional["DepartmentBrief"] = None
|
||
has_photo: bool = False
|
||
skills: str = ""
|
||
phone: str = ""
|
||
on_shift: Optional[bool] = None
|
||
demo_mode: bool = False
|
||
crane_map: bool = False # включена отдельная карта кранов
|
||
reliability: bool = False # доступен раздел «Анализ надёжности» (включён и у пользователя есть права)
|
||
docs: bool = False # подключена документация из Nextcloud
|
||
portal_admin: bool = False # администратор портала: настройки сервера и обратная связь
|
||
is_root: bool = False # администратор по умолчанию (admin): выдаёт права администратора портала
|
||
|
||
|
||
class FeedbackIn(BaseModel):
|
||
kind: Literal["idea", "bug", "other"] = "idea"
|
||
text: str = Field(min_length=3, max_length=4000)
|
||
page: str = Field(default="", max_length=200)
|
||
client: Literal["web", "m", "android"] = "web"
|
||
|
||
|
||
class FeedbackPatch(BaseModel):
|
||
status: Optional[Literal["new", "taken", "done", "rejected"]] = None
|
||
answer: Optional[str] = Field(default=None, max_length=4000)
|
||
|
||
|
||
class FeedbackOut(ORM):
|
||
id: int
|
||
created_at: datetime
|
||
kind: str
|
||
text: str
|
||
page: str
|
||
client: str
|
||
status: str
|
||
answer: str
|
||
user_id: Optional[int] = None
|
||
user_name: Optional[str] = None
|
||
answered_by_name: Optional[str] = None
|
||
answered_at: Optional[datetime] = None
|
||
|
||
|
||
# ----- справочники -----
|
||
class PatternStep(BaseModel):
|
||
"""Шаг цикла: несколько подряд идущих дней одного типа."""
|
||
type: Literal["work", "off"] = "work"
|
||
days: int = Field(default=1, ge=1, le=60)
|
||
start_time: time = time(8, 0) # для work
|
||
end_time: time = time(20, 0) # для work; end <= start — смена переходит на следующие сутки (равно — ровно 24 ч)
|
||
label: str = Field(default="", max_length=40)
|
||
|
||
|
||
class ScheduleIn(BaseModel):
|
||
name: str
|
||
kind: Literal["weekly", "cycle", "pattern"] = "weekly"
|
||
pattern: Optional[list[PatternStep]] = None
|
||
weekdays: list[int] = [0, 1, 2, 3, 4]
|
||
work_days: int = 2
|
||
off_days: int = 2
|
||
cycle_start: Optional[date] = None
|
||
start_time: time = time(8, 0)
|
||
end_time: time = time(20, 0)
|
||
|
||
|
||
class ScheduleOut(ScheduleIn, ORM):
|
||
id: int
|
||
|
||
|
||
class SchedulePreviewIn(BaseModel):
|
||
schedule: ScheduleIn
|
||
from_date: Optional[date] = None
|
||
days: int = Field(default=28, ge=1, le=120)
|
||
|
||
|
||
class PreviewDay(BaseModel):
|
||
date: date
|
||
weekday: int
|
||
work: bool
|
||
start: Optional[datetime] = None
|
||
end: Optional[datetime] = None
|
||
label: str = ""
|
||
|
||
|
||
class PositionIn(BaseModel):
|
||
name: str
|
||
kind: Literal["technician", "engineer"] = "technician"
|
||
schedule_id: Optional[int] = None # устарело и не используется: график задаётся сотруднику
|
||
|
||
|
||
class PositionOut(ORM):
|
||
id: int
|
||
name: str
|
||
kind: str
|
||
schedule_id: Optional[int]
|
||
|
||
|
||
class LocationIn(BaseModel):
|
||
name: str
|
||
parent_id: Optional[int] = None
|
||
|
||
|
||
class LocationOut(LocationIn, ORM):
|
||
id: int
|
||
|
||
|
||
class EquipmentIn(BaseModel):
|
||
name: str
|
||
inventory_no: str = ""
|
||
location_id: Optional[int] = None
|
||
notes: str = ""
|
||
is_active: bool = True
|
||
icon: str = Field(default="", max_length=300) # builtin:<имя>; загруженное фото — через /equipment/{id}/icon
|
||
department_id: Optional[int] = None # за каким отделом закреплено (пусто — видят все)
|
||
is_crane: bool = False # кран: показывается на отдельной карте кранов
|
||
|
||
@field_validator("icon")
|
||
@classmethod
|
||
def _icon_ok(cls, v):
|
||
if v and not ICON_RE.fullmatch(v):
|
||
raise ValueError("Недопустимая иконка")
|
||
return v
|
||
|
||
|
||
class EquipmentOut(EquipmentIn, ORM):
|
||
id: int
|
||
meter_hours: float = 0.0
|
||
meter_updated_at: Optional[datetime] = None
|
||
map_x: Optional[float] = None
|
||
map_y: Optional[float] = None
|
||
docs_path: str = "" # папка документации в Nextcloud (привязывается через PUT /equipment/{id}/docs)
|
||
|
||
|
||
# ----- регламентное обслуживание: узлы машины -----
|
||
ServiceMode = Literal["hours", "calendar", "both"]
|
||
IntervalUnit = Literal["day", "week", "month"]
|
||
|
||
|
||
class ServiceUnitIn(BaseModel):
|
||
name: str = Field(min_length=1, max_length=150)
|
||
notes: str = ""
|
||
mode: ServiceMode = "hours"
|
||
interval_hours: Optional[float] = Field(default=None, gt=0, le=1_000_000)
|
||
interval_value: Optional[int] = Field(default=None, ge=1, le=3650)
|
||
interval_unit: Optional[IntervalUnit] = None
|
||
warn_percent: int = Field(default=10, ge=0, le=90)
|
||
is_active: bool = True
|
||
# начальное состояние (при создании): когда и при какой наработке узел обслуживался в последний раз
|
||
last_service_at: Optional[datetime] = None
|
||
last_service_hours: Optional[float] = Field(default=None, ge=0)
|
||
|
||
def check_rule(self):
|
||
if self.mode in ("hours", "both") and not self.interval_hours:
|
||
raise ValueError("Укажите, через сколько моточасов обслуживать")
|
||
if self.mode in ("calendar", "both") and not (self.interval_value and self.interval_unit):
|
||
raise ValueError("Укажите календарный интервал (число и единицу)")
|
||
|
||
|
||
class ServiceUnitPatch(BaseModel):
|
||
name: Optional[str] = Field(default=None, min_length=1, max_length=150)
|
||
notes: Optional[str] = None
|
||
mode: Optional[ServiceMode] = None
|
||
interval_hours: Optional[float] = Field(default=None, gt=0, le=1_000_000)
|
||
interval_value: Optional[int] = Field(default=None, ge=1, le=3650)
|
||
interval_unit: Optional[IntervalUnit] = None
|
||
warn_percent: Optional[int] = Field(default=None, ge=0, le=90)
|
||
is_active: Optional[bool] = None
|
||
|
||
|
||
class ServiceUnitOut(ORM):
|
||
id: int
|
||
equipment_id: int
|
||
name: str
|
||
notes: str
|
||
mode: str
|
||
interval_hours: Optional[float]
|
||
interval_value: Optional[int]
|
||
interval_unit: Optional[str]
|
||
warn_percent: int
|
||
is_active: bool
|
||
last_service_at: Optional[datetime]
|
||
last_service_hours: float
|
||
# расчётные поля
|
||
rule: str = ""
|
||
state: str = "ok" # ok | soon | due | nodata
|
||
percent: int = 0
|
||
used_hours: Optional[float] = None
|
||
remaining_hours: Optional[float] = None
|
||
due_date: Optional[str] = None
|
||
remaining_days: Optional[int] = None
|
||
forecast_date: Optional[str] = None
|
||
|
||
|
||
class ServiceResetIn(BaseModel):
|
||
at: Optional[datetime] = None # когда обслужено (по умолчанию — сейчас)
|
||
hours: Optional[float] = Field(default=None, ge=0) # наработка на тот момент (по умолчанию — текущая)
|
||
note: str = Field(default="", max_length=1000)
|
||
|
||
|
||
class ServiceEventOut(ORM):
|
||
id: int
|
||
unit_id: int
|
||
at: datetime
|
||
hours: float
|
||
kind: str
|
||
order_id: Optional[int]
|
||
user_id: Optional[int]
|
||
user_name: Optional[str] = None
|
||
note: str
|
||
|
||
|
||
class MeterIn(BaseModel):
|
||
hours: float = Field(ge=0, le=100_000_000)
|
||
at: Optional[datetime] = None
|
||
correction: bool = False # счётчик заменён/обнулён: допускает значение меньше текущего
|
||
note: str = Field(default="", max_length=500)
|
||
|
||
|
||
class MeterOut(ORM):
|
||
id: int
|
||
equipment_id: int
|
||
hours: float
|
||
at: datetime
|
||
source: str
|
||
order_id: Optional[int]
|
||
user_id: Optional[int]
|
||
note: str
|
||
|
||
|
||
# ----- чек-листы -----
|
||
class ChecklistItemIn(BaseModel):
|
||
text: str
|
||
answer_type: AnswerType = "status"
|
||
options: Optional[list[str]] = None
|
||
unit: str = ""
|
||
min_value: Optional[float] = None
|
||
max_value: Optional[float] = None
|
||
required: bool = True
|
||
photo: Literal["none", "optional", "required"] = "none"
|
||
photo_keep: bool = False # фото хранить более года (не удаляются автоочисткой)
|
||
|
||
|
||
class ChecklistItemOut(ChecklistItemIn, ORM):
|
||
id: int
|
||
position: int
|
||
|
||
|
||
ChecklistKind = Literal["inspection", "regular"]
|
||
|
||
|
||
class ChecklistIn(BaseModel):
|
||
name: str
|
||
description: str = ""
|
||
kind: ChecklistKind = "inspection" # regular — регламентное обслуживание (узлы берутся из машины)
|
||
only_due: bool = False # regular: включать только узлы, которым «пора» или «скоро»
|
||
items: list[ChecklistItemIn] = []
|
||
|
||
|
||
class ChecklistOut(ORM):
|
||
id: int
|
||
name: str
|
||
description: str
|
||
kind: str = "inspection"
|
||
only_due: bool = False
|
||
items: list[ChecklistItemOut]
|
||
|
||
|
||
# ----- шаблоны нарядов (расписание) -----
|
||
class OrderTemplateIn(BaseModel):
|
||
title: str
|
||
checklist_id: int
|
||
equipment_id: Optional[int] = None
|
||
location_id: Optional[int] = None
|
||
assignee_user_id: Optional[int] = None
|
||
assignee_position_id: Optional[int] = None
|
||
reviewer_user_id: Optional[int] = None
|
||
reviewer_position_id: Optional[int] = None
|
||
due_hours: int = 24
|
||
recurrence: Literal["daily", "weekly", "monthly", "interval"] = "daily"
|
||
weekdays: list[int] = []
|
||
month_day: int = 1
|
||
interval_days: int = 7
|
||
run_time: time = time(8, 0)
|
||
start_date: Optional[date] = None
|
||
is_active: bool = True
|
||
|
||
|
||
class OrderTemplateOut(OrderTemplateIn, ORM):
|
||
id: int
|
||
start_date: date
|
||
next_run: Optional[datetime]
|
||
last_run: Optional[datetime]
|
||
|
||
|
||
# ----- наряды -----
|
||
class OrderIn(BaseModel):
|
||
title: str
|
||
checklist_id: int
|
||
equipment_id: Optional[int] = None
|
||
location_id: Optional[int] = None
|
||
assignee_user_id: Optional[int] = None
|
||
assignee_position_id: Optional[int] = None
|
||
reviewer_user_id: Optional[int] = None
|
||
reviewer_position_id: Optional[int] = None
|
||
due_at: Optional[datetime] = None
|
||
|
||
|
||
class PhotoOut(ORM):
|
||
id: int
|
||
created_at: datetime
|
||
user_id: Optional[int]
|
||
|
||
|
||
class OrderItemOut(ORM):
|
||
id: int
|
||
position: int
|
||
text: str
|
||
answer_type: str
|
||
options: Optional[list[str]]
|
||
unit: str
|
||
min_value: Optional[float]
|
||
max_value: Optional[float]
|
||
required: bool
|
||
value: Optional[str]
|
||
answered_at: Optional[datetime]
|
||
out_of_range: bool
|
||
photo: str
|
||
photo_keep: bool = False
|
||
service_unit_id: Optional[int] = None
|
||
is_meter: bool = False
|
||
photos: list[PhotoOut] = []
|
||
|
||
|
||
class OrderOut(BaseModel):
|
||
id: int
|
||
title: str
|
||
status: str
|
||
is_overdue: bool
|
||
template_id: Optional[int]
|
||
checklist_id: Optional[int]
|
||
equipment_id: Optional[int]
|
||
equipment_name: Optional[str]
|
||
location_id: Optional[int]
|
||
location_name: Optional[str]
|
||
assignee_user_id: Optional[int]
|
||
assignee_position_id: Optional[int]
|
||
assignee_name: Optional[str]
|
||
reviewer_user_id: Optional[int]
|
||
reviewer_position_id: Optional[int]
|
||
reviewer_name: Optional[str]
|
||
executor_id: Optional[int]
|
||
executor_name: Optional[str]
|
||
review_comment: str
|
||
created_at: datetime
|
||
updated_at: Optional[datetime] = None
|
||
due_at: Optional[datetime]
|
||
started_at: Optional[datetime]
|
||
submitted_at: Optional[datetime]
|
||
completed_at: Optional[datetime]
|
||
done_count: int
|
||
total_count: int
|
||
comments_count: int = 0
|
||
can_execute: bool = False
|
||
can_review: bool = False
|
||
items: Optional[list[OrderItemOut]] = None
|
||
|
||
|
||
class AnswerIn(BaseModel):
|
||
value: Optional[str] = None
|
||
|
||
|
||
class ReviewIn(BaseModel):
|
||
approve: bool
|
||
comment: str = ""
|
||
|
||
|
||
class CommentIn(BaseModel):
|
||
text: str = Field(min_length=1, max_length=4000)
|
||
|
||
|
||
class CommentOut(BaseModel):
|
||
id: int
|
||
user_id: Optional[int]
|
||
user_name: Optional[str]
|
||
text: str
|
||
created_at: datetime
|
||
|
||
|
||
class SettingsIn(BaseModel):
|
||
photo_dir: Optional[str] = None
|
||
max_photo_mb: Optional[int] = Field(default=None, ge=1, le=100)
|
||
vapid_contact: Optional[str] = None
|
||
fcm_credentials_path: Optional[str] = None
|
||
public_incidents: Optional[bool] = None # публичная форма /report включена
|
||
backup_auto_hours: Optional[int] = Field(default=None, ge=0, le=24 * 30)
|
||
backup_keep: Optional[int] = Field(default=None, ge=1, le=365)
|
||
backup_media: Optional[bool] = None
|
||
setup_done: Optional[str] = Field(default=None, max_length=1000)
|
||
crane_map: Optional[bool] = None # отдельная карта кранов
|
||
reliability: Optional[bool] = None # раздел «Анализ надёжности»
|
||
photo_keep_days: Optional[int] = Field(default=None, ge=0, le=36500) # удалять фото старше N дней (0 — не удалять)
|
||
|
||
|
||
class PushSubIn(BaseModel):
|
||
endpoint: str = Field(max_length=1000)
|
||
keys: dict = {}
|
||
|
||
@field_validator("endpoint")
|
||
@classmethod
|
||
def _endpoint_ok(cls, v):
|
||
if not is_allowed_push_endpoint(v):
|
||
raise ValueError("Недопустимый адрес push-сервиса")
|
||
return v
|
||
|
||
|
||
class SyncItemIn(BaseModel):
|
||
item_id: int
|
||
value: Optional[str] = None
|
||
answered_at: Optional[datetime] = None # когда ответ дан на устройстве (для разрешения конфликтов)
|
||
|
||
|
||
class SyncIn(BaseModel):
|
||
items: list[SyncItemIn] = []
|
||
submit: bool = False
|
||
|
||
|
||
class SyncItemResult(BaseModel):
|
||
item_id: int
|
||
status: Literal["ok", "stale", "error"]
|
||
detail: str = ""
|
||
|
||
|
||
class SyncOut(BaseModel):
|
||
results: list[SyncItemResult]
|
||
submitted: bool = False
|
||
submit_error: str = ""
|
||
order: OrderOut
|
||
|
||
|
||
class DeviceIn(BaseModel):
|
||
token: str = Field(min_length=10, max_length=300)
|
||
platform: str = "android"
|
||
|
||
|
||
# ================= Портал =================
|
||
Qty = Annotated[float, Field(gt=0, le=1_000_000)]
|
||
|
||
|
||
class WarehouseIn(BaseModel):
|
||
name: str = Field(min_length=1, max_length=150)
|
||
notes: str = ""
|
||
is_active: bool = True
|
||
|
||
|
||
class Zone(BaseModel):
|
||
"""Прямоугольник на карте, % от размеров картинки."""
|
||
x: float = Field(ge=0, le=100)
|
||
y: float = Field(ge=0, le=100)
|
||
w: float = Field(gt=0, le=100)
|
||
h: float = Field(gt=0, le=100)
|
||
|
||
|
||
class WarehouseOut(ORM):
|
||
id: int
|
||
name: str
|
||
notes: str
|
||
is_active: bool
|
||
zone: Optional[Zone] = None
|
||
positions: int = 0 # сколько разных позиций лежит
|
||
total_qty: float = 0.0
|
||
|
||
|
||
class ZoneIn(BaseModel):
|
||
zone: Optional[Zone] = None # null — убрать зону с карты
|
||
|
||
|
||
class InventoryItemIn(BaseModel):
|
||
name: str = Field(min_length=1, max_length=200)
|
||
category: str = Field(default="", max_length=100)
|
||
unit: str = Field(default="шт", max_length=20)
|
||
inventory_no: str = Field(default="", max_length=64)
|
||
notes: str = ""
|
||
is_active: bool = True
|
||
|
||
|
||
class InventoryItemOut(InventoryItemIn, ORM):
|
||
id: int
|
||
in_stock: float = 0.0 # на складах
|
||
issued: float = 0.0 # выдано сотрудникам
|
||
|
||
|
||
class StockOut(BaseModel):
|
||
item_id: int
|
||
item_name: str
|
||
category: str
|
||
unit: str
|
||
inventory_no: str
|
||
warehouse_id: Optional[int] = None
|
||
warehouse_name: Optional[str] = None
|
||
user_id: Optional[int] = None
|
||
user_name: Optional[str] = None
|
||
qty: float
|
||
since: datetime
|
||
|
||
|
||
class Place(BaseModel):
|
||
"""Где находится: склад или сотрудник (ровно одно)."""
|
||
warehouse_id: Optional[int] = None
|
||
user_id: Optional[int] = None
|
||
|
||
|
||
class MoveIn(BaseModel):
|
||
"""kind=receive: только to (поступление); writeoff: только from (списание); move: from и to (выдача, возврат, перемещение)."""
|
||
kind: Literal["receive", "move", "writeoff"]
|
||
item_id: int
|
||
qty: Qty = 1
|
||
src: Optional[Place] = Field(default=None, alias="from")
|
||
dst: Optional[Place] = Field(default=None, alias="to")
|
||
note: str = Field(default="", max_length=1000)
|
||
model_config = ConfigDict(populate_by_name=True)
|
||
|
||
|
||
class MoveOut(BaseModel):
|
||
id: int
|
||
item_id: int
|
||
item_name: str
|
||
unit: str
|
||
qty: float
|
||
kind: str
|
||
from_text: str
|
||
to_text: str
|
||
by_name: Optional[str]
|
||
at: datetime
|
||
note: str
|
||
|
||
|
||
RequestStatus = Literal["new", "approved", "ordered", "done", "rejected"]
|
||
|
||
|
||
class ToolRequestIn(BaseModel):
|
||
what: str = Field(min_length=2, max_length=300)
|
||
why: str = Field(min_length=2, max_length=4000)
|
||
link: str = Field(default="", max_length=1000)
|
||
qty: Qty = 1
|
||
|
||
@field_validator("link")
|
||
@classmethod
|
||
def _link_ok(cls, v):
|
||
v = v.strip()
|
||
if v and not re.match(r"https?://[^\s/$.?#][^\s]*$", v, re.I):
|
||
raise ValueError("Ссылка должна начинаться с http:// или https://")
|
||
return v
|
||
|
||
|
||
class ToolRequestAnswer(BaseModel):
|
||
status: RequestStatus
|
||
answer: str = Field(default="", max_length=4000)
|
||
|
||
|
||
class ToolRequestOut(ORM):
|
||
id: int
|
||
author_id: int
|
||
author_name: Optional[str] = None
|
||
what: str
|
||
why: str
|
||
link: str
|
||
qty: float
|
||
status: str
|
||
answer: str
|
||
handled_by_id: Optional[int]
|
||
handled_by_name: Optional[str] = None
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
|
||
|
||
class IncidentUnitIn(BaseModel):
|
||
unit_id: int
|
||
replaced: bool = False
|
||
|
||
|
||
IncidentKind = Literal["incident", "fault"]
|
||
|
||
|
||
class IncidentIn(BaseModel):
|
||
kind: IncidentKind = "incident" # incident — авария; fault — сообщение о неисправности (оборудование работает)
|
||
equipment_id: int
|
||
title: str = Field(min_length=2, max_length=200)
|
||
description: str = Field(default="", max_length=8000)
|
||
occurred_at: Optional[datetime] = None
|
||
units: list[IncidentUnitIn] = []
|
||
replaced: bool = False # была ли замена (в целом)
|
||
replacement_note: str = Field(default="", max_length=4000)
|
||
|
||
|
||
class PublicIncidentIn(BaseModel):
|
||
"""Публичная форма (без входа): место, машина, описание и контакты обязательны."""
|
||
location_id: int
|
||
equipment_id: int
|
||
kind: IncidentKind = "incident" # оборудование остановлено (incident) или работает с неисправностью (fault)
|
||
description: str = Field(min_length=5, max_length=4000)
|
||
title: str = Field(default="", max_length=200)
|
||
reporter_name: str = Field(min_length=2, max_length=150)
|
||
reporter_contact: str = Field(min_length=5, max_length=200)
|
||
website: str = "" # ловушка для ботов: люди это поле не видят и не заполняют
|
||
|
||
|
||
class ExternalIncidentIn(BaseModel):
|
||
"""Инцидент из внешней системы. Машина — по id портала или по инвентарному номеру."""
|
||
equipment_id: Optional[int] = None
|
||
inventory_no: Optional[str] = Field(default=None, max_length=64)
|
||
kind: IncidentKind = "incident"
|
||
title: str = Field(min_length=2, max_length=200)
|
||
description: str = Field(default="", max_length=8000)
|
||
occurred_at: Optional[datetime] = None
|
||
reporter_name: str = Field(default="", max_length=150)
|
||
reporter_contact: str = Field(default="", max_length=200)
|
||
external_id: str = Field(default="", max_length=100)
|
||
|
||
|
||
class ApiKeyIn(BaseModel):
|
||
name: str = Field(min_length=2, max_length=150)
|
||
|
||
|
||
class ApiKeyOut(ORM):
|
||
id: int
|
||
name: str
|
||
prefix: str
|
||
is_active: bool
|
||
created_at: datetime
|
||
last_used_at: Optional[datetime]
|
||
key: Optional[str] = None # только в ответе на создание
|
||
|
||
|
||
class IncidentCloseIn(BaseModel):
|
||
"""Закрытие отказа или неисправности. Для отказа незаполненные поля сервер подставляет сам
|
||
(начало работ — момент отказа, ввод — сейчас, наработка — снимок при регистрации), поэтому старые клиенты работают."""
|
||
note: str = Field(default="", max_length=4000) # что сделано (описание устранения)
|
||
repair_started_at: Optional[datetime] = None
|
||
restored_at: Optional[datetime] = None
|
||
parts_wait_hours: Optional[float] = Field(default=None, ge=0, le=100000)
|
||
hours_at_failure: Optional[float] = Field(default=None, ge=0, le=10_000_000)
|
||
meter_at_restore: Optional[float] = Field(default=None, ge=0, le=10_000_000)
|
||
cause_unit_id: Optional[int] = None
|
||
cause_unit_other: str = Field(default="", max_length=200)
|
||
action: Optional[Literal["repair", "replace", "adjust", "restart"]] = None
|
||
nature: Optional[Literal["sudden", "gradual"]] = None
|
||
cause: Optional[Literal["wear", "operation", "external", "power", "defect", "secondary", "unknown"]] = None
|
||
precursor: Optional[Literal["inspection", "fault_report", "none"]] = None
|
||
replaced_units: list[int] = [] # узлы, заменённые при ремонте (сброс ресурса)
|
||
|
||
|
||
class IncidentNoteIn(BaseModel):
|
||
note: str = Field(default="", max_length=4000)
|
||
|
||
|
||
class IncidentUnitOut(ORM):
|
||
unit_id: Optional[int]
|
||
unit_name: str
|
||
replaced: bool
|
||
|
||
|
||
class IncidentOut(ORM):
|
||
id: int
|
||
kind: str = "incident"
|
||
equipment_id: int
|
||
equipment_name: Optional[str] = None
|
||
author_id: Optional[int]
|
||
author_name: Optional[str] = None
|
||
occurred_at: datetime
|
||
created_at: datetime
|
||
title: str
|
||
description: str
|
||
replaced: bool
|
||
replacement_note: str
|
||
status: str
|
||
closed_at: Optional[datetime]
|
||
closed_by_name: Optional[str] = None
|
||
close_note: str
|
||
source: str = "staff"
|
||
reporter_name: str = ""
|
||
reporter_contact: str = ""
|
||
external_id: str = ""
|
||
units: list[IncidentUnitOut] = []
|
||
hours_at_open: Optional[float] = None
|
||
escalated_at: Optional[datetime] = None
|
||
repair_started_at: Optional[datetime] = None
|
||
restored_at: Optional[datetime] = None
|
||
parts_wait_hours: Optional[float] = None
|
||
hours_at_failure: Optional[float] = None
|
||
meter_at_restore: Optional[float] = None
|
||
cause_unit_id: Optional[int] = None
|
||
cause_unit_name: Optional[str] = None
|
||
cause_unit_other: str = ""
|
||
action: str = ""
|
||
nature: str = ""
|
||
cause: str = ""
|
||
precursor: str = ""
|
||
|
||
|
||
TaskStatus = Literal["assigned", "in_progress", "paused", "done", "cancelled"]
|
||
|
||
|
||
class TaskIn(BaseModel):
|
||
title: str = Field(min_length=2, max_length=200)
|
||
description: str = Field(default="", max_length=8000)
|
||
day: Optional[date] = None
|
||
assignee_id: Optional[int] = None # пусто — себе
|
||
equipment_id: Optional[int] = None
|
||
unit_id: Optional[int] = None
|
||
labor_hours: Optional[float] = Field(default=None, ge=0, le=1000)
|
||
photo_keep: bool = False # фото к заданию хранить более года
|
||
|
||
|
||
class TaskPatch(BaseModel):
|
||
title: Optional[str] = Field(default=None, min_length=2, max_length=200)
|
||
description: Optional[str] = Field(default=None, max_length=8000)
|
||
day: Optional[date] = None
|
||
assignee_id: Optional[int] = None
|
||
equipment_id: Optional[int] = None
|
||
unit_id: Optional[int] = None
|
||
labor_hours: Optional[float] = Field(default=None, ge=0, le=1000)
|
||
photo_keep: Optional[bool] = None
|
||
|
||
|
||
class TaskStatusIn(BaseModel):
|
||
status: TaskStatus
|
||
comment: str = Field(default="", max_length=4000)
|
||
|
||
|
||
class TaskEventOut(BaseModel):
|
||
at: datetime
|
||
user_name: Optional[str]
|
||
from_status: str
|
||
to_status: str
|
||
comment: str
|
||
kind: str = "status"
|
||
from_user_name: Optional[str] = None
|
||
to_user_name: Optional[str] = None
|
||
|
||
|
||
class TaskTransferIn(BaseModel):
|
||
assignee_id: int
|
||
comment: str = Field(min_length=1, max_length=4000)
|
||
|
||
|
||
class TaskOut(BaseModel):
|
||
id: int
|
||
title: str
|
||
description: str
|
||
day: date
|
||
assignee_id: int
|
||
assignee_name: Optional[str]
|
||
created_by_id: Optional[int]
|
||
created_by_name: Optional[str]
|
||
equipment_id: Optional[int]
|
||
equipment_name: Optional[str]
|
||
unit_id: Optional[int]
|
||
unit_name: Optional[str]
|
||
status: str
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
finished_at: Optional[datetime]
|
||
labor_hours: Optional[float] = None
|
||
photo_keep: bool = False
|
||
last_comment: str = ""
|
||
photos: list[int] = []
|
||
can_change: bool = False
|
||
can_edit: bool = False
|
||
can_transfer: bool = False
|
||
events: Optional[list[TaskEventOut]] = None
|
||
|
||
|
||
class MapMarkerIn(BaseModel):
|
||
"""Положение оборудования на карте; x/y = null — убрать с карты."""
|
||
x: Optional[float] = Field(default=None, ge=0, le=100)
|
||
y: Optional[float] = Field(default=None, ge=0, le=100)
|
||
|
||
|
||
class ProfileIn(BaseModel):
|
||
skills: Optional[str] = Field(default=None, max_length=4000)
|
||
phone: Optional[str] = Field(default=None, max_length=40)
|