openapi: 3.1.0
info:
  title: wattmen ERP API
  version: 0.8.0
  description: |
    REST API мультиклиентской ERP для монтажных компаний.

    **Деньги** передаются целыми числами в КОПЕЙКАХ (250 000 ₽ = 25000000).
    **Время** — UTC в формате ISO-8601 с «Z»; показывать в часовом поясе пользователя.
    Исключение — плановое время в графике работ (`time_start`/`time_end`): это локальное
    время компании вида «с 8:00», оно не привязано к моменту и в UTC не переводится.

    Авторизация — либо сессионная кука (браузер), либо личный токен
    `Authorization: Bearer wm_…`, который пользователь выпускает в профиле.

    Все тенантские эндпоинты живут под префиксом `/{slug}/api/`, где `slug` —
    адрес компании (например `/demo/api/auth/login`). Авторизация — сессионная
    кука (per-slug, HttpOnly, 30 дней), выдаётся на `POST auth/login`.
    Ответы об ошибках: `{"error": "текст"}` (422/403/404) либо стандартный
    формат Yii `{"name", "message", "status"}` для системных ошибок.
servers:
  - url: https://app.wattmen.ru/{slug}/api
    variables:
      slug:
        default: demo
        description: Адрес (slug) компании

tags:
  - name: auth
  - name: profile
  - name: users
  - name: roles
  - name: invites
  - name: objects
  - name: customers
  - name: schedule
  - name: money
  - name: legal
  - name: dadata
  - name: system

