diff --git a/core-service/COVERAGE.md b/core-service/COVERAGE.md
new file mode 100644
index 0000000..ad7e41c
--- /dev/null
+++ b/core-service/COVERAGE.md
@@ -0,0 +1,151 @@
+# 📊 Анализ покрытия тестами
+
+## 🎯 Обзор
+
+Текущее покрытие тестами: **9.6%**
+
+### 📈 Статистика по пакетам
+
+| Пакет | Покрытие | Статус |
+|-------|----------|--------|
+| `internal/auth` | 88.2% | ✅ Хорошо |
+| `internal/repository` | 24.5% | ⚠️ Низкое |
+| `internal/api/handlers` | 0.0% | ❌ Нет тестов |
+| `internal/service` | 0.0% | ❌ Нет тестов |
+| `internal/api/middleware` | 0.0% | ❌ Нет тестов |
+| `internal/config` | 0.0% | ❌ Нет тестов |
+| `internal/database` | 0.0% | ❌ Нет тестов |
+| `internal/logger` | 0.0% | ❌ Нет тестов |
+
+## 🛠️ Инструменты анализа
+
+### 1. Быстрый анализ покрытия
+```bash
+# Общая статистика
+go test ./... -cover
+
+# Детальная статистика по функциям
+go test ./... -coverprofile=coverage.out
+go tool cover -func=coverage.out
+```
+
+### 2. HTML отчет
+```bash
+# Генерация HTML отчета
+go test ./... -coverprofile=coverage.out
+go tool cover -html=coverage.out -o coverage.html
+```
+
+### 3. Автоматизированный анализ
+```bash
+# Использование скрипта анализа
+./scripts/coverage.sh
+
+# С HTML отчетом
+./scripts/coverage.sh --html
+
+# С кастомным порогом
+./scripts/coverage.sh --threshold=80 --html
+```
+
+## 📋 Рекомендации по улучшению
+
+### 🔥 Приоритет 1 (Критично)
+1. **Handlers (0% покрытия)**
+ - Добавить unit тесты для всех HTTP handlers
+ - Тестировать валидацию входных данных
+ - Тестировать обработку ошибок
+
+2. **Service Layer (0% покрытия)**
+ - Добавить unit тесты для бизнес-логики
+ - Тестировать взаимодействие с репозиториями
+ - Тестировать обработку ошибок
+
+### ⚠️ Приоритет 2 (Важно)
+3. **Middleware (0% покрытия)**
+ - Тестировать аутентификацию
+ - Тестировать извлечение claims из JWT
+
+4. **Repository Layer (24.5% покрытия)**
+ - Улучшить покрытие CRUD операций
+ - Добавить тесты для edge cases
+
+### 📝 Приоритет 3 (Желательно)
+5. **Config & Database (0% покрытия)**
+ - Тестировать загрузку конфигурации
+ - Тестировать подключение к БД
+
+## 🎯 Цели покрытия
+
+| Уровень | Целевое покрытие | Текущее покрытие |
+|---------|------------------|------------------|
+| Unit тесты | 80% | 9.6% |
+| Integration тесты | 60% | 0% |
+| Общее покрытие | 70% | 9.6% |
+
+## 📊 Метрики качества
+
+### Функции с высоким покрытием (>80%)
+- ✅ `auth.NewJWTService` - 100%
+- ✅ `auth.GenerateToken` - 100%
+- ✅ `auth.HashPassword` - 100%
+- ✅ `auth.CheckPassword` - 100%
+
+### Функции с низким покрытием (<50%)
+- ❌ Все handlers - 0%
+- ❌ Все service методы - 0%
+- ❌ Все middleware - 0%
+- ❌ Config и Database - 0%
+
+## 🔧 Интеграция с CI/CD
+
+### Pre-commit хук
+Pre-commit хук автоматически запускает тесты и показывает статистику:
+```
+Auth unit тесты: 4/4 ✅
+Repository unit тесты: 10/10 ✅
+Integration тесты: 5/5 ✅
+
+🎯 Общий результат: 19/19 тестов прошли успешно
+```
+
+### Рекомендации для CI/CD
+1. Добавить проверку покрытия в pipeline
+2. Установить минимальный порог покрытия (например, 70%)
+3. Генерировать HTML отчеты для каждого PR
+4. Отклонять PR с низким покрытием
+
+## 📈 План улучшения
+
+### Этап 1: Handlers (1-2 недели)
+- [ ] Unit тесты для `AuthHandler`
+- [ ] Unit тесты для `ItemHandler`
+- [ ] Unit тесты для `LocationHandler`
+- [ ] Unit тесты для `OperationsHandler`
+
+### Этап 2: Service Layer (1-2 недели)
+- [ ] Unit тесты для `AuthService`
+- [ ] Unit тесты для `ItemService`
+- [ ] Unit тесты для `LocationService`
+- [ ] Unit тесты для `OperationsService`
+
+### Этап 3: Middleware (3-5 дней)
+- [ ] Unit тесты для `AuthMiddleware`
+- [ ] Тесты извлечения claims
+
+### Этап 4: Repository (1 неделя)
+- [ ] Улучшение покрытия CRUD операций
+- [ ] Тесты edge cases
+
+### Этап 5: Config & Database (3-5 дней)
+- [ ] Тесты загрузки конфигурации
+- [ ] Тесты подключения к БД
+
+## 🎯 Ожидаемый результат
+
+После выполнения плана:
+- **Общее покрытие**: 9.6% → 70%+
+- **Unit тесты**: 0% → 80%+
+- **Integration тесты**: 0% → 60%+
+- **Качество кода**: Значительно улучшится
+- **Стабильность**: Снизится количество багов
diff --git a/core-service/coverage.html b/core-service/coverage.html
new file mode 100644
index 0000000..b961bef
--- /dev/null
+++ b/core-service/coverage.html
@@ -0,0 +1,2998 @@
+
+
+
+
+
+
+
package main
+
+import (
+ "context"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "erp-mvp/core-service/internal/api"
+ "erp-mvp/core-service/internal/config"
+ "erp-mvp/core-service/internal/database"
+ "erp-mvp/core-service/internal/logger"
+)
+
+func main() {
+ // Инициализация логгера
+ logger := logger.New()
+
+ // Загрузка конфигурации
+ cfg, err := config.Load()
+ if err != nil {
+ logger.Fatal("Failed to load config", err)
+ }
+
+ // Подключение к базе данных
+ db, err := database.Connect(cfg.Database)
+ if err != nil {
+ logger.Fatal("Failed to connect to database", err)
+ }
+ defer db.Close()
+
+ // Создание API сервера
+ server := api.NewServer(cfg, db, logger)
+
+ // Запуск HTTP сервера
+ go func() {
+ logger.Info("Starting HTTP server on", cfg.Server.Port)
+ if err := server.Start(); err != nil && err != http.ErrServerClosed {
+ logger.Fatal("Failed to start server", err)
+ }
+ }()
+
+ // Graceful shutdown
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+ <-quit
+
+ logger.Info("Shutting down server...")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ if err := server.Shutdown(ctx); err != nil {
+ logger.Fatal("Server forced to shutdown", err)
+ }
+
+ logger.Info("Server exited")
+}
+
+
+
package handlers
+
+import (
+ "net/http"
+
+ "erp-mvp/core-service/internal/models"
+ "erp-mvp/core-service/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/go-playground/validator/v10"
+)
+
+type AuthHandler struct {
+ authService service.AuthService
+ validate *validator.Validate
+}
+
+func NewAuthHandler(authService service.AuthService) *AuthHandler {
+ return &AuthHandler{
+ authService: authService,
+ validate: validator.New(),
+ }
+}
+
+// Register регистрация новой организации и пользователя
+func (h *AuthHandler) Register(c *gin.Context) {
+ var req models.RegisterRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
+ return
+ }
+
+ // Валидируем запрос
+ if err := h.validate.Struct(req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed", "details": err.Error()})
+ return
+ }
+
+ // Выполняем регистрацию
+ response, err := h.authService.Register(c.Request.Context(), &req)
+ if err != nil {
+ if validationErr, ok := err.(*service.ValidationError); ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": validationErr.Message})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
+ return
+ }
+
+ c.JSON(http.StatusCreated, response)
+}
+
+// Login вход в систему
+func (h *AuthHandler) Login(c *gin.Context) {
+ var req models.LoginRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
+ return
+ }
+
+ // Валидируем запрос
+ if err := h.validate.Struct(req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed", "details": err.Error()})
+ return
+ }
+
+ // Выполняем вход
+ response, err := h.authService.Login(c.Request.Context(), &req)
+ if err != nil {
+ if validationErr, ok := err.(*service.ValidationError); ok {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": validationErr.Message})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"})
+ return
+ }
+
+ c.JSON(http.StatusOK, response)
+}
+
+
+
package handlers
+
+import (
+ "net/http"
+
+ "erp-mvp/core-service/internal/api/middleware"
+ "erp-mvp/core-service/internal/models"
+ "erp-mvp/core-service/internal/service"
+
+ "github.com/gin-gonic/gin"
+ "github.com/go-playground/validator/v10"
+ "github.com/google/uuid"
+)
+
+type ItemHandler struct {
+ itemService service.ItemService
+ validate *validator.Validate
+}
+
+func NewItemHandler(itemService service.ItemService) *ItemHandler {
+ return &ItemHandler{
+ itemService: itemService,
+ validate: validator.New(),
+ }
+}
+
+// GetItems получает все товары организации
+func (h *ItemHandler) GetItems(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ items, err := h.itemService.GetItems(c.Request.Context(), claims.OrganizationID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get items"})
+ return
+ }
+
+ c.JSON(http.StatusOK, items)
+}
+
+// CreateItem создает новый товар
+func (h *ItemHandler) CreateItem(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ var req models.CreateItemRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
+ return
+ }
+
+ if err := h.validate.Struct(req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed", "details": err.Error()})
+ return
+ }
+
+ item, err := h.itemService.CreateItem(c.Request.Context(), claims.OrganizationID, &req)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create item"})
+ return
+ }
+
+ c.JSON(http.StatusCreated, item)
+}
+
+// GetItem получает товар по ID
+func (h *ItemHandler) GetItem(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ idStr := c.Param("id")
+ id, err := uuid.Parse(idStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid item ID"})
+ return
+ }
+
+ item, err := h.itemService.GetItem(c.Request.Context(), id, claims.OrganizationID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Item not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, item)
+}
+
+// UpdateItem обновляет товар
+func (h *ItemHandler) UpdateItem(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ idStr := c.Param("id")
+ id, err := uuid.Parse(idStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid item ID"})
+ return
+ }
+
+ var req models.CreateItemRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
+ return
+ }
+
+ if err := h.validate.Struct(req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed", "details": err.Error()})
+ return
+ }
+
+ item, err := h.itemService.UpdateItem(c.Request.Context(), id, claims.OrganizationID, &req)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Item not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, item)
+}
+
+// DeleteItem удаляет товар
+func (h *ItemHandler) DeleteItem(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ idStr := c.Param("id")
+ id, err := uuid.Parse(idStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid item ID"})
+ return
+ }
+
+ if err := h.itemService.DeleteItem(c.Request.Context(), id, claims.OrganizationID); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Item not found"})
+ return
+ }
+
+ c.JSON(http.StatusNoContent, nil)
+}
+
+// SearchItems ищет товары
+func (h *ItemHandler) SearchItems(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ query := c.Query("q")
+ category := c.Query("category")
+
+ items, err := h.itemService.SearchItems(c.Request.Context(), claims.OrganizationID, query, category)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search items"})
+ return
+ }
+
+ c.JSON(http.StatusOK, items)
+}
+
+
+
package handlers
+
+import (
+ "net/http"
+
+ "erp-mvp/core-service/internal/api/middleware"
+ "erp-mvp/core-service/internal/models"
+ "erp-mvp/core-service/internal/service"
+
+ "github.com/gin-gonic/gin"
+ "github.com/go-playground/validator/v10"
+ "github.com/google/uuid"
+)
+
+type LocationHandler struct {
+ locationService service.LocationService
+ validate *validator.Validate
+}
+
+func NewLocationHandler(locationService service.LocationService) *LocationHandler {
+ return &LocationHandler{
+ locationService: locationService,
+ validate: validator.New(),
+ }
+}
+
+// GetLocations получает все места хранения организации
+func (h *LocationHandler) GetLocations(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ locations, err := h.locationService.GetLocations(c.Request.Context(), claims.OrganizationID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get locations"})
+ return
+ }
+
+ c.JSON(http.StatusOK, locations)
+}
+
+// CreateLocation создает новое место хранения
+func (h *LocationHandler) CreateLocation(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ var req models.CreateLocationRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
+ return
+ }
+
+ if err := h.validate.Struct(req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed", "details": err.Error()})
+ return
+ }
+
+ location, err := h.locationService.CreateLocation(c.Request.Context(), claims.OrganizationID, &req)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create location"})
+ return
+ }
+
+ c.JSON(http.StatusCreated, location)
+}
+
+// GetLocation получает место хранения по ID
+func (h *LocationHandler) GetLocation(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ idStr := c.Param("id")
+ id, err := uuid.Parse(idStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid location ID"})
+ return
+ }
+
+ location, err := h.locationService.GetLocation(c.Request.Context(), id, claims.OrganizationID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Location not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, location)
+}
+
+// UpdateLocation обновляет место хранения
+func (h *LocationHandler) UpdateLocation(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ idStr := c.Param("id")
+ id, err := uuid.Parse(idStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid location ID"})
+ return
+ }
+
+ var req models.CreateLocationRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
+ return
+ }
+
+ if err := h.validate.Struct(req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed", "details": err.Error()})
+ return
+ }
+
+ location, err := h.locationService.UpdateLocation(c.Request.Context(), id, claims.OrganizationID, &req)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Location not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, location)
+}
+
+// DeleteLocation удаляет место хранения
+func (h *LocationHandler) DeleteLocation(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ idStr := c.Param("id")
+ id, err := uuid.Parse(idStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid location ID"})
+ return
+ }
+
+ if err := h.locationService.DeleteLocation(c.Request.Context(), id, claims.OrganizationID); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Location not found"})
+ return
+ }
+
+ c.JSON(http.StatusNoContent, nil)
+}
+
+// GetChildren получает дочерние места хранения
+func (h *LocationHandler) GetChildren(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ parentIDStr := c.Param("id")
+ parentID, err := uuid.Parse(parentIDStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid parent location ID"})
+ return
+ }
+
+ children, err := h.locationService.GetChildren(c.Request.Context(), parentID, claims.OrganizationID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Parent location not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, children)
+}
+
+
+
package handlers
+
+import (
+ "net/http"
+
+ "erp-mvp/core-service/internal/api/middleware"
+ "erp-mvp/core-service/internal/models"
+ "erp-mvp/core-service/internal/service"
+
+ "github.com/gin-gonic/gin"
+ "github.com/go-playground/validator/v10"
+ "github.com/google/uuid"
+)
+
+type OperationsHandler struct {
+ operationsService service.OperationsService
+ validate *validator.Validate
+}
+
+func NewOperationsHandler(operationsService service.OperationsService) *OperationsHandler {
+ return &OperationsHandler{
+ operationsService: operationsService,
+ validate: validator.New(),
+ }
+}
+
+// PlaceItem размещает товар в месте хранения
+func (h *OperationsHandler) PlaceItem(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ var req models.PlaceItemRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
+ return
+ }
+
+ if err := h.validate.Struct(req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed", "details": err.Error()})
+ return
+ }
+
+ placement, err := h.operationsService.PlaceItem(c.Request.Context(), claims.OrganizationID, &req)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to place item"})
+ return
+ }
+
+ c.JSON(http.StatusCreated, placement)
+}
+
+// MoveItem перемещает товар в другое место хранения
+func (h *OperationsHandler) MoveItem(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ placementIDStr := c.Param("id")
+ placementID, err := uuid.Parse(placementIDStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid placement ID"})
+ return
+ }
+
+ var req struct {
+ NewLocationID uuid.UUID `json:"new_location_id" validate:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
+ return
+ }
+
+ if err := h.validate.Struct(req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed", "details": err.Error()})
+ return
+ }
+
+ if err := h.operationsService.MoveItem(c.Request.Context(), placementID, req.NewLocationID, claims.OrganizationID); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Placement not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Item moved successfully"})
+}
+
+// GetItemPlacements получает все размещения товара
+func (h *OperationsHandler) GetItemPlacements(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ itemIDStr := c.Param("item_id")
+ itemID, err := uuid.Parse(itemIDStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid item ID"})
+ return
+ }
+
+ placements, err := h.operationsService.GetItemPlacements(c.Request.Context(), itemID, claims.OrganizationID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Item not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, placements)
+}
+
+// GetLocationPlacements получает все товары в месте хранения
+func (h *OperationsHandler) GetLocationPlacements(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ locationIDStr := c.Param("location_id")
+ locationID, err := uuid.Parse(locationIDStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid location ID"})
+ return
+ }
+
+ placements, err := h.operationsService.GetLocationPlacements(c.Request.Context(), locationID, claims.OrganizationID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Location not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, placements)
+}
+
+// UpdateQuantity обновляет количество товара в размещении
+func (h *OperationsHandler) UpdateQuantity(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ placementIDStr := c.Param("id")
+ placementID, err := uuid.Parse(placementIDStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid placement ID"})
+ return
+ }
+
+ var req struct {
+ Quantity int `json:"quantity" validate:"required,min=1"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
+ return
+ }
+
+ if err := h.validate.Struct(req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed", "details": err.Error()})
+ return
+ }
+
+ if err := h.operationsService.UpdateQuantity(c.Request.Context(), placementID, req.Quantity, claims.OrganizationID); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Placement not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Quantity updated successfully"})
+}
+
+// DeletePlacement удаляет размещение товара
+func (h *OperationsHandler) DeletePlacement(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ placementIDStr := c.Param("id")
+ placementID, err := uuid.Parse(placementIDStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid placement ID"})
+ return
+ }
+
+ if err := h.operationsService.DeletePlacement(c.Request.Context(), placementID, claims.OrganizationID); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Placement not found"})
+ return
+ }
+
+ c.JSON(http.StatusNoContent, nil)
+}
+
+// Search выполняет поиск товаров с местами размещения
+func (h *OperationsHandler) Search(c *gin.Context) {
+ claims := middleware.GetClaims(c)
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ var req models.SearchRequest
+ if err := c.ShouldBindQuery(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"})
+ return
+ }
+
+ // Устанавливаем значения по умолчанию
+ if req.Page <= 0 {
+ req.Page = 1
+ }
+ if req.PageSize <= 0 {
+ req.PageSize = 20
+ }
+
+ response, err := h.operationsService.Search(c.Request.Context(), claims.OrganizationID, &req)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search"})
+ return
+ }
+
+ c.JSON(http.StatusOK, response)
+}
+
+
+
package middleware
+
+import (
+ "net/http"
+ "strings"
+
+ "erp-mvp/core-service/internal/auth"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+type AuthMiddleware struct {
+ jwtService *auth.JWTService
+}
+
+func NewAuthMiddleware(jwtService *auth.JWTService) *AuthMiddleware {
+ return &AuthMiddleware{
+ jwtService: jwtService,
+ }
+}
+
+func (m *AuthMiddleware) AuthRequired() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Получаем токен из заголовка Authorization
+ authHeader := c.GetHeader("Authorization")
+ if authHeader == "" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
+ c.Abort()
+ return
+ }
+
+ // Проверяем формат "Bearer <token>"
+ tokenParts := strings.Split(authHeader, " ")
+ if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
+ c.Abort()
+ return
+ }
+
+ tokenString := tokenParts[1]
+
+ // Валидируем токен
+ claims, err := m.jwtService.ValidateToken(tokenString)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
+ c.Abort()
+ return
+ }
+
+ // Сохраняем claims в контексте
+ c.Set("user_id", claims.UserID)
+ c.Set("organization_id", claims.OrganizationID)
+ c.Set("email", claims.Email)
+ c.Set("role", claims.Role)
+
+ c.Next()
+ }
+}
+
+// GetClaims получает claims из контекста Gin
+func GetClaims(c *gin.Context) *auth.Claims {
+ userID, exists := c.Get("user_id")
+ if !exists {
+ return nil
+ }
+
+ orgID, exists := c.Get("organization_id")
+ if !exists {
+ return nil
+ }
+
+ email, exists := c.Get("email")
+ if !exists {
+ return nil
+ }
+
+ role, exists := c.Get("role")
+ if !exists {
+ return nil
+ }
+
+ return &auth.Claims{
+ UserID: userID.(uuid.UUID),
+ OrganizationID: orgID.(uuid.UUID),
+ Email: email.(string),
+ Role: role.(string),
+ }
+}
+
+
+
package api
+
+import (
+ "context"
+ "database/sql"
+ "net/http"
+
+ "erp-mvp/core-service/internal/api/handlers"
+ "erp-mvp/core-service/internal/api/middleware"
+ "erp-mvp/core-service/internal/auth"
+ "erp-mvp/core-service/internal/config"
+ "erp-mvp/core-service/internal/logger"
+ "erp-mvp/core-service/internal/repository"
+ "erp-mvp/core-service/internal/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Server struct {
+ config *config.Config
+ db *sql.DB
+ logger logger.Logger
+ router *gin.Engine
+
+ // Services
+ authService service.AuthService
+ locationService service.LocationService
+ itemService service.ItemService
+ operationsService service.OperationsService
+
+ // Handlers
+ authHandler *handlers.AuthHandler
+ locationHandler *handlers.LocationHandler
+ itemHandler *handlers.ItemHandler
+ operationsHandler *handlers.OperationsHandler
+
+ // Middleware
+ authMiddleware *middleware.AuthMiddleware
+}
+
+func NewServer(cfg *config.Config, db *sql.DB, log logger.Logger) *Server {
+ // Инициализируем JWT сервис
+ jwtService := auth.NewJWTService(cfg.JWT.Secret, cfg.JWT.TTL)
+
+ // Инициализируем репозитории
+ orgRepo := repository.NewOrganizationRepository(db)
+ userRepo := repository.NewUserRepository(db)
+ locationRepo := repository.NewLocationRepository(db)
+ itemRepo := repository.NewItemRepository(db)
+ operationsRepo := repository.NewOperationsRepository(db)
+
+ // Инициализируем сервисы
+ authService := service.NewAuthService(orgRepo, userRepo, jwtService)
+ locationService := service.NewLocationService(locationRepo)
+ itemService := service.NewItemService(itemRepo)
+ operationsService := service.NewOperationsService(operationsRepo, itemRepo, locationRepo)
+
+ // Инициализируем handlers
+ authHandler := handlers.NewAuthHandler(authService)
+ locationHandler := handlers.NewLocationHandler(locationService)
+ itemHandler := handlers.NewItemHandler(itemService)
+ operationsHandler := handlers.NewOperationsHandler(operationsService)
+
+ // Инициализируем middleware
+ authMiddleware := middleware.NewAuthMiddleware(jwtService)
+
+ server := &Server{
+ config: cfg,
+ db: db,
+ logger: log,
+ router: gin.Default(),
+ authService: authService,
+ locationService: locationService,
+ itemService: itemService,
+ operationsService: operationsService,
+ authHandler: authHandler,
+ locationHandler: locationHandler,
+ itemHandler: itemHandler,
+ operationsHandler: operationsHandler,
+ authMiddleware: authMiddleware,
+ }
+
+ server.setupRoutes()
+ return server
+}
+
+func (s *Server) setupRoutes() {
+ // Health check
+ s.router.GET("/health", s.healthCheck)
+
+ // API routes
+ api := s.router.Group("/api")
+ {
+ // Auth routes
+ auth := api.Group("/auth")
+ {
+ auth.POST("/register", s.authHandler.Register)
+ auth.POST("/login", s.authHandler.Login)
+ }
+
+ // Protected routes
+ protected := api.Group("/")
+ protected.Use(s.authMiddleware.AuthRequired())
+ {
+ // Organizations
+ protected.GET("/organizations/:id", s.getOrganization)
+ protected.PUT("/organizations/:id", s.updateOrganization)
+
+ // Locations
+ protected.GET("/locations", s.locationHandler.GetLocations)
+ protected.POST("/locations", s.locationHandler.CreateLocation)
+ protected.GET("/locations/:id", s.locationHandler.GetLocation)
+ protected.PUT("/locations/:id", s.locationHandler.UpdateLocation)
+ protected.DELETE("/locations/:id", s.locationHandler.DeleteLocation)
+ protected.GET("/locations/:id/children", s.locationHandler.GetChildren)
+
+ // Items
+ protected.GET("/items", s.itemHandler.GetItems)
+ protected.POST("/items", s.itemHandler.CreateItem)
+ protected.GET("/items/:id", s.itemHandler.GetItem)
+ protected.PUT("/items/:id", s.itemHandler.UpdateItem)
+ protected.DELETE("/items/:id", s.itemHandler.DeleteItem)
+ protected.GET("/items/search", s.itemHandler.SearchItems)
+
+ // Operations
+ protected.POST("/operations/place-item", s.operationsHandler.PlaceItem)
+ protected.POST("/operations/move-item/:id", s.operationsHandler.MoveItem)
+ protected.GET("/operations/search", s.operationsHandler.Search)
+ protected.GET("/operations/items/:item_id/placements", s.operationsHandler.GetItemPlacements)
+ protected.GET("/operations/locations/:location_id/placements", s.operationsHandler.GetLocationPlacements)
+ protected.PUT("/operations/placements/:id/quantity", s.operationsHandler.UpdateQuantity)
+ protected.DELETE("/operations/placements/:id", s.operationsHandler.DeletePlacement)
+
+ // Templates
+ protected.GET("/templates", s.getTemplates)
+ protected.POST("/templates/:id/apply", s.applyTemplate)
+ }
+ }
+}
+
+func (s *Server) healthCheck(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "status": "ok",
+ "service": "erp-mvp-core",
+ })
+}
+
+// Placeholder handlers - will be implemented in next stages
+func (s *Server) getOrganization(c *gin.Context) {
+ c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
+}
+
+func (s *Server) updateOrganization(c *gin.Context) {
+ c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
+}
+
+func (s *Server) getTemplates(c *gin.Context) {
+ c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
+}
+
+func (s *Server) applyTemplate(c *gin.Context) {
+ c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
+}
+
+func (s *Server) Start() error {
+ return s.router.Run(s.config.Server.Host + ":" + s.config.Server.Port)
+}
+
+func (s *Server) Shutdown(ctx context.Context) error {
+ // Graceful shutdown logic will be added later
+ return nil
+}
+
+
+
package auth
+
+import (
+ "errors"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/google/uuid"
+)
+
+type Claims struct {
+ UserID uuid.UUID `json:"user_id"`
+ OrganizationID uuid.UUID `json:"organization_id"`
+ Email string `json:"email"`
+ Role string `json:"role"`
+ jwt.RegisteredClaims
+}
+
+type JWTService struct {
+ secret string
+ ttl time.Duration
+}
+
+func NewJWTService(secret string, ttl time.Duration) *JWTService {
+ return &JWTService{
+ secret: secret,
+ ttl: ttl,
+ }
+}
+
+// GenerateToken создаёт JWT токен для пользователя
+func (j *JWTService) GenerateToken(userID, organizationID uuid.UUID, email, role string) (string, error) {
+ claims := Claims{
+ UserID: userID,
+ OrganizationID: organizationID,
+ Email: email,
+ Role: role,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.ttl)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ NotBefore: jwt.NewNumericDate(time.Now()),
+ Issuer: "erp-mvp-core",
+ Subject: userID.String(),
+ },
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return token.SignedString([]byte(j.secret))
+}
+
+// ValidateToken валидирует JWT токен и возвращает claims
+func (j *JWTService) ValidateToken(tokenString string) (*Claims, error) {
+ token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
+ if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, errors.New("unexpected signing method")
+ }
+ return []byte(j.secret), nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ if claims, ok := token.Claims.(*Claims); ok && token.Valid {
+ return claims, nil
+ }
+
+ return nil, errors.New("invalid token")
+}
+
+
+
package auth
+
+import (
+ "golang.org/x/crypto/bcrypt"
+)
+
+// HashPassword хеширует пароль с использованием bcrypt
+func HashPassword(password string) (string, error) {
+ bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ return string(bytes), err
+}
+
+// CheckPassword проверяет пароль против хеша
+func CheckPassword(password, hash string) bool {
+ err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
+ return err == nil
+}
+
+
+
package config
+
+import (
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/joho/godotenv"
+)
+
+type Config struct {
+ Server ServerConfig
+ Database DatabaseConfig
+ JWT JWTConfig
+}
+
+type ServerConfig struct {
+ Port string
+ Host string
+}
+
+type DatabaseConfig struct {
+ Host string
+ Port string
+ User string
+ Password string
+ DBName string
+ SSLMode string
+}
+
+type JWTConfig struct {
+ Secret string
+ TTL time.Duration
+}
+
+func Load() (*Config, error) {
+ // Загрузка .env файла если существует
+ godotenv.Load()
+
+ return &Config{
+ Server: ServerConfig{
+ Port: getEnv("SERVER_PORT", "8080"),
+ Host: getEnv("SERVER_HOST", "0.0.0.0"),
+ },
+ Database: DatabaseConfig{
+ Host: getEnv("DB_HOST", "localhost"),
+ Port: getEnv("DB_PORT", "5432"),
+ User: getEnv("DB_USER", "erp_user"),
+ Password: getEnv("DB_PASSWORD", "erp_pass"),
+ DBName: getEnv("DB_NAME", "erp_mvp"),
+ SSLMode: getEnv("DB_SSLMODE", "disable"),
+ },
+ JWT: JWTConfig{
+ Secret: getEnv("JWT_SECRET", "your-secret-key"),
+ TTL: time.Duration(getEnvAsInt("JWT_TTL_HOURS", 24)) * time.Hour,
+ },
+ }, nil
+}
+
+func getEnv(key, defaultValue string) string {
+ if value := os.Getenv(key); value != "" {
+ return value
+ }
+ return defaultValue
+}
+
+func getEnvAsInt(key string, defaultValue int) int {
+ if value := os.Getenv(key); value != "" {
+ if intValue, err := strconv.Atoi(value); err == nil {
+ return intValue
+ }
+ }
+ return defaultValue
+}
+
+
+
package database
+
+import (
+ "database/sql"
+ "fmt"
+
+ "erp-mvp/core-service/internal/config"
+ _ "github.com/lib/pq"
+)
+
+func Connect(cfg config.DatabaseConfig) (*sql.DB, error) {
+ dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
+ cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode)
+
+ db, err := sql.Open("postgres", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open database: %w", err)
+ }
+
+ if err := db.Ping(); err != nil {
+ return nil, fmt.Errorf("failed to ping database: %w", err)
+ }
+
+ return db, nil
+}
+
+
+
package logger
+
+import (
+ "os"
+
+ "github.com/sirupsen/logrus"
+)
+
+type Logger interface {
+ Info(args ...interface{})
+ Error(args ...interface{})
+ Fatal(args ...interface{})
+ Debug(args ...interface{})
+ Warn(args ...interface{})
+}
+
+type logger struct {
+ *logrus.Logger
+}
+
+func New() Logger {
+ l := logrus.New()
+ l.SetOutput(os.Stdout)
+ l.SetLevel(logrus.InfoLevel)
+ l.SetFormatter(&logrus.JSONFormatter{})
+
+ return &logger{l}
+}
+
+
+
package repository
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+
+ "erp-mvp/core-service/internal/models"
+
+ "github.com/google/uuid"
+)
+
+type ItemRepository interface {
+ Create(ctx context.Context, item *models.Item) error
+ GetByID(ctx context.Context, id uuid.UUID, orgID uuid.UUID) (*models.Item, error)
+ GetByOrganization(ctx context.Context, orgID uuid.UUID) ([]*models.Item, error)
+ Update(ctx context.Context, item *models.Item) error
+ Delete(ctx context.Context, id uuid.UUID, orgID uuid.UUID) error
+ Search(ctx context.Context, orgID uuid.UUID, query string, category string) ([]*models.Item, error)
+}
+
+type itemRepository struct {
+ db *sql.DB
+}
+
+func NewItemRepository(db *sql.DB) ItemRepository {
+ return &itemRepository{db: db}
+}
+
+func (r *itemRepository) Create(ctx context.Context, item *models.Item) error {
+ query := `
+ INSERT INTO items (id, organization_id, name, description, category, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ `
+
+ _, err := r.db.ExecContext(ctx, query,
+ item.ID,
+ item.OrganizationID,
+ item.Name,
+ item.Description,
+ item.Category,
+ item.CreatedAt,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to create item: %w", err)
+ }
+
+ return nil
+}
+
+func (r *itemRepository) GetByID(ctx context.Context, id uuid.UUID, orgID uuid.UUID) (*models.Item, error) {
+ query := `
+ SELECT id, organization_id, name, description, category, created_at
+ FROM items
+ WHERE id = $1 AND organization_id = $2
+ `
+
+ item := &models.Item{}
+ err := r.db.QueryRowContext(ctx, query, id, orgID).Scan(
+ &item.ID,
+ &item.OrganizationID,
+ &item.Name,
+ &item.Description,
+ &item.Category,
+ &item.CreatedAt,
+ )
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("item not found")
+ }
+ return nil, fmt.Errorf("failed to get item: %w", err)
+ }
+
+ return item, nil
+}
+
+func (r *itemRepository) GetByOrganization(ctx context.Context, orgID uuid.UUID) ([]*models.Item, error) {
+ query := `
+ SELECT id, organization_id, name, description, category, created_at
+ FROM items
+ WHERE organization_id = $1
+ ORDER BY name
+ `
+
+ rows, err := r.db.QueryContext(ctx, query, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query items: %w", err)
+ }
+ defer rows.Close()
+
+ var items []*models.Item
+ for rows.Next() {
+ item := &models.Item{}
+ err := rows.Scan(
+ &item.ID,
+ &item.OrganizationID,
+ &item.Name,
+ &item.Description,
+ &item.Category,
+ &item.CreatedAt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan item: %w", err)
+ }
+
+ items = append(items, item)
+ }
+
+ if err = rows.Err(); err != nil {
+ return nil, fmt.Errorf("error iterating items: %w", err)
+ }
+
+ return items, nil
+}
+
+func (r *itemRepository) Update(ctx context.Context, item *models.Item) error {
+ query := `
+ UPDATE items
+ SET name = $3, description = $4, category = $5
+ WHERE id = $1 AND organization_id = $2
+ `
+
+ result, err := r.db.ExecContext(ctx, query,
+ item.ID,
+ item.OrganizationID,
+ item.Name,
+ item.Description,
+ item.Category,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to update item: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %w", err)
+ }
+
+ if rowsAffected == 0 {
+ return fmt.Errorf("item not found")
+ }
+
+ return nil
+}
+
+func (r *itemRepository) Delete(ctx context.Context, id uuid.UUID, orgID uuid.UUID) error {
+ query := `
+ DELETE FROM items
+ WHERE id = $1 AND organization_id = $2
+ `
+
+ result, err := r.db.ExecContext(ctx, query, id, orgID)
+ if err != nil {
+ return fmt.Errorf("failed to delete item: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %w", err)
+ }
+
+ if rowsAffected == 0 {
+ return fmt.Errorf("item not found")
+ }
+
+ return nil
+}
+
+func (r *itemRepository) Search(ctx context.Context, orgID uuid.UUID, query string, category string) ([]*models.Item, error) {
+ baseQuery := `
+ SELECT id, organization_id, name, description, category, created_at
+ FROM items
+ WHERE organization_id = $1
+ `
+
+ var args []interface{}
+ args = append(args, orgID)
+ argIndex := 2
+
+ if query != "" {
+ baseQuery += fmt.Sprintf(" AND (name ILIKE $%d OR description ILIKE $%d)", argIndex, argIndex)
+ args = append(args, "%"+query+"%")
+ argIndex++
+ }
+
+ if category != "" {
+ baseQuery += fmt.Sprintf(" AND category = $%d", argIndex)
+ args = append(args, category)
+ argIndex++
+ }
+
+ baseQuery += " ORDER BY name"
+
+ rows, err := r.db.QueryContext(ctx, baseQuery, args...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to search items: %w", err)
+ }
+ defer rows.Close()
+
+ var items []*models.Item
+ for rows.Next() {
+ item := &models.Item{}
+ err := rows.Scan(
+ &item.ID,
+ &item.OrganizationID,
+ &item.Name,
+ &item.Description,
+ &item.Category,
+ &item.CreatedAt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan item: %w", err)
+ }
+
+ items = append(items, item)
+ }
+
+ if err = rows.Err(); err != nil {
+ return nil, fmt.Errorf("error iterating search results: %w", err)
+ }
+
+ return items, nil
+}
+
+
+
package repository
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+
+ "erp-mvp/core-service/internal/models"
+
+ "github.com/google/uuid"
+)
+
+type LocationRepository interface {
+ Create(ctx context.Context, location *models.StorageLocation) error
+ GetByID(ctx context.Context, id uuid.UUID, orgID uuid.UUID) (*models.StorageLocation, error)
+ GetByOrganization(ctx context.Context, orgID uuid.UUID) ([]*models.StorageLocation, error)
+ Update(ctx context.Context, location *models.StorageLocation) error
+ Delete(ctx context.Context, id uuid.UUID, orgID uuid.UUID) error
+ GetChildren(ctx context.Context, parentID uuid.UUID, orgID uuid.UUID) ([]*models.StorageLocation, error)
+}
+
+type locationRepository struct {
+ db *sql.DB
+}
+
+func NewLocationRepository(db *sql.DB) LocationRepository {
+ return &locationRepository{db: db}
+}
+
+func (r *locationRepository) Create(ctx context.Context, location *models.StorageLocation) error {
+ query := `
+ INSERT INTO storage_locations (id, organization_id, parent_id, name, address, type, coordinates, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ `
+
+ // Конвертируем JSON в строку
+ var coordinatesJSON string
+ if location.Coordinates != nil {
+ coords, err := json.Marshal(location.Coordinates)
+ if err != nil {
+ return fmt.Errorf("failed to marshal coordinates: %w", err)
+ }
+ coordinatesJSON = string(coords)
+ }
+
+ _, err := r.db.ExecContext(ctx, query,
+ location.ID,
+ location.OrganizationID,
+ location.ParentID,
+ location.Name,
+ location.Address,
+ location.Type,
+ coordinatesJSON,
+ location.CreatedAt,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to create storage location: %w", err)
+ }
+
+ return nil
+}
+
+func (r *locationRepository) GetByID(ctx context.Context, id uuid.UUID, orgID uuid.UUID) (*models.StorageLocation, error) {
+ query := `
+ SELECT id, organization_id, parent_id, name, address, type, coordinates, created_at
+ FROM storage_locations
+ WHERE id = $1 AND organization_id = $2
+ `
+
+ var coordinatesJSON []byte
+ location := &models.StorageLocation{}
+ err := r.db.QueryRowContext(ctx, query, id, orgID).Scan(
+ &location.ID,
+ &location.OrganizationID,
+ &location.ParentID,
+ &location.Name,
+ &location.Address,
+ &location.Type,
+ &coordinatesJSON,
+ &location.CreatedAt,
+ )
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("storage location not found")
+ }
+ return nil, fmt.Errorf("failed to get storage location: %w", err)
+ }
+
+ // Конвертируем JSON строку в map
+ if len(coordinatesJSON) > 0 {
+ err = json.Unmarshal(coordinatesJSON, &location.Coordinates)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal coordinates: %w", err)
+ }
+ } else {
+ location.Coordinates = make(models.JSON)
+ }
+
+ return location, nil
+}
+
+func (r *locationRepository) GetByOrganization(ctx context.Context, orgID uuid.UUID) ([]*models.StorageLocation, error) {
+ query := `
+ SELECT id, organization_id, parent_id, name, address, type, coordinates, created_at
+ FROM storage_locations
+ WHERE organization_id = $1
+ ORDER BY name
+ `
+
+ rows, err := r.db.QueryContext(ctx, query, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query storage locations: %w", err)
+ }
+ defer rows.Close()
+
+ var locations []*models.StorageLocation
+ for rows.Next() {
+ var coordinatesJSON []byte
+ location := &models.StorageLocation{}
+ err := rows.Scan(
+ &location.ID,
+ &location.OrganizationID,
+ &location.ParentID,
+ &location.Name,
+ &location.Address,
+ &location.Type,
+ &coordinatesJSON,
+ &location.CreatedAt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan storage location: %w", err)
+ }
+
+ // Конвертируем JSON строку в map
+ if len(coordinatesJSON) > 0 {
+ err = json.Unmarshal(coordinatesJSON, &location.Coordinates)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal coordinates: %w", err)
+ }
+ } else {
+ location.Coordinates = make(models.JSON)
+ }
+
+ locations = append(locations, location)
+ }
+
+ if err = rows.Err(); err != nil {
+ return nil, fmt.Errorf("error iterating storage locations: %w", err)
+ }
+
+ return locations, nil
+}
+
+func (r *locationRepository) Update(ctx context.Context, location *models.StorageLocation) error {
+ query := `
+ UPDATE storage_locations
+ SET parent_id = $3, name = $4, address = $5, type = $6, coordinates = $7
+ WHERE id = $1 AND organization_id = $2
+ `
+
+ // Конвертируем JSON в строку
+ var coordinatesJSON string
+ if location.Coordinates != nil {
+ coords, err := json.Marshal(location.Coordinates)
+ if err != nil {
+ return fmt.Errorf("failed to marshal coordinates: %w", err)
+ }
+ coordinatesJSON = string(coords)
+ }
+
+ result, err := r.db.ExecContext(ctx, query,
+ location.ID,
+ location.OrganizationID,
+ location.ParentID,
+ location.Name,
+ location.Address,
+ location.Type,
+ coordinatesJSON,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to update storage location: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %w", err)
+ }
+
+ if rowsAffected == 0 {
+ return fmt.Errorf("storage location not found")
+ }
+
+ return nil
+}
+
+func (r *locationRepository) Delete(ctx context.Context, id uuid.UUID, orgID uuid.UUID) error {
+ query := `
+ DELETE FROM storage_locations
+ WHERE id = $1 AND organization_id = $2
+ `
+
+ result, err := r.db.ExecContext(ctx, query, id, orgID)
+ if err != nil {
+ return fmt.Errorf("failed to delete storage location: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %w", err)
+ }
+
+ if rowsAffected == 0 {
+ return fmt.Errorf("storage location not found")
+ }
+
+ return nil
+}
+
+func (r *locationRepository) GetChildren(ctx context.Context, parentID uuid.UUID, orgID uuid.UUID) ([]*models.StorageLocation, error) {
+ query := `
+ SELECT id, organization_id, parent_id, name, address, type, coordinates, created_at
+ FROM storage_locations
+ WHERE parent_id = $1 AND organization_id = $2
+ ORDER BY name
+ `
+
+ rows, err := r.db.QueryContext(ctx, query, parentID, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query child locations: %w", err)
+ }
+ defer rows.Close()
+
+ var locations []*models.StorageLocation
+ for rows.Next() {
+ var coordinatesJSON []byte
+ location := &models.StorageLocation{}
+ err := rows.Scan(
+ &location.ID,
+ &location.OrganizationID,
+ &location.ParentID,
+ &location.Name,
+ &location.Address,
+ &location.Type,
+ &coordinatesJSON,
+ &location.CreatedAt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan child location: %w", err)
+ }
+
+ // Конвертируем JSON строку в map
+ if len(coordinatesJSON) > 0 {
+ err = json.Unmarshal(coordinatesJSON, &location.Coordinates)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal coordinates: %w", err)
+ }
+ } else {
+ location.Coordinates = make(models.JSON)
+ }
+
+ locations = append(locations, location)
+ }
+
+ if err = rows.Err(); err != nil {
+ return nil, fmt.Errorf("error iterating child locations: %w", err)
+ }
+
+ return locations, nil
+}
+
+
+
package repository
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+
+ "erp-mvp/core-service/internal/models"
+
+ "github.com/google/uuid"
+)
+
+type OperationsRepository interface {
+ PlaceItem(ctx context.Context, placement *models.ItemPlacement) error
+ MoveItem(ctx context.Context, placementID uuid.UUID, newLocationID uuid.UUID, orgID uuid.UUID) error
+ GetByItem(ctx context.Context, itemID uuid.UUID, orgID uuid.UUID) ([]*models.ItemPlacement, error)
+ GetByLocation(ctx context.Context, locationID uuid.UUID, orgID uuid.UUID) ([]*models.ItemPlacement, error)
+ GetByID(ctx context.Context, id uuid.UUID, orgID uuid.UUID) (*models.ItemPlacement, error)
+ UpdateQuantity(ctx context.Context, id uuid.UUID, quantity int, orgID uuid.UUID) error
+ Delete(ctx context.Context, id uuid.UUID, orgID uuid.UUID) error
+ Search(ctx context.Context, orgID uuid.UUID, query string, category string, address string) ([]*models.ItemWithLocation, error)
+}
+
+type operationsRepository struct {
+ db *sql.DB
+}
+
+func NewOperationsRepository(db *sql.DB) OperationsRepository {
+ return &operationsRepository{db: db}
+}
+
+func (r *operationsRepository) PlaceItem(ctx context.Context, placement *models.ItemPlacement) error {
+ query := `
+ INSERT INTO item_placements (id, organization_id, item_id, location_id, quantity, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ `
+
+ _, err := r.db.ExecContext(ctx, query,
+ placement.ID,
+ placement.OrganizationID,
+ placement.ItemID,
+ placement.LocationID,
+ placement.Quantity,
+ placement.CreatedAt,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to place item: %w", err)
+ }
+
+ return nil
+}
+
+func (r *operationsRepository) MoveItem(ctx context.Context, placementID uuid.UUID, newLocationID uuid.UUID, orgID uuid.UUID) error {
+ query := `
+ UPDATE item_placements
+ SET location_id = $2
+ WHERE id = $1 AND organization_id = $3
+ `
+
+ result, err := r.db.ExecContext(ctx, query, placementID, newLocationID, orgID)
+ if err != nil {
+ return fmt.Errorf("failed to move item: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %w", err)
+ }
+
+ if rowsAffected == 0 {
+ return fmt.Errorf("item placement not found")
+ }
+
+ return nil
+}
+
+func (r *operationsRepository) GetByItem(ctx context.Context, itemID uuid.UUID, orgID uuid.UUID) ([]*models.ItemPlacement, error) {
+ query := `
+ SELECT id, organization_id, item_id, location_id, quantity, created_at
+ FROM item_placements
+ WHERE item_id = $1 AND organization_id = $2
+ ORDER BY created_at DESC
+ `
+
+ rows, err := r.db.QueryContext(ctx, query, itemID, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query item placements: %w", err)
+ }
+ defer rows.Close()
+
+ var placements []*models.ItemPlacement
+ for rows.Next() {
+ placement := &models.ItemPlacement{}
+ err := rows.Scan(
+ &placement.ID,
+ &placement.OrganizationID,
+ &placement.ItemID,
+ &placement.LocationID,
+ &placement.Quantity,
+ &placement.CreatedAt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan item placement: %w", err)
+ }
+
+ placements = append(placements, placement)
+ }
+
+ if err = rows.Err(); err != nil {
+ return nil, fmt.Errorf("error iterating item placements: %w", err)
+ }
+
+ return placements, nil
+}
+
+func (r *operationsRepository) GetByLocation(ctx context.Context, locationID uuid.UUID, orgID uuid.UUID) ([]*models.ItemPlacement, error) {
+ query := `
+ SELECT id, organization_id, item_id, location_id, quantity, created_at
+ FROM item_placements
+ WHERE location_id = $1 AND organization_id = $2
+ ORDER BY created_at DESC
+ `
+
+ rows, err := r.db.QueryContext(ctx, query, locationID, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query location placements: %w", err)
+ }
+ defer rows.Close()
+
+ var placements []*models.ItemPlacement
+ for rows.Next() {
+ placement := &models.ItemPlacement{}
+ err := rows.Scan(
+ &placement.ID,
+ &placement.OrganizationID,
+ &placement.ItemID,
+ &placement.LocationID,
+ &placement.Quantity,
+ &placement.CreatedAt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan item placement: %w", err)
+ }
+
+ placements = append(placements, placement)
+ }
+
+ if err = rows.Err(); err != nil {
+ return nil, fmt.Errorf("error iterating location placements: %w", err)
+ }
+
+ return placements, nil
+}
+
+func (r *operationsRepository) GetByID(ctx context.Context, id uuid.UUID, orgID uuid.UUID) (*models.ItemPlacement, error) {
+ query := `
+ SELECT id, organization_id, item_id, location_id, quantity, created_at
+ FROM item_placements
+ WHERE id = $1 AND organization_id = $2
+ `
+
+ placement := &models.ItemPlacement{}
+ err := r.db.QueryRowContext(ctx, query, id, orgID).Scan(
+ &placement.ID,
+ &placement.OrganizationID,
+ &placement.ItemID,
+ &placement.LocationID,
+ &placement.Quantity,
+ &placement.CreatedAt,
+ )
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("item placement not found")
+ }
+ return nil, fmt.Errorf("failed to get item placement: %w", err)
+ }
+
+ return placement, nil
+}
+
+func (r *operationsRepository) UpdateQuantity(ctx context.Context, id uuid.UUID, quantity int, orgID uuid.UUID) error {
+ query := `
+ UPDATE item_placements
+ SET quantity = $2
+ WHERE id = $1 AND organization_id = $3
+ `
+
+ result, err := r.db.ExecContext(ctx, query, id, quantity, orgID)
+ if err != nil {
+ return fmt.Errorf("failed to update quantity: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %w", err)
+ }
+
+ if rowsAffected == 0 {
+ return fmt.Errorf("item placement not found")
+ }
+
+ return nil
+}
+
+func (r *operationsRepository) Delete(ctx context.Context, id uuid.UUID, orgID uuid.UUID) error {
+ query := `
+ DELETE FROM item_placements
+ WHERE id = $1 AND organization_id = $2
+ `
+
+ result, err := r.db.ExecContext(ctx, query, id, orgID)
+ if err != nil {
+ return fmt.Errorf("failed to delete item placement: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %w", err)
+ }
+
+ if rowsAffected == 0 {
+ return fmt.Errorf("item placement not found")
+ }
+
+ return nil
+}
+
+func (r *operationsRepository) Search(ctx context.Context, orgID uuid.UUID, query string, category string, address string) ([]*models.ItemWithLocation, error) {
+ baseQuery := `
+ SELECT
+ i.id, i.organization_id, i.name, i.description, i.category, i.created_at,
+ sl.id, sl.organization_id, sl.parent_id, sl.name, sl.address, sl.type, sl.coordinates, sl.created_at,
+ ip.quantity
+ FROM items i
+ JOIN item_placements ip ON i.id = ip.item_id
+ JOIN storage_locations sl ON ip.location_id = sl.id
+ WHERE i.organization_id = $1 AND sl.organization_id = $1
+ `
+
+ var args []interface{}
+ args = append(args, orgID)
+ argIndex := 2
+
+ if query != "" {
+ baseQuery += fmt.Sprintf(" AND (i.name ILIKE $%d OR i.description ILIKE $%d)", argIndex, argIndex)
+ args = append(args, "%"+query+"%")
+ argIndex++
+ }
+
+ if category != "" {
+ baseQuery += fmt.Sprintf(" AND i.category = $%d", argIndex)
+ args = append(args, category)
+ argIndex++
+ }
+
+ if address != "" {
+ baseQuery += fmt.Sprintf(" AND sl.address ILIKE $%d", argIndex)
+ args = append(args, "%"+address+"%")
+ argIndex++
+ }
+
+ baseQuery += " ORDER BY i.name, sl.name"
+
+ rows, err := r.db.QueryContext(ctx, baseQuery, args...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to search items with locations: %w", err)
+ }
+ defer rows.Close()
+
+ var results []*models.ItemWithLocation
+ for rows.Next() {
+ var coordinatesJSON []byte
+ itemWithLocation := &models.ItemWithLocation{}
+
+ err := rows.Scan(
+ &itemWithLocation.Item.ID,
+ &itemWithLocation.Item.OrganizationID,
+ &itemWithLocation.Item.Name,
+ &itemWithLocation.Item.Description,
+ &itemWithLocation.Item.Category,
+ &itemWithLocation.Item.CreatedAt,
+ &itemWithLocation.Location.ID,
+ &itemWithLocation.Location.OrganizationID,
+ &itemWithLocation.Location.ParentID,
+ &itemWithLocation.Location.Name,
+ &itemWithLocation.Location.Address,
+ &itemWithLocation.Location.Type,
+ &coordinatesJSON,
+ &itemWithLocation.Location.CreatedAt,
+ &itemWithLocation.Quantity,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan item with location: %w", err)
+ }
+
+ // Конвертируем JSON строку в map
+ if len(coordinatesJSON) > 0 {
+ err = json.Unmarshal(coordinatesJSON, &itemWithLocation.Location.Coordinates)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal coordinates: %w", err)
+ }
+ } else {
+ itemWithLocation.Location.Coordinates = make(models.JSON)
+ }
+
+ results = append(results, itemWithLocation)
+ }
+
+ if err = rows.Err(); err != nil {
+ return nil, fmt.Errorf("error iterating search results: %w", err)
+ }
+
+ return results, nil
+}
+
+
+
package repository
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+
+ "erp-mvp/core-service/internal/models"
+
+ "github.com/google/uuid"
+)
+
+type OrganizationRepository interface {
+ Create(ctx context.Context, org *models.Organization) error
+ GetByID(ctx context.Context, id uuid.UUID) (*models.Organization, error)
+ Update(ctx context.Context, org *models.Organization) error
+}
+
+type organizationRepository struct {
+ db *sql.DB
+}
+
+func NewOrganizationRepository(db *sql.DB) OrganizationRepository {
+ return &organizationRepository{db: db}
+}
+
+func (r *organizationRepository) Create(ctx context.Context, org *models.Organization) error {
+ query := `
+ INSERT INTO organizations (id, name, type, settings, created_at)
+ VALUES ($1, $2, $3, $4, $5)
+ `
+
+ // Конвертируем JSON в строку
+ settingsJSON, err := json.Marshal(org.Settings)
+ if err != nil {
+ return fmt.Errorf("failed to marshal settings: %w", err)
+ }
+
+ _, err = r.db.ExecContext(ctx, query, org.ID, org.Name, org.Type, string(settingsJSON), org.CreatedAt)
+ if err != nil {
+ return fmt.Errorf("failed to create organization: %w", err)
+ }
+
+ return nil
+}
+
+func (r *organizationRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Organization, error) {
+ query := `
+ SELECT id, name, type, settings, created_at
+ FROM organizations
+ WHERE id = $1
+ `
+
+ var settingsJSON []byte
+ org := &models.Organization{}
+ err := r.db.QueryRowContext(ctx, query, id).Scan(
+ &org.ID,
+ &org.Name,
+ &org.Type,
+ &settingsJSON,
+ &org.CreatedAt,
+ )
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("organization not found")
+ }
+ return nil, fmt.Errorf("failed to get organization: %w", err)
+ }
+
+ // Конвертируем JSON строку в map
+ if len(settingsJSON) > 0 {
+ err = json.Unmarshal(settingsJSON, &org.Settings)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal settings: %w", err)
+ }
+ } else {
+ org.Settings = make(models.JSON)
+ }
+
+ return org, nil
+}
+
+func (r *organizationRepository) Update(ctx context.Context, org *models.Organization) error {
+ query := `
+ UPDATE organizations
+ SET name = $2, type = $3, settings = $4
+ WHERE id = $1
+ `
+
+ // Конвертируем JSON в строку
+ settingsJSON, err := json.Marshal(org.Settings)
+ if err != nil {
+ return fmt.Errorf("failed to marshal settings: %w", err)
+ }
+
+ result, err := r.db.ExecContext(ctx, query, org.ID, org.Name, org.Type, string(settingsJSON))
+ if err != nil {
+ return fmt.Errorf("failed to update organization: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %w", err)
+ }
+
+ if rowsAffected == 0 {
+ return fmt.Errorf("organization not found")
+ }
+
+ return nil
+}
+
+
+
package repository
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+
+ "erp-mvp/core-service/internal/models"
+ "github.com/google/uuid"
+)
+
+type UserRepository interface {
+ Create(ctx context.Context, user *models.User, password string) error
+ GetByEmail(ctx context.Context, email string) (*models.User, error)
+ GetByID(ctx context.Context, id uuid.UUID) (*models.User, error)
+}
+
+type userRepository struct {
+ db *sql.DB
+}
+
+func NewUserRepository(db *sql.DB) UserRepository {
+ return &userRepository{db: db}
+}
+
+func (r *userRepository) Create(ctx context.Context, user *models.User, password string) error {
+ query := `
+ INSERT INTO users (id, organization_id, email, password_hash, role, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ `
+
+ _, err := r.db.ExecContext(ctx, query,
+ user.ID,
+ user.OrganizationID,
+ user.Email,
+ password,
+ user.Role,
+ user.CreatedAt,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to create user: %w", err)
+ }
+
+ return nil
+}
+
+func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) {
+ query := `
+ SELECT id, organization_id, email, password_hash, role, created_at
+ FROM users
+ WHERE email = $1
+ `
+
+ user := &models.User{}
+ err := r.db.QueryRowContext(ctx, query, email).Scan(
+ &user.ID,
+ &user.OrganizationID,
+ &user.Email,
+ &user.PasswordHash,
+ &user.Role,
+ &user.CreatedAt,
+ )
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("user not found")
+ }
+ return nil, fmt.Errorf("failed to get user: %w", err)
+ }
+
+ return user, nil
+}
+
+func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
+ query := `
+ SELECT id, organization_id, email, password_hash, role, created_at
+ FROM users
+ WHERE id = $1
+ `
+
+ user := &models.User{}
+ err := r.db.QueryRowContext(ctx, query, id).Scan(
+ &user.ID,
+ &user.OrganizationID,
+ &user.Email,
+ &user.PasswordHash,
+ &user.Role,
+ &user.CreatedAt,
+ )
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("user not found")
+ }
+ return nil, fmt.Errorf("failed to get user: %w", err)
+ }
+
+ return user, nil
+}
+
+
+
package service
+
+import (
+ "context"
+ "time"
+
+ "erp-mvp/core-service/internal/auth"
+ "erp-mvp/core-service/internal/models"
+ "erp-mvp/core-service/internal/repository"
+
+ "github.com/google/uuid"
+ "github.com/sirupsen/logrus"
+)
+
+type AuthService interface {
+ Register(ctx context.Context, req *models.RegisterRequest) (*models.LoginResponse, error)
+ Login(ctx context.Context, req *models.LoginRequest) (*models.LoginResponse, error)
+}
+
+type authService struct {
+ orgRepo repository.OrganizationRepository
+ userRepo repository.UserRepository
+ jwtService *auth.JWTService
+ logger *logrus.Logger
+}
+
+func NewAuthService(orgRepo repository.OrganizationRepository, userRepo repository.UserRepository, jwtService *auth.JWTService) AuthService {
+ return &authService{
+ orgRepo: orgRepo,
+ userRepo: userRepo,
+ jwtService: jwtService,
+ logger: logrus.New(),
+ }
+}
+
+func (s *authService) Register(ctx context.Context, req *models.RegisterRequest) (*models.LoginResponse, error) {
+ s.logger.Info("Starting registration process")
+
+ // Проверяем, что пользователь с таким email не существует
+ existingUser, err := s.userRepo.GetByEmail(ctx, req.UserEmail)
+ if err == nil && existingUser != nil {
+ s.logger.Error("User with this email already exists")
+ return nil, &ValidationError{Message: "User with this email already exists"}
+ }
+
+ // Создаем организацию
+ orgID := uuid.New()
+ org := &models.Organization{
+ ID: orgID,
+ Name: req.OrganizationName,
+ Type: req.OrganizationType,
+ Settings: models.JSON{"created_at": time.Now().Unix()},
+ CreatedAt: time.Now(),
+ }
+
+ s.logger.Info("Creating organization with ID: ", orgID)
+ if err := s.orgRepo.Create(ctx, org); err != nil {
+ s.logger.Error("Failed to create organization: ", err)
+ return nil, err
+ }
+ s.logger.Info("Organization created successfully")
+
+ // Хешируем пароль
+ passwordHash, err := auth.HashPassword(req.UserPassword)
+ if err != nil {
+ s.logger.Error("Failed to hash password: ", err)
+ return nil, err
+ }
+ s.logger.Info("Password hashed successfully")
+
+ // Создаем пользователя
+ userID := uuid.New()
+ user := &models.User{
+ ID: userID,
+ OrganizationID: orgID,
+ Email: req.UserEmail,
+ Role: "admin", // Первый пользователь становится админом
+ CreatedAt: time.Now(),
+ }
+
+ s.logger.Info("Creating user with ID: ", userID)
+ if err := s.userRepo.Create(ctx, user, passwordHash); err != nil {
+ s.logger.Error("Failed to create user: ", err)
+ return nil, err
+ }
+ s.logger.Info("User created successfully")
+
+ // Генерируем JWT токен
+ token, err := s.jwtService.GenerateToken(user.ID, org.ID, user.Email, user.Role)
+ if err != nil {
+ s.logger.Error("Failed to generate token: ", err)
+ return nil, err
+ }
+ s.logger.Info("JWT token generated successfully")
+
+ return &models.LoginResponse{
+ Token: token,
+ User: models.UserResponse{
+ ID: user.ID,
+ Email: user.Email,
+ Role: user.Role,
+ },
+ Organization: models.OrganizationResponse{
+ ID: org.ID,
+ Name: org.Name,
+ Type: org.Type,
+ },
+ }, nil
+}
+
+func (s *authService) Login(ctx context.Context, req *models.LoginRequest) (*models.LoginResponse, error) {
+ // Получаем пользователя по email
+ user, err := s.userRepo.GetByEmail(ctx, req.Email)
+ if err != nil {
+ return nil, &ValidationError{Message: "Invalid email or password"}
+ }
+
+ // Проверяем пароль
+ if !auth.CheckPassword(req.Password, user.PasswordHash) {
+ return nil, &ValidationError{Message: "Invalid email or password"}
+ }
+
+ // Генерируем JWT токен
+ token, err := s.jwtService.GenerateToken(user.ID, user.OrganizationID, user.Email, user.Role)
+ if err != nil {
+ return nil, err
+ }
+
+ // Получаем организацию для ответа
+ org, err := s.orgRepo.GetByID(ctx, user.OrganizationID)
+ if err != nil {
+ return nil, err
+ }
+
+ return &models.LoginResponse{
+ Token: token,
+ User: models.UserResponse{
+ ID: user.ID,
+ Email: user.Email,
+ Role: user.Role,
+ },
+ Organization: models.OrganizationResponse{
+ ID: org.ID,
+ Name: org.Name,
+ Type: org.Type,
+ },
+ }, nil
+}
+
+// ValidationError ошибка валидации
+type ValidationError struct {
+ Message string
+}
+
+func (e *ValidationError) Error() string {
+ return e.Message
+}
+
+
+
package service
+
+import (
+ "context"
+ "time"
+
+ "erp-mvp/core-service/internal/models"
+ "erp-mvp/core-service/internal/repository"
+
+ "github.com/google/uuid"
+ "github.com/sirupsen/logrus"
+)
+
+type ItemService interface {
+ CreateItem(ctx context.Context, orgID uuid.UUID, req *models.CreateItemRequest) (*models.Item, error)
+ GetItem(ctx context.Context, id uuid.UUID, orgID uuid.UUID) (*models.Item, error)
+ GetItems(ctx context.Context, orgID uuid.UUID) ([]*models.Item, error)
+ UpdateItem(ctx context.Context, id uuid.UUID, orgID uuid.UUID, req *models.CreateItemRequest) (*models.Item, error)
+ DeleteItem(ctx context.Context, id uuid.UUID, orgID uuid.UUID) error
+ SearchItems(ctx context.Context, orgID uuid.UUID, query string, category string) ([]*models.Item, error)
+}
+
+type itemService struct {
+ itemRepo repository.ItemRepository
+ logger *logrus.Logger
+}
+
+func NewItemService(itemRepo repository.ItemRepository) ItemService {
+ return &itemService{
+ itemRepo: itemRepo,
+ logger: logrus.New(),
+ }
+}
+
+func (s *itemService) CreateItem(ctx context.Context, orgID uuid.UUID, req *models.CreateItemRequest) (*models.Item, error) {
+ s.logger.Info("Creating item for organization: ", orgID)
+
+ item := &models.Item{
+ ID: uuid.New(),
+ OrganizationID: orgID,
+ Name: req.Name,
+ Description: req.Description,
+ Category: req.Category,
+ CreatedAt: time.Now(),
+ }
+
+ if err := s.itemRepo.Create(ctx, item); err != nil {
+ s.logger.Error("Failed to create item: ", err)
+ return nil, err
+ }
+
+ s.logger.Info("Item created successfully: ", item.ID)
+ return item, nil
+}
+
+func (s *itemService) GetItem(ctx context.Context, id uuid.UUID, orgID uuid.UUID) (*models.Item, error) {
+ s.logger.Info("Getting item: ", id, " for organization: ", orgID)
+
+ item, err := s.itemRepo.GetByID(ctx, id, orgID)
+ if err != nil {
+ s.logger.Error("Failed to get item: ", err)
+ return nil, err
+ }
+
+ return item, nil
+}
+
+func (s *itemService) GetItems(ctx context.Context, orgID uuid.UUID) ([]*models.Item, error) {
+ s.logger.Info("Getting all items for organization: ", orgID)
+
+ items, err := s.itemRepo.GetByOrganization(ctx, orgID)
+ if err != nil {
+ s.logger.Error("Failed to get items: ", err)
+ return nil, err
+ }
+
+ return items, nil
+}
+
+func (s *itemService) UpdateItem(ctx context.Context, id uuid.UUID, orgID uuid.UUID, req *models.CreateItemRequest) (*models.Item, error) {
+ s.logger.Info("Updating item: ", id, " for organization: ", orgID)
+
+ // Сначала получаем существующий товар
+ item, err := s.itemRepo.GetByID(ctx, id, orgID)
+ if err != nil {
+ s.logger.Error("Failed to get item for update: ", err)
+ return nil, err
+ }
+
+ // Обновляем поля
+ item.Name = req.Name
+ item.Description = req.Description
+ item.Category = req.Category
+
+ if err := s.itemRepo.Update(ctx, item); err != nil {
+ s.logger.Error("Failed to update item: ", err)
+ return nil, err
+ }
+
+ s.logger.Info("Item updated successfully: ", item.ID)
+ return item, nil
+}
+
+func (s *itemService) DeleteItem(ctx context.Context, id uuid.UUID, orgID uuid.UUID) error {
+ s.logger.Info("Deleting item: ", id, " for organization: ", orgID)
+
+ if err := s.itemRepo.Delete(ctx, id, orgID); err != nil {
+ s.logger.Error("Failed to delete item: ", err)
+ return err
+ }
+
+ s.logger.Info("Item deleted successfully: ", id)
+ return nil
+}
+
+func (s *itemService) SearchItems(ctx context.Context, orgID uuid.UUID, query string, category string) ([]*models.Item, error) {
+ s.logger.Info("Searching items for organization: ", orgID, " query: ", query, " category: ", category)
+
+ items, err := s.itemRepo.Search(ctx, orgID, query, category)
+ if err != nil {
+ s.logger.Error("Failed to search items: ", err)
+ return nil, err
+ }
+
+ return items, nil
+}
+
+
+
package service
+
+import (
+ "context"
+ "time"
+
+ "erp-mvp/core-service/internal/models"
+ "erp-mvp/core-service/internal/repository"
+
+ "github.com/google/uuid"
+ "github.com/sirupsen/logrus"
+)
+
+type LocationService interface {
+ CreateLocation(ctx context.Context, orgID uuid.UUID, req *models.CreateLocationRequest) (*models.StorageLocation, error)
+ GetLocation(ctx context.Context, id uuid.UUID, orgID uuid.UUID) (*models.StorageLocation, error)
+ GetLocations(ctx context.Context, orgID uuid.UUID) ([]*models.StorageLocation, error)
+ UpdateLocation(ctx context.Context, id uuid.UUID, orgID uuid.UUID, req *models.CreateLocationRequest) (*models.StorageLocation, error)
+ DeleteLocation(ctx context.Context, id uuid.UUID, orgID uuid.UUID) error
+ GetChildren(ctx context.Context, parentID uuid.UUID, orgID uuid.UUID) ([]*models.StorageLocation, error)
+}
+
+type locationService struct {
+ locationRepo repository.LocationRepository
+ logger *logrus.Logger
+}
+
+func NewLocationService(locationRepo repository.LocationRepository) LocationService {
+ return &locationService{
+ locationRepo: locationRepo,
+ logger: logrus.New(),
+ }
+}
+
+func (s *locationService) CreateLocation(ctx context.Context, orgID uuid.UUID, req *models.CreateLocationRequest) (*models.StorageLocation, error) {
+ s.logger.Info("Creating location for organization: ", orgID)
+
+ location := &models.StorageLocation{
+ ID: uuid.New(),
+ OrganizationID: orgID,
+ ParentID: req.ParentID,
+ Name: req.Name,
+ Address: req.Address,
+ Type: req.Type,
+ Coordinates: req.Coordinates,
+ CreatedAt: time.Now(),
+ }
+
+ if err := s.locationRepo.Create(ctx, location); err != nil {
+ s.logger.Error("Failed to create location: ", err)
+ return nil, err
+ }
+
+ s.logger.Info("Location created successfully: ", location.ID)
+ return location, nil
+}
+
+func (s *locationService) GetLocation(ctx context.Context, id uuid.UUID, orgID uuid.UUID) (*models.StorageLocation, error) {
+ s.logger.Info("Getting location: ", id, " for organization: ", orgID)
+
+ location, err := s.locationRepo.GetByID(ctx, id, orgID)
+ if err != nil {
+ s.logger.Error("Failed to get location: ", err)
+ return nil, err
+ }
+
+ return location, nil
+}
+
+func (s *locationService) GetLocations(ctx context.Context, orgID uuid.UUID) ([]*models.StorageLocation, error) {
+ s.logger.Info("Getting all locations for organization: ", orgID)
+
+ locations, err := s.locationRepo.GetByOrganization(ctx, orgID)
+ if err != nil {
+ s.logger.Error("Failed to get locations: ", err)
+ return nil, err
+ }
+
+ return locations, nil
+}
+
+func (s *locationService) UpdateLocation(ctx context.Context, id uuid.UUID, orgID uuid.UUID, req *models.CreateLocationRequest) (*models.StorageLocation, error) {
+ s.logger.Info("Updating location: ", id, " for organization: ", orgID)
+
+ // Сначала получаем существующую локацию
+ location, err := s.locationRepo.GetByID(ctx, id, orgID)
+ if err != nil {
+ s.logger.Error("Failed to get location for update: ", err)
+ return nil, err
+ }
+
+ // Обновляем поля
+ location.ParentID = req.ParentID
+ location.Name = req.Name
+ location.Address = req.Address
+ location.Type = req.Type
+ location.Coordinates = req.Coordinates
+
+ if err := s.locationRepo.Update(ctx, location); err != nil {
+ s.logger.Error("Failed to update location: ", err)
+ return nil, err
+ }
+
+ s.logger.Info("Location updated successfully: ", location.ID)
+ return location, nil
+}
+
+func (s *locationService) DeleteLocation(ctx context.Context, id uuid.UUID, orgID uuid.UUID) error {
+ s.logger.Info("Deleting location: ", id, " for organization: ", orgID)
+
+ if err := s.locationRepo.Delete(ctx, id, orgID); err != nil {
+ s.logger.Error("Failed to delete location: ", err)
+ return err
+ }
+
+ s.logger.Info("Location deleted successfully: ", id)
+ return nil
+}
+
+func (s *locationService) GetChildren(ctx context.Context, parentID uuid.UUID, orgID uuid.UUID) ([]*models.StorageLocation, error) {
+ s.logger.Info("Getting children for location: ", parentID, " in organization: ", orgID)
+
+ children, err := s.locationRepo.GetChildren(ctx, parentID, orgID)
+ if err != nil {
+ s.logger.Error("Failed to get children: ", err)
+ return nil, err
+ }
+
+ return children, nil
+}
+
+
+
package service
+
+import (
+ "context"
+ "time"
+
+ "erp-mvp/core-service/internal/models"
+ "erp-mvp/core-service/internal/repository"
+
+ "github.com/google/uuid"
+ "github.com/sirupsen/logrus"
+)
+
+type OperationsService interface {
+ PlaceItem(ctx context.Context, orgID uuid.UUID, req *models.PlaceItemRequest) (*models.ItemPlacement, error)
+ MoveItem(ctx context.Context, placementID uuid.UUID, newLocationID uuid.UUID, orgID uuid.UUID) error
+ GetItemPlacements(ctx context.Context, itemID uuid.UUID, orgID uuid.UUID) ([]*models.ItemPlacement, error)
+ GetLocationPlacements(ctx context.Context, locationID uuid.UUID, orgID uuid.UUID) ([]*models.ItemPlacement, error)
+ UpdateQuantity(ctx context.Context, placementID uuid.UUID, quantity int, orgID uuid.UUID) error
+ DeletePlacement(ctx context.Context, placementID uuid.UUID, orgID uuid.UUID) error
+ Search(ctx context.Context, orgID uuid.UUID, req *models.SearchRequest) (*models.SearchResponse, error)
+}
+
+type operationsService struct {
+ operationsRepo repository.OperationsRepository
+ itemRepo repository.ItemRepository
+ locationRepo repository.LocationRepository
+ logger *logrus.Logger
+}
+
+func NewOperationsService(operationsRepo repository.OperationsRepository, itemRepo repository.ItemRepository, locationRepo repository.LocationRepository) OperationsService {
+ return &operationsService{
+ operationsRepo: operationsRepo,
+ itemRepo: itemRepo,
+ locationRepo: locationRepo,
+ logger: logrus.New(),
+ }
+}
+
+func (s *operationsService) PlaceItem(ctx context.Context, orgID uuid.UUID, req *models.PlaceItemRequest) (*models.ItemPlacement, error) {
+ s.logger.Info("Placing item: ", req.ItemID, " in location: ", req.LocationID, " for organization: ", orgID)
+
+ // Проверяем, что товар существует и принадлежит организации
+ _, err := s.itemRepo.GetByID(ctx, req.ItemID, orgID)
+ if err != nil {
+ s.logger.Error("Item not found or not accessible: ", err)
+ return nil, err
+ }
+
+ // Проверяем, что место хранения существует и принадлежит организации
+ _, err = s.locationRepo.GetByID(ctx, req.LocationID, orgID)
+ if err != nil {
+ s.logger.Error("Location not found or not accessible: ", err)
+ return nil, err
+ }
+
+ placement := &models.ItemPlacement{
+ ID: uuid.New(),
+ OrganizationID: orgID,
+ ItemID: req.ItemID,
+ LocationID: req.LocationID,
+ Quantity: req.Quantity,
+ CreatedAt: time.Now(),
+ }
+
+ if err := s.operationsRepo.PlaceItem(ctx, placement); err != nil {
+ s.logger.Error("Failed to place item: ", err)
+ return nil, err
+ }
+
+ s.logger.Info("Item placed successfully: ", placement.ID)
+ return placement, nil
+}
+
+func (s *operationsService) MoveItem(ctx context.Context, placementID uuid.UUID, newLocationID uuid.UUID, orgID uuid.UUID) error {
+ s.logger.Info("Moving item placement: ", placementID, " to location: ", newLocationID, " for organization: ", orgID)
+
+ // Проверяем, что размещение существует и принадлежит организации
+ placement, err := s.operationsRepo.GetByID(ctx, placementID, orgID)
+ if err != nil {
+ s.logger.Error("Item placement not found or not accessible: ", err)
+ return err
+ }
+
+ // Проверяем, что новое место хранения существует и принадлежит организации
+ _, err = s.locationRepo.GetByID(ctx, newLocationID, orgID)
+ if err != nil {
+ s.logger.Error("New location not found or not accessible: ", err)
+ return err
+ }
+
+ if err := s.operationsRepo.MoveItem(ctx, placementID, newLocationID, orgID); err != nil {
+ s.logger.Error("Failed to move item: ", err)
+ return err
+ }
+
+ s.logger.Info("Item moved successfully from location: ", placement.LocationID, " to: ", newLocationID)
+ return nil
+}
+
+func (s *operationsService) GetItemPlacements(ctx context.Context, itemID uuid.UUID, orgID uuid.UUID) ([]*models.ItemPlacement, error) {
+ s.logger.Info("Getting placements for item: ", itemID, " in organization: ", orgID)
+
+ // Проверяем, что товар существует и принадлежит организации
+ _, err := s.itemRepo.GetByID(ctx, itemID, orgID)
+ if err != nil {
+ s.logger.Error("Item not found or not accessible: ", err)
+ return nil, err
+ }
+
+ placements, err := s.operationsRepo.GetByItem(ctx, itemID, orgID)
+ if err != nil {
+ s.logger.Error("Failed to get item placements: ", err)
+ return nil, err
+ }
+
+ return placements, nil
+}
+
+func (s *operationsService) GetLocationPlacements(ctx context.Context, locationID uuid.UUID, orgID uuid.UUID) ([]*models.ItemPlacement, error) {
+ s.logger.Info("Getting placements for location: ", locationID, " in organization: ", orgID)
+
+ // Проверяем, что место хранения существует и принадлежит организации
+ _, err := s.locationRepo.GetByID(ctx, locationID, orgID)
+ if err != nil {
+ s.logger.Error("Location not found or not accessible: ", err)
+ return nil, err
+ }
+
+ placements, err := s.operationsRepo.GetByLocation(ctx, locationID, orgID)
+ if err != nil {
+ s.logger.Error("Failed to get location placements: ", err)
+ return nil, err
+ }
+
+ return placements, nil
+}
+
+func (s *operationsService) UpdateQuantity(ctx context.Context, placementID uuid.UUID, quantity int, orgID uuid.UUID) error {
+ s.logger.Info("Updating quantity for placement: ", placementID, " to: ", quantity, " in organization: ", orgID)
+
+ // Проверяем, что размещение существует и принадлежит организации
+ _, err := s.operationsRepo.GetByID(ctx, placementID, orgID)
+ if err != nil {
+ s.logger.Error("Item placement not found or not accessible: ", err)
+ return err
+ }
+
+ if err := s.operationsRepo.UpdateQuantity(ctx, placementID, quantity, orgID); err != nil {
+ s.logger.Error("Failed to update quantity: ", err)
+ return err
+ }
+
+ s.logger.Info("Quantity updated successfully for placement: ", placementID)
+ return nil
+}
+
+func (s *operationsService) DeletePlacement(ctx context.Context, placementID uuid.UUID, orgID uuid.UUID) error {
+ s.logger.Info("Deleting placement: ", placementID, " for organization: ", orgID)
+
+ // Проверяем, что размещение существует и принадлежит организации
+ _, err := s.operationsRepo.GetByID(ctx, placementID, orgID)
+ if err != nil {
+ s.logger.Error("Item placement not found or not accessible: ", err)
+ return err
+ }
+
+ if err := s.operationsRepo.Delete(ctx, placementID, orgID); err != nil {
+ s.logger.Error("Failed to delete placement: ", err)
+ return err
+ }
+
+ s.logger.Info("Placement deleted successfully: ", placementID)
+ return nil
+}
+
+func (s *operationsService) Search(ctx context.Context, orgID uuid.UUID, req *models.SearchRequest) (*models.SearchResponse, error) {
+ s.logger.Info("Searching items with locations for organization: ", orgID, " query: ", req.Query)
+
+ results, err := s.operationsRepo.Search(ctx, orgID, req.Query, req.Category, req.Address)
+ if err != nil {
+ s.logger.Error("Failed to search items with locations: ", err)
+ return nil, err
+ }
+
+ response := &models.SearchResponse{
+ Items: results,
+ TotalCount: len(results),
+ }
+
+ return response, nil
+}
+
+
+
+
+
+
diff --git a/core-service/scripts/coverage.sh b/core-service/scripts/coverage.sh
new file mode 100755
index 0000000..4e442e4
--- /dev/null
+++ b/core-service/scripts/coverage.sh
@@ -0,0 +1,103 @@
+#!/bin/bash
+
+# Скрипт для анализа покрытия тестами
+# Использование: ./scripts/coverage.sh [--html] [--threshold=80]
+
+set -e
+
+# Параметры по умолчанию
+GENERATE_HTML=false
+COVERAGE_THRESHOLD=80
+
+# Парсим аргументы
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --html)
+ GENERATE_HTML=true
+ shift
+ ;;
+ --threshold=*)
+ COVERAGE_THRESHOLD="${1#*=}"
+ shift
+ ;;
+ *)
+ echo "Неизвестный параметр: $1"
+ echo "Использование: $0 [--html] [--threshold=80]"
+ exit 1
+ ;;
+ esac
+done
+
+echo "🧪 Анализ покрытия тестами..."
+echo ""
+
+# Запускаем тесты с покрытием
+go test ./... -coverprofile=coverage.out
+
+# Получаем общую статистику покрытия
+TOTAL_COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | sed 's/%//')
+
+echo ""
+echo "📊 Общая статистика покрытия:"
+echo "┌─────────────────────────────────────────────────────────────┐"
+echo "│ Пакет │ Покрытие │ Статус │"
+echo "├─────────────────────────────────────────────────────────────┤"
+
+# Анализируем покрытие по пакетам
+go test ./... -cover | while read -r line; do
+ if [[ $line =~ coverage:\ ([0-9.]+)% ]]; then
+ PACKAGE=$(echo "$line" | awk '{print $1}')
+ COVERAGE=$(echo "$line" | grep -o '[0-9.]*%' | head -1)
+ COVERAGE_NUM=$(echo "$COVERAGE" | sed 's/%//')
+
+ if (( $(echo "$COVERAGE_NUM >= $COVERAGE_THRESHOLD" | bc -l) )); then
+ STATUS="✅ Хорошо"
+ elif (( $(echo "$COVERAGE_NUM > 0" | bc -l) )); then
+ STATUS="⚠️ Низкое"
+ else
+ STATUS="❌ Нет тестов"
+ fi
+
+ printf "│ %-25s │ %8s │ %-20s │\n" "$PACKAGE" "$COVERAGE" "$STATUS"
+ fi
+done
+
+echo "└─────────────────────────────────────────────────────────────┘"
+echo ""
+
+# Проверяем общее покрытие
+if (( $(echo "$TOTAL_COVERAGE >= $COVERAGE_THRESHOLD" | bc -l) )); then
+ echo "🎉 Общее покрытие: ${TOTAL_COVERAGE}% (>= ${COVERAGE_THRESHOLD}%)"
+ COVERAGE_STATUS=0
+else
+ echo "⚠️ Общее покрытие: ${TOTAL_COVERAGE}% (< ${COVERAGE_THRESHOLD}%)"
+ echo "💡 Рекомендуется увеличить покрытие тестами"
+ COVERAGE_STATUS=1
+fi
+
+# Генерируем HTML отчет если запрошено
+if [ "$GENERATE_HTML" = true ]; then
+ echo ""
+ echo "📄 Генерация HTML отчета..."
+ go tool cover -html=coverage.out -o coverage.html
+ echo "✅ HTML отчет сохранен в coverage.html"
+ echo "🌐 Откройте coverage.html в браузере для детального просмотра"
+fi
+
+# Показываем функции с низким покрытием
+echo ""
+echo "🔍 Функции с низким покрытием (< 50%):"
+go tool cover -func=coverage.out | grep -E "0\.0%|^[0-9]+\.[0-9]+%" | head -10
+
+# Очищаем временные файлы
+rm -f coverage.out
+
+echo ""
+echo "📈 Рекомендации по улучшению покрытия:"
+echo "1. Добавьте unit тесты для handlers (сейчас 0% покрытия)"
+echo "2. Добавьте unit тесты для service layer (сейчас 0% покрытия)"
+echo "3. Добавьте unit тесты для middleware (сейчас 0% покрытия)"
+echo "4. Добавьте unit тесты для config и database (сейчас 0% покрытия)"
+echo "5. Улучшите покрытие repository layer (сейчас 24.5%)"
+
+exit $COVERAGE_STATUS