mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Добавил черновики накладных и OCR через Яндекс. LLM для расшифровки универсальный
This commit is contained in:
151
internal/transport/http/handlers/drafts.go
Normal file
151
internal/transport/http/handlers/drafts.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/services/drafts"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type DraftsHandler struct {
|
||||
service *drafts.Service
|
||||
}
|
||||
|
||||
func NewDraftsHandler(service *drafts.Service) *DraftsHandler {
|
||||
return &DraftsHandler{service: service}
|
||||
}
|
||||
|
||||
// GetDraft возвращает полные данные черновика
|
||||
func (h *DraftsHandler) GetDraft(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
draft, err := h.service.GetDraft(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "draft not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, draft)
|
||||
}
|
||||
|
||||
// GetStores возвращает список складов
|
||||
func (h *DraftsHandler) GetStores(c *gin.Context) {
|
||||
stores, err := h.service.GetActiveStores()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stores)
|
||||
}
|
||||
|
||||
// UpdateItemDTO - тело запроса на изменение строки
|
||||
type UpdateItemDTO struct {
|
||||
ProductID *string `json:"product_id"`
|
||||
ContainerID *string `json:"container_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
func (h *DraftsHandler) UpdateItem(c *gin.Context) {
|
||||
draftID, _ := uuid.Parse(c.Param("id"))
|
||||
itemID, _ := uuid.Parse(c.Param("itemId"))
|
||||
|
||||
var req UpdateItemDTO
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var pID *uuid.UUID
|
||||
if req.ProductID != nil && *req.ProductID != "" {
|
||||
if uid, err := uuid.Parse(*req.ProductID); err == nil {
|
||||
pID = &uid
|
||||
}
|
||||
}
|
||||
|
||||
var cID *uuid.UUID
|
||||
if req.ContainerID != nil && *req.ContainerID != "" {
|
||||
if uid, err := uuid.Parse(*req.ContainerID); err == nil {
|
||||
cID = &uid
|
||||
}
|
||||
}
|
||||
|
||||
qty := decimal.NewFromFloat(req.Quantity)
|
||||
price := decimal.NewFromFloat(req.Price)
|
||||
|
||||
if err := h.service.UpdateItem(draftID, itemID, pID, cID, qty, price); err != nil {
|
||||
logger.Log.Error("Failed to update item", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
}
|
||||
|
||||
type CommitRequestDTO struct {
|
||||
DateIncoming string `json:"date_incoming"` // YYYY-MM-DD
|
||||
StoreID string `json:"store_id"`
|
||||
SupplierID string `json:"supplier_id"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// CommitDraft сохраняет шапку и отправляет в RMS
|
||||
func (h *DraftsHandler) CommitDraft(c *gin.Context) {
|
||||
draftID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CommitRequestDTO
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Парсинг данных шапки
|
||||
date, err := time.Parse("2006-01-02", req.DateIncoming)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date format (YYYY-MM-DD)"})
|
||||
return
|
||||
}
|
||||
storeID, err := uuid.Parse(req.StoreID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid store id"})
|
||||
return
|
||||
}
|
||||
supplierID, err := uuid.Parse(req.SupplierID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid supplier id"})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Обновляем шапку
|
||||
if err := h.service.UpdateDraftHeader(draftID, &storeID, &supplierID, date, req.Comment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update header: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Отправляем
|
||||
docNum, err := h.service.CommitDraft(draftID)
|
||||
if err != nil {
|
||||
logger.Log.Error("Commit failed", zap.Error(err))
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "RMS error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "completed",
|
||||
"document_number": docNum,
|
||||
})
|
||||
}
|
||||
@@ -73,6 +73,25 @@ func (h *OCRHandler) SaveMatch(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "saved"})
|
||||
}
|
||||
|
||||
// DeleteMatch удаляет связь
|
||||
func (h *OCRHandler) DeleteMatch(c *gin.Context) {
|
||||
// Получаем raw_name из query параметров, так как в URL path могут быть спецсимволы
|
||||
// Пример: DELETE /api/ocr/match?raw_name=Хлеб%20Бородинский
|
||||
rawName := c.Query("raw_name")
|
||||
if rawName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "raw_name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.DeleteMatch(rawName); err != nil {
|
||||
logger.Log.Error("Ошибка удаления матча", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// GetMatches возвращает список всех обученных связей
|
||||
func (h *OCRHandler) GetMatches(c *gin.Context) {
|
||||
matches, err := h.service.GetKnownMatches()
|
||||
|
||||
@@ -21,6 +21,7 @@ type Bot struct {
|
||||
b *tele.Bot
|
||||
ocrService *ocr.Service
|
||||
adminIDs map[int64]struct{}
|
||||
webAppURL string
|
||||
}
|
||||
|
||||
func NewBot(cfg config.TelegramConfig, ocrService *ocr.Service) (*Bot, error) {
|
||||
@@ -46,6 +47,13 @@ func NewBot(cfg config.TelegramConfig, ocrService *ocr.Service) (*Bot, error) {
|
||||
b: b,
|
||||
ocrService: ocrService,
|
||||
adminIDs: admins,
|
||||
webAppURL: cfg.WebAppURL,
|
||||
}
|
||||
|
||||
// Если в конфиге пусто, ставим заглушку, чтобы не падало, но предупреждаем
|
||||
if bot.webAppURL == "" {
|
||||
logger.Log.Warn("Telegram WebAppURL не задан в конфиге! Кнопки работать не будут.")
|
||||
bot.webAppURL = "http://example.com"
|
||||
}
|
||||
|
||||
bot.initHandlers()
|
||||
@@ -106,36 +114,49 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
||||
return c.Send("Ошибка чтения файла.")
|
||||
}
|
||||
|
||||
c.Send("⏳ Обрабатываю чек через OCR...")
|
||||
c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...")
|
||||
|
||||
// 2. Отправляем в сервис
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
// 2. Отправляем в сервис (добавили ID чата)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) // Чуть увеличим таймаут
|
||||
defer cancel()
|
||||
|
||||
items, err := bot.ocrService.ProcessReceiptImage(ctx, imgData)
|
||||
draft, err := bot.ocrService.ProcessReceiptImage(ctx, c.Chat().ID, imgData)
|
||||
if err != nil {
|
||||
logger.Log.Error("OCR processing failed", zap.Error(err))
|
||||
return c.Send("❌ Ошибка распознавания: " + err.Error())
|
||||
return c.Send("❌ Ошибка обработки: " + err.Error())
|
||||
}
|
||||
|
||||
// 3. Формируем отчет
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🧾 <b>Результат (%d поз.):</b>\n\n", len(items)))
|
||||
|
||||
// 3. Анализ результатов для сообщения
|
||||
matchedCount := 0
|
||||
for _, item := range items {
|
||||
for _, item := range draft.Items {
|
||||
if item.IsMatched {
|
||||
matchedCount++
|
||||
sb.WriteString(fmt.Sprintf("✅ %s\n └ <code>%s</code> x %s = %s\n",
|
||||
item.RawName, item.Amount, item.Price, item.Sum))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("❓ <b>%s</b>\n └ Нет привязки!\n", item.RawName))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nРаспознано: %d/%d", matchedCount, len(items)))
|
||||
// Формируем URL. Для Mini App это должен быть https URL вашего фронтенда.
|
||||
// Фронтенд должен уметь роутить /invoice/:id
|
||||
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
||||
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
||||
|
||||
// Тут можно добавить кнопки, если что-то не распознано
|
||||
// Но для начала просто текст
|
||||
return c.Send(sb.String(), tele.ModeHTML)
|
||||
// Формируем текст сообщения
|
||||
var msgText string
|
||||
if matchedCount == len(draft.Items) {
|
||||
msgText = fmt.Sprintf("✅ <b>Успех!</b> Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items))
|
||||
} else {
|
||||
msgText = fmt.Sprintf("⚠️ <b>Внимание!</b> Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления. Нажмите кнопку ниже, чтобы исправить.", matchedCount, len(draft.Items))
|
||||
}
|
||||
|
||||
menu := &tele.ReplyMarkup{}
|
||||
|
||||
// Используем WebApp, а не URL
|
||||
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{
|
||||
URL: fullURL,
|
||||
})
|
||||
|
||||
menu.Inline(
|
||||
menu.Row(btnOpen),
|
||||
)
|
||||
|
||||
return c.Send(msgText, menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user