paths:
  /health:
    get:
      tags: [system]
      summary: Проверка живости тенанта
      security: []
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: {type: string, example: ok}
                  time: {type: string, format: date-time}
                  tenant: {type: [string, 'null'], example: demo}
                  readonly: {type: boolean}
                  db: {type: boolean}

  /auth/login:
    post:
      tags: [auth]
      summary: Вход по телефону и паролю
      description: Телефон нормализуется («8 900...», «+7 (900)...» → +79001234567).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone, password]
              properties:
                phone: {type: string, example: '+79001234567'}
                password: {type: string}
      responses:
        '200':
          description: Успех, ставится сессионная кука
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Me'}
        '422': {$ref: '#/components/responses/Error'}
        '403': {description: Заблокирован или инвайт не принят}

  /auth/logout:
    post:
      tags: [auth]
      summary: Выход (текущая сессия)
      responses:
        '200': {$ref: '#/components/responses/Ok'}

  /auth/me:
    get:
      tags: [auth]
      summary: Текущий пользователь
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Me'}
        '403': {description: Не авторизован}

  /auth/accept-invite:
    post:
      tags: [auth]
      summary: Завершение регистрации по приглашению
      description: Email берётся из инвайта и считается подтверждённым. После успеха пользователь сразу залогинен.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, phone, password, first_name]
              properties:
                token: {type: string, description: Токен из ссылки-приглашения}
                phone: {type: string}
                password: {type: string, minLength: 8}
                first_name: {type: string}
                last_name: {type: string}
                middle_name: {type: string}
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Me'}
        '422': {$ref: '#/components/responses/Error'}

  /invites:
    get:
      tags: [invites]
      summary: Список приглашений
      description: Требует permission `user.manage`.
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/Invite'}
    post:
      tags: [invites]
      summary: Пригласить пользователя по email
      description: |
        Требует permission `user.manage`. Ссылка-приглашение возвращается в ответе
        (и отправляется письмом, если настроен SMTP). Токен живёт 72 часа.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: {type: string, format: email}
                role: {type: string, example: manager, description: Код роли (owner/manager/installer)}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: {type: integer}
                  link: {type: string, example: 'https://app.wattmen.ru/demo/invite/abc...'}
                  mail_sent: {type: boolean}
        '422': {$ref: '#/components/responses/Error'}

  /invites/{id}:
    delete:
      tags: [invites]
      summary: Отозвать неиспользованное приглашение
      parameters: [{$ref: '#/components/parameters/id'}]
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '404': {$ref: '#/components/responses/Error'}

  /profile:
    patch:
      tags: [profile]
      summary: Изменить ФИО
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                last_name: {type: string}
                first_name: {type: string, minLength: 2}
                middle_name: {type: string}
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '422': {$ref: '#/components/responses/Error'}

  /profile/password:
    post:
      tags: [profile]
      summary: Смена пароля
      description: Все остальные сессии пользователя завершаются, текущая остаётся.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current, new]
              properties:
                current: {type: string}
                new: {type: string, minLength: 8}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: {type: boolean}
                  revoked_sessions: {type: integer}
        '422': {$ref: '#/components/responses/Error'}

  /profile/tokens:
    get:
      tags: [profile]
      summary: Личные токены доступа к API
      description: Сам токен нигде не хранится — в списке видно только его начало.
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/ApiToken'}
        '403': {description: 'По токену профиль недоступен — нужен вход в браузере'}
    post:
      tags: [profile]
      summary: Выпустить токен
      description: >
        Единственный ответ, где возвращается сам токен (`secret`). Больше его показать
        неоткуда: в базе лежит только SHA-256. Максимум 10 действующих токенов на человека.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string, maxLength: 100, example: 'Выгрузка в 1С'}
                permissions:
                  type: [array, 'null']
                  items: {type: string}
                  example: ['object.view', 'customer.view']
                  description: >
                    Что токену разрешено. Пропущено или null — все права владельца.
                    Список всегда пересекается с правами владельца: расширить
                    собственные полномочия токеном нельзя. Коды — из GET /permissions.
      responses:
        '201':
          content:
            application/json:
              schema:
                allOf:
                  - {$ref: '#/components/schemas/ApiToken'}
                  - type: object
                    properties:
                      secret: {type: string, example: 'wm_9f3a1c…', description: Показывается один раз}
        '422': {$ref: '#/components/responses/Error'}

  /profile/tokens/{id}:
    parameters:
      - {$ref: '#/components/parameters/id'}
    delete:
      tags: [profile]
      summary: Отозвать токен
      description: Строка удаляется, восстановить токен нельзя — только выпустить новый.
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '404': {$ref: '#/components/responses/Error'}

  /profile/sessions:
    get:
      tags: [profile]
      summary: Активные сессии (устройства)
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/Session'}

  /profile/sessions/{hash}:
    delete:
      tags: [profile]
      summary: Завершить сессию
      description: Если завершается текущая — это выход.
      parameters:
        - name: hash
          in: path
          required: true
          schema: {type: string, pattern: '^[a-f0-9]{32}$'}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: {type: boolean}
                  current: {type: boolean, description: true, если завершена текущая сессия}
        '404': {$ref: '#/components/responses/Error'}

  /profile/sessions/revoke-others:
    post:
      tags: [profile]
      summary: Завершить все сессии, кроме текущей
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: {type: boolean}
                  revoked: {type: integer}

  /objects:
    get:
      tags: [objects]
      summary: Список объектов
      description: Требует `object.view`. Сортировка — по колонкам канбана и позиции в колонке.
      parameters:
        - {name: column_id, in: query, schema: {type: integer}}
        - {name: search, in: query, schema: {type: string}, description: 'По названию, адресу, заказчику'}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/ObjectListItem'}
    post:
      tags: [objects]
      summary: Создать объект
      description: |
        Требует `object.manage`. Без `column_id` объект попадает в системную колонку
        «Неразобранное», в конец. При переданном `work_sum` источник суммы = manual.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ObjectWrite'}
      responses:
        '201':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Object'}
        '422': {$ref: '#/components/responses/Error'}

  /objects/{id}:
    get:
      tags: [objects]
      summary: Карточка объекта
      parameters: [{$ref: '#/components/parameters/id'}]
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Object'}
        '404': {$ref: '#/components/responses/Error'}
    patch:
      tags: [objects]
      summary: Изменить объект (в т.ч. перенос по канбану)
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody:
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ObjectWrite'}
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Object'}
        '422': {$ref: '#/components/responses/Error'}
    delete:
      tags: [objects]
      summary: Удалить объект
      parameters: [{$ref: '#/components/parameters/id'}]
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '404': {$ref: '#/components/responses/Error'}

  /customers:
    get:
      tags: [customers]
      summary: Поиск заказчиков
      description: Требует `customer.view`. До 50 результатов.
      parameters:
        - {name: search, in: query, schema: {type: string}, description: По имени или телефону}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/Customer'}
    post:
      tags: [customers]
      summary: Создать заказчика
      description: Требует `customer.manage`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, type_id]
              properties:
                name: {type: string}
                phone: {type: string}
                phone2: {type: string}
                email: {type: string, format: email}
                comment: {type: string}
                type_id: {type: integer, description: Обязателен — заказчик не может быть без типа}
      responses:
        '201':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Customer'}
        '422': {$ref: '#/components/responses/Error'}

  /customers/{id}:
    patch:
      tags: [customers]
      summary: Изменить заказчика
      description: Требует `customer.manage`.
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                phone: {type: string}
                phone2: {type: string}
                email: {type: string}
                comment: {type: string}
                type_id: {type: integer}
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Customer'}
        '422': {$ref: '#/components/responses/Error'}
    delete:
      tags: [customers]
      summary: Удалить заказчика
      description: Разрешено только если к заказчику не привязаны объекты.
      parameters: [{$ref: '#/components/parameters/id'}]
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '422': {$ref: '#/components/responses/Error'}

  /customer-types:
    get:
      tags: [customers]
      summary: Типы заказчиков (справочник, включая неактивные)
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/CustomerType'}
    post:
      tags: [customers]
      summary: Создать тип заказчика
      description: Требует `customer.manage`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string}
                duration_factor: {type: number, default: 1}
                sort: {type: integer}
                is_active: {type: boolean, default: true}
      responses:
        '201':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CustomerType'}
        '422': {$ref: '#/components/responses/Error'}

  /customer-types/{id}:
    patch:
      tags: [customers]
      summary: Изменить тип заказчика
      parameters: [{$ref: '#/components/parameters/id'}]
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CustomerType'}
        '422': {$ref: '#/components/responses/Error'}
    delete:
      tags: [customers]
      summary: Удалить тип заказчика
      description: Разрешено только для неиспользуемых типов; используемые — деактивировать.
      parameters: [{$ref: '#/components/parameters/id'}]
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '422': {$ref: '#/components/responses/Error'}

  /legal-entities:
    get:
      tags: [legal]
      summary: Юрлица с количеством банков, счетов и заказчиков
      parameters:
        - {name: own, in: query, schema: {type: integer, enum: [0, 1]}, description: 'Только свои (1) или только контрагентов (0)'}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/LegalEntity'}
    post:
      tags: [legal]
      summary: Добавить юрлицо
      description: Реквизиты обычно приходят из GET /dadata/party по ИНН, но заполнить можно и вручную.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/LegalEntity'}
      responses:
        '201':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/LegalEntity'}
        '422': {$ref: '#/components/responses/Error'}

  /legal-entities/{id}:
    parameters:
      - {$ref: '#/components/parameters/id'}
    get:
      tags: [legal]
      summary: Карточка юрлица вместе с банками и счетами
      responses:
        '200':
          content:
            application/json:
              schema:
                allOf:
                  - {$ref: '#/components/schemas/LegalEntity'}
                  - type: object
                    properties:
                      banks:
                        type: array
                        items: {$ref: '#/components/schemas/Bank'}
                      accounts:
                        type: array
                        items: {$ref: '#/components/schemas/BankAccount'}
        '404': {$ref: '#/components/responses/Error'}
    patch:
      tags: [legal]
      summary: Изменить реквизиты
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/LegalEntity'}
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/LegalEntity'}
        '422': {$ref: '#/components/responses/Error'}
    delete:
      tags: [legal]
      summary: Удалить юрлицо
      description: Банки и счета удаляются вместе с ним. Если на юрлицо ссылаются заказчики — 422.
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '422': {$ref: '#/components/responses/Error'}

  /legal-entities/{id}/banks:
    parameters:
      - {$ref: '#/components/parameters/id'}
    post:
      tags: [legal]
      summary: Добавить банк юрлицу
      description: Заполняется по БИК из GET /dadata/bank — вместе с корсчётом.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/Bank'}
      responses:
        '201':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Bank'}
        '422': {$ref: '#/components/responses/Error'}

  /banks/{id}:
    parameters:
      - {$ref: '#/components/parameters/id'}
    patch:
      tags: [legal]
      summary: Изменить банк
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/Bank'}
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Bank'}
        '422': {$ref: '#/components/responses/Error'}
    delete:
      tags: [legal]
      summary: Удалить банк
      description: Пока в банке открыты счета — 422.
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '422': {$ref: '#/components/responses/Error'}

  /legal-entities/{id}/accounts:
    parameters:
      - {$ref: '#/components/parameters/id'}
    post:
      tags: [legal]
      summary: Открыть расчётный счёт
      description: >
        Счёт открыт в банке, но принадлежит юрлицу: банк обязан принадлежать тому же юрлицу.
        Основной счёт у юрлица один — назначение нового снимает отметку со старого.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/BankAccount'}
      responses:
        '201':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/BankAccount'}
        '422': {$ref: '#/components/responses/Error'}

  /bank-accounts/{id}:
    parameters:
      - {$ref: '#/components/parameters/id'}
    patch:
      tags: [legal]
      summary: Изменить счёт
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/BankAccount'}
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/BankAccount'}
        '422': {$ref: '#/components/responses/Error'}
    delete:
      tags: [legal]
      summary: Удалить счёт
      responses:
        '200': {$ref: '#/components/responses/Ok'}

  /account-types:
    get:
      tags: [money]
      summary: Типы счетов
      description: Справочник компании. Системный только признак is_cash — по нему сводка делит деньги.
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/AccountType'}
    post:
      tags: [money]
      summary: Добавить тип счёта
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/AccountType'}
      responses:
        '201':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/AccountType'}

  /accounts:
    get:
      tags: [money]
      summary: Счета с балансами
      description: >
        Возвращает счета, доступные пользователю: свои, общие и открытые ему по списку.
        Право finance.all открывает все. Баланс = остаток на начало плюс сумма операций.
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/Account'}
    post:
      tags: [money]
      summary: Завести счёт
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/Account'}
      responses:
        '201':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Account'}
        '422': {$ref: '#/components/responses/Error'}

  /accounts/{id}:
    parameters:
      - {$ref: '#/components/parameters/id'}
    get:
      tags: [money]
      summary: Счёт
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Account'}
        '404': {$ref: '#/components/responses/Error'}
    patch:
      tags: [money]
      summary: Изменить счёт
      description: Менять счёт может его ответственный или обладатель права finance.all.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/Account'}
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Account'}
        '403': {$ref: '#/components/responses/Error'}
    delete:
      tags: [money]
      summary: Удалить счёт
      description: Со операциями счёт не удаляется — это история денег; его можно только закрыть.
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '422': {$ref: '#/components/responses/Error'}

  /money:
    get:
      tags: [money]
      summary: Лента операций
      parameters:
        - {name: from, in: query, schema: {type: string, format: date}}
        - {name: to, in: query, schema: {type: string, format: date}}
        - {name: accountId, in: query, schema: {type: integer}}
        - {name: categoryId, in: query, schema: {type: integer}}
        - {name: customerId, in: query, schema: {type: integer}}
        - {name: objectId, in: query, schema: {type: integer}, description: Операции, отнесённые на объект}
        - {name: kind, in: query, schema: {type: string, enum: [income, expense, transfer]}}
        - {name: limit, in: query, schema: {type: integer, default: 200, maximum: 1000}}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/MoneyTx'}
    post:
      tags: [money]
      summary: Приход или расход
      description: >
        Сумма передаётся положительной, знак система ставит сама по типу операции:
        приход хранится плюсом, расход минусом. Переводы создаются через /money/transfer.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MoneyTxInput'}
      responses:
        '201':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/MoneyTx'}
        '422': {$ref: '#/components/responses/Error'}

  /money/transfer:
    post:
      tags: [money]
      summary: Перевод между своими счетами
      description: >
        Создаёт ДВЕ операции с общим transfer_id: списание и зачисление.
        В сводке перевод не считается ни доходом, ни расходом — деньги не появились.
        Удаление любой из половин удаляет обе.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from_account_id, to_account_id, amount]
              properties:
                from_account_id: {type: integer}
                to_account_id: {type: integer}
                amount: {type: integer, description: КОПЕЙКИ, положительное}
                date: {type: string, format: date}
                comment: {type: [string, 'null']}
      responses:
        '201':
          content:
            application/json:
              schema:
                type: object
                properties:
                  transfer_id: {type: string}
                  transactions:
                    type: array
                    items: {$ref: '#/components/schemas/MoneyTx'}
        '422': {$ref: '#/components/responses/Error'}

  /money/{id}:
    parameters:
      - {$ref: '#/components/parameters/id'}
    patch:
      tags: [money]
      summary: Изменить операцию
      description: >
        У перевода правятся только дата и комментарий — они синхронно меняются
        в обеих половинах, иначе они разъедутся.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MoneyTxInput'}
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/MoneyTx'}
    delete:
      tags: [money]
      summary: Удалить операцию
      responses:
        '200': {$ref: '#/components/responses/Ok'}

  /money/summary:
    get:
      tags: [money]
      summary: Сводка ДДС за период
      description: Переводы между своими счетами в оборот не попадают.
      parameters:
        - {name: from, in: query, schema: {type: string, format: date}}
        - {name: to, in: query, schema: {type: string, format: date}}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  from: {type: string, format: date}
                  to: {type: string, format: date}
                  income: {type: integer, description: КОПЕЙКИ}
                  expense: {type: integer, description: 'КОПЕЙКИ, отрицательное'}
                  profit: {type: integer}
                  balance: {type: integer, description: Остаток на всех доступных счетах}
                  balance_cash: {type: integer}
                  balance_bank: {type: integer}
                  categories:
                    type: array
                    items:
                      type: object
                      properties:
                        category_id: {type: [integer, 'null']}
                        name: {type: string}
                        color: {type: [string, 'null']}
                        kind: {type: string}
                        total: {type: integer}
                        count: {type: integer}

  /money/payroll:
    get:
      tags: [money]
      summary: Ведомость зарплаты
      description: >
        Начисление считается на лету из табеля, условий оплаты и производственного календаря;
        нигде не хранится, поэтому правка часов сразу видна в расчёте.
        Почасовая и подённая оплата берутся по дням (полсмены — половина ставки),
        процент начисляется один раз днём последнего выхода на объект.
        Условия с объекта важнее условий из карточки человека.
        Выплаченным считается расход со статьёй is_payroll и указанным сотрудником.
      parameters:
        - {name: from, in: query, schema: {type: string, format: date}}
        - {name: to, in: query, schema: {type: string, format: date}}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  from: {type: string, format: date}
                  to: {type: string, format: date}
                  accrued: {type: integer}
                  paid: {type: integer}
                  balance: {type: integer, description: Сколько остались должны людям}
                  people:
                    type: array
                    items: {$ref: '#/components/schemas/PayrollPerson'}

  /my/payroll:
    get:
      tags: [money]
      summary: Своя зарплата
      description: Доступна любому авторизованному — расчёт всегда только по себе.
      parameters:
        - {name: from, in: query, schema: {type: string, format: date}}
        - {name: to, in: query, schema: {type: string, format: date}}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  from: {type: string, format: date}
                  to: {type: string, format: date}
                  person:
                    oneOf:
                      - {$ref: '#/components/schemas/PayrollPerson'}
                      - {type: 'null'}

  /money/debts:
    get:
      tags: [money]
      summary: Долги заказчиков
      description: >
        Сравнивает стоимость объектов заказчика (работы плюс материалы) с полученными
        от него деньгами. Приход, разнесённый на объект, гасит долг по этому объекту;
        неразнесённый остаток платежа с указанным заказчиком тоже идёт в зачёт —
        иначе аванс «за всё сразу» выглядел бы как неоплата.
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  total_plan: {type: integer}
                  total_received: {type: integer}
                  total_debt: {type: integer}
                  customers:
                    type: array
                    items:
                      type: object
                      properties:
                        customer_id: {type: integer}
                        name: {type: string}
                        objects_count: {type: integer}
                        plan: {type: integer}
                        received: {type: integer}
                        debt: {type: integer}

  /money-categories:
    get:
      tags: [money]
      summary: Статьи движения денег
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/MoneyCategory'}
    post:
      tags: [money]
      summary: Добавить статью
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MoneyCategory'}
      responses:
        '201':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/MoneyCategory'}

  /objects/{id}/money:
    parameters:
      - {$ref: '#/components/parameters/id'}
    get:
      tags: [money]
      summary: Экономика объекта
      description: >
        Считается по разнесению операций на объект. Затраты объекта — это деньги
        на материалы и прочее ПЛЮС начисленная бригаде работа; выплаченная зарплата
        вторым разом не учитывается, иначе она задвоила бы расход.
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  object_id: {type: integer}
                  work_sum: {type: integer, description: План по работам, КОПЕЙКИ}
                  material_sum: {type: integer, description: План по материалам}
                  plan: {type: integer, description: Работы плюс материалы}
                  income: {type: integer, description: Получено от заказчика}
                  expense: {type: integer, description: Все расходы деньгами}
                  expense_other: {type: integer, description: Расходы без выплат зарплаты}
                  expense_payroll: {type: integer, description: Выплаченная зарплата}
                  accrued: {type: integer, description: Начислено бригаде по табелю и процентам}
                  costs: {type: integer, description: Затраты объекта = расходы без ЗП плюс начисления}
                  profit: {type: integer, description: По факту полученных денег}
                  profit_expected: {type: integer, description: Если заказчик заплатит всё по договору}
                  left_to_receive: {type: integer}
                  categories: {type: array, items: {type: object}}
                  transactions:
                    type: array
                    items: {$ref: '#/components/schemas/MoneyTx'}

  /dadata/party:
    get:
      tags: [dadata]
      summary: Организации и ИП (прокси DaData)
      description: >
        По ИНН/ОГРН отдаёт точную запись, по названию — подсказки. Запросы короче 3 символов
        возвращают пустой список. Ключ DaData хранится на сервере и наружу не попадает.
      parameters:
        - {name: q, in: query, required: true, schema: {type: string}, example: '6163208662'}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  suggestions:
                    type: array
                    items:
                      type: object
                      properties:
                        value: {type: string, example: 'ООО «Ромашка»'}
                        full_name: {type: string}
                        kind: {type: string, enum: [legal, individual]}
                        inn: {type: [string, 'null']}
                        kpp: {type: [string, 'null']}
                        ogrn: {type: [string, 'null']}
                        address: {type: [string, 'null']}
                        director: {type: [string, 'null']}
                        status: {type: [string, 'null'], description: 'ACTIVE, LIQUIDATED и т.п. из ЕГРЮЛ'}
        '503': {description: Ключ DaData не настроен}

  /dadata/bank:
    get:
      tags: [dadata]
      summary: Банки по БИК или названию (прокси DaData)
      parameters:
        - {name: q, in: query, required: true, schema: {type: string}, example: '046015207'}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  suggestions:
                    type: array
                    items:
                      type: object
                      properties:
                        value: {type: string}
                        bic: {type: [string, 'null']}
                        corr_account: {type: [string, 'null']}
                        address: {type: [string, 'null']}
                        swift: {type: [string, 'null']}
                        status: {type: [string, 'null']}
        '503': {description: Ключ DaData не настроен}

  /users:
    get:
      tags: [users]
      summary: Пользователи компании
      description: Требует `user.view`.
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/User'}

  /users/{id}:
    patch:
      tags: [users]
      summary: Изменить пользователя (статус, ФИО)
      description: |
        Требует `user.manage`. Блокировка завершает все сессии пользователя.
        Себе статус менять нельзя; последнего активного владельца заблокировать нельзя.
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                status: {type: string, enum: [invited, active, blocked]}
                last_name: {type: string}
                first_name: {type: string}
                middle_name: {type: string}
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '422': {$ref: '#/components/responses/Error'}

  /users/{id}/roles:
    put:
      tags: [users]
      summary: Назначить роли пользователю (полная замена набора)
      description: Требует `user.manage`. Нельзя снять роль владельца у последнего активного владельца.
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [roles]
              properties:
                roles:
                  type: array
                  items: {type: integer}
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '422': {$ref: '#/components/responses/Error'}

  /roles:
    get:
      tags: [roles]
      summary: Роли и их права
      description: 'Требует `user.view`. У владельца всегда `permissions: ["*"]`.'
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: {$ref: '#/components/schemas/Role'}
    post:
      tags: [roles]
      summary: Создать роль
      description: Требует `user.manage`. Код роли генерится из названия (транслит).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string}
                permissions:
                  type: array
                  items: {type: string}
                  description: Коды из GET /permissions
      responses:
        '201':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Role'}
        '422': {$ref: '#/components/responses/Error'}

  /roles/{id}:
    patch:
      tags: [roles]
      summary: Изменить роль
      description: |
        Права владельца не редактируются. У системных ролей можно менять
        только набор прав, но не название.
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                permissions:
                  type: array
                  items: {type: string}
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Role'}
        '422': {$ref: '#/components/responses/Error'}
    delete:
      tags: [roles]
      summary: Удалить роль
      description: Только несистемную, без назначенных пользователей и активных приглашений.
      parameters: [{$ref: '#/components/parameters/id'}]
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '422': {$ref: '#/components/responses/Error'}

  /permissions:
    get:
      tags: [roles]
      summary: Реестр прав (фиксирован в коде)
      description: Список для конструктора ролей. Новые права появляются вместе с фичами.
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    code: {type: string, example: object.manage}
                    group: {type: string, example: Объекты}
                    label: {type: string, example: Создание и редактирование объектов}

  /config:
    get:
      tags: [system]
      summary: Публичные настройки SPA
      security: []
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  maps:
                    type: object
                    properties:
                      provider: {type: string, example: yandex}
                      api_key: {type: [string, 'null'], description: Ключ JS API Яндекс.Карт}

  /dadata/address:
    get:
      tags: [dadata]
      summary: Подсказки адресов (прокси DaData)
      description: Запросы короче 3 символов возвращают пустой список.
      parameters:
        - {name: q, in: query, required: true, schema: {type: string}}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  suggestions:
                    type: array
                    items:
                      type: object
                      properties:
                        value: {type: string, example: 'г Краснодар, ул Красная, д 1'}
                        lat: {type: [number, 'null']}
                        lng: {type: [number, 'null']}
                        city: {type: [string, 'null']}
                        postal_code: {type: [string, 'null']}
        '503': {description: Ключ DaData не настроен}

  /schedule:
    get:
      tags: [schedule]
      summary: График работ за период
      description: >
        Возвращает строки графика (бригады и активных людей) и слоты, пересекающие окно.
        Слот с time_start/time_end занимает не весь день, а окно внутри каждого своего дня —
        это позволяет строить разрез по часам.
      parameters:
        - {name: from, in: query, schema: {type: string, format: date}, description: 'По умолчанию понедельник текущей недели'}
        - {name: to, in: query, schema: {type: string, format: date}, description: 'По умолчанию from + 27 дней'}
        - name: objectId
          in: query
          schema: {type: integer}
          description: >
            Все выходы на конкретный объект без ограничения по датам —
            карточка объекта показывает так свою связь с графиком.
            При заданном objectId параметры from/to игнорируются.
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  from: {type: string, format: date}
                  to: {type: string, format: date}
                  crews:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: integer}
                        name: {type: string}
                  people:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: integer}
                        name: {type: string}
                  slots:
                    type: array
                    items: {$ref: '#/components/schemas/ScheduleSlot'}
    post:
      tags: [schedule]
      summary: Поставить объект в график
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ScheduleSlotInput'}
      responses:
        '201':
          description: Слот создан; conflicts — предупреждение о занятости, не ошибка
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ScheduleSlotResult'}
        '422': {$ref: '#/components/responses/Error'}

  /schedule/{id}:
    parameters:
      - {$ref: '#/components/parameters/id'}
    patch:
      tags: [schedule]
      summary: Перенести, растянуть или переназначить слот
      description: >
        Принимает любое подмножество полей. Перетаскивание в интерфейсе шлёт сюда же:
        сдвиг по дням меняет date_start/date_end, сдвиг по часам — time_start/time_end,
        перенос на другую строку — crew_id/user_id. Пустая строка в time_* убирает окно
        и возвращает слот на весь день.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ScheduleSlotInput'}
      responses:
        '200':
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ScheduleSlotResult'}
        '404': {$ref: '#/components/responses/Error'}
        '422': {$ref: '#/components/responses/Error'}
    delete:
      tags: [schedule]
      summary: Убрать объект из графика
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '404': {$ref: '#/components/responses/Error'}

  /schedule/{id}/attendance:
    parameters:
      - {$ref: '#/components/parameters/id'}
    get:
      tags: [schedule]
      summary: Табель по слоту
      description: >
        Генерится автоматически по составу бригады на каждый день слота.
        Часы по умолчанию берутся из рабочего окна, без окна — полная смена 8 часов.
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: {type: integer}
                    user_id: {type: integer}
                    name: {type: string}
                    date: {type: string, format: date}
                    hours: {type: integer, minimum: 0, maximum: 24}
                    absence_id: {type: [integer, 'null']}
                    absence_name: {type: [string, 'null']}
                    absence_color: {type: [string, 'null']}
    patch:
      tags: [schedule]
      summary: Отметить часы или отсутствие
      description: Часы и вид отсутствия взаимоисключающие — при отсутствии часы обнуляются.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [work_day_id]
              properties:
                work_day_id: {type: integer}
                hours: {type: integer, minimum: 0, maximum: 24}
                absence_id: {type: [integer, 'null']}
      responses:
        '200': {$ref: '#/components/responses/Ok'}
        '404': {$ref: '#/components/responses/Error'}
        '422': {$ref: '#/components/responses/Error'}

