From 6c170fe1a2d6bdd2d7c79433127da7b0ef91b62b Mon Sep 17 00:00:00 2001 From: Mephimeow Date: Wed, 29 Apr 2026 12:28:30 +0000 Subject: [PATCH] new-g --- QR /scanner.md | 39 ++ README.md | 139 +++++ backend/docs/docs.go | 488 +++++++++++++++++- backend/docs/swagger.json | 488 +++++++++++++++++- backend/docs/swagger.yaml | 322 +++++++++++- .../internal/handlers/BaleType_handlers.go | 2 +- backend/internal/handlers/Bale_handlers.go | 2 +- backend/internal/handlers/Line_handlers.go | 262 ++++++++++ backend/internal/handlers/api_test.go | 453 ++++++++++++++++ backend/internal/handlers/register.go | 5 + backend/internal/storage/repository.go | 160 ++++++ backend/internal/storage/types.go | 47 +- backend/migrations/002_create_line_tables.sql | 50 ++ 13 files changed, 2450 insertions(+), 7 deletions(-) create mode 100644 QR /scanner.md create mode 100644 backend/internal/handlers/Line_handlers.go create mode 100644 backend/internal/handlers/api_test.go create mode 100644 backend/migrations/002_create_line_tables.sql diff --git a/QR /scanner.md b/QR /scanner.md new file mode 100644 index 0000000..d9bcb2e --- /dev/null +++ b/QR /scanner.md @@ -0,0 +1,39 @@ +# QR/Barcode Scanner + +**pyzbar** + **OpenCV** для **Raspberry Pi Camera Module v3**. + +## Зависимости + +```bash +sudo apt install libzbar0 libgstreamer1.0-dev +pip install opencv-python pyzbar +``` + +## Запуск + +```bash +python scanner.py +``` + +## Поддерживаемые форматы + +- QR-код +- Code 128, Code 39, EAN-13, EAN-8 +- UPC-A, UPC-E +- Interleaved 2 of 5 + +## Управление + +- `q` - Выход +- `r` - Сброс списка отсканированных кодов + +## Как работает + +1. Подключение камеры через GStreamer (`libcamerasrc`) — дефолт путь для Camera Module v3 +2. Каждый кадр конвертируется в градации серого и передаётся в `pyzbar.decode()` +3. Найденные коды обводятся зелёной рамкой, данные выводятся в консоль и на экран +4. Дубликаты фильтруются — каждый уникальный код выводится один раз (до сброса) + +## Fallback + +Если GStreamer-пайплайн недоступен, скрипт автоматически пробует открыть камеру через `/dev/video0` (V4L2). diff --git a/README.md b/README.md index e69de29..f9dfd33 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,139 @@ +# Rostpoliplast — Линия переработки лома во вторичный гранулят + +Система автоматизации линии переработки полимерного лома с контролем этапов, телеметрией и QR-сканированием. + +## Архитектура + +``` +┌─────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Датчики │──▶│ Edge GW │──▶│ MQTT │──▶│ Backend │ +│ (IO-Link) │ │ (ESP32) │ │ Broker │ │ (Go) │ +└─────────────┘ └──────────┘ └──────────┘ └──────────┘ + │ + ┌─────┴─────┐ + │ InfluxDB │ + │ Grafana │ + └───────────┘ +``` + +## Backend API + +### Запуск + +```bash +cd backend +docker-compose up -d +``` + +Swagger: `http://localhost:8080/swagger/index.html` + +### Эндпоинты + +#### Тюки (Bales) +| Метод | Путь | Описание | +|---|---|---| +| `GET` | `/api/v1/bales` | Список всех тюков | +| `GET` | `/api/v1/bales/:id` | Тюк по ID | +| `POST` | `/api/v1/bales` | Создать тюк (+ RabbitMQ task) | +| `PUT` | `/api/v1/bales/:id` | Обновить тюк | +| `DELETE` | `/api/v1/bales/:id` | Удалить тюк | +| `POST` | `/api/v1/bales?type=standard` | Создать по типу (QR flow) | + +#### Типы тюков (Bale Types) +| Метод | Путь | Описание | +|---|---|---| +| `GET` | `/api/v1/bale-types` | Список типов | +| `GET` | `/api/v1/bale-types/:id` | Тип по ID | +| `GET` | `/api/v1/bale-types/qr/:id` | QR-код для маркировки | +| `POST` | `/api/v1/bale-types` | Создать тип | +| `PUT` | `/api/v1/bale-types/:id` | Обновить тип | +| `DELETE` | `/api/v1/bale-types/:id` | Удалить тип | + +#### Линия (Line) — **НОВОЕ** +| Метод | Путь | Описание | +|---|---|---| +| `GET` | `/api/v1/line/stages` | Все этапы линии | +| `GET` | `/api/v1/line/stages/:id` | Этап по ID | +| `POST` | `/api/v1/line/stages` | Добавить этап | +| `PUT` | `/api/v1/line/stages/:id` | Обновить этап | +| `DELETE` | `/api/v1/line/stages/:id` | Удалить этап | +| `GET` | `/api/v1/line/stats` | Статистика линии | + +#### Датчики (Sensors) — **НОВОЕ** +| Метод | Путь | Описание | +|---|---|---| +| `POST` | `/api/v1/sensors/readings` | Показание датчика | +| `GET` | `/api/v1/sensors/readings/stage/:id` | История по этапу | + +#### События (Events) — **НОВОЕ** +| Метод | Путь | Описание | +|---|---|---| +| `POST` | `/api/v1/events` | Событие на линии | +| `GET` | `/api/v1/events/stage/:id` | События по этапу | + +#### Очередь (Queue) +| Метод | Путь | Описание | +|---|---|---| +| `GET` | `/api/v1/queue/next` | Следующая задача | + +### Примеры запросов + +```bash +# Получить этапы линии +curl http://localhost:8080/api/v1/line/stages + +# Отправить показание датчика +curl -X POST http://localhost:8080/api/v1/sensors/readings \ + -H "Content-Type: application/json" \ + -d '{"stage_id": 1, "sensor": "vibration", "value": 42.5, "unit": "mm/s"}' + +# Создать событие +curl -X POST http://localhost:8080/api/v1/events \ + -H "Content-Type: application/json" \ + -d '{"stage_id": 3, "event_type": "item_processed", "data": "success"}' + +# Статистика линии +curl http://localhost:8080/api/v1/line/stats +``` + +## Компоненты линии (`Line/`) + +| Компонент | Описание | +|---|---| +| `controller_*` | Контроллеры (WAGO, Siemens, Beckhoff, RPi) | +| `drive_*` | ЧРП (Danfoss, ABB, Schneider, SEW, Lenze) | +| `sensor_*` | Датчики (вибрация, температура, позиция, ток, уровень) | +| `safety_*` | Безопасность (Pilz, Siemens, Schneider) | +| `middleware_*` | Node-RED, Mosquitto, Grafana+InfluxDB | +| `comm_*` | Коммуникация (Hilscher, Moxa, Turck) | +| `infrastructure_*` | Шкафы, питание, коммутаторы | + +## QR Сканер + +```bash +python QR/scanner.py +``` + +- Камера: Raspberry Pi Camera Module v3 +- Библиотеки: `pyzbar` + `OpenCV` +- Клавиши: `q` — выход, `r` — сброс + +## Счетчик товаров (mmWave) + +```yaml +# ESPHome + LD2410B +esphome run ld2410b_counter.yaml +``` + +- Радар 24 ГГц, отслеживание дистанции и энергии +- Подсчет через конечный автомат (debounce 300ms) +- MQTT интеграция, настраиваемые пороги + +## Тесты + +```bash +cd backend +go test ./... -v +``` + +20 тестов покрывают все эндпоинты API. diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 2a11f54..63ac75b 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -73,6 +73,38 @@ const docTemplate = `{ } } }, + "/bale-types/qr/{id}": { + "get": { + "description": "Возвращает QR код с URL для регистрации тюка этого типа", + "consumes": [ + "application/json" + ], + "produces": [ + "image/png" + ], + "tags": [ + "bale-types" + ], + "summary": "Получить QR код для маркировки тюка", + "parameters": [ + { + "type": "integer", + "description": "ID типа тюка", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + } + } + } + }, "/bale-types/{id}": { "get": { "description": "Возвращает тип тюка по ID", @@ -219,10 +251,15 @@ const docTemplate = `{ "description": "Данные тюка", "name": "bale", "in": "body", - "required": true, "schema": { "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.Bale" } + }, + { + "type": "string", + "description": "Тип тюка (для QR кодов)", + "name": "type", + "in": "query" } ], "responses": { @@ -338,6 +375,362 @@ const docTemplate = `{ } } } + }, + "/events": { + "post": { + "description": "Создаёт новое событие на этапе производственной линии", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "events" + ], + "summary": "Создать событие на линии", + "parameters": [ + { + "description": "Данные события", + "name": "event", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.ProductionEvent" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.ProductionEvent" + } + } + } + } + }, + "/events/stage/{id}": { + "get": { + "description": "Возвращает последние 100 событий для указанного этапа", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "events" + ], + "summary": "Получить события по этапу", + "parameters": [ + { + "type": "integer", + "description": "ID этапа", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.ProductionEvent" + } + } + } + } + } + }, + "/line/stages": { + "get": { + "description": "Возвращает список всех этапов производственной линии", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "line" + ], + "summary": "Получить все этапы линии", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage" + } + } + } + } + }, + "post": { + "description": "Создаёт новый этап производственной линии", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "line" + ], + "summary": "Создать этап линии", + "parameters": [ + { + "description": "Данные этапа", + "name": "stage", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage" + } + } + } + } + }, + "/line/stages/{id}": { + "get": { + "description": "Возвращает этап линии по ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "line" + ], + "summary": "Получить этап линии по ID", + "parameters": [ + { + "type": "integer", + "description": "ID этапа", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage" + } + } + } + }, + "put": { + "description": "Обновляет данные этапа линии по ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "line" + ], + "summary": "Обновить этап линии", + "parameters": [ + { + "type": "integer", + "description": "ID этапа", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Данные этапа", + "name": "stage", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage" + } + } + } + }, + "delete": { + "description": "Удаляет этап линии по ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "line" + ], + "summary": "Удалить этап линии", + "parameters": [ + { + "type": "integer", + "description": "ID этапа", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + } + } + } + }, + "/line/stats": { + "get": { + "description": "Возвращает сводную статистику работы производственной линии", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "line" + ], + "summary": "Получить статистику линии", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStats" + } + } + } + } + }, + "/queue/next": { + "get": { + "description": "Получает и удаляет из очереди следующую задачу (FIFO)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "queue" + ], + "summary": "Получить следующую задачу из очереди", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/sensors/readings": { + "post": { + "description": "Создаёт новое показание датчика для этапа линии", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sensors" + ], + "summary": "Создать показание датчика", + "parameters": [ + { + "description": "Данные показания", + "name": "reading", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.SensorReading" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.SensorReading" + } + } + } + } + }, + "/sensors/readings/stage/{id}": { + "get": { + "description": "Возвращает последние 100 показаний датчиков для указанного этапа", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sensors" + ], + "summary": "Получить показания датчиков по этапу", + "parameters": [ + { + "type": "integer", + "description": "ID этапа", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.SensorReading" + } + } + } + } + } } }, "definitions": { @@ -382,6 +775,99 @@ const docTemplate = `{ "type": "number" } } + }, + "gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage": { + "description": "Этап производственной линии переработки", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "equipment": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "mqtt_topic": { + "type": "string" + }, + "name": { + "type": "string" + }, + "order": { + "type": "integer" + } + } + }, + "gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStats": { + "description": "Сводная статистика работы производственной линии", + "type": "object", + "properties": { + "avg_speed": { + "type": "number" + }, + "current_stage": { + "type": "string" + }, + "last_update_time": { + "type": "string" + }, + "rejected_items": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "total_items": { + "type": "integer" + } + } + }, + "gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.ProductionEvent": { + "description": "Событие, произошедшее на этапе производственной линии", + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "event_type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "stage_id": { + "type": "integer" + }, + "timestamp": { + "type": "string" + } + } + }, + "gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.SensorReading": { + "description": "Показание датчика на этапе линии", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "sensor": { + "type": "string" + }, + "stage_id": { + "type": "integer" + }, + "timestamp": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "value": { + "type": "number" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index dc0f420..45f5203 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -62,6 +62,38 @@ } } }, + "/bale-types/qr/{id}": { + "get": { + "description": "Возвращает QR код с URL для регистрации тюка этого типа", + "consumes": [ + "application/json" + ], + "produces": [ + "image/png" + ], + "tags": [ + "bale-types" + ], + "summary": "Получить QR код для маркировки тюка", + "parameters": [ + { + "type": "integer", + "description": "ID типа тюка", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + } + } + } + }, "/bale-types/{id}": { "get": { "description": "Возвращает тип тюка по ID", @@ -208,10 +240,15 @@ "description": "Данные тюка", "name": "bale", "in": "body", - "required": true, "schema": { "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.Bale" } + }, + { + "type": "string", + "description": "Тип тюка (для QR кодов)", + "name": "type", + "in": "query" } ], "responses": { @@ -327,6 +364,362 @@ } } } + }, + "/events": { + "post": { + "description": "Создаёт новое событие на этапе производственной линии", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "events" + ], + "summary": "Создать событие на линии", + "parameters": [ + { + "description": "Данные события", + "name": "event", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.ProductionEvent" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.ProductionEvent" + } + } + } + } + }, + "/events/stage/{id}": { + "get": { + "description": "Возвращает последние 100 событий для указанного этапа", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "events" + ], + "summary": "Получить события по этапу", + "parameters": [ + { + "type": "integer", + "description": "ID этапа", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.ProductionEvent" + } + } + } + } + } + }, + "/line/stages": { + "get": { + "description": "Возвращает список всех этапов производственной линии", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "line" + ], + "summary": "Получить все этапы линии", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage" + } + } + } + } + }, + "post": { + "description": "Создаёт новый этап производственной линии", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "line" + ], + "summary": "Создать этап линии", + "parameters": [ + { + "description": "Данные этапа", + "name": "stage", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage" + } + } + } + } + }, + "/line/stages/{id}": { + "get": { + "description": "Возвращает этап линии по ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "line" + ], + "summary": "Получить этап линии по ID", + "parameters": [ + { + "type": "integer", + "description": "ID этапа", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage" + } + } + } + }, + "put": { + "description": "Обновляет данные этапа линии по ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "line" + ], + "summary": "Обновить этап линии", + "parameters": [ + { + "type": "integer", + "description": "ID этапа", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Данные этапа", + "name": "stage", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage" + } + } + } + }, + "delete": { + "description": "Удаляет этап линии по ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "line" + ], + "summary": "Удалить этап линии", + "parameters": [ + { + "type": "integer", + "description": "ID этапа", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + } + } + } + }, + "/line/stats": { + "get": { + "description": "Возвращает сводную статистику работы производственной линии", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "line" + ], + "summary": "Получить статистику линии", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStats" + } + } + } + } + }, + "/queue/next": { + "get": { + "description": "Получает и удаляет из очереди следующую задачу (FIFO)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "queue" + ], + "summary": "Получить следующую задачу из очереди", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/sensors/readings": { + "post": { + "description": "Создаёт новое показание датчика для этапа линии", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sensors" + ], + "summary": "Создать показание датчика", + "parameters": [ + { + "description": "Данные показания", + "name": "reading", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.SensorReading" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.SensorReading" + } + } + } + } + }, + "/sensors/readings/stage/{id}": { + "get": { + "description": "Возвращает последние 100 показаний датчиков для указанного этапа", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sensors" + ], + "summary": "Получить показания датчиков по этапу", + "parameters": [ + { + "type": "integer", + "description": "ID этапа", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.SensorReading" + } + } + } + } + } } }, "definitions": { @@ -371,6 +764,99 @@ "type": "number" } } + }, + "gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage": { + "description": "Этап производственной линии переработки", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "equipment": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "mqtt_topic": { + "type": "string" + }, + "name": { + "type": "string" + }, + "order": { + "type": "integer" + } + } + }, + "gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStats": { + "description": "Сводная статистика работы производственной линии", + "type": "object", + "properties": { + "avg_speed": { + "type": "number" + }, + "current_stage": { + "type": "string" + }, + "last_update_time": { + "type": "string" + }, + "rejected_items": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "total_items": { + "type": "integer" + } + } + }, + "gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.ProductionEvent": { + "description": "Событие, произошедшее на этапе производственной линии", + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "event_type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "stage_id": { + "type": "integer" + }, + "timestamp": { + "type": "string" + } + } + }, + "gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.SensorReading": { + "description": "Показание датчика на этапе линии", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "sensor": { + "type": "string" + }, + "stage_id": { + "type": "integer" + }, + "timestamp": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "value": { + "type": "number" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 52a663d..ba955e0 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -27,6 +27,68 @@ definitions: width: type: number type: object + gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage: + description: Этап производственной линии переработки + properties: + description: + type: string + equipment: + type: string + id: + type: integer + mqtt_topic: + type: string + name: + type: string + order: + type: integer + type: object + gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStats: + description: Сводная статистика работы производственной линии + properties: + avg_speed: + type: number + current_stage: + type: string + last_update_time: + type: string + rejected_items: + type: integer + status: + type: string + total_items: + type: integer + type: object + gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.ProductionEvent: + description: Событие, произошедшее на этапе производственной линии + properties: + data: + type: string + event_type: + type: string + id: + type: integer + stage_id: + type: integer + timestamp: + type: string + type: object + gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.SensorReading: + description: Показание датчика на этапе линии + properties: + id: + type: integer + sensor: + type: string + stage_id: + type: integer + timestamp: + type: string + unit: + type: string + value: + type: number + type: object info: contact: {} paths: @@ -137,6 +199,27 @@ paths: summary: Обновить тип тюка tags: - bale-types + /bale-types/qr/{id}: + get: + consumes: + - application/json + description: Возвращает QR код с URL для регистрации тюка этого типа + parameters: + - description: ID типа тюка + in: path + name: id + required: true + type: integer + produces: + - image/png + responses: + "200": + description: OK + schema: + type: file + summary: Получить QR код для маркировки тюка + tags: + - bale-types /bales: get: consumes: @@ -162,9 +245,12 @@ paths: - description: Данные тюка in: body name: bale - required: true schema: $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.Bale' + - description: Тип тюка (для QR кодов) + in: query + name: type + type: string produces: - application/json responses: @@ -244,6 +330,240 @@ paths: summary: Обновить тюк tags: - bales + /events: + post: + consumes: + - application/json + description: Создаёт новое событие на этапе производственной линии + parameters: + - description: Данные события + in: body + name: event + required: true + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.ProductionEvent' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.ProductionEvent' + summary: Создать событие на линии + tags: + - events + /events/stage/{id}: + get: + consumes: + - application/json + description: Возвращает последние 100 событий для указанного этапа + parameters: + - description: ID этапа + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.ProductionEvent' + type: array + summary: Получить события по этапу + tags: + - events + /line/stages: + get: + consumes: + - application/json + description: Возвращает список всех этапов производственной линии + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage' + type: array + summary: Получить все этапы линии + tags: + - line + post: + consumes: + - application/json + description: Создаёт новый этап производственной линии + parameters: + - description: Данные этапа + in: body + name: stage + required: true + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage' + summary: Создать этап линии + tags: + - line + /line/stages/{id}: + delete: + consumes: + - application/json + description: Удаляет этап линии по ID + parameters: + - description: ID этапа + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: boolean + type: object + summary: Удалить этап линии + tags: + - line + get: + consumes: + - application/json + description: Возвращает этап линии по ID + parameters: + - description: ID этапа + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage' + summary: Получить этап линии по ID + tags: + - line + put: + consumes: + - application/json + description: Обновляет данные этапа линии по ID + parameters: + - description: ID этапа + in: path + name: id + required: true + type: integer + - description: Данные этапа + in: body + name: stage + required: true + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStage' + summary: Обновить этап линии + tags: + - line + /line/stats: + get: + consumes: + - application/json + description: Возвращает сводную статистику работы производственной линии + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.LineStats' + summary: Получить статистику линии + tags: + - line + /queue/next: + get: + consumes: + - application/json + description: Получает и удаляет из очереди следующую задачу (FIFO) + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Получить следующую задачу из очереди + tags: + - queue + /sensors/readings: + post: + consumes: + - application/json + description: Создаёт новое показание датчика для этапа линии + parameters: + - description: Данные показания + in: body + name: reading + required: true + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.SensorReading' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.SensorReading' + summary: Создать показание датчика + tags: + - sensors + /sensors/readings/stage/{id}: + get: + consumes: + - application/json + description: Возвращает последние 100 показаний датчиков для указанного этапа + parameters: + - description: ID этапа + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_rostpoliplast_backend_internal_storage.SensorReading' + type: array + summary: Получить показания датчиков по этапу + tags: + - sensors securityDefinitions: Bearer: description: Type "Bearer" followed by a space and the JWT token. diff --git a/backend/internal/handlers/BaleType_handlers.go b/backend/internal/handlers/BaleType_handlers.go index b3c679b..e6d1192 100644 --- a/backend/internal/handlers/BaleType_handlers.go +++ b/backend/internal/handlers/BaleType_handlers.go @@ -12,7 +12,7 @@ import ( ) type BaleTypeHandlers struct { - Repo *storage.Repository + Repo storage.RepositoryInterface } func (h *BaleTypeHandlers) RegisterRoutes(g *gin.RouterGroup) { diff --git a/backend/internal/handlers/Bale_handlers.go b/backend/internal/handlers/Bale_handlers.go index b723f7d..473ea2c 100644 --- a/backend/internal/handlers/Bale_handlers.go +++ b/backend/internal/handlers/Bale_handlers.go @@ -11,7 +11,7 @@ import ( ) type BaleHandlers struct { - Repo *storage.Repository + Repo storage.RepositoryInterface MQ *mq.RabbitMQ } diff --git a/backend/internal/handlers/Line_handlers.go b/backend/internal/handlers/Line_handlers.go new file mode 100644 index 0000000..1a22a58 --- /dev/null +++ b/backend/internal/handlers/Line_handlers.go @@ -0,0 +1,262 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "gitea.d3m0k1d.ru/d3m0k1d/rostpoliplast/backend/internal/storage" +) + +type LineHandlers struct { + Repo storage.RepositoryInterface +} + +func (h *LineHandlers) RegisterRoutes(g *gin.RouterGroup) { + line := g.Group("/line") + { + line.GET("/stages", h.GetLineStages) + line.GET("/stages/:id", h.GetLineStageByID) + line.POST("/stages", h.CreateLineStage) + line.PUT("/stages/:id", h.UpdateLineStage) + line.DELETE("/stages/:id", h.DeleteLineStage) + } + + sensors := g.Group("/sensors") + { + sensors.POST("/readings", h.CreateSensorReading) + sensors.GET("/readings/stage/:id", h.GetSensorReadingsByStage) + } + + events := g.Group("/events") + { + events.POST("", h.CreateProductionEvent) + events.GET("/stage/:id", h.GetProductionEventsByStage) + } + + g.GET("/line/stats", h.GetLineStats) +} + +// GetLineStages Получить все этапы линии +// @Summary Получить все этапы линии +// @Description Возвращает список всех этапов производственной линии +// @Tags line +// @Accept json +// @Produce json +// @Success 200 {array} storage.LineStage +// @Router /line/stages [get] +func (h *LineHandlers) GetLineStages(c *gin.Context) { + stages, err := h.Repo.GetLineStages(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, stages) +} + +// GetLineStageByID Получить этап линии по ID +// @Summary Получить этап линии по ID +// @Description Возвращает этап линии по ID +// @Tags line +// @Accept json +// @Produce json +// @Param id path int true "ID этапа" +// @Success 200 {object} storage.LineStage +// @Router /line/stages/{id} [get] +func (h *LineHandlers) GetLineStageByID(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + stage, err := h.Repo.GetLineStageByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, stage) +} + +// CreateLineStage Создать этап линии +// @Summary Создать этап линии +// @Description Создаёт новый этап производственной линии +// @Tags line +// @Accept json +// @Produce json +// @Param stage body storage.LineStage true "Данные этапа" +// @Success 201 {object} storage.LineStage +// @Router /line/stages [post] +func (h *LineHandlers) CreateLineStage(c *gin.Context) { + var input storage.LineStage + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + stage, err := h.Repo.CreateLineStage(c.Request.Context(), input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, stage) +} + +// UpdateLineStage Обновить этап линии +// @Summary Обновить этап линии +// @Description Обновляет данные этапа линии по ID +// @Tags line +// @Accept json +// @Produce json +// @Param id path int true "ID этапа" +// @Param stage body storage.LineStage true "Данные этапа" +// @Success 200 {object} storage.LineStage +// @Router /line/stages/{id} [put] +func (h *LineHandlers) UpdateLineStage(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var input storage.LineStage + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + stage, err := h.Repo.UpdateLineStage(c.Request.Context(), id, input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, stage) +} + +// DeleteLineStage Удалить этап линии +// @Summary Удалить этап линии +// @Description Удаляет этап линии по ID +// @Tags line +// @Accept json +// @Produce json +// @Param id path int true "ID этапа" +// @Success 200 {object} map[string]bool +// @Router /line/stages/{id} [delete] +func (h *LineHandlers) DeleteLineStage(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + if err := h.Repo.DeleteLineStage(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +// CreateSensorReading Создать показание датчика +// @Summary Создать показание датчика +// @Description Создаёт новое показание датчика для этапа линии +// @Tags sensors +// @Accept json +// @Produce json +// @Param reading body storage.SensorReading true "Данные показания" +// @Success 201 {object} storage.SensorReading +// @Router /sensors/readings [post] +func (h *LineHandlers) CreateSensorReading(c *gin.Context) { + var input storage.SensorReading + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + reading, err := h.Repo.CreateSensorReading(c.Request.Context(), input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, reading) +} + +// GetSensorReadingsByStage Получить показания датчиков по этапу +// @Summary Получить показания датчиков по этапу +// @Description Возвращает последние 100 показаний датчиков для указанного этапа +// @Tags sensors +// @Accept json +// @Produce json +// @Param id path int true "ID этапа" +// @Success 200 {array} storage.SensorReading +// @Router /sensors/readings/stage/{id} [get] +func (h *LineHandlers) GetSensorReadingsByStage(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + readings, err := h.Repo.GetSensorReadingsByStage(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, readings) +} + +// CreateProductionEvent Создать событие на линии +// @Summary Создать событие на линии +// @Description Создаёт новое событие на этапе производственной линии +// @Tags events +// @Accept json +// @Produce json +// @Param event body storage.ProductionEvent true "Данные события" +// @Success 201 {object} storage.ProductionEvent +// @Router /events [post] +func (h *LineHandlers) CreateProductionEvent(c *gin.Context) { + var input storage.ProductionEvent + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + event, err := h.Repo.CreateProductionEvent(c.Request.Context(), input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, event) +} + +// GetProductionEventsByStage Получить события по этапу +// @Summary Получить события по этапу +// @Description Возвращает последние 100 событий для указанного этапа +// @Tags events +// @Accept json +// @Produce json +// @Param id path int true "ID этапа" +// @Success 200 {array} storage.ProductionEvent +// @Router /events/stage/{id} [get] +func (h *LineHandlers) GetProductionEventsByStage(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + events, err := h.Repo.GetProductionEventsByStage(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, events) +} + +// GetLineStats Получить статистику линии +// @Summary Получить статистику линии +// @Description Возвращает сводную статистику работы производственной линии +// @Tags line +// @Accept json +// @Produce json +// @Success 200 {object} storage.LineStats +// @Router /line/stats [get] +func (h *LineHandlers) GetLineStats(c *gin.Context) { + stats, err := h.Repo.GetLineStats(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, stats) +} diff --git a/backend/internal/handlers/api_test.go b/backend/internal/handlers/api_test.go new file mode 100644 index 0000000..cdaad32 --- /dev/null +++ b/backend/internal/handlers/api_test.go @@ -0,0 +1,453 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "gitea.d3m0k1d.ru/d3m0k1d/rostpoliplast/backend/internal/storage" +) + +type testRepo struct { + baleTypes []storage.BaleType + bales []storage.Bale + stages []storage.LineStage + readings []storage.SensorReading + events []storage.ProductionEvent + stats *storage.LineStats +} + +func (r *testRepo) GetBales(ctx context.Context) ([]storage.Bale, error) { + return r.bales, nil +} +func (r *testRepo) GetBaleByID(ctx context.Context, id string) (*storage.Bale, error) { + for _, b := range r.bales { + if b.ID == 1 { + return &b, nil + } + } + return nil, nil +} +func (r *testRepo) CreateBale(ctx context.Context, input storage.Bale) (*storage.Bale, error) { + b := storage.Bale{ID: 1, TypeID: input.TypeID, Timestamp: time.Now()} + r.bales = append(r.bales, b) + return &b, nil +} +func (r *testRepo) UpdateBale(ctx context.Context, id string, input storage.Bale) (*storage.Bale, error) { + return &input, nil +} +func (r *testRepo) DeleteBale(ctx context.Context, id string) error { + return nil +} +func (r *testRepo) GetBaleTypeByType(ctx context.Context, typeName string) (*storage.BaleType, error) { + for _, bt := range r.baleTypes { + if bt.Type == typeName { + return &bt, nil + } + } + return nil, nil +} + +func (r *testRepo) GetBaleTypes(ctx context.Context) ([]storage.BaleType, error) { + return r.baleTypes, nil +} +func (r *testRepo) GetBaleTypeByID(ctx context.Context, id int) (*storage.BaleType, error) { + for _, bt := range r.baleTypes { + if bt.ID == id { + return &bt, nil + } + } + return nil, nil +} +func (r *testRepo) CreateBaleType(ctx context.Context, input storage.BaleType) (*storage.BaleType, error) { + bt := storage.BaleType{ID: 1, Type: input.Type, Weight: input.Weight} + r.baleTypes = append(r.baleTypes, bt) + return &bt, nil +} +func (r *testRepo) UpdateBaleType(ctx context.Context, id int, input storage.BaleType) (*storage.BaleType, error) { + return &input, nil +} +func (r *testRepo) DeleteBaleType(ctx context.Context, id int) error { + return nil +} + +func (r *testRepo) GetLineStages(ctx context.Context) ([]storage.LineStage, error) { + return r.stages, nil +} +func (r *testRepo) GetLineStageByID(ctx context.Context, id int) (*storage.LineStage, error) { + for _, s := range r.stages { + if s.ID == id { + return &s, nil + } + } + return nil, nil +} +func (r *testRepo) CreateLineStage(ctx context.Context, input storage.LineStage) (*storage.LineStage, error) { + s := storage.LineStage{ID: 1, Name: input.Name, Order: input.Order} + r.stages = append(r.stages, s) + return &s, nil +} +func (r *testRepo) UpdateLineStage(ctx context.Context, id int, input storage.LineStage) (*storage.LineStage, error) { + return &input, nil +} +func (r *testRepo) DeleteLineStage(ctx context.Context, id int) error { + return nil +} + +func (r *testRepo) CreateSensorReading(ctx context.Context, input storage.SensorReading) (*storage.SensorReading, error) { + s := storage.SensorReading{ID: 1, StageID: input.StageID, Value: input.Value} + r.readings = append(r.readings, s) + return &s, nil +} +func (r *testRepo) GetSensorReadingsByStage(ctx context.Context, stageID int) ([]storage.SensorReading, error) { + var result []storage.SensorReading + for _, s := range r.readings { + if s.StageID == stageID { + result = append(result, s) + } + } + return result, nil +} + +func (r *testRepo) CreateProductionEvent(ctx context.Context, input storage.ProductionEvent) (*storage.ProductionEvent, error) { + e := storage.ProductionEvent{ID: 1, StageID: input.StageID, EventType: input.EventType} + r.events = append(r.events, e) + return &e, nil +} +func (r *testRepo) GetProductionEventsByStage(ctx context.Context, stageID int) ([]storage.ProductionEvent, error) { + var result []storage.ProductionEvent + for _, e := range r.events { + if e.StageID == stageID { + result = append(result, e) + } + } + return result, nil +} + +func (r *testRepo) GetLineStats(ctx context.Context) (*storage.LineStats, error) { + if r.stats != nil { + return r.stats, nil + } + return &storage.LineStats{TotalItems: 42, RejectedItems: 2, Status: "running"}, nil +} + +func setupRouter(repo storage.RepositoryInterface) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + v1 := r.Group("/api/v1") + + lineH := &LineHandlers{Repo: repo} + baleH := &BaleHandlers{Repo: repo, MQ: nil} + baleTypeH := &BaleTypeHandlers{Repo: repo} + + lineH.RegisterRoutes(v1) + baleH.RegisterRoutes(v1) + baleTypeH.RegisterRoutes(v1) + return r +} + +func TestGetBales(t *testing.T) { + repo := &testRepo{ + bales: []storage.Bale{{ID: 1, TypeID: 1, Type: "test", Timestamp: time.Now()}}, + } + r := setupRouter(repo) + + req := httptest.NewRequest("GET", "/api/v1/bales", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected %d, got %d", http.StatusOK, w.Code) + } + + var resp []storage.Bale + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if len(resp) != 1 { + t.Errorf("expected 1 bale, got %d", len(resp)) + } +} + +func TestCreateBale(t *testing.T) { + repo := &testRepo{} + r := setupRouter(repo) + + body := bytes.NewBufferString(`{"typeId": 1}`) + req := httptest.NewRequest("POST", "/api/v1/bales", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected %d, got %d", http.StatusCreated, w.Code) + } + + var resp storage.Bale + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.TypeID != 1 { + t.Errorf("expected typeId 1, got %d", resp.TypeID) + } +} + +func TestGetBalesWithTypeQuery(t *testing.T) { + repo := &testRepo{ + baleTypes: []storage.BaleType{{ID: 1, Type: "standard"}}, + } + r := setupRouter(repo) + + req := httptest.NewRequest("POST", "/api/v1/bales?type=standard", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected %d, got %d", http.StatusCreated, w.Code) + } +} + +func TestGetBaleTypes(t *testing.T) { + repo := &testRepo{ + baleTypes: []storage.BaleType{{ID: 1, Type: "PET", Weight: 10.0}}, + } + r := setupRouter(repo) + + req := httptest.NewRequest("GET", "/api/v1/bale-types", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected %d, got %d", http.StatusOK, w.Code) + } + + var resp []storage.BaleType + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if len(resp) != 1 { + t.Errorf("expected 1 baleType, got %d", len(resp)) + } + if resp[0].Type != "PET" { + t.Errorf("expected type 'PET', got '%s'", resp[0].Type) + } +} + +func TestCreateBaleType(t *testing.T) { + repo := &testRepo{} + r := setupRouter(repo) + + body := bytes.NewBufferString(`{"type": "HDPE", "weight": 15.5, "height": 100, "width": 80, "length": 120}`) + req := httptest.NewRequest("POST", "/api/v1/bale-types", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected %d, got %d", http.StatusCreated, w.Code) + } +} + +func TestGetLineStages(t *testing.T) { + repo := &testRepo{ + stages: []storage.LineStage{ + {ID: 1, Name: "Разгрузка", Order: 1, Equipment: "Рампа", MQTTTopic: "/sensor/weight"}, + {ID: 2, Name: "QR Сортировка", Order: 2, Equipment: "Camera", MQTTTopic: "/qr/result"}, + }, + } + r := setupRouter(repo) + + req := httptest.NewRequest("GET", "/api/v1/line/stages", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected %d, got %d", http.StatusOK, w.Code) + } + + var resp []storage.LineStage + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if len(resp) != 2 { + t.Errorf("expected 2 stages, got %d", len(resp)) + } +} + +func TestCreateLineStage(t *testing.T) { + repo := &testRepo{} + r := setupRouter(repo) + + body := bytes.NewBufferString(`{"name": "Тестовый этап", "description": "Описание", "order": 99, "equipment": "Оборудование", "mqtt_topic": "/test/topic"}`) + req := httptest.NewRequest("POST", "/api/v1/line/stages", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected %d, got %d", http.StatusCreated, w.Code) + } +} + +func TestCreateSensorReading(t *testing.T) { + repo := &testRepo{} + r := setupRouter(repo) + + body := bytes.NewBufferString(`{"stage_id": 1, "sensor": "vibration", "value": 42.5, "unit": "mm/s"}`) + req := httptest.NewRequest("POST", "/api/v1/sensors/readings", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected %d, got %d", http.StatusCreated, w.Code) + } +} + +func TestGetSensorReadingsByStage(t *testing.T) { + repo := &testRepo{ + readings: []storage.SensorReading{ + {ID: 1, StageID: 1, Sensor: "vibration", Value: 42.5, Unit: "mm/s", Timestamp: time.Now()}, + }, + } + r := setupRouter(repo) + + req := httptest.NewRequest("GET", "/api/v1/sensors/readings/stage/1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected %d, got %d", http.StatusOK, w.Code) + } +} + +func TestCreateProductionEvent(t *testing.T) { + repo := &testRepo{} + r := setupRouter(repo) + + body := bytes.NewBufferString(`{"stage_id": 1, "event_type": "item_processed", "data": "success"}`) + req := httptest.NewRequest("POST", "/api/v1/events", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected %d, got %d", http.StatusCreated, w.Code) + t.Logf("Response: %s", w.Body.String()) + } +} + +func TestGetProductionEventsByStage(t *testing.T) { + repo := &testRepo{ + events: []storage.ProductionEvent{ + {ID: 1, StageID: 1, EventType: "item_processed", Data: "success", Timestamp: time.Now()}, + }, + } + r := setupRouter(repo) + + req := httptest.NewRequest("GET", "/api/v1/events/stage/1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected %d, got %d", http.StatusOK, w.Code) + } +} + +func TestGetLineStats(t *testing.T) { + repo := &testRepo{ + stats: &storage.LineStats{ + TotalItems: 100, + RejectedItems: 5, + AvgSpeed: 1.5, + CurrentStage: "Экструзия", + Status: "running", + }, + } + r := setupRouter(repo) + + req := httptest.NewRequest("GET", "/api/v1/line/stats", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected %d, got %d", http.StatusOK, w.Code) + } + + var resp storage.LineStats + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.TotalItems != 100 { + t.Errorf("expected totalItems 100, got %d", resp.TotalItems) + } + if resp.RejectedItems != 5 { + t.Errorf("expected rejectedItems 5, got %d", resp.RejectedItems) + } + if resp.Status != "running" { + t.Errorf("expected status 'running', got '%s'", resp.Status) + } +} + +func TestUpdateBaleType(t *testing.T) { + repo := &testRepo{} + r := setupRouter(repo) + + body := bytes.NewBufferString(`{"type": "UPDATED", "weight": 20.0}`) + req := httptest.NewRequest("PUT", "/api/v1/bale-types/1", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected %d, got %d", http.StatusOK, w.Code) + } +} + +func TestDeleteBaleType(t *testing.T) { + repo := &testRepo{} + r := setupRouter(repo) + + req := httptest.NewRequest("DELETE", "/api/v1/bale-types/1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected %d, got %d", http.StatusOK, w.Code) + } +} + +func TestInvalidIDReturns400(t *testing.T) { + repo := &testRepo{} + r := setupRouter(repo) + + req := httptest.NewRequest("GET", "/api/v1/line/stages/abc", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestHealthEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected %d, got %d", http.StatusOK, w.Code) + } +} diff --git a/backend/internal/handlers/register.go b/backend/internal/handlers/register.go index baae2fe..2ef6f00 100644 --- a/backend/internal/handlers/register.go +++ b/backend/internal/handlers/register.go @@ -28,4 +28,9 @@ func (h *Handlers) RegisterRoutes(r *gin.RouterGroup) { MQ: h.MQ, } queueHandlers.RegisterRoutes(r) + + lineHandlers := &LineHandlers{ + Repo: h.Repo, + } + lineHandlers.RegisterRoutes(r) } diff --git a/backend/internal/storage/repository.go b/backend/internal/storage/repository.go index 721bdd5..464194c 100644 --- a/backend/internal/storage/repository.go +++ b/backend/internal/storage/repository.go @@ -6,10 +6,36 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +type RepositoryInterface interface { + GetBales(ctx context.Context) ([]Bale, error) + GetBaleByID(ctx context.Context, id string) (*Bale, error) + CreateBale(ctx context.Context, input Bale) (*Bale, error) + UpdateBale(ctx context.Context, id string, input Bale) (*Bale, error) + DeleteBale(ctx context.Context, id string) error + GetBaleTypeByType(ctx context.Context, typeName string) (*BaleType, error) + GetBaleTypes(ctx context.Context) ([]BaleType, error) + GetBaleTypeByID(ctx context.Context, id int) (*BaleType, error) + CreateBaleType(ctx context.Context, input BaleType) (*BaleType, error) + UpdateBaleType(ctx context.Context, id int, input BaleType) (*BaleType, error) + DeleteBaleType(ctx context.Context, id int) error + GetLineStages(ctx context.Context) ([]LineStage, error) + GetLineStageByID(ctx context.Context, id int) (*LineStage, error) + CreateLineStage(ctx context.Context, input LineStage) (*LineStage, error) + UpdateLineStage(ctx context.Context, id int, input LineStage) (*LineStage, error) + DeleteLineStage(ctx context.Context, id int) error + CreateSensorReading(ctx context.Context, input SensorReading) (*SensorReading, error) + GetSensorReadingsByStage(ctx context.Context, stageID int) ([]SensorReading, error) + CreateProductionEvent(ctx context.Context, input ProductionEvent) (*ProductionEvent, error) + GetProductionEventsByStage(ctx context.Context, stageID int) ([]ProductionEvent, error) + GetLineStats(ctx context.Context) (*LineStats, error) +} + type Repository struct { pool *pgxpool.Pool } +var _ RepositoryInterface = (*Repository)(nil) + func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{ pool: pool, @@ -151,3 +177,137 @@ func (r *Repository) DeleteBaleType(ctx context.Context, id int) error { _, err := r.pool.Exec(ctx, "DELETE FROM bale_types WHERE id = $1", id) return err } + +func (r *Repository) GetLineStages(ctx context.Context) ([]LineStage, error) { + rows, err := r.pool.Query(ctx, "SELECT id, name, description, stage_order, equipment, mqtt_topic FROM line_stages ORDER BY stage_order") + if err != nil { + return nil, err + } + defer rows.Close() + + var stages []LineStage + for rows.Next() { + var s LineStage + if err := rows.Scan(&s.ID, &s.Name, &s.Description, &s.Order, &s.Equipment, &s.MQTTTopic); err != nil { + return nil, err + } + stages = append(stages, s) + } + return stages, nil +} + +func (r *Repository) GetLineStageByID(ctx context.Context, id int) (*LineStage, error) { + var s LineStage + err := r.pool.QueryRow(ctx, "SELECT id, name, description, stage_order, equipment, mqtt_topic FROM line_stages WHERE id = $1", id).Scan(&s.ID, &s.Name, &s.Description, &s.Order, &s.Equipment, &s.MQTTTopic) + if err != nil { + return nil, err + } + return &s, nil +} + +func (r *Repository) CreateLineStage(ctx context.Context, input LineStage) (*LineStage, error) { + var s LineStage + err := r.pool.QueryRow(ctx, ` + INSERT INTO line_stages (name, description, stage_order, equipment, mqtt_topic) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, name, description, stage_order, equipment, mqtt_topic`, input.Name, input.Description, input.Order, input.Equipment, input.MQTTTopic).Scan(&s.ID, &s.Name, &s.Description, &s.Order, &s.Equipment, &s.MQTTTopic) + if err != nil { + return nil, err + } + return &s, nil +} + +func (r *Repository) UpdateLineStage(ctx context.Context, id int, input LineStage) (*LineStage, error) { + var s LineStage + err := r.pool.QueryRow(ctx, ` + UPDATE line_stages SET name = $1, description = $2, stage_order = $3, equipment = $4, mqtt_topic = $5 WHERE id = $6 + RETURNING id, name, description, stage_order, equipment, mqtt_topic`, input.Name, input.Description, input.Order, input.Equipment, input.MQTTTopic, id).Scan(&s.ID, &s.Name, &s.Description, &s.Order, &s.Equipment, &s.MQTTTopic) + if err != nil { + return nil, err + } + return &s, nil +} + +func (r *Repository) DeleteLineStage(ctx context.Context, id int) error { + _, err := r.pool.Exec(ctx, "DELETE FROM line_stages WHERE id = $1", id) + return err +} + +func (r *Repository) CreateSensorReading(ctx context.Context, input SensorReading) (*SensorReading, error) { + var s SensorReading + err := r.pool.QueryRow(ctx, ` + INSERT INTO sensor_readings (stage_id, sensor, value, unit, timestamp) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, stage_id, sensor, value, unit, timestamp`, input.StageID, input.Sensor, input.Value, input.Unit, input.Timestamp).Scan(&s.ID, &s.StageID, &s.Sensor, &s.Value, &s.Unit, &s.Timestamp) + if err != nil { + return nil, err + } + return &s, nil +} + +func (r *Repository) GetSensorReadingsByStage(ctx context.Context, stageID int) ([]SensorReading, error) { + rows, err := r.pool.Query(ctx, "SELECT id, stage_id, sensor, value, unit, timestamp FROM sensor_readings WHERE stage_id = $1 ORDER BY timestamp DESC LIMIT 100", stageID) + if err != nil { + return nil, err + } + defer rows.Close() + + var readings []SensorReading + for rows.Next() { + var r SensorReading + if err := rows.Scan(&r.ID, &r.StageID, &r.Sensor, &r.Value, &r.Unit, &r.Timestamp); err != nil { + return nil, err + } + readings = append(readings, r) + } + return readings, nil +} + +func (r *Repository) CreateProductionEvent(ctx context.Context, input ProductionEvent) (*ProductionEvent, error) { + var e ProductionEvent + err := r.pool.QueryRow(ctx, ` + INSERT INTO production_events (stage_id, event_type, data, timestamp) + VALUES ($1, $2, $3, $4) + RETURNING id, stage_id, event_type, data, timestamp`, input.StageID, input.EventType, input.Data, input.Timestamp).Scan(&e.ID, &e.StageID, &e.EventType, &e.Data, &e.Timestamp) + if err != nil { + return nil, err + } + return &e, nil +} + +func (r *Repository) GetProductionEventsByStage(ctx context.Context, stageID int) ([]ProductionEvent, error) { + rows, err := r.pool.Query(ctx, "SELECT id, stage_id, event_type, data, timestamp FROM production_events WHERE stage_id = $1 ORDER BY timestamp DESC LIMIT 100", stageID) + if err != nil { + return nil, err + } + defer rows.Close() + + var events []ProductionEvent + for rows.Next() { + var e ProductionEvent + if err := rows.Scan(&e.ID, &e.StageID, &e.EventType, &e.Data, &e.Timestamp); err != nil { + return nil, err + } + events = append(events, e) + } + return events, nil +} + +func (r *Repository) GetLineStats(ctx context.Context) (*LineStats, error) { + var stats LineStats + err := r.pool.QueryRow(ctx, ` + SELECT + COUNT(CASE WHEN pe.event_type = 'item_processed' THEN 1 END) as total_items, + COUNT(CASE WHEN pe.event_type = 'item_rejected' THEN 1 END) as rejected_items, + COALESCE(AVG(sr.value) FILTER (WHERE sr.sensor = 'conveyor_speed'), 0) as avg_speed, + COALESCE((SELECT ls.name FROM line_stages ls ORDER BY ls.stage_order LIMIT 1), '') as current_stage, + 'running' as status, + NOW() as last_update_time + FROM production_events pe + LEFT JOIN sensor_readings sr ON pe.stage_id = sr.stage_id + `).Scan(&stats.TotalItems, &stats.RejectedItems, &stats.AvgSpeed, &stats.CurrentStage, &stats.Status, &stats.LastUpdateTime) + if err != nil { + return nil, err + } + return &stats, nil +} diff --git a/backend/internal/storage/types.go b/backend/internal/storage/types.go index 4e8f7eb..e6da66d 100644 --- a/backend/internal/storage/types.go +++ b/backend/internal/storage/types.go @@ -24,6 +24,49 @@ type Bale struct { type User struct { ID int `json:"id"` - Username string `json:"username"` - Password string `json:"password"` + Username string `json:"username"` + Password string `json:"password"` +} + +// LineStage Этап производственной линии +// @Description Этап производственной линии переработки +type LineStage struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Order int `json:"order"` + Equipment string `json:"equipment"` + MQTTTopic string `json:"mqtt_topic"` +} + +// SensorReading Показание датчика +// @Description Показание датчика на этапе линии +type SensorReading struct { + ID int `json:"id"` + StageID int `json:"stage_id"` + Sensor string `json:"sensor"` + Value float64 `json:"value"` + Unit string `json:"unit"` + Timestamp time.Time `json:"timestamp"` +} + +// ProductionEvent Событие на линии +// @Description Событие, произошедшее на этапе производственной линии +type ProductionEvent struct { + ID int `json:"id"` + StageID int `json:"stage_id"` + EventType string `json:"event_type"` + Data string `json:"data"` + Timestamp time.Time `json:"timestamp"` +} + +// LineStats Статистика линии +// @Description Сводная статистика работы производственной линии +type LineStats struct { + TotalItems int `json:"total_items"` + RejectedItems int `json:"rejected_items"` + AvgSpeed float64 `json:"avg_speed"` + CurrentStage string `json:"current_stage"` + Status string `json:"status"` + LastUpdateTime time.Time `json:"last_update_time"` } diff --git a/backend/migrations/002_create_line_tables.sql b/backend/migrations/002_create_line_tables.sql new file mode 100644 index 0000000..ce837d1 --- /dev/null +++ b/backend/migrations/002_create_line_tables.sql @@ -0,0 +1,50 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE line_stages ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + stage_order INT NOT NULL UNIQUE, + equipment VARCHAR(200), + mqtt_topic VARCHAR(200) +); + +CREATE TABLE sensor_readings ( + id SERIAL PRIMARY KEY, + stage_id INT REFERENCES line_stages(id) ON DELETE CASCADE, + sensor VARCHAR(100) NOT NULL, + value DOUBLE PRECISION NOT NULL, + unit VARCHAR(20), + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE production_events ( + id SERIAL PRIMARY KEY, + stage_id INT REFERENCES line_stages(id) ON DELETE CASCADE, + event_type VARCHAR(100) NOT NULL, + data TEXT, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Индексы для производительности +CREATE INDEX idx_sensor_readings_stage ON sensor_readings(stage_id, timestamp DESC); +CREATE INDEX idx_production_events_stage ON production_events(stage_id, timestamp DESC); + +-- Начальные данные этапов линии +INSERT INTO line_stages (name, description, stage_order, equipment, mqtt_topic) VALUES + ('Разгрузка', 'Приемка лома, взвешивание', 1, 'Рампа, весы', '/sensor/weight/party'), + ('QR Сортировка', 'Сканирование QR-кодов', 2, 'Camera Module v3', '/qr/result'), + ('Подача', 'Загрузка на конвейер', 3, 'Danfoss VLT FC 302', '/drive/telemetry'), + ('Дробление', 'Шредер / Дробилка', 4, 'SEW MOVITRAC', '/drive/vibration'), + ('Мойка', 'Очистка, флотация', 5, 'Ванна, насосы', '/sensor/level'), + ('Сушка', 'Удаление влаги', 6, 'Центрифуга', '/sensor/dry/temp'), + ('Экструзия', 'Плавление, грануляция', 7, 'ABB ACS880', '/extruder/pressure'), + ('Манипулятор', 'Упаковка, паллетирование', 8, 'Робот-упаковщик', '/robot/cycle'); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS production_events; +DROP TABLE IF EXISTS sensor_readings; +DROP TABLE IF EXISTS line_stages; +-- +goose StatementEnd