{
  "openapi": "3.0.3",
  "info": {
    "title": "Ротор.Склад — REST API складского учёта",
    "description": "Сервис учёта склада запчастей «Ротор». Товары, поставщики и движения остатков: приход от поставщика, списание, резерв под заказ и снятие резерва. Остаток считается по формуле: available = receipt − writeoff − reserve + unreserve. Отрицательный остаток запрещён — возвращается 409 Conflict.\n\nКаждый запрос к /api/* требует API-ключ в заголовке `X-API-Key` (или `Authorization: Bearer <key>`). Ключи заводятся администратором через `POST /api/keys`.",
    "version": "1.0.0"
  },
  "servers": [
    { "url": "/" }
  ],
  "tags": [
    { "name": "auth", "description": "Проверка API-ключа и его роли" },
    { "name": "products", "description": "Товары: список, карточка, поиск, создание и изменение" },
    { "name": "suppliers", "description": "Справочник поставщиков" },
    { "name": "movements", "description": "Движения остатков и история" },
    { "name": "reports", "description": "Отчёт остатков на дату" },
    { "name": "keys", "description": "Администрирование API-ключей (только административный ключ)" }
  ],
  "security": [
    { "ApiKeyAuth": [] },
    { "BearerAuth": [] }
  ],
  "paths": {
    "/api/auth/validate": {
      "post": {
        "tags": ["auth"],
        "summary": "Проверить API-ключ и его роль",
        "responses": {
          "200": {
            "description": "Ключ действителен",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AuthResult" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/api/products": {
      "get": {
        "tags": ["products"],
        "summary": "Список товаров с поиском по артикулу и названию",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "description": "Подстрока поиска по артикулу или названию, например ?q=2108 или ?q=колодки",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "Список товаров",
            "content": {
              "application/json": {
                "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Product" } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "post": {
        "tags": ["products"],
        "summary": "Создать товар",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ProductInput" },
              "example": { "article": "2108-3501080", "name": "Колодки передние", "price": 1450, "unit": "шт" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Созданный товар",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Product" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "422": { "$ref": "#/components/responses/Unprocessable" }
        }
      }
    },
    "/api/products/{id}": {
      "get": {
        "tags": ["products"],
        "summary": "Карточка товара",
        "parameters": [
          { "$ref": "#/components/parameters/Id" }
        ],
        "responses": {
          "200": {
            "description": "Товар с текущим остатком",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Product" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      },
      "patch": {
        "tags": ["products"],
        "summary": "Изменить товар (цену, единицу, название, артикул)",
        "parameters": [
          { "$ref": "#/components/parameters/Id" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ProductPatch" },
              "example": { "price": 1550, "unit": "шт" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Обновлённый товар",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Product" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "422": { "$ref": "#/components/responses/Unprocessable" }
        }
      }
    },
    "/api/suppliers": {
      "get": {
        "tags": ["suppliers"],
        "summary": "Список поставщиков",
        "responses": {
          "200": {
            "description": "Список поставщиков",
            "content": {
              "application/json": {
                "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Supplier" } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "post": {
        "tags": ["suppliers"],
        "summary": "Создать поставщика",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/SupplierInput" },
              "example": { "name": "ООО «АвтоДеталь-Юг»", "contact": "+7 861 200-00-00" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Созданный поставщик",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Supplier" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "422": { "$ref": "#/components/responses/Unprocessable" }
        }
      }
    },
    "/api/suppliers/{id}": {
      "get": {
        "tags": ["suppliers"],
        "summary": "Получить поставщика по id",
        "parameters": [
          { "$ref": "#/components/parameters/Id" }
        ],
        "responses": {
          "200": {
            "description": "Поставщик",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Supplier" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      },
      "patch": {
        "tags": ["suppliers"],
        "summary": "Изменить поставщика (название, контакты)",
        "parameters": [
          { "$ref": "#/components/parameters/Id" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/SupplierPatch" },
              "example": { "contact": "+7 861 210-10-10" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Обновлённый поставщик",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Supplier" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "422": { "$ref": "#/components/responses/Unprocessable" }
        }
      }
    },
    "/api/movements": {
      "get": {
        "tags": ["movements"],
        "summary": "История движений, от новых к старым",
        "parameters": [
          {
            "name": "product_id",
            "in": "query",
            "description": "Фильтр по товару",
            "schema": { "type": "integer" }
          }
        ],
        "responses": {
          "200": {
            "description": "История движений",
            "content": {
              "application/json": {
                "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Movement" } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "post": {
        "tags": ["movements"],
        "summary": "Создать движение остатка",
        "description": "movement_type: `receipt` (приход), `writeoff` (списание), `reserve` (резерв), `unreserve` (снятие резерва). Приход увеличивает остаток, списание уменьшает, резерв уменьшает доступный остаток, снятие резерва возвращает. Движение, приводящее к отрицательному остатку, возвращает 409 Conflict.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/MovementInput" },
              "examples": {
                "receipt": { "summary": "Приход", "value": { "product_id": 1, "supplier_id": 1, "movement_type": "receipt", "qty": 20 } },
                "writeoff": { "summary": "Списание", "value": { "product_id": 1, "movement_type": "writeoff", "qty": 3 } },
                "reserve": { "summary": "Резерв", "value": { "product_id": 1, "movement_type": "reserve", "qty": 4, "order_ref": "ЗАКАЗ-123" } },
                "unreserve": { "summary": "Снятие резерва", "value": { "product_id": 1, "movement_type": "unreserve", "qty": 4 } }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Созданное движение с автором и временем",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Movement" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/NegativeStock" },
          "422": { "$ref": "#/components/responses/Unprocessable" }
        }
      }
    },
    "/api/reports/stock": {
      "get": {
        "tags": ["reports"],
        "summary": "Отчёт остатков на дату",
        "parameters": [
          {
            "name": "date",
            "in": "query",
            "description": "Дата в формате YYYY-MM-DD (по умолчанию сегодня)",
            "schema": { "type": "string", "example": "2025-06-30" }
          }
        ],
        "responses": {
          "200": {
            "description": "Остатки по товарам с учётом резерва",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/StockReport" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/api/keys": {
      "get": {
        "tags": ["keys"],
        "summary": "Список API-ключей (без хеша)",
        "security": [{ "ApiKeyAuth": [] }],
        "responses": {
          "200": {
            "description": "Список ключей",
            "content": {
              "application/json": {
                "schema": { "type": "array", "items": { "$ref": "#/components/schemas/APIKey" } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      },
      "post": {
        "tags": ["keys"],
        "summary": "Завести новый API-ключ (роль site, accounting или admin)",
        "description": "Ключ показывается ровно один раз в ответе, в поле `key`. Сохраните его сразу.",
        "security": [{ "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/APIKeyInput" },
              "example": { "name": "сайт", "role": "site" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Созданный ключ (поле key — только сейчас)",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/APIKeyCreated" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "422": { "$ref": "#/components/responses/Unprocessable" }
        }
      }
    },
    "/api/keys/{id}": {
      "patch": {
        "tags": ["keys"],
        "summary": "Активировать или отозвать API-ключ",
        "security": [{ "ApiKeyAuth": [] }],
        "parameters": [
          { "$ref": "#/components/parameters/Id" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/APIKeyPatch" },
              "example": { "is_active": false }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Обновлённый ключ",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/APIKey" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "422": { "$ref": "#/components/responses/Unprocessable" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key"
      },
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer"
      }
    },
    "parameters": {
      "Id": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": { "type": "integer" }
      }
    },
    "responses": {
      "Unauthorized": { "description": "Отсутствует, неверный или неактивный API-ключ", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "Forbidden": { "description": "Требуется административный API-ключ", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "NotFound": { "description": "Запись не найдена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "Conflict": { "description": "Конфликт данных", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "NegativeStock": { "description": "Остаток не может быть отрицательным", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NegativeStockError" } } } },
      "Unprocessable": { "description": "Ошибка валидации полей", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "BadRequest": { "description": "Некорректный запрос", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
    },
    "schemas": {
      "Product": {
        "type": "object",
        "required": ["id", "article", "name", "price", "unit", "on_hand", "reserved", "available", "created_at", "updated_at"],
        "properties": {
          "id": { "type": "integer" },
          "article": { "type": "string", "example": "2108-3501080" },
          "name": { "type": "string", "example": "Колодки передние" },
          "price": { "type": "number", "example": 1450 },
          "unit": { "type": "string", "example": "шт" },
          "on_hand": { "type": "number", "description": "Остаток = приход − списание" },
          "reserved": { "type": "number", "description": "В резерве = резерв − снятие резерва" },
          "available": { "type": "number", "description": "Доступный остаток = on_hand − reserved" },
          "created_at": { "type": "string", "format": "date-time" },
          "updated_at": { "type": "string", "format": "date-time" }
        }
      },
      "ProductInput": {
        "type": "object",
        "required": ["article", "name", "price", "unit"],
        "properties": {
          "article": { "type": "string" },
          "name": { "type": "string" },
          "price": { "type": "number", "minimum": 0 },
          "unit": { "type": "string" }
        }
      },
      "ProductPatch": {
        "type": "object",
        "properties": {
          "article": { "type": "string" },
          "name": { "type": "string" },
          "price": { "type": "number", "minimum": 0 },
          "unit": { "type": "string" }
        }
      },
      "Supplier": {
        "type": "object",
        "required": ["id", "name", "created_at"],
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "string", "example": "ООО «АвтоДеталь-Юг»" },
          "contact": { "type": "string", "nullable": true },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "SupplierInput": {
        "type": "object",
        "required": ["name"],
        "properties": {
          "name": { "type": "string" },
          "contact": { "type": "string", "nullable": true }
        }
      },
      "SupplierPatch": {
        "type": "object",
        "properties": {
          "name": { "type": "string" },
          "contact": { "type": "string", "nullable": true }
        }
      },
      "Movement": {
        "type": "object",
        "required": ["id", "product_id", "movement_type", "qty", "api_key_id", "author", "created_at"],
        "properties": {
          "id": { "type": "integer" },
          "product_id": { "type": "integer" },
          "supplier_id": { "type": "integer", "nullable": true },
          "movement_type": { "type": "string", "enum": ["receipt", "writeoff", "reserve", "unreserve"] },
          "qty": { "type": "number" },
          "order_ref": { "type": "string", "nullable": true },
          "api_key_id": { "type": "integer" },
          "author": { "type": "string", "description": "Имя API-ключа, выполнившего движение" },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "MovementInput": {
        "type": "object",
        "required": ["product_id", "movement_type", "qty"],
        "properties": {
          "product_id": { "type": "integer" },
          "supplier_id": { "type": "integer", "nullable": true },
          "movement_type": { "type": "string", "enum": ["receipt", "writeoff", "reserve", "unreserve"] },
          "qty": { "type": "number", "minimum": 0, "exclusiveMinimum": true },
          "order_ref": { "type": "string", "nullable": true }
        }
      },
      "StockReportItem": {
        "type": "object",
        "properties": {
          "product_id": { "type": "integer" },
          "article": { "type": "string" },
          "name": { "type": "string" },
          "unit": { "type": "string" },
          "on_hand": { "type": "number" },
          "reserved": { "type": "number" },
          "available": { "type": "number" }
        }
      },
      "StockReport": {
        "type": "object",
        "properties": {
          "on_date": { "type": "string", "format": "date" },
          "items": { "type": "array", "items": { "$ref": "#/components/schemas/StockReportItem" } }
        }
      },
      "APIKey": {
        "type": "object",
        "required": ["id", "name", "role", "is_active", "created_at"],
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "string" },
          "role": { "type": "string", "enum": ["site", "accounting", "admin"] },
          "is_active": { "type": "boolean" },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "APIKeyInput": {
        "type": "object",
        "required": ["name", "role"],
        "properties": {
          "name": { "type": "string" },
          "role": { "type": "string", "enum": ["site", "accounting", "admin"] }
        }
      },
      "APIKeyCreated": {
        "allOf": [
          { "$ref": "#/components/schemas/APIKey" },
          {
            "type": "object",
            "required": ["key"],
            "properties": {
              "key": { "type": "string", "description": "Полный ключ — показывается только один раз" }
            }
          }
        ]
      },
      "APIKeyPatch": {
        "type": "object",
        "required": ["is_active"],
        "properties": {
          "is_active": { "type": "boolean" }
        }
      },
      "AuthResult": {
        "type": "object",
        "properties": {
          "valid": { "type": "boolean" },
          "id": { "type": "integer" },
          "name": { "type": "string" },
          "role": { "type": "string", "enum": ["site", "accounting", "admin"] }
        }
      },
      "NegativeStockError": {
        "type": "object",
        "properties": {
          "error": { "type": "string" },
          "details": {
            "type": "object",
            "properties": {
              "movement_type": { "type": "string" },
              "qty": { "type": "number" },
              "on_hand": { "type": "number" },
              "reserved": { "type": "number" },
              "available": { "type": "number" },
              "formula": { "type": "string" }
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": { "type": "string" }
        }
      }
    }
  }
}