components:
  securitySchemes:
    session:
      type: apiKey
      in: cookie
      name: wattmen_session
      description: >
        Сессионная кука, выдаётся на POST auth/login. Своя на каждого клиента (slug),
        HttpOnly, Secure, срок 30 дней. Используется браузерным приложением.
    token:
      type: http
      scheme: bearer
      description: >
        Личный токен доступа вида `wm_…`, выпускается пользователем в профиле.
        Передаётся заголовком `Authorization: Bearer wm_…`, сессию не создаёт.
        Действует от имени владельца и строго в рамках его прав; управлять
        профилем (пароль, почта, 2FA, сами токены) по токену нельзя.
  parameters:
    id:
      name: id
      in: path
      required: true
      schema: {type: integer}
  responses:
    Ok:
      description: Успех
      content:
        application/json:
          schema:
            type: object
            properties:
              ok: {type: boolean}
    Error:
      description: Ошибка
      content:
        application/json:
          schema:
            type: object
            properties:
              error: {type: string}
  schemas:
    Me:
      type: object
      properties:
        user:
          type: object
          properties:
            id: {type: integer}
            phone: {type: string}
            email: {type: [string, 'null']}
            last_name: {type: [string, 'null']}
            first_name: {type: string}
            middle_name: {type: [string, 'null']}
            full_name: {type: string}
        roles:
          type: array
          items: {type: string}
        permissions:
          type: array
          items: {type: string}
          description: «*» — все права (owner)
        tenant:
          type: object
          properties:
            slug: {type: string}
            name: {type: string}
        readonly: {type: boolean}
    Invite:
      type: object
      properties:
        id: {type: integer}
        email: {type: string}
        role: {type: string}
        expires_at: {type: string}
        used_at: {type: [string, 'null']}
        created_at: {type: string}
    Session:
      type: object
      properties:
        id: {type: string, description: Хеш сессии (не сырой id)}
        current: {type: boolean}
        ip: {type: string}
        device: {type: string, example: 'Chrome · Windows'}
        last_seen: {type: string}
        created_at: {type: string}
    User:
      type: object
      properties:
        id: {type: integer}
        phone: {type: string}
        email: {type: [string, 'null']}
        last_name: {type: [string, 'null']}
        first_name: {type: string}
        middle_name: {type: [string, 'null']}
        full_name: {type: string}
        status: {type: string, enum: [invited, active, blocked]}
        last_login_at: {type: [string, 'null']}
        is_me: {type: boolean}
        pay_type: {type: [string, 'null'], enum: [percent, per_day, null], description: Как считается оплата за работы}
        rate: {type: integer, description: 'percent — проценты; per_day — копейки за выход'}
        productivity: {type: [integer, 'null'], description: Копейки работ в день
        }
        crews:
          type: array
          items:
            type: object
            properties:
              id: {type: integer}
              name: {type: string}
        roles:
          type: array
          items:
            type: object
            properties:
              id: {type: integer}
              code: {type: string}
              name: {type: string}
    Role:
      type: object
      properties:
        id: {type: integer}
        code: {type: string}
        name: {type: string}
        is_system: {type: boolean}
        permissions:
          type: array
          items: {type: string}
          description: '["*"] у владельца'
        users_count: {type: integer}
    Customer:
      type: object
      properties:
        id: {type: integer}
        name: {type: string}
        phone: {type: [string, 'null']}
        phone2: {type: [string, 'null']}
        email: {type: [string, 'null']}
        comment: {type: [string, 'null']}
        type_id: {type: [integer, 'null']}
        type_name: {type: [string, 'null']}
        objects_count: {type: integer}
    CustomerType:
      type: object
      properties:
        id: {type: integer}
        name: {type: string, example: Прораб}
        duration_factor: {type: number, example: 1.5, description: Коэффициент длительности для графика}
        sort: {type: integer}
        is_active: {type: boolean}
    ObjectWrite:
      type: object
      properties:
        name: {type: string}
        address: {type: [string, 'null']}
        lat: {type: [number, 'null']}
        lng: {type: [number, 'null']}
        customer_id: {type: [integer, 'null']}
        column_id: {type: integer}
        sort_in_column: {type: integer}
        work_sum: {type: [integer, 'null'], description: Сумма работ в КОПЕЙКАХ}
        material_sum: {type: [integer, 'null'], description: Материалы в КОПЕЙКАХ}
        tg_link: {type: [string, 'null']}
        comment: {type: [string, 'null']}
        planned: {type: boolean}
        parent_id: {type: [integer, 'null'], description: Подкарточка сданного объекта}
    ObjectListItem:
      type: object
      properties:
        id: {type: integer}
        parent_id: {type: [integer, 'null']}
        column_id: {type: integer}
        sort_in_column: {type: integer}
        name: {type: string}
        customer_id: {type: [integer, 'null']}
        customer_name: {type: [string, 'null']}
        address: {type: [string, 'null'], description: 'Адрес дома; подъезд и квартира — отдельными полями'}
        entrance: {type: [string, 'null'], example: '4'}
        apartment: {type: [string, 'null'], example: '1043'}
        floor: {type: [string, 'null'], example: '12'}
        lat: {type: [number, 'null']}
        lng: {type: [number, 'null']}
        work_sum: {type: [integer, 'null'], description: Сумма работ в КОПЕЙКАХ}
        planned: {type: boolean}
        created_at: {type: string}
    Object:
      allOf:
        - {$ref: '#/components/schemas/ObjectListItem'}
        - type: object
          properties:
            crew_id: {type: [integer, 'null']}
            work_sum_source: {type: [string, 'null'], enum: [manual, ai, null]}
            material_sum: {type: [integer, 'null'], description: Материалы в КОПЕЙКАХ}
            tg_link: {type: [string, 'null']}
            comment: {type: [string, 'null']}
            updated_at: {type: [string, 'null']}
    AccountType:
      type: object
      required: [name]
      properties:
        id: {type: integer, readOnly: true}
        name: {type: string, example: 'Касса'}
        is_cash: {type: boolean, default: true, description: 'Наличные; иначе счёт считается безналичным'}
        color: {type: [string, 'null']}
        sort: {type: integer}
        is_active: {type: boolean}
        used_count: {type: integer, readOnly: true}
    Account:
      type: object
      required: [name, type_id]
      properties:
        id: {type: integer, readOnly: true}
        name: {type: string, example: 'Касса'}
        type_id: {type: integer}
        type_name: {type: string, readOnly: true}
        type_is_cash: {type: boolean, readOnly: true}
        owner_user_id: {type: [integer, 'null'], description: Ответственный за деньги}
        legal_entity_id: {type: [integer, 'null']}
        bank_account_id: {type: [integer, 'null'], description: Расчётный счёт того же юрлица}
        visibility:
          type: string
          enum: [private, shared, public]
          default: public
          description: >
            Кто видит счёт: только владелец, перечисленные в shared_with или вся команда.
            По умолчанию команда — деньги на руках у монтажника принадлежат компании.
        shared_with: {type: array, items: {type: integer}}
        opening_balance: {type: integer, description: 'Остаток на начало, КОПЕЙКИ'}
        balance: {type: integer, readOnly: true, description: 'Текущий остаток, КОПЕЙКИ; может быть отрицательным'}
        color: {type: [string, 'null']}
        comment: {type: [string, 'null']}
        is_active: {type: boolean}
        can_manage: {type: boolean, readOnly: true}
    MoneyCategory:
      type: object
      required: [name]
      properties:
        id: {type: integer, readOnly: true}
        name: {type: string, example: 'Материалы'}
        kind: {type: string, enum: [income, expense, both], default: expense}
        is_payroll: {type: boolean, description: 'Выплата зарплаты: такие расходы гасят долг перед сотрудником'}
        color: {type: [string, 'null']}
        sort: {type: integer}
        is_active: {type: boolean}
        used_count: {type: integer, readOnly: true}
    MoneyTxInput:
      type: object
      required: [account_id, kind, amount]
      properties:
        account_id: {type: integer}
        kind: {type: string, enum: [income, expense]}
        amount: {type: integer, description: 'КОПЕЙКИ, положительное — знак ставит система'}
        date: {type: string, format: date, description: По умолчанию сегодня}
        category_id: {type: [integer, 'null']}
        customer_id: {type: [integer, 'null']}
        user_id: {type: [integer, 'null'], description: 'Сотрудник: кому выплатили'}
        comment: {type: [string, 'null']}
        objects:
          type: array
          description: >
            Разнесение по объектам. Сумма долей не может превышать сумму операции;
            остаток допустим и возвращается в поле unallocated.
          items:
            type: object
            properties:
              object_id: {type: integer}
              amount: {type: integer, description: 'КОПЕЙКИ, без знака'}
    MoneyTx:
      allOf:
        - {$ref: '#/components/schemas/MoneyTxInput'}
        - type: object
          properties:
            id: {type: integer}
            amount: {type: integer, description: 'КОПЕЙКИ со знаком: + приход, − расход'}
            kind: {type: string, enum: [income, expense, transfer]}
            transfer_id: {type: [string, 'null']}
            account_name: {type: string}
            category_name: {type: [string, 'null']}
            customer_name: {type: [string, 'null']}
            user_name: {type: [string, 'null']}
            unallocated: {type: integer, description: Сколько ещё не отнесено ни на один объект}
    PayrollPerson:
      type: object
      properties:
        user_id: {type: integer}
        name: {type: string}
        avatar_url: {type: [string, 'null']}
        hours: {type: integer, description: Часов по табелю за период}
        days: {type: integer}
        accrued: {type: integer, description: 'Начислено, КОПЕЙКИ'}
        paid: {type: integer, description: Выплачено деньгами за период}
        balance: {type: integer, description: Сколько остались должны}
        lines:
          type: array
          description: 'Расшифровка: за какой день, объект и по какой ставке'
          items:
            type: object
            properties:
              type: {type: string, enum: [per_hour, per_day, percent]}
              date: {type: string, format: date}
              object_id: {type: integer}
              object_name: {type: string}
              hours: {type: [integer, 'null']}
              rate: {type: integer, description: 'Ставка в КОПЕЙКАХ, для процента — сам процент'}
              multiplier: {type: number, description: 'Коэффициент за праздник или выходной'}
              amount: {type: integer}
    LegalEntity:
      type: object
      required: [name]
      properties:
        id: {type: integer, readOnly: true}
        name: {type: string, maxLength: 190, example: 'ООО «Ромашка»'}
        full_name: {type: [string, 'null'], example: 'Общество с ограниченной ответственностью «Ромашка»'}
        is_own:
          type: boolean
          description: 'Наше юрлицо — от него выставляем счета. Своих может быть несколько.'
        kind: {type: string, enum: [legal, individual], default: legal}
        inn: {type: [string, 'null'], description: '10 цифр у организации, 12 у ИП; уникален'}
        kpp: {type: [string, 'null'], description: 9 цифр}
        ogrn: {type: [string, 'null'], description: '13 цифр, у ИП 15'}
        address: {type: [string, 'null'], description: Юридический адрес}
        director: {type: [string, 'null']}
        phone: {type: [string, 'null']}
        email: {type: [string, 'null']}
        comment: {type: [string, 'null']}
        is_active: {type: boolean, default: true}
        banks_count: {type: integer, readOnly: true}
        accounts_count: {type: integer, readOnly: true}
        customers_count: {type: integer, readOnly: true}
    Bank:
      type: object
      required: [name, bic]
      properties:
        id: {type: integer, readOnly: true}
        legal_entity_id: {type: integer, readOnly: true}
        name: {type: string, example: 'ПАО Сбербанк'}
        bic: {type: string, description: '9 цифр; у одного юрлица банк не повторяется'}
        corr_account: {type: [string, 'null'], description: Корреспондентский счёт, 20 цифр}
        address: {type: [string, 'null']}
        swift: {type: [string, 'null']}
    BankAccount:
      type: object
      required: [bank_id, number]
      properties:
        id: {type: integer, readOnly: true}
        legal_entity_id: {type: integer, readOnly: true}
        bank_id: {type: integer, description: Банк того же юрлица}
        number: {type: string, description: 'Расчётный счёт, 20 цифр'}
        name: {type: [string, 'null'], example: 'Основной'}
        is_default: {type: boolean, description: 'Основной счёт у юрлица один'}
        is_active: {type: boolean, default: true}
    ApiToken:
      type: object
      properties:
        id: {type: integer}
        name: {type: string, example: 'Выгрузка в 1С'}
        prefix: {type: string, example: 'wm_9f3a1c2', description: Начало токена, чтобы отличать ключи в списке}
        permissions:
          type: [array, 'null']
          items: {type: string}
          description: 'null — токен работает со всеми правами владельца'
        created_at: {type: string, format: date-time}
        last_used_at: {type: [string, 'null'], format: date-time}
    ScheduleSlotInput:
      type: object
      required: [object_id, date_start, date_end]
      properties:
        object_id: {type: integer}
        crew_id: {type: [integer, 'null'], description: 'Либо бригада, либо человек — не оба сразу'}
        user_id: {type: [integer, 'null']}
        work_type_id: {type: [integer, 'null']}
        date_start: {type: string, format: date}
        date_end: {type: string, format: date}
        time_start:
          type: [string, 'null']
          example: '08:00'
          description: >
            Начало рабочего окна внутри каждого дня слота. Локальное время компании,
            в UTC не переводится: это план «с 8 утра», а не момент времени.
            null или пустая строка — весь день.
        time_end: {type: [string, 'null'], example: '13:00'}
        comment: {type: [string, 'null'], maxLength: 255}
    ScheduleSlot:
      allOf:
        - {$ref: '#/components/schemas/ScheduleSlotInput'}
        - type: object
          properties:
            id: {type: integer}
            user_name: {type: [string, 'null']}
            work_type_name: {type: [string, 'null']}
            work_type_color: {type: [string, 'null']}
            object_name: {type: string}
            object_address: {type: [string, 'null']}
            complex_name: {type: [string, 'null']}
            customer_name: {type: [string, 'null']}
            customer_phone: {type: [string, 'null']}
            keys_contact: {type: [string, 'null']}
            keys_phone: {type: [string, 'null']}
            keys_info: {type: [string, 'null']}
            status_id: {type: [integer, 'null']}
            status_name: {type: [string, 'null']}
            status_color: {type: [string, 'null']}
            work_sum: {type: [integer, 'null'], description: Сумма работ в КОПЕЙКАХ}
            lat: {type: [number, 'null']}
            lng: {type: [number, 'null']}
            tg_link: {type: [string, 'null']}
    ScheduleSlotResult:
      type: object
      properties:
        slot: {$ref: '#/components/schemas/ScheduleSlot'}
        conflicts:
          type: array
          description: >
            Кто из состава уже стоит в это же время на другом объекте.
            Это предупреждение, а не запрет: день реально бывает поделён.
            Если у обоих слотов заданы окна, конфликт считается только при их пересечении.
          items:
            type: object
            properties:
              user: {type: string}
              date: {type: string, format: date}
              object: {type: string}
              time: {type: [string, 'null'], example: '08:00–13:00'}

security:
  - session: []
  - token: []
