Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
+911
@@ -0,0 +1,911 @@
|
||||
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
|
||||
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
|
||||
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
|
||||
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 # доступен раздел «Анализ надёжности» (включён и у пользователя есть права)
|
||||
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
|
||||
|
||||
|
||||
# ----- регламентное обслуживание: узлы машины -----
|
||||
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"
|
||||
|
||||
|
||||
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
|
||||
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 # раздел «Анализ надёжности»
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
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)
|
||||
Reference in New Issue
Block a